From f8c3d73ef94c6128d393407c1aa4f22c53cbc71c Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 14 Jul 2026 18:09:46 +0200 Subject: [PATCH 01/22] fix(deploy): reboot stale nodes so containerd adopts refreshed GHCR auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #2625 synchronizes Talos registry auth with --mode=no-reboot and then proves the node with `talosctl image pull`. Both halves miss the running containerd, so the sync certifies nodes that still cannot pull. containerd reads registry auth from its STATIC config (plugins.'io.containerd.cri.v1.images'.registry.configs.'ghcr.io'.auth), loaded ONCE at process start. Talos re-renders /etc/cri/conf.d/01-registries.part on a config change but does not restart containerd, and refuses to let anything else do it either: $ talosctl service cri restart error: service "cri" doesn't support restart operation via API So a --mode=no-reboot patch leaves the new credential on disk, correct and inert, while the running containerd keeps presenting the revoked one. The `image pull` gate cannot catch that: it goes through the TALOS image API, which builds auth from the machine config just written, not from containerd's CRI plugin. It passes on a node whose kubelet pulls are failing 403 — which is what happened on prod (2026-07-14): all 10 nodes carried ghcr-pull-verified-revision while every ksail-operator pod sat in ImagePullBackOff and the operator stayed four releases behind for over a day. Observed, and why it stayed silent: failed to authorize: failed to fetch oauth token: unexpected status from GET request to https://ghcr.io/token?...&service=ghcr.io: 403 Forbidden A revoked credential is worse than none — ghcr.io answers bad basic-auth with 403 DENIED instead of falling back to anonymous, so public packages break too. But cached images are never re-pulled, so workloads stayed up and only upgrades failed; Helm then rolled back, leaving the HelmRelease Ready at the old version and its parent Kustomization healthy, so nothing alerted. - refresh-flux-ghcr-auth.sh: reboot each stale node after applying auth and wait for it to return Ready before touching the next (targets are already sorted workers-first and processed serially, so etcd keeps quorum). Record the verified-revision marker only afterwards, so it means "containerd has provably loaded this credential". Already-verified nodes are still skipped, so a reboot only happens on an actual rotation. - alert.yaml: watch HelmRelease directly. A rolled-back release is legitimately Ready, so the parent-Kustomization gate can never see this class. - authenticate-ghcr-pulls.yaml: document the reboot requirement (comments only). Co-Authored-By: Claude Opus 4.8 --- .../flux-notifications/alert.yaml | 30 ++++-- scripts/refresh-flux-ghcr-auth.sh | 50 ++++++++++ scripts/tests/test_refresh_flux_ghcr_auth.py | 92 ++++++++++++++++++- talos/cluster/authenticate-ghcr-pulls.yaml | 20 +++- 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml b/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml index afbeccf48..bc8d88f3a 100644 --- a/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml +++ b/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml @@ -5,15 +5,31 @@ # server-side apply, an unresolved dependency, or a child resource that never # goes Ready under a wait:true Kustomization. # -# Watching every Kustomization at error severity catches the whole class: the +# Watching every Kustomization at error severity catches most of the class: the # four top-level health gates (bootstrap → infrastructure-controllers → -# infrastructure → apps) wait on their children, so a failed controller / app / -# HelmRelease surfaces as its parent Kustomization going NotReady → an error -# event here → Slack. Event-driven (no polling pod, so nothing for the kubescape -# scan to flag) and posting to the same channel as the Coroot alerts. +# infrastructure → apps) wait on their children, so a failed controller / app +# surfaces as its parent Kustomization going NotReady → an error event here → +# Slack. Event-driven (no polling pod, so nothing for the kubescape scan to +# flag) and posting to the same channel as the Coroot alerts. +# +# HelmRelease is watched DIRECTLY rather than left to that parent-Kustomization +# gate, because a failing HelmRelease can be INVISIBLE to it. With Helm +# remediation enabled a failed upgrade is ROLLED BACK to the last-good release — +# after which the HelmRelease is legitimately Ready again, its parent +# Kustomization stays healthy, and nothing ever pages. The release just silently +# stops advancing. +# +# Not hypothetical: on 2026-07-14 the ksail-operator HelmRelease failed its +# upgrade and rolled back on every attempt for over a day (stuck four releases +# behind, every ReplicaSet since v7.168.0 at 0 replicas, because the nodes' +# containerd still held a revoked ghcr.io credential — see +# scripts/refresh-flux-ghcr-auth.sh). Prod looked green the entire time and no +# alert fired. Watching HelmRelease routes helm-controller's own error events +# (upgrade failed / retries exhausted / rollback) instead of depending on a +# health gate that a successful rollback deliberately satisfies. # # notification-controller dedupes + rate-limits, so a persistently-failing -# Kustomization pages once, not every reconcile. +# resource pages once, not every reconcile. apiVersion: notification.toolkit.fluxcd.io/v1beta3 kind: Alert metadata: @@ -26,4 +42,6 @@ spec: eventSources: - kind: Kustomization name: "*" + - kind: HelmRelease + name: "*" summary: "Flux reconciliation error — prod platform" diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index ab937d52f..5ad6d58da 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -271,6 +271,9 @@ sync_talos_registry_auth() { : > "${talos_result_file}" chmod 600 "${talos_result_file}" + # Targets are pre-sorted workers-first, and this loop is strictly sequential, + # so the reboot below rolls one node at a time and control planes go last — + # etcd keeps quorum throughout. while IFS=$'\t' read -r _node_role node_name node_ip; do if ! talosctl \ --nodes "${node_ip}" \ @@ -281,6 +284,49 @@ sync_talos_registry_auth() { echo "::error::Talos node ${node_name} did not accept the Git/SOPS GHCR registry auth." return 1 fi + + # Writing the credential is NOT enough to make a running node use it, and + # this is the step whose absence caused the 2026-07-14 outage. + # + # containerd reads registry auth from its STATIC config + # (plugins.'io.containerd.cri.v1.images'.registry.configs.'ghcr.io'.auth), + # which it loads ONCE at process start. Talos re-renders that file + # (/etc/cri/conf.d/01-registries.part) immediately on a config change, but + # it does not restart containerd — and it refuses to let us either: + # + # $ talosctl service cri restart + # error: service "cri" doesn't support restart operation via API + # + # So after a --mode=no-reboot patch the new credential sits on disk, + # correct and INERT, while the running containerd keeps presenting the old + # one. A REBOOT is the only supported way to make it adopt the new auth. + # + # Do not be tempted to drop this and trust the `image pull` check below: + # that check goes through the TALOS image API, which builds its auth from + # the machine config we just wrote, NOT from containerd's CRI plugin. It + # therefore passes on a node whose kubelet pulls are still failing 403 — + # which is exactly what happened: every node was annotated + # ghcr-pull-verified-revision while every ksail-operator pod sat in + # ImagePullBackOff, and prod stayed four releases behind for over a day. + # The pull check proves the CREDENTIAL is good; only the reboot proves + # CONTAINERD is using it. + if ! talosctl --nodes "${node_ip}" reboot >"${talos_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not reboot to load the refreshed GHCR registry auth." + return 1 + fi + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + wait \ + --for=condition=Ready \ + "node/${node_name}" \ + --timeout=10m \ + >"${talos_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not return Ready after its GHCR auth reboot; refusing to roll the next node." + return 1 + fi + + # Credential validity against GHCR (see the caveat above: this is not, on + # its own, proof that containerd is using it — the reboot is). if ! talosctl \ --nodes "${node_ip}" \ image pull "${operator_image}" \ @@ -289,6 +335,10 @@ sync_talos_registry_auth() { echo "::error::Talos node ${node_name} could not pull the exact live KSail image after its auth refresh." return 1 fi + + # Recorded LAST, and only now: the marker means "this node's containerd has + # provably loaded this credential revision", so it must not be written + # before the reboot that makes that true. if ! talosctl \ --nodes "${node_ip}" \ patch machineconfig \ diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 3929c7829..3a3154b32 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -199,6 +199,7 @@ def setUp(self) -> None: fi test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" test -f "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" jq -e --arg revision "$EXPECTED_GHCR_REVISION" ' .machine.nodeAnnotations[ @@ -216,8 +217,27 @@ def setUp(self) -> None: exit 0 fi + # A running containerd only adopts new registry auth by restarting, + # and Talos refuses `service cri restart`, so the node must reboot. + # The auth patch must already have landed. + if [[ "$arguments" == *" reboot "* ]]; then + test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + printf 'talos-reboot:%s\n' "$node" >> "$TALOS_LOG" + printf 'talos-reboot:%s\n' "$node" >> "$OPERATION_LOG" + if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ + && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == reboot ]]; then + echo "talos reboot failed" >&2 + exit 49 + fi + touch "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + exit 0 + fi + if [[ "$arguments" == *" image pull "* ]]; then test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + # The pull check is only meaningful once containerd has actually + # reloaded the credential, i.e. after the reboot. + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" [[ "$arguments" == *" --namespace cri "* ]] image="" previous="" @@ -388,6 +408,25 @@ def setUp(self) -> None: exit 0 fi + # Node readiness gate after a GHCR-auth reboot. Cluster-scoped, so it + # must be handled before the namespace guard below. + if [[ "$arguments" == *" wait "* ]]; then + [[ "$arguments" == *" --for=condition=Ready "* ]] + node_target="" + for argument in "$@"; do + case "$argument" in + node/*) node_target="${argument#node/}" ;; + esac + done + test -n "$node_target" + printf 'node-ready:%s\n' "$node_target" >> "$OPERATION_LOG" + if [[ "$node_target" == "${FAKE_NODE_READY_FAIL_NODE:-disabled}" ]]; then + echo 'node did not become ready' >&2 + exit 50 + fi + exit 0 + fi + test -n "$namespace" if [[ "$arguments" == *" get deployment ksail-operator "* ]]; then @@ -725,15 +764,24 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: ) def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> None: - """Patch nodes serially and pull the exact live operator image first.""" + """Patch, REBOOT, then pull the exact live operator image, workers first. + + The reboot is load-bearing: containerd reads registry auth from its + static config only at process start, and Talos refuses + `service cri restart`, so a --mode=no-reboot patch leaves the running + containerd on the OLD credential. Without the reboot every ghcr.io pull + on the node keeps failing 403 while the node still reports "verified". + """ result = self._run_helper(self._valid_config()) self.assertEqual(result.returncode, 0, result.stderr) expected_talos_operations = [ "talos-auth:10.0.0.2", + "talos-reboot:10.0.0.2", "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:v7.169.0", "talos-revision:10.0.0.2", "talos-auth:10.0.0.1", + "talos-reboot:10.0.0.1", "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:v7.169.0", "talos-revision:10.0.0.1", ] @@ -741,9 +789,22 @@ def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> No self.talos_log.read_text(encoding="utf-8").splitlines(), expected_talos_operations, ) + # Each node is drained through the full sequence, and readiness is + # awaited after its reboot, before the next node is touched. self.assertEqual( - self.operation_log.read_text(encoding="utf-8").splitlines()[:6], - expected_talos_operations, + self.operation_log.read_text(encoding="utf-8").splitlines()[:10], + [ + "talos-auth:10.0.0.2", + "talos-reboot:10.0.0.2", + "node-ready:prod-worker-1", + "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:v7.169.0", + "talos-revision:10.0.0.2", + "talos-auth:10.0.0.1", + "talos-reboot:10.0.0.1", + "node-ready:prod-control-plane-1", + "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:v7.169.0", + "talos-revision:10.0.0.1", + ], ) temporary_patch = Path( self.talos_patch_path_log.read_text(encoding="utf-8") @@ -751,9 +812,32 @@ def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> No self.assertFalse(temporary_patch.exists()) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_unready_node_after_reboot_stops_the_roll(self) -> None: + """Never roll the next node while the rebooted one is still down.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_READY_FAIL_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + # The worker rebooted but never came back, so the control plane is left + # alone and no Kubernetes credential consumer is mutated. + self.assertEqual( + operations, + [ + "talos-auth:10.0.0.2", + "talos-reboot:10.0.0.2", + "node-ready:prod-worker-1", + ], + ) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_talos_failure_prevents_kubernetes_credential_mutation(self) -> None: """Stop before Flux fan-out when any node cannot accept or use auth.""" - for operation in ("auth", "pull", "revision"): + for operation in ("auth", "reboot", "pull", "revision"): with self.subTest(operation=operation): result = self._run_helper( self._valid_config(), diff --git a/talos/cluster/authenticate-ghcr-pulls.yaml b/talos/cluster/authenticate-ghcr-pulls.yaml index 887b9e820..888227e19 100644 --- a/talos/cluster/authenticate-ghcr-pulls.yaml +++ b/talos/cluster/authenticate-ghcr-pulls.yaml @@ -32,9 +32,27 @@ # talos-local/. # # Existing nodes are synchronized separately by refresh-flux-ghcr-auth.sh using -# this same RegistryAuthConfig shape, one node at a time in no-reboot mode, and +# this same RegistryAuthConfig shape, one node at a time (workers first), and # each node must then pull the exact live ksail-operator image successfully. # +# That sync REBOOTS each stale node, and must: containerd loads registry auth +# from its static config once, at process start. Writing a new credential — even +# though Talos re-renders /etc/cri/conf.d/01-registries.part immediately — does +# not reach the running containerd, and Talos refuses to restart it +# (`talosctl service cri restart` → "service 'cri' doesn't support restart +# operation via API"). So on a running node a rotated token is inert until the +# node reboots, and every ghcr.io pull keeps failing with the OLD credential. +# +# It fails SILENTLY, which is why this bit is easy to get wrong: images already +# cached on a node are never re-pulled, so running workloads stay up and the +# cluster looks green — only a FRESH pull (i.e. an upgrade) breaks, and Helm +# then times out and rolls back, deleting the evidence. (2026-07-14: the +# ksail-operator sat four releases behind for over a day this way.) +# +# Note also that a REVOKED credential is worse than none: ghcr.io answers bad +# basic-auth with 403 DENIED on the token exchange instead of falling back to +# anonymous, so a stale token breaks pulls of PUBLIC packages too. +# # Reference: # https://docs.siderolabs.com/talos/v1.13/reference/configuration/cri/registryauthconfig apiVersion: v1alpha1 From 54b04ac8df1f49cf4d6710fb52413339b2041171 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 14 Jul 2026 20:12:28 +0200 Subject: [PATCH 02/22] fix(alerting): watch every HelmRelease namespace; drain + quorum-gate the reboots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the Codex review on this PR. Three of the four findings were real. P1 — the HelmRelease alert watched almost nothing. notification-controller has NO namespace wildcard: it defaults an omitted eventSources[].namespace to the Alert's own namespace, then matches on strict equality (internal/server/event_handlers.go): if source.Namespace == "" { source.Namespace = alert.Namespace } if event.InvolvedObject.Namespace != source.Namespace || ... { return false } `name: "*"` wildcards NAMES only. So the bare HelmRelease source I added watched only flux-system — 2 of the 45 HelmReleases — and would have missed ksail-operator, the one that caused the outage it was written for. Enumerate all 31 namespaces holding a HelmRelease. Enumeration drifts, and a silently-uncovered namespace is precisely the bug the alert exists to catch, so scripts/validate-alert-coverage.sh renders the overlays and fails CI when a HelmRelease appears in an unwatched namespace (fails closed if the overlays don't build). Verified: removing ksail-operator from the list fails the guard. P2 — reboots did not drain. `talosctl reboot` only cordons + evicts under PDB control with --drain; without it the node's pods are killed with it. Added. P2 — a control-plane reboot could drop etcd quorum. The roll is serial and control planes sort last, but a control plane already unhealthy before the run makes ours the second one down. Preflight every OTHER control plane's Ready condition and refuse otherwise. P2 (fresh-node reboots) — NOT taken, deliberately. Skipping the reboot when a node already carries the current ghcr-pull-desired-revision looks right but is unsafe: that marker tracks MACHINE CONFIG state, and machine-config state being decoupled from what containerd loaded IS this bug. The deploy runs this bridge twice — before AND after `ksail cluster update` (deploy-prod) — and cluster update stamps the current marker onto nodes whose containerd still holds the old credential; the manual runbook path can do the same. Skipping on that evidence would silently re-create the outage. An over-eager reboot costs ~90s on a drained node; a skipped one cost a day of ImagePullBackOff. Proving a node fresh needs evidence about containerd (start time vs when the credential landed), not the machine config — filed separately. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yaml | 12 ++ .../flux-notifications/alert.yaml | 114 +++++++++++++++++- scripts/refresh-flux-ghcr-auth.sh | 81 ++++++++++++- scripts/tests/test_refresh_flux_ghcr_auth.py | 65 +++++++++- scripts/validate-alert-coverage.sh | 81 +++++++++++++ 5 files changed, 343 insertions(+), 10 deletions(-) create mode 100755 scripts/validate-alert-coverage.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ea31771a1..078e1548b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -58,6 +58,7 @@ jobs: - 'go.sum' - 'scripts/tests/test-cilium-bandwidth-manager-component.sh' - 'tests/cilium-bandwidth-manager-bbr/**' + - 'scripts/validate-alert-coverage.sh' - 'scripts/refresh-flux-ghcr-auth.sh' - 'scripts/ghcr-auth-lib.sh' - 'scripts/run-ksail-prod-with-pull-auth.sh' @@ -139,6 +140,17 @@ jobs: # on the GitHub-hosted runner; no cluster or secrets required. run: bash scripts/tests/test-cilium-bandwidth-manager-component.sh + - name: 📢 Validate reconciliation-Alert namespace coverage + if: needs.changes.outputs.k8s == 'true' + # notification-controller has no namespace wildcard, so the Alert must list + # every namespace holding a HelmRelease. A namespace missing from that list + # is invisible: the release fails, rolls back, reports Ready, and nothing + # pages (ksail-operator, 2026-07-14). Drift is a build failure, not a + # comment. Bash + the runner's kubectl/yq; no cluster or secrets required. + run: | + shellcheck scripts/validate-alert-coverage.sh + bash scripts/validate-alert-coverage.sh + - name: ⚙️ Setup KSail # Renovate-managed (datasource github-releases; grouped 'ksail' with the # deploy-prod / dr-rebuild pins). The validate step below renders diff --git a/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml b/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml index bc8d88f3a..0f9ee2c30 100644 --- a/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml +++ b/k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml @@ -24,9 +24,26 @@ # behind, every ReplicaSet since v7.168.0 at 0 replicas, because the nodes' # containerd still held a revoked ghcr.io credential — see # scripts/refresh-flux-ghcr-auth.sh). Prod looked green the entire time and no -# alert fired. Watching HelmRelease routes helm-controller's own error events -# (upgrade failed / retries exhausted / rollback) instead of depending on a -# health gate that a successful rollback deliberately satisfies. +# alert fired. +# +# ── WHY THE NAMESPACES ARE ENUMERATED ────────────────────────────────────────── +# There is NO namespace wildcard. notification-controller defaults an omitted +# eventSources[].namespace to the ALERT's own namespace and then matches on +# strict equality (internal/server/event_handlers.go): +# +# if source.Namespace == "" { source.Namespace = alert.Namespace } +# if event.InvolvedObject.Namespace != source.Namespace || ... { return false } +# +# `name: "*"` is a wildcard over NAMES only. So a bare `kind: HelmRelease, +# name: "*"` on this flux-system Alert would watch ONLY the 2 HelmReleases in +# flux-system and miss the other 43 — including ksail-operator, the one that +# caused the outage. Every namespace holding a HelmRelease must therefore be +# listed. (Kustomization needs no list: all four Flux Kustomizations live in +# flux-system, this Alert's own namespace.) +# +# Enumeration drifts, and a silently-uncovered namespace is the very bug this +# alert exists to prevent — so scripts/validate-alert-coverage.sh fails CI if a +# HelmRelease appears in a namespace not listed below. # # notification-controller dedupes + rate-limits, so a persistently-failing # resource pages once, not every reconcile. @@ -44,4 +61,95 @@ spec: name: "*" - kind: HelmRelease name: "*" + namespace: actual-budget + - kind: HelmRelease + name: "*" + namespace: backstage + - kind: HelmRelease + name: "*" + namespace: cert-manager + - kind: HelmRelease + name: "*" + namespace: cnpg-system + - kind: HelmRelease + name: "*" + namespace: crossplane-system + - kind: HelmRelease + name: "*" + namespace: crossview + - kind: HelmRelease + name: "*" + namespace: dex + - kind: HelmRelease + name: "*" + namespace: external-dns + - kind: HelmRelease + name: "*" + namespace: external-secrets + - kind: HelmRelease + name: "*" + namespace: flagger-system + - kind: HelmRelease + name: "*" + namespace: flux-system + - kind: HelmRelease + name: "*" + namespace: headlamp + - kind: HelmRelease + name: "*" + namespace: homepage + - kind: HelmRelease + name: "*" + namespace: keda + - kind: HelmRelease + name: "*" + namespace: kro-system + - kind: HelmRelease + name: "*" + namespace: ksail-operator + - kind: HelmRelease + name: "*" + namespace: kube-system + - kind: HelmRelease + name: "*" + namespace: kubescape + - kind: HelmRelease + name: "*" + namespace: kyverno + - kind: HelmRelease + name: "*" + namespace: longhorn-system + - kind: HelmRelease + name: "*" + namespace: oauth2-proxy + - kind: HelmRelease + name: "*" + namespace: observability + - kind: HelmRelease + name: "*" + namespace: open-feature-operator-system + - kind: HelmRelease + name: "*" + namespace: openbao + - kind: HelmRelease + name: "*" + namespace: opencost + - kind: HelmRelease + name: "*" + namespace: policy-reporter + - kind: HelmRelease + name: "*" + namespace: reloader + - kind: HelmRelease + name: "*" + namespace: umami + - kind: HelmRelease + name: "*" + namespace: velero + - kind: HelmRelease + name: "*" + namespace: vertical-pod-autoscaler + - kind: HelmRelease + name: "*" + namespace: whoami summary: "Flux reconciliation error — prod platform" diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 5ad6d58da..7999985b9 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -165,6 +165,32 @@ verify_consumer_secret() { fi } +# Every control plane OTHER than the named one must be Ready before we take it +# down. etcd tolerates exactly one member down in a 3-member cluster, so rebooting +# a second one drops quorum and takes the API server with it. +other_control_planes_ready() { + local rebooting="$1" + local unready + + unready="$(jq -r --arg rebooting "${rebooting}" ' + .items[] + | select(.metadata.name != $rebooting) + | (.metadata.labels // {}) as $labels + | select(($labels | has("node-role.kubernetes.io/control-plane")) + or ($labels | has("node-role.kubernetes.io/master"))) + | select( + [.status.conditions[]? | select(.type == "Ready" and .status == "True")] + | length == 0 + ) + | .metadata.name + ' "${talos_nodes_file}")" + + if [[ -n "${unready}" ]]; then + echo "Control planes not Ready: $(echo "${unready}" | tr '\n' ' ')" + return 1 + fi +} + # Apply Git/SOPS auth to stale Talos nodes, prove an exact image pull, and only # then record the non-secret revision marker that makes the operation retryable. sync_talos_registry_auth() { @@ -173,7 +199,8 @@ sync_talos_registry_auth() { local image_reference local node_name local node_ip - local _node_role + local node_role + local _node_desired if ! kubectl \ --context "${KUBE_CONTEXT}" \ @@ -219,7 +246,10 @@ sync_talos_registry_auth() { then "1" else "0" end), .metadata.name, ([.status.addresses[] - | select(.type == "InternalIP") | .address][0]) + | select(.type == "InternalIP") | .address][0]), + (.metadata.annotations[ + "platform.devantler.tech/ghcr-pull-desired-revision" + ] // "") ] | @tsv ' "${talos_nodes_file}" \ @@ -274,7 +304,7 @@ sync_talos_registry_auth() { # Targets are pre-sorted workers-first, and this loop is strictly sequential, # so the reboot below rolls one node at a time and control planes go last — # etcd keeps quorum throughout. - while IFS=$'\t' read -r _node_role node_name node_ip; do + while IFS=$'\t' read -r node_role node_name node_ip _node_desired; do if ! talosctl \ --nodes "${node_ip}" \ patch machineconfig \ @@ -285,7 +315,7 @@ sync_talos_registry_auth() { return 1 fi - # Writing the credential is NOT enough to make a running node use it, and + # Writing the credential is NOT enough to make a RUNNING node use it, and # this is the step whose absence caused the 2026-07-14 outage. # # containerd reads registry auth from its STATIC config @@ -310,8 +340,47 @@ sync_talos_registry_auth() { # ImagePullBackOff, and prod stayed four releases behind for over a day. # The pull check proves the CREDENTIAL is good; only the reboot proves # CONTAINERD is using it. - if ! talosctl --nodes "${node_ip}" reboot >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} did not reboot to load the refreshed GHCR registry auth." + # + # The reboot is UNCONDITIONAL for every selected node, which does mean a + # freshly-autoscaled node — whose containerd already booted with the current + # credential — gets one avoidable reboot on the next deploy. That is + # deliberate, and the tempting optimisation is a trap: + # + # The obvious way to spot a "fresh" node is its ghcr-pull-desired-revision + # marker (mark-ghcr-pull-revision.yaml). But that marker tracks MACHINE + # CONFIG state, and machine-config state being decoupled from what containerd + # actually loaded IS the bug this whole function exists to fix. The deploy + # runs this bridge twice — once before `ksail cluster update` and once after + # (deploy-prod action) — and `cluster update` stamps the current desired + # marker onto every node, including ones whose containerd is still holding + # the old credential. Any node the manual runbook path updates ahead of the + # bridge lands in the same state. Skipping a reboot on that evidence would + # silently re-create the exact 2026-07-14 outage. + # + # Correctness beats one wasted reboot: an over-eager reboot costs ~90s on a + # drained node, a skipped one costs a day of silent ImagePullBackOff. Skipping + # provably-fresh nodes needs evidence about CONTAINERD (its start time versus + # when the credential landed), not about the machine config — tracked + # separately. + # + # etcd tolerates exactly one control plane down in a 3-member cluster. This + # loop is serial and control planes sort last, but a control plane that was + # ALREADY unhealthy before this run would make our reboot the second one down + # and drop quorum. Prove the others are Ready first. + if [[ "${node_role}" == "1" ]] \ + && ! other_control_planes_ready "${node_name}"; then + echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready, so rebooting this one risks etcd quorum." + return 1 + fi + + # --drain cordons the node and evicts its pods under PodDisruptionBudget + # control first; without it the workloads are killed along with the node. + if ! talosctl \ + --nodes "${node_ip}" \ + reboot \ + --drain \ + >"${talos_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not drain+reboot to load the refreshed GHCR registry auth." return 1 fi if ! kubectl \ diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 3a3154b32..768b73c41 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -219,9 +219,11 @@ def setUp(self) -> None: # A running containerd only adopts new registry auth by restarting, # and Talos refuses `service cri restart`, so the node must reboot. - # The auth patch must already have landed. + # The auth patch must already have landed, and the reboot must drain + # (cordon + evict under PDB) rather than killing the pods with it. if [[ "$arguments" == *" reboot "* ]]; then test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + [[ "$arguments" == *" --drain "* ]] printf 'talos-reboot:%s\n' "$node" >> "$TALOS_LOG" printf 'talos-reboot:%s\n' "$node" >> "$OPERATION_LOG" if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ @@ -812,6 +814,67 @@ def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> No self.assertFalse(temporary_patch.exists()) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: + """Never take a SECOND control plane down — etcd would lose quorum. + + The reboot roll is serial and control planes go last, but a control plane + that was ALREADY unhealthy before this run makes our reboot the second one + down. The worker still syncs (it cannot affect quorum); the control-plane + reboot is refused, and no Kubernetes credential consumer is mutated. + """ + revision = self._expected_revision() + ready = [{"type": "Ready", "status": "True"}] + inventory = { + "items": [ + { + "metadata": {"name": "prod-worker-1", "labels": {}}, + "status": { + "addresses": [{"type": "InternalIP", "address": "10.0.0.2"}], + "conditions": ready, + }, + }, + { + "metadata": { + "name": "prod-control-plane-1", + "labels": {"node-role.kubernetes.io/control-plane": ""}, + }, + "status": { + "addresses": [{"type": "InternalIP", "address": "10.0.0.1"}], + "conditions": ready, + }, + }, + { + # Already down, and already at the desired revision — so it is + # not itself a sync target, it is just a quorum member. + "metadata": { + "name": "prod-control-plane-2", + "labels": {"node-role.kubernetes.io/control-plane": ""}, + "annotations": { + "platform.devantler.tech/ghcr-pull-verified-revision": + revision, + }, + }, + "status": { + "addresses": [{"type": "InternalIP", "address": "10.0.0.3"}], + "conditions": [{"type": "Ready", "status": "False"}], + }, + }, + ] + } + + result = self._run_helper( + self._valid_config(), + FAKE_NODE_JSON=json.dumps(inventory), + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("risks etcd quorum", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-reboot:10.0.0.1", operations) + self.assertNotIn("root-patch", operations) + self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_unready_node_after_reboot_stops_the_roll(self) -> None: """Never roll the next node while the rebooted one is still down.""" result = self._run_helper( diff --git a/scripts/validate-alert-coverage.sh b/scripts/validate-alert-coverage.sh new file mode 100755 index 000000000..1509a07a0 --- /dev/null +++ b/scripts/validate-alert-coverage.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Fail if a HelmRelease lives in a namespace the reconciliation Alert does not watch. +# +# notification-controller has NO namespace wildcard: it defaults an omitted +# eventSources[].namespace to the Alert's own namespace and then matches on strict +# equality (internal/server/event_handlers.go): +# +# if source.Namespace == "" { source.Namespace = alert.Namespace } +# if event.InvolvedObject.Namespace != source.Namespace || ... { return false } +# +# `name: "*"` wildcards the NAME only. So every namespace holding a HelmRelease has +# to be listed explicitly in the Alert, and that list drifts the moment someone adds +# a HelmRelease in a new namespace. +# +# An uncovered namespace is invisible: the HelmRelease reconciles, fails, rolls back, +# reports Ready — and no alert ever fires. That silent-rollback blindness is exactly +# what the Alert exists to prevent (2026-07-14: ksail-operator sat four releases +# behind for over a day with prod looking green), so drift is a CI failure, not a +# comment. + +set -euo pipefail + +readonly ALERT_FILE="k8s/providers/hetzner/infrastructure/flux-notifications/alert.yaml" +readonly LAYERS=( + "k8s/providers/hetzner/bootstrap" + "k8s/providers/hetzner/infrastructure/controllers" + "k8s/providers/hetzner/infrastructure" + "k8s/providers/hetzner/apps" +) + +for tool in kubectl yq; do + command -v "${tool}" >/dev/null 2>&1 || { + echo "::error::${tool} is required to validate Alert coverage." + exit 64 + } +done +[[ -f "${ALERT_FILE}" ]] || { + echo "::error::${ALERT_FILE} not found (run from the repository root)." + exit 64 +} + +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT +declared="${work_dir}/declared.txt" +watched="${work_dir}/watched.txt" + +# Every namespace that actually contains a HelmRelease, rendered from Git. +for layer in "${LAYERS[@]}"; do + [[ -f "${layer}/kustomization.yaml" ]] || continue + kubectl kustomize "${layer}" 2>/dev/null \ + | yq ea 'select(.kind == "HelmRelease") | .metadata.namespace' - 2>/dev/null +done | grep -vE '^null$|^---$|^$' | LC_ALL=C sort -u > "${declared}" + +if [[ ! -s "${declared}" ]]; then + echo "::error::Rendered no HelmReleases at all — the overlays failed to build, so coverage cannot be proven. Failing closed." + exit 1 +fi + +# Every namespace the Alert watches for HelmRelease events. +yq e '.spec.eventSources[] | select(.kind == "HelmRelease") | .namespace' "${ALERT_FILE}" \ + | grep -vE '^null$|^$' | LC_ALL=C sort -u > "${watched}" + +uncovered="$(comm -23 "${declared}" "${watched}")" +if [[ -n "${uncovered}" ]]; then + echo "::error::The reconciliation Alert does not watch every namespace holding a HelmRelease." + echo "A HelmRelease in an unwatched namespace can fail, roll back and report Ready — silently, with no alert." + echo "Add a HelmRelease eventSource for each namespace below to ${ALERT_FILE}:" + while read -r ns; do + [[ -n "${ns}" ]] && printf ' - kind: HelmRelease\n name: "*"\n namespace: %s\n' "${ns}" + done <<< "${uncovered}" + exit 1 +fi + +# A listed-but-empty namespace never matches anything: harmless, but it is dead +# config, so say so without failing the build. +stale="$(comm -13 "${declared}" "${watched}" || true)" +if [[ -n "${stale}" ]]; then + echo "::warning::The Alert watches namespaces that hold no HelmRelease (dead entries): $(echo "${stale}" | tr '\n' ' ')" +fi + +echo "✅ Alert covers all $(wc -l < "${declared}" | tr -d ' ') namespaces holding a HelmRelease." From 4955ebcd06e1a4ab7e8ecdae43be938074518c32 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Wed, 15 Jul 2026 04:31:24 +0200 Subject: [PATCH 03/22] fix(scripts): fail closed on unreadable cordon state and partial alert-coverage render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Codex fail-closed findings on #2635: - refresh-flux-ghcr-auth.sh: node_is_cordoned swallowed a transient `kubectl get node` failure into an empty string, reading as "not cordoned" — so the post-reboot uncordon could silently make a deliberately-cordoned node schedulable. It now returns a distinct code (2) on an unreadable state and the reboot loop aborts rather than rolling a node whose cordon it cannot preserve. - validate-alert-coverage.sh: `kubectl kustomize ... || true` let the coverage check pass on a partial render — a broken layer's namespaces silently drop out of the declared set and are never compared against the Alert. It now surfaces the render error and exits non-zero. Co-Authored-By: Claude Opus 4.8 --- scripts/refresh-flux-ghcr-auth.sh | 35 +++++++++++++++++++++--------- scripts/validate-alert-coverage.sh | 11 +++++++++- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index cfe107520..123215177 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -205,17 +205,27 @@ other_control_planes_ready() { fi } -# True when the node is already cordoned. `talosctl reboot --drain` cordons and -# drains, then UNCORDONS on the way out (Talos defers uncordonNodes), so a node -# an operator had deliberately cordoned would come back schedulable. Remember the -# pre-existing cordon and restore it after the reboot. +# 0 when the node is already cordoned, 1 when it is not, 2 when the cordon state +# could not be READ. `talosctl reboot --drain` cordons and drains, then UNCORDONS on +# the way out (Talos defers uncordonNodes), so a node an operator had deliberately +# cordoned would come back schedulable. Remember the pre-existing cordon and restore +# it after the reboot. +# +# Fail CLOSED on an unreadable state: swallowing a transient `kubectl get node` failure +# into an empty string would read as "not cordoned", so the post-reboot uncordon would +# silently make a deliberately-cordoned node schedulable again. Distinguish that failure +# (2) from a genuine not-cordoned answer (1) so the caller can abort rather than roll a +# node whose cordon it cannot preserve. Capture kubectl's status instead of testing it +# inside the `[[ ]]`, so an API failure is never conflated with an empty value. node_is_cordoned() { - local node_name="$1" - - [[ "$(kubectl \ + local node_name="$1" unschedulable + if ! unschedulable="$(kubectl \ --context "${KUBE_CONTEXT}" \ get node "${node_name}" \ - --output jsonpath='{.spec.unschedulable}' 2>/dev/null)" == "true" ]] + --output jsonpath='{.spec.unschedulable}' 2>/dev/null)"; then + return 2 + fi + [[ "${unschedulable}" == "true" ]] } # Talos returns gRPC NotFound with the exact image reference when that image is @@ -391,8 +401,13 @@ sync_talos_registry_auth() { # out. A node an operator had already cordoned (maintenance, investigation, # autoscaler scale-down) must not silently come back schedulable because we # rebooted it for a credential refresh — remember the cordon and restore it. - local was_cordoned=0 - if node_is_cordoned "${node_name}"; then + local was_cordoned=0 cordon_rc=0 + node_is_cordoned "${node_name}" || cordon_rc=$? + if [[ "${cordon_rc}" -eq 2 ]]; then + echo "::error::Refusing to reboot ${node_name}: its cordon state could not be read, so the post-reboot uncordon could silently make a deliberately-cordoned node schedulable." + return 1 + fi + if [[ "${cordon_rc}" -eq 0 ]]; then was_cordoned=1 fi diff --git a/scripts/validate-alert-coverage.sh b/scripts/validate-alert-coverage.sh index 66cf6f496..1e78b6095 100755 --- a/scripts/validate-alert-coverage.sh +++ b/scripts/validate-alert-coverage.sh @@ -50,9 +50,18 @@ trap 'rm -rf "${work_dir}"' EXIT rendered="${work_dir}/rendered.yaml" # Render every layer once; both kinds are then read from the same output. +# +# Fail CLOSED on a render error. Swallowing `kubectl kustomize` failures (a bad +# kustomization, an unreachable remote base, a schema/input error) would drop every +# namespace declared only in that layer from `declared-*`, so an uncovered namespace +# would never be compared against the Alert — CI would bless the exact gap this check +# exists to catch, precisely when a layer is broken. A partial render is not a pass. for layer in "${LAYERS[@]}"; do [[ -f "${layer}/kustomization.yaml" ]] || continue - kubectl kustomize "${layer}" 2>/dev/null || true + if ! kubectl kustomize "${layer}"; then + echo "::error::kubectl kustomize failed to render ${layer}; refusing to validate Alert coverage on a partial render." >&2 + exit 1 + fi echo '---' done > "${rendered}" From af67b2819e91a3e6c86108d1f329cc8f899faa5d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 00:38:49 +0200 Subject: [PATCH 04/22] fix(deploy): harden GHCR auth reboot safety --- .github/workflows/ci.yaml | 12 +- scripts/refresh-flux-ghcr-auth-safety.sh | 204 ++++++++++++++ scripts/refresh-flux-ghcr-auth.sh | 126 +++------ .../test-refresh-flux-ghcr-auth-safety.sh | 263 ++++++++++++++++++ scripts/tests/test_refresh_flux_ghcr_auth.py | 164 ++++++++--- talos/cluster/mark-ghcr-pull-revision.yaml | 8 +- 6 files changed, 636 insertions(+), 141 deletions(-) create mode 100644 scripts/refresh-flux-ghcr-auth-safety.sh create mode 100644 scripts/tests/test-refresh-flux-ghcr-auth-safety.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 1ca511d4d..c3583be26 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -60,6 +60,7 @@ jobs: - 'tests/cilium-bandwidth-manager-bbr/**' - 'scripts/validate-alert-coverage.sh' - 'scripts/refresh-flux-ghcr-auth.sh' + - 'scripts/refresh-flux-ghcr-auth-safety.sh' - 'scripts/ghcr-auth-lib.sh' - 'scripts/run-ksail-prod-with-pull-auth.sh' - '.github/actions/deploy-prod/**' @@ -67,8 +68,10 @@ jobs: - '.github/workflows/ci.yaml' bridge_validation: - 'scripts/refresh-flux-ghcr-auth.sh' + - 'scripts/refresh-flux-ghcr-auth-safety.sh' - 'scripts/ghcr-auth-lib.sh' - 'scripts/run-ksail-prod-with-pull-auth.sh' + - 'scripts/tests/test-refresh-flux-ghcr-auth-safety.sh' - 'scripts/tests/test_refresh_flux_ghcr_auth.py' - 'docs/dr/runbook.md' - '.github/actions/deploy-prod/**' @@ -114,12 +117,17 @@ jobs: run: | bash -n \ scripts/ghcr-auth-lib.sh \ + scripts/refresh-flux-ghcr-auth-safety.sh \ scripts/refresh-flux-ghcr-auth.sh \ - scripts/run-ksail-prod-with-pull-auth.sh + scripts/run-ksail-prod-with-pull-auth.sh \ + scripts/tests/test-refresh-flux-ghcr-auth-safety.sh shellcheck \ scripts/ghcr-auth-lib.sh \ + scripts/refresh-flux-ghcr-auth-safety.sh \ scripts/refresh-flux-ghcr-auth.sh \ - scripts/run-ksail-prod-with-pull-auth.sh + scripts/run-ksail-prod-with-pull-auth.sh \ + scripts/tests/test-refresh-flux-ghcr-auth-safety.sh + bash scripts/tests/test-refresh-flux-ghcr-auth-safety.sh python3 -m unittest scripts.tests.test_refresh_flux_ghcr_auth - name: 🧩 Validate embedded JSON blobs diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh new file mode 100644 index 000000000..ce9d5f1e4 --- /dev/null +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash + +# Safety-critical helpers for refresh-flux-ghcr-auth.sh. Keep these functions +# side-effect free except where their names explicitly describe an operation so +# the production ordering and quorum gates can be exercised with command fakes. + +readonly GHCR_PULL_VERIFIED_REVISION_ANNOTATION="platform.devantler.tech/ghcr-pull-verified-revision-v2" +readonly GHCR_PULL_VERIFIED_IMAGE_ANNOTATION="platform.devantler.tech/ghcr-pull-verified-image-v2" + +# Select nodes that have not completed the reboot-backed v2 proof for both the +# incoming credential revision and the exact image used for the registry pull. +# Legacy unversioned annotations deliberately do not satisfy this selector: the +# old bridge wrote them without rebooting containerd, so every such node must +# roll once after this contract lands. +select_talos_node_targets() { + local nodes_file="$1" + local desired_revision="$2" + local operator_image="$3" + local targets_file="$4" + local unsorted_targets="${targets_file}.unsorted" + + if ! jq -r \ + --arg revision "${desired_revision}" \ + --arg image "${operator_image}" \ + --arg revision_annotation "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION}" \ + --arg image_annotation "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION}" ' + .items[] + | select( + (.metadata.annotations[$revision_annotation] // "") != $revision + or (.metadata.annotations[$image_annotation] // "") != $image + ) + | (.metadata.labels // {}) as $labels + | [ + (if (($labels | has("node-role.kubernetes.io/control-plane")) + or ($labels | has("node-role.kubernetes.io/master"))) + then "1" else "0" end), + .metadata.name, + ([.status.addresses[] + | select(.type == "InternalIP") | .address][0]), + (.metadata.annotations[ + "platform.devantler.tech/ghcr-pull-desired-revision" + ] // "") + ] + | @tsv + ' "${nodes_file}" > "${unsorted_targets}"; then + rm -f "${unsorted_targets}" + return 1 + fi + + if ! LC_ALL=C sort -k1,1 -k2,2 \ + "${unsorted_targets}" > "${targets_file}"; then + rm -f "${unsorted_targets}" + return 1 + fi + rm -f "${unsorted_targets}" +} + +# Existing clusters must make every Kubernetes pull consumer safe before the +# first Talos reboot drains application pods. The root Flux Secret remains last +# so a failed node rollout cannot advance reconciliation onto a partial state. +stage_fanout_before_talos() { + local desired_revision="$1" + local operator_image="$2" + local namespace + local rc + shift 2 + + patch_variables_base || { + rc=$? + return "${rc}" + } + force_sync_resource pushsecret flux-system seed-ghcr || { + rc=$? + return "${rc}" + } + for namespace in "$@"; do + force_sync_resource externalsecret "${namespace}" ghcr-auth || { + rc=$? + return "${rc}" + } + verify_consumer_secret "${namespace}" || { + rc=$? + return "${rc}" + } + done + sync_talos_registry_auth "${desired_revision}" "${operator_image}" || { + rc=$? + return "${rc}" + } + patch_root_secret || { + rc=$? + return "${rc}" + } +} + +# Prove every OTHER production control-plane member is Kubernetes-Ready, +# reachable through the Talos etcd status RPC, and alarm-free immediately before +# taking one member down. Any inventory, command, or output-shape ambiguity is a +# hard failure: blocking a reboot is safer than guessing about quorum. +other_control_planes_safe_to_reboot() { + local rebooting="$1" + local kube_context="$2" + local state_dir="$3" + local live_nodes_file="${state_dir}/control-plane-health-${rebooting}.json" + local peer_file="${state_dir}/control-plane-etcd-peers-${rebooting}.tsv" + local peer_name + local peer_ip + local peer_number=0 + local status_file + local alarms_file + + if ! kubectl \ + --context "${kube_context}" \ + get nodes \ + --output json \ + > "${live_nodes_file}" 2>/dev/null; then + echo "Could not re-read control-plane health before rebooting ${rebooting}." + return 1 + fi + + if ! jq -er --arg rebooting "${rebooting}" ' + [ + .items[] + | select(.metadata.name != $rebooting) + | (.metadata.labels // {}) as $labels + | select(($labels | has("node-role.kubernetes.io/control-plane")) + or ($labels | has("node-role.kubernetes.io/master"))) + | { + name: .metadata.name, + internal_ips: [ + .status.addresses[]? + | select(.type == "InternalIP") + | .address + ], + ready: any(.status.conditions[]?; + .type == "Ready" and .status == "True") + } + ] as $peers + | if ($peers | length) < 2 then + error("fewer than two other control-plane peers") + elif all($peers[]; + .ready == true + and (.name | type == "string" and test("^[^\\t\\r\\n]+$")) + and (.internal_ips | length) == 1 + and (.internal_ips[0] + | type == "string" + and test("^[^\\t\\r\\n]+$"))) | not then + error("a control-plane peer is unready or has an invalid InternalIP") + elif ([$peers[].internal_ips[0]] | unique | length) + != ($peers | length) then + error("control-plane peer InternalIPs are not unique") + else + $peers[] | [.name, .internal_ips[0]] | @tsv + end + ' "${live_nodes_file}" > "${peer_file}" 2>/dev/null; then + echo "Could not prove that every other control plane is Ready with a unique InternalIP before rebooting ${rebooting}." + return 1 + fi + + while IFS=$'\t' read -r peer_name peer_ip; do + peer_number=$((peer_number + 1)) + status_file="${state_dir}/etcd-status-${rebooting}-${peer_number}.txt" + alarms_file="${state_dir}/etcd-alarms-${rebooting}-${peer_number}.txt" + + if ! talosctl \ + --nodes "${peer_ip}" \ + etcd status \ + > "${status_file}" 2>&1; then + echo "Could not read etcd status from control-plane peer ${peer_name}." + return 1 + fi + if ! awk -v expected_node="${peer_ip}" ' + NR == 1 { header = ($1 == "NODE" && $2 == "MEMBER") } + NR > 1 && $1 == expected_node { rows++ } + END { exit !(header && rows == 1) } + ' "${status_file}"; then + echo "Control-plane peer ${peer_name} returned an incomplete etcd status response." + return 1 + fi + + if ! talosctl \ + --nodes "${peer_ip}" \ + etcd alarm list \ + > "${alarms_file}" 2>&1; then + echo "Could not read etcd alarms from control-plane peer ${peer_name}." + return 1 + fi + if ! awk ' + NF == 0 { next } + { + nonempty++ + if (nonempty == 1) { + header = ($1 == "NODE" && $2 == "MEMBER" && $3 == "ALARM") + next + } + rows++ + } + END { exit !(header && rows == 0) } + ' "${alarms_file}"; then + echo "Control-plane peer ${peer_name} has an etcd alarm or returned an unrecognized alarm response." + return 1 + fi + done < "${peer_file}" +} diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 123215177..351211d32 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -12,6 +12,8 @@ SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" readonly SCRIPT_DIR # shellcheck source=scripts/ghcr-auth-lib.sh source "${SCRIPT_DIR}/ghcr-auth-lib.sh" +# shellcheck source=scripts/refresh-flux-ghcr-auth-safety.sh +source "${SCRIPT_DIR}/refresh-flux-ghcr-auth-safety.sh" require_flux_ghcr_yaml_tool check_only=false @@ -165,46 +167,6 @@ verify_consumer_secret() { fi } -# Every control plane OTHER than the named one must be Ready before we take it -# down. etcd tolerates exactly one member down in a 3-member cluster, so rebooting -# a second one drops quorum and takes the API server with it. -# -# The readiness snapshot is re-taken per node, not read from the one captured -# before the roll started: an earlier node's reboot can leave a peer NotReady -# while it settles, and a stale snapshot would wave that through. -other_control_planes_ready() { - local rebooting="$1" - local unready - local live_nodes_file="${work_dir}/control-plane-health-${rebooting}.json" - - if ! kubectl \ - --context "${KUBE_CONTEXT}" \ - get nodes \ - --output json \ - >"${live_nodes_file}" 2>/dev/null; then - echo "Could not re-read node health before rebooting ${rebooting}." - return 1 - fi - - unready="$(jq -r --arg rebooting "${rebooting}" ' - .items[] - | select(.metadata.name != $rebooting) - | (.metadata.labels // {}) as $labels - | select(($labels | has("node-role.kubernetes.io/control-plane")) - or ($labels | has("node-role.kubernetes.io/master"))) - | select( - [.status.conditions[]? | select(.type == "Ready" and .status == "True")] - | length == 0 - ) - | .metadata.name - ' "${live_nodes_file}")" - - if [[ -n "${unready}" ]]; then - echo "Control planes not Ready: $(echo "${unready}" | tr '\n' ' ')" - return 1 - fi -} - # 0 when the node is already cordoned, 1 when it is not, 2 when the cordon state # could not be READ. `talosctl reboot --drain` cordons and drains, then UNCORDONS on # the way out (Talos defers uncordonNodes), so a node an operator had deliberately @@ -281,36 +243,11 @@ sync_talos_registry_auth() { return 1 fi - if ! jq -r \ - --arg revision "${desired_revision}" \ - --arg image "${operator_image}" ' - .items[] - | select( - (.metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-revision" - ] // "") - != $revision - or (.metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-image" - ] // "") - != $image - ) - | (.metadata.labels // {}) as $labels - | [ - (if (($labels | has("node-role.kubernetes.io/control-plane")) - or ($labels | has("node-role.kubernetes.io/master"))) - then "1" else "0" end), - .metadata.name, - ([.status.addresses[] - | select(.type == "InternalIP") | .address][0]), - (.metadata.annotations[ - "platform.devantler.tech/ghcr-pull-desired-revision" - ] // "") - ] - | @tsv - ' "${talos_nodes_file}" \ - | LC_ALL=C sort -k1,1 -k2,2 \ - > "${talos_node_targets}"; then + if ! select_talos_node_targets \ + "${talos_nodes_file}" \ + "${desired_revision}" \ + "${operator_image}" \ + "${talos_node_targets}"; then echo "::error::Could not select Talos nodes requiring the GHCR auth revision." return 1 fi @@ -357,8 +294,8 @@ sync_talos_registry_auth() { # that check goes through the TALOS image API, which builds its auth from # the machine config we just wrote, NOT from containerd's CRI plugin. It # therefore passes on a node whose kubelet pulls are still failing 403 — - # which is exactly what happened: every node was annotated - # ghcr-pull-verified-revision while every ksail-operator pod sat in + # which is exactly what happened: every node had the legacy unversioned + # ghcr-pull-verified-revision marker while every ksail-operator pod sat in # ImagePullBackOff, and prod stayed four releases behind for over a day. # The pull check proves the CREDENTIAL is good; only the reboot proves # CONTAINERD is using it. @@ -386,14 +323,14 @@ sync_talos_registry_auth() { # separately. # # etcd tolerates exactly one control plane down in a 3-member cluster. This - # loop is serial and control planes sort last, but a control plane that was - # ALREADY unhealthy before this run — or one left NotReady by the reboot of - # an earlier node in this very loop — would make our reboot the second one - # down and drop quorum. Prove the others are Ready first, against a freshly - # re-read snapshot. + # loop is serial and control planes sort last, but a peer can be + # Kubernetes-Ready while its etcd member is unhealthy. Re-read the peer + # inventory, then prove every other peer is Ready, answers `etcd status`, + # and has no etcd alarm immediately before each control-plane reboot. if [[ "${node_role}" == "1" ]] \ - && ! other_control_planes_ready "${node_name}"; then - echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready, so rebooting this one risks etcd quorum." + && ! other_control_planes_safe_to_reboot \ + "${node_name}" "${KUBE_CONTEXT}" "${work_dir}"; then + echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready with healthy, alarm-free etcd, so rebooting this one risks quorum." return 1 fi @@ -559,9 +496,10 @@ if [[ "${check_only}" == "true" ]]; then fi # Talos image verification resolves cosign artifacts with host registry auth; -# pod imagePullSecrets cannot satisfy that request. Use the supported v1.13 -# RegistryAuthConfig document and a file-backed patch so credentials never -# enter argv. Synchronize and verify nodes before touching Kubernetes Secrets. +# pod imagePullSecrets cannot satisfy that request. Prepare the supported v1.13 +# RegistryAuthConfig and post-reboot proof patch without placing credentials in +# argv. Existing-cluster nodes are synchronized only after the complete tenant +# fan-out has been staged and verified below. jq ' { apiVersion: "v1alpha1", @@ -575,18 +513,19 @@ pull_revision="$(flux_ghcr_revision "${SECRET_FILE}")" readonly pull_revision jq -n \ --arg revision "${pull_revision}" \ - --arg image "${KSAIL_OPERATOR_IMAGE}" ' + --arg image "${KSAIL_OPERATOR_IMAGE}" \ + --arg revision_annotation "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION}" \ + --arg image_annotation "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION}" ' { machine: { nodeAnnotations: { - "platform.devantler.tech/ghcr-pull-verified-revision": $revision, - "platform.devantler.tech/ghcr-pull-verified-image": $image + ($revision_annotation): $revision, + ($image_annotation): $image } } } ' > "${talos_revision_patch_file}" chmod 600 "${talos_auth_patch_file}" "${talos_revision_patch_file}" -sync_talos_registry_auth "${pull_revision}" "${KSAIL_OPERATOR_IMAGE}" # Merge only Secret data fields so ownership metadata survives. The sensitive # payload stays in pipes/temp files and never appears in argv or logs. @@ -706,14 +645,11 @@ if [[ "${fanout_complete}" != "true" ]]; then fi # Existing clusters update and verify the whole SOPS -> variables-base -> -# PushSecret -> OpenBao -> ExternalSecret chain before switching root Flux auth. -patch_variables_base -force_sync_resource pushsecret flux-system seed-ghcr -for namespace in "${FANOUT_NAMESPACES[@]}"; do - force_sync_resource externalsecret "${namespace}" ghcr-auth - verify_consumer_secret "${namespace}" -done - -patch_root_secret +# PushSecret -> OpenBao -> ExternalSecret chain before the first Talos drain. +# Root Flux auth remains last so any failed node proof leaves it unchanged. +stage_fanout_before_talos \ + "${pull_revision}" \ + "${KSAIL_OPERATOR_IMAGE}" \ + "${FANOUT_NAMESPACES[@]}" echo "✅ Synchronised every existing consumer and refreshed root Flux GHCR auth from Git/SOPS." diff --git a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh new file mode 100644 index 000000000..609ac57d6 --- /dev/null +++ b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash + +set -uo pipefail + +ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/../.." && pwd)" +readonly ROOT +readonly SAFETY_LIB="${ROOT}/scripts/refresh-flux-ghcr-auth-safety.sh" + +failures=0 + +pass() { + printf 'ok - %s\n' "$1" +} + +fail() { + printf 'not ok - %s\n' "$1" + failures=$((failures + 1)) +} + +work_dir="$(mktemp -d)" +trap 'rm -rf "${work_dir}"' EXIT + +if [[ -r "${SAFETY_LIB}" ]]; then + # shellcheck source=scripts/refresh-flux-ghcr-auth-safety.sh + source "${SAFETY_LIB}" +else + fail "production safety helpers exist" +fi + +legacy_nodes="${work_dir}/legacy-nodes.json" +legacy_targets="${work_dir}/legacy-targets.tsv" +current_nodes="${work_dir}/current-nodes.json" +current_targets="${work_dir}/current-targets.tsv" +readonly DESIRED_REVISION="ciphertext-revision" +readonly DESIRED_IMAGE="ghcr.io/devantler-tech/ksail:v7.170.1" + +jq -n \ + --arg revision "${DESIRED_REVISION}" \ + --arg image "${DESIRED_IMAGE}" ' + { + items: [{ + metadata: { + name: "prod-worker-1", + labels: {}, + annotations: { + "platform.devantler.tech/ghcr-pull-verified-revision": $revision, + "platform.devantler.tech/ghcr-pull-verified-image": $image + } + }, + status: {addresses: [ + {type: "InternalIP", address: "10.0.0.4"} + ]} + }] + } +' > "${legacy_nodes}" + +if declare -F select_talos_node_targets >/dev/null; then + if select_talos_node_targets \ + "${legacy_nodes}" \ + "${DESIRED_REVISION}" \ + "${DESIRED_IMAGE}" \ + "${legacy_targets}" \ + && [[ -s "${legacy_targets}" ]]; then + pass "legacy verification markers force a one-time reboot" + else + fail "legacy verification markers force a one-time reboot" + fi + + jq -n \ + --arg revision "${DESIRED_REVISION}" \ + --arg image "${DESIRED_IMAGE}" \ + --arg revision_key "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION:-}" \ + --arg image_key "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION:-}" ' + { + items: [{ + metadata: { + name: "prod-worker-1", + labels: {}, + annotations: { + ($revision_key): $revision, + ($image_key): $image + } + }, + status: {addresses: [ + {type: "InternalIP", address: "10.0.0.4"} + ]} + }] + } + ' > "${current_nodes}" + + if [[ "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION:-}" == *-v2 ]] \ + && [[ "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION:-}" == *-v2 ]] \ + && select_talos_node_targets \ + "${current_nodes}" \ + "${DESIRED_REVISION}" \ + "${DESIRED_IMAGE}" \ + "${current_targets}" \ + && [[ ! -s "${current_targets}" ]]; then + pass "v2 post-reboot markers suppress an already-proved reboot" + else + fail "v2 post-reboot markers suppress an already-proved reboot" + fi +else + fail "legacy verification markers force a one-time reboot" + fail "v2 post-reboot markers suppress an already-proved reboot" +fi + +operation_log="${work_dir}/operations.log" +patch_variables_base() { + printf '%s\n' variables-patch >> "${operation_log}" +} +force_sync_resource() { + printf 'force:%s/%s/%s\n' "$1" "$2" "$3" >> "${operation_log}" +} +verify_consumer_secret() { + printf 'verify:%s/ghcr-auth\n' "$1" >> "${operation_log}" +} +sync_talos_registry_auth() { + printf 'talos:%s:%s\n' "$1" "$2" >> "${operation_log}" +} +patch_root_secret() { + printf '%s\n' root-patch >> "${operation_log}" +} + +if declare -F stage_fanout_before_talos >/dev/null; then + : > "${operation_log}" + stage_fanout_before_talos \ + "${DESIRED_REVISION}" \ + "${DESIRED_IMAGE}" \ + wedding-app ascoachingogvaner kyverno + expected_operations="$(printf '%s\n' \ + variables-patch \ + force:pushsecret/flux-system/seed-ghcr \ + force:externalsecret/wedding-app/ghcr-auth \ + verify:wedding-app/ghcr-auth \ + force:externalsecret/ascoachingogvaner/ghcr-auth \ + verify:ascoachingogvaner/ghcr-auth \ + force:externalsecret/kyverno/ghcr-auth \ + verify:kyverno/ghcr-auth \ + "talos:${DESIRED_REVISION}:${DESIRED_IMAGE}" \ + root-patch)" + if [[ "$(<"${operation_log}")" == "${expected_operations}" ]]; then + pass "verified tenant fanout completes before any Talos drain" + else + fail "verified tenant fanout completes before any Talos drain" + fi +else + fail "verified tenant fanout completes before any Talos drain" +fi + +control_plane_inventory="${work_dir}/control-planes.json" +jq -n ' + { + items: [ + { + metadata: { + name: "prod-control-plane-1", + labels: {"node-role.kubernetes.io/control-plane": ""} + }, + status: { + addresses: [{type: "InternalIP", address: "10.0.0.1"}], + conditions: [{type: "Ready", status: "True"}] + } + }, + { + metadata: { + name: "prod-control-plane-2", + labels: {"node-role.kubernetes.io/control-plane": ""} + }, + status: { + addresses: [{type: "InternalIP", address: "10.0.0.2"}], + conditions: [{type: "Ready", status: "True"}] + } + }, + { + metadata: { + name: "prod-control-plane-3", + labels: {"node-role.kubernetes.io/control-plane": ""} + }, + status: { + addresses: [{type: "InternalIP", address: "10.0.0.3"}], + conditions: [{type: "Ready", status: "True"}] + } + } + ] + } +' > "${control_plane_inventory}" + +kubectl() { + cp "${control_plane_inventory}" /dev/stdout +} + +talosctl() { + local arguments=" $* " + local node="" + local previous="" + local argument + + for argument in "$@"; do + if [[ "${previous}" == "--nodes" ]]; then + node="${argument}" + fi + previous="${argument}" + done + + if [[ "${arguments}" == *" etcd status "* ]]; then + if [[ "${node}" == "${ETCD_STATUS_FAIL_NODE:-}" ]]; then + return 1 + fi + printf 'NODE MEMBER DB-SIZE\n%s member-id 1MB\n' "${node}" + return 0 + fi + + if [[ "${arguments}" == *" etcd alarm list "* ]]; then + printf 'NODE MEMBER ALARM\n' + if [[ "${node}" == "${ETCD_ALARM_NODE:-}" ]]; then + printf '%s member-id NOSPACE\n' "${node}" + fi + return 0 + fi + + return 64 +} + +if declare -F other_control_planes_safe_to_reboot >/dev/null; then + ETCD_STATUS_FAIL_NODE="" + ETCD_ALARM_NODE="" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null; then + pass "healthy alarm-free etcd peers permit a control-plane reboot" + else + fail "healthy alarm-free etcd peers permit a control-plane reboot" + fi + + ETCD_STATUS_FAIL_NODE="10.0.0.2" + ETCD_ALARM_NODE="" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then + fail "unreadable etcd peer status blocks a control-plane reboot" + else + pass "unreadable etcd peer status blocks a control-plane reboot" + fi + + ETCD_STATUS_FAIL_NODE="" + ETCD_ALARM_NODE="10.0.0.3" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then + fail "an etcd peer alarm blocks a control-plane reboot" + else + pass "an etcd peer alarm blocks a control-plane reboot" + fi +else + fail "healthy alarm-free etcd peers permit a control-plane reboot" + fail "unreadable etcd peer status blocks a control-plane reboot" + fail "an etcd peer alarm blocks a control-plane reboot" +fi + +if ((failures > 0)); then + printf '%d safety regression test(s) failed\n' "${failures}" >&2 + exit 1 +fi + +printf 'All GHCR auth safety regression tests passed.\n' diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 22d1c1df4..e06965c08 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -173,6 +173,27 @@ def setUp(self) -> None: done test -n "$node" + if [[ "$arguments" == *" etcd status "* ]]; then + if [[ "$node" == "${FAKE_ETCD_STATUS_FAIL_NODE:-disabled}" ]]; then + echo "etcd status failed" >&2 + exit 51 + fi + printf 'NODE MEMBER DB-SIZE\n%s member-id 1MB\n' "$node" + exit 0 + fi + + if [[ "$arguments" == *" etcd alarm list "* ]]; then + if [[ "$node" == "${FAKE_ETCD_ALARM_READ_FAIL_NODE:-disabled}" ]]; then + echo "etcd alarm read failed" >&2 + exit 52 + fi + printf 'NODE MEMBER ALARM\n' + if [[ "$node" == "${FAKE_ETCD_ALARM_NODE:-disabled}" ]]; then + printf '%s member-id NOSPACE\n' "$node" + fi + exit 0 + fi + if [[ "$arguments" == *" patch machineconfig "* ]]; then [[ "$arguments" == *" --mode=no-reboot "* ]] test -f "$patch_file" @@ -204,12 +225,12 @@ def setUp(self) -> None: test -f "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" jq -e --arg revision "$EXPECTED_GHCR_REVISION" ' .machine.nodeAnnotations[ - "platform.devantler.tech/ghcr-pull-verified-revision" + "platform.devantler.tech/ghcr-pull-verified-revision-v2" ] == $revision ' "$patch_file" >/dev/null jq -e --arg image "$EXPECTED_KSAIL_TARGET_IMAGE" ' .machine.nodeAnnotations[ - "platform.devantler.tech/ghcr-pull-verified-image" + "platform.devantler.tech/ghcr-pull-verified-image-v2" ] == $image ' "$patch_file" >/dev/null printf 'talos-revision:%s\n' "$node" >> "$TALOS_LOG" @@ -408,7 +429,8 @@ def setUp(self) -> None: --arg revision "$EXPECTED_GHCR_REVISION" \ --arg current "${FAKE_TALOS_NODES_CURRENT:-false}" \ --arg verified_image \ - "${FAKE_TALOS_VERIFIED_IMAGE:-$EXPECTED_KSAIL_TARGET_IMAGE}" ' + "${FAKE_TALOS_VERIFIED_IMAGE:-$EXPECTED_KSAIL_TARGET_IMAGE}" \ + --arg target_image "$EXPECTED_KSAIL_TARGET_IMAGE" ' { items: [ { @@ -440,15 +462,61 @@ def setUp(self) -> None: {type: "InternalIP", address: "10.0.0.1"}, {type: "ExternalIP", address: "198.51.100.1"} ]} + }, + { + metadata: { + name: "prod-control-plane-2", + labels: { + "node-role.kubernetes.io/control-plane": "" + }, + annotations: { + "platform.devantler.tech/ghcr-pull-desired-revision": + $revision, + "platform.devantler.tech/ghcr-pull-verified-revision-v2": + $revision, + "platform.devantler.tech/ghcr-pull-verified-image-v2": + $target_image + } + }, + status: { + addresses: [ + {type: "InternalIP", address: "10.0.0.3"}, + {type: "ExternalIP", address: "198.51.100.3"} + ], + conditions: [{type: "Ready", status: "True"}] + } + }, + { + metadata: { + name: "prod-control-plane-3", + labels: { + "node-role.kubernetes.io/control-plane": "" + }, + annotations: { + "platform.devantler.tech/ghcr-pull-desired-revision": + $revision, + "platform.devantler.tech/ghcr-pull-verified-revision-v2": + $revision, + "platform.devantler.tech/ghcr-pull-verified-image-v2": + $target_image + } + }, + status: { + addresses: [ + {type: "InternalIP", address: "10.0.0.4"}, + {type: "ExternalIP", address: "198.51.100.4"} + ], + conditions: [{type: "Ready", status: "True"}] + } } ] } | if $current == "true" then - .items[].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-revision" + .items[0:2][].metadata.annotations[ + "platform.devantler.tech/ghcr-pull-verified-revision-v2" ] = $revision - | .items[].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-image" + | .items[0:2][].metadata.annotations[ + "platform.devantler.tech/ghcr-pull-verified-image-v2" ] = $verified_image else . end ' @@ -841,8 +909,8 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: ], ) - def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> None: - """Patch, REBOOT, drop the cached image, then pull it — workers first. + def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: + """Verify fanout, then patch, REBOOT, drop cache, and pull workers first. The reboot is load-bearing: containerd reads registry auth from its static config only at process start, and Talos refuses @@ -875,11 +943,17 @@ def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> No self.talos_log.read_text(encoding="utf-8").splitlines(), expected_talos_operations, ) - # Each node is drained through the full sequence, and readiness is - # awaited after its reboot, before the next node is touched. + # Every Kubernetes pull consumer is refreshed and verified before the + # first drain. Each node then completes its full sequence before the + # next node is touched, and root Flux auth remains the final mutation. self.assertEqual( - self.operation_log.read_text(encoding="utf-8").splitlines()[:12], + self.operation_log.read_text(encoding="utf-8").splitlines(), [ + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", @@ -896,6 +970,7 @@ def test_refreshes_talos_auth_before_kubernetes_credential_consumers(self) -> No "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:" f"v{KSAIL_OPERATOR_VERSION}", "talos-revision:10.0.0.1", + "root-patch", ], ) temporary_patch = Path( @@ -910,7 +985,7 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: The reboot roll is serial and control planes go last, but a control plane that was ALREADY unhealthy before this run makes our reboot the second one down. The worker still syncs (it cannot affect quorum); the control-plane - reboot is refused, and no Kubernetes credential consumer is mutated. + reboot is refused after the Kubernetes credential fanout is made safe. """ revision = self._expected_revision() ready = [{"type": "Ready", "status": "True"}] @@ -940,8 +1015,12 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: "name": "prod-control-plane-2", "labels": {"node-role.kubernetes.io/control-plane": ""}, "annotations": { - "platform.devantler.tech/ghcr-pull-verified-revision": + "platform.devantler.tech/ghcr-pull-verified-revision-v2": revision, + "platform.devantler.tech/ghcr-pull-verified-image-v2": ( + "ghcr.io/devantler-tech/ksail:" + f"v{KSAIL_OPERATOR_VERSION}" + ), }, }, "status": { @@ -958,7 +1037,7 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: ) self.assertNotEqual(result.returncode, 0) - self.assertIn("risks etcd quorum", result.stdout + result.stderr) + self.assertIn("risks quorum", result.stdout + result.stderr) operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("talos-reboot:10.0.0.1", operations) @@ -1009,11 +1088,16 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: self.assertNotEqual(result.returncode, 0) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - # The worker rebooted but never came back, so the control plane is left - # alone and no Kubernetes credential consumer is mutated. + # The fanout is already safe. The worker rebooted but never came back, + # so the control plane is left alone and root Flux auth is unchanged. self.assertEqual( operations, [ + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", @@ -1023,8 +1107,8 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - def test_talos_failure_prevents_kubernetes_credential_mutation(self) -> None: - """Stop before Flux fan-out when any node cannot accept or use auth.""" + def test_talos_failure_after_safe_fanout_keeps_root_auth_unchanged(self) -> None: + """Keep root Flux auth unchanged when the post-fanout node roll fails.""" for operation in ("auth", "reboot", "remove", "pull", "revision"): with self.subTest(operation=operation): result = self._run_helper( @@ -1034,9 +1118,17 @@ def test_talos_failure_prevents_kubernetes_credential_mutation(self) -> None: ) self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.variables_patch_capture.exists()) + self.assertTrue(self.variables_patch_capture.exists()) self.assertFalse(self.patch_capture.exists()) - self.assertFalse(self.fanout_log.exists()) + self.assertEqual( + self.fanout_log.read_text(encoding="utf-8").splitlines(), + [ + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", + ], + ) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) def test_missing_cached_image_still_pulls_and_records_revision(self) -> None: @@ -1106,8 +1198,8 @@ def test_matching_revision_revalidates_changed_declared_image(self) -> None: ) self.assertNotIn(previous_image, "\n".join(operations)) - def test_dr_without_operator_uses_exact_declared_image(self) -> None: - """Verify stale bootstrap nodes with the declared incoming image.""" + def test_dr_without_fanout_does_not_drain_nodes(self) -> None: + """Never drain workloads before a DR cluster has its pull fanout.""" result = self._run_helper( self._valid_config(), ("--allow-incomplete-fanout",), @@ -1115,20 +1207,8 @@ def test_dr_without_operator_uses_exact_declared_image(self) -> None: ) self.assertEqual(result.returncode, 0, result.stderr) - pulls = [ - line - for line in self.talos_log.read_text(encoding="utf-8").splitlines() - if line.startswith("talos-pull:") - ] - self.assertEqual( - pulls, - [ - "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - ], - ) + self.assertFalse(self.talos_log.exists()) + self.assertTrue(self.patch_capture.exists()) def test_invalid_node_inventory_fails_closed(self) -> None: """Reject empty, duplicate, and ambiguous Talos node InternalIPs.""" @@ -1183,8 +1263,10 @@ def test_invalid_node_inventory_fails_closed(self) -> None: self.assertFalse(self.talos_log.exists()) self.assertFalse(self.patch_capture.exists()) - def test_node_discovery_failure_prevents_kubernetes_credential_mutation(self) -> None: - """Fail closed before mutation when production nodes cannot be listed.""" + def test_node_discovery_failure_after_safe_fanout_keeps_root_unchanged( + self, + ) -> None: + """Fail closed after safe fanout when production nodes cannot be listed.""" result = self._run_helper( self._valid_config(), FAKE_NODE_DISCOVERY_FAIL="true", @@ -1192,7 +1274,8 @@ def test_node_discovery_failure_prevents_kubernetes_credential_mutation(self) -> self.assertNotEqual(result.returncode, 0) self.assertFalse(self.talos_log.exists()) - self.assertFalse(self.variables_patch_capture.exists()) + self.assertTrue(self.variables_patch_capture.exists()) + self.assertTrue(self.fanout_log.exists()) self.assertFalse(self.patch_capture.exists()) def test_ksail_lifecycle_wrapper_uses_only_sops_pull_token(self) -> None: @@ -1822,6 +1905,7 @@ def test_bridge_docs_and_tests_validate_without_deploying(self) -> None: ] for validation_only_path in ( + "scripts/tests/test-refresh-flux-ghcr-auth-safety.sh", "scripts/tests/test_refresh_flux_ghcr_auth.py", "docs/dr/runbook.md", ): diff --git a/talos/cluster/mark-ghcr-pull-revision.yaml b/talos/cluster/mark-ghcr-pull-revision.yaml index 49f24bbd8..f9913983b 100644 --- a/talos/cluster/mark-ghcr-pull-revision.yaml +++ b/talos/cluster/mark-ghcr-pull-revision.yaml @@ -5,10 +5,10 @@ # ciphertext gives token rotations a visible, non-secret input without # publishing a stable hash of the decrypted token. Production lifecycle # commands provide ${GHCR_PULL_REVISION}. This desired marker intentionally -# differs from the helper-owned ghcr-pull-verified-revision annotation: a newly -# created/autoscaled node must not claim a successful pull before it has proved -# one. The helper records that second marker only after an exact private KSail -# image pull succeeds. +# differs from the helper-owned ghcr-pull-verified-revision-v2 annotation: a +# newly created/autoscaled node must not claim a successful pull before it has +# rebooted containerd and proved an exact private KSail image pull. The helper +# records that second marker only after both steps succeed. machine: nodeAnnotations: platform.devantler.tech/ghcr-pull-desired-revision: ${GHCR_PULL_REVISION} From f23b49137c4bcec137998bd03559b0611dc375b1 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 14:28:20 +0200 Subject: [PATCH 05/22] fix(platform): close GHCR recovery safety gaps --- .github/workflows/ci.yaml | 2 + scripts/refresh-flux-ghcr-auth.sh | 29 ++-- scripts/tests/test_refresh_flux_ghcr_auth.py | 20 +++ scripts/tests/test_validate_alert_coverage.py | 161 ++++++++++++++++++ scripts/validate-alert-coverage.sh | 7 +- 5 files changed, 206 insertions(+), 13 deletions(-) create mode 100644 scripts/tests/test_validate_alert_coverage.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c38d8474d..ccc40e116 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,6 +59,7 @@ jobs: - 'scripts/tests/test-cilium-bandwidth-manager-component.sh' - 'tests/cilium-bandwidth-manager-bbr/**' - 'scripts/validate-alert-coverage.sh' + - 'scripts/tests/test_validate_alert_coverage.py' - 'scripts/refresh-flux-ghcr-auth.sh' - 'scripts/refresh-flux-ghcr-auth-safety.sh' - 'scripts/ghcr-auth-lib.sh' @@ -156,6 +157,7 @@ jobs: # pages (ksail-operator, 2026-07-14). Drift is a build failure, not a # comment. Bash + the runner's kubectl/yq; no cluster or secrets required. run: | + python3 -m unittest scripts.tests.test_validate_alert_coverage shellcheck scripts/validate-alert-coverage.sh bash scripts/validate-alert-coverage.sh diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 351211d32..a7e395dc1 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -190,6 +190,21 @@ node_is_cordoned() { [[ "${unschedulable}" == "true" ]] } +# Restore operator intent on both the normal and post-reboot readiness-failure paths. +restore_node_cordon_if_needed() { + local node_name="$1" was_cordoned="$2" result_file="$3" + [[ "${was_cordoned}" == "1" ]] || return 0 + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + cordon "${node_name}" \ + >"${result_file}" 2>&1; then + echo "::error::Talos node ${node_name} was cordoned before its GHCR auth reboot but could not be re-cordoned afterwards." + return 1 + fi + echo "Restored the pre-existing cordon on ${node_name}." +} + # Talos returns gRPC NotFound with the exact image reference when that image is # already absent from the selected runtime namespace. Match both so transport, # authorization, and unrelated removal failures remain fatal. @@ -366,19 +381,13 @@ sync_talos_registry_auth() { --timeout=10m \ >"${talos_result_file}" 2>&1; then echo "::error::Talos node ${node_name} did not return Ready after its GHCR auth reboot; refusing to roll the next node." + restore_node_cordon_if_needed \ + "${node_name}" "${was_cordoned}" "${talos_result_file}" || return 1 return 1 fi - if [[ "${was_cordoned}" == "1" ]]; then - if ! kubectl \ - --context "${KUBE_CONTEXT}" \ - cordon "${node_name}" \ - >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} was cordoned before its GHCR auth reboot but could not be re-cordoned afterwards." - return 1 - fi - echo "Restored the pre-existing cordon on ${node_name}." - fi + restore_node_cordon_if_needed \ + "${node_name}" "${was_cordoned}" "${talos_result_file}" || return 1 # A cached image can make a pull look healthy without proving that the # node's runtime can authenticate to GHCR. Remove the incoming exact target diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index c48f37440..a280ba8f7 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -1114,6 +1114,26 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_unready_node_restores_its_pre_existing_cordon(self) -> None: + """Keep an operator-cordoned node cordoned when readiness fails.""" + result = self._run_helper( + self._valid_config(), + FAKE_CORDONED_NODES="prod-worker-1", + FAKE_NODE_READY_FAIL_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-ready:prod-worker-1", operations) + self.assertIn("node-cordon:prod-worker-1", operations) + self.assertLess( + operations.index("node-ready:prod-worker-1"), + operations.index("node-cordon:prod-worker-1"), + ) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_talos_failure_after_safe_fanout_keeps_root_auth_unchanged(self) -> None: """Keep root Flux auth unchanged when the post-fanout node roll fails.""" for operation in ("auth", "reboot", "remove", "pull", "revision"): diff --git a/scripts/tests/test_validate_alert_coverage.py b/scripts/tests/test_validate_alert_coverage.py new file mode 100644 index 000000000..2d2feb8c6 --- /dev/null +++ b/scripts/tests/test_validate_alert_coverage.py @@ -0,0 +1,161 @@ +"""Behavioral tests for reconciliation-Alert namespace coverage.""" + +from __future__ import annotations + +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import textwrap +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +VALIDATOR = ROOT / "scripts" / "validate-alert-coverage.sh" +CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yaml" +LAYERS = ( + "k8s/clusters/prod", + "k8s/providers/hetzner/bootstrap", + "k8s/providers/hetzner/infrastructure/controllers", + "k8s/providers/hetzner/infrastructure", + "k8s/providers/hetzner/apps", +) + + +class ValidateAlertCoverageTests(unittest.TestCase): + """Exercise the validator against isolated Kustomize fixtures.""" + + def setUp(self) -> None: + """Create a minimal repository-shaped fixture for every test.""" + self.temp_dir = tempfile.TemporaryDirectory() + self.addCleanup(self.temp_dir.cleanup) + self.workspace = Path(self.temp_dir.name) + script = self.workspace / "scripts" / VALIDATOR.name + script.parent.mkdir(parents=True) + shutil.copy2(VALIDATOR, script) + self.script = script + + for index, layer in enumerate(LAYERS): + self._write_layer(layer, index) + self._write_alert(wildcard=True) + + def _write_layer(self, relative_path: str, index: int) -> None: + """Write one valid layer containing both watched Flux kinds.""" + layer = self.workspace / relative_path + layer.mkdir(parents=True, exist_ok=True) + (layer / "kustomization.yaml").write_text( + textwrap.dedent( + """\ + apiVersion: kustomize.config.k8s.io/v1beta1 + kind: Kustomization + resources: + - resources.yaml + """ + ), + encoding="utf-8", + ) + (layer / "resources.yaml").write_text( + textwrap.dedent( + f"""\ + apiVersion: helm.toolkit.fluxcd.io/v2 + kind: HelmRelease + metadata: + name: release-{index} + namespace: flux-system + spec: {{}} + --- + apiVersion: kustomize.toolkit.fluxcd.io/v1 + kind: Kustomization + metadata: + name: layer-{index} + namespace: flux-system + spec: {{}} + """ + ), + encoding="utf-8", + ) + + def _write_alert(self, *, wildcard: bool) -> None: + """Write an Alert with wildcard or resource-specific event sources.""" + alert = ( + self.workspace + / "k8s" + / "providers" + / "hetzner" + / "infrastructure" + / "flux-notifications" + / "alert.yaml" + ) + alert.parent.mkdir(parents=True, exist_ok=True) + name = "*" if wildcard else "one-resource" + alert.write_text( + textwrap.dedent( + f"""\ + apiVersion: notification.toolkit.fluxcd.io/v1beta3 + kind: Alert + metadata: + name: reconciliation + namespace: flux-system + spec: + eventSources: + - kind: HelmRelease + name: "{name}" + namespace: flux-system + - kind: Kustomization + name: "{name}" + namespace: flux-system + """ + ), + encoding="utf-8", + ) + + def _run_validator(self) -> subprocess.CompletedProcess[str]: + """Run the copied validator with the real local kubectl and yq.""" + return subprocess.run( + ["bash", str(self.script)], + cwd=self.workspace, + env=os.environ.copy(), + check=False, + capture_output=True, + text=True, + ) + + def test_valid_wildcard_alert_covers_every_rendered_namespace(self) -> None: + """Keep the known-good whole-namespace coverage path green.""" + result = self._run_validator() + + self.assertEqual(result.returncode, 0, result.stderr) + + def test_missing_layer_fails_closed_instead_of_being_skipped(self) -> None: + """A missing Kustomization must invalidate the coverage proof.""" + missing = self.workspace / LAYERS[-1] / "kustomization.yaml" + missing.unlink() + + result = self._run_validator() + + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn(LAYERS[-1], result.stdout + result.stderr) + + def test_named_event_source_does_not_cover_its_whole_namespace(self) -> None: + """Only name '*' proves coverage for every resource in a namespace.""" + self._write_alert(wildcard=False) + + result = self._run_validator() + + self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("does not watch every namespace", result.stdout + result.stderr) + + def test_ci_runs_the_behavioral_regressions_when_they_change(self) -> None: + """Keep the validator's failure-mode coverage in the required CI job.""" + workflow = CI_WORKFLOW.read_text(encoding="utf-8") + + self.assertIn("'scripts/tests/test_validate_alert_coverage.py'", workflow) + self.assertIn( + "python3 -m unittest scripts.tests.test_validate_alert_coverage", + workflow, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/validate-alert-coverage.sh b/scripts/validate-alert-coverage.sh index 1e78b6095..c1e6c0009 100755 --- a/scripts/validate-alert-coverage.sh +++ b/scripts/validate-alert-coverage.sh @@ -57,7 +57,6 @@ rendered="${work_dir}/rendered.yaml" # would never be compared against the Alert — CI would bless the exact gap this check # exists to catch, precisely when a layer is broken. A partial render is not a pass. for layer in "${LAYERS[@]}"; do - [[ -f "${layer}/kustomization.yaml" ]] || continue if ! kubectl kustomize "${layer}"; then echo "::error::kubectl kustomize failed to render ${layer}; refusing to validate Alert coverage on a partial render." >&2 exit 1 @@ -89,8 +88,10 @@ for kind in HelmRelease Kustomization; do exit 1 fi - yq e ".spec.eventSources[] | select(.kind == \"${kind}\") | .namespace" "${ALERT_FILE}" \ - | grep -vE '^null$|^$' | LC_ALL=C sort -u > "${watched}" + # yq treats `== "*"` as a wildcard comparison, so anchor a regex to require + # the literal whole-resource wildcard instead of accepting any named source. + yq e ".spec.eventSources[] | select(.kind == \"${kind}\" and (.name | test(\"^\\\\*$\"))) | .namespace | select(. != null and . != \"\")" "${ALERT_FILE}" \ + | LC_ALL=C sort -u > "${watched}" uncovered="$(comm -23 "${declared}" "${watched}")" if [[ -n "${uncovered}" ]]; then From fc26b352606e79934c92619f12e7d53ebeff3fbf Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 19:06:52 +0200 Subject: [PATCH 06/22] fix(deploy): drain nodes through validated kube context --- scripts/refresh-flux-ghcr-auth.sh | 254 ++++++++++--- scripts/tests/test_refresh_flux_ghcr_auth.py | 378 +++++++++++++++++-- 2 files changed, 558 insertions(+), 74 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index a7e395dc1..01a6dd71e 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -37,6 +37,9 @@ readonly SECRET_FILE="${FLUX_GHCR_SECRET_FILE:-k8s/bases/bootstrap/secret.enc.ya readonly KUBE_CONTEXT="${KUBE_CONTEXT:-admin@prod}" readonly SYNC_ATTEMPTS="${FLUX_GHCR_SYNC_ATTEMPTS:-60}" readonly SYNC_INTERVAL="${FLUX_GHCR_SYNC_INTERVAL:-2}" +readonly DRAIN_TIMEOUT="${FLUX_GHCR_DRAIN_TIMEOUT:-45m}" +readonly CORDON_OWNER_ANNOTATION="platform.devantler.tech/ghcr-auth-drain-owner" +readonly CORDON_OWNER_JSON_PATH="/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" KSAIL_OPERATOR_VERSION="$(yq -er '.spec.chart.spec.version' \ k8s/bases/infrastructure/controllers/ksail-operator/helm-release.yaml)" readonly KSAIL_OPERATOR_VERSION @@ -60,8 +63,9 @@ readonly -a FANOUT_NAMESPACES=( ) if ! [[ "${SYNC_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]] \ - || ! [[ "${SYNC_INTERVAL}" =~ ^[0-9]+([.][0-9]+)?$ ]]; then - echo "::error::FLUX_GHCR_SYNC_ATTEMPTS and FLUX_GHCR_SYNC_INTERVAL must be non-negative numbers, with at least one attempt." + || ! [[ "${SYNC_INTERVAL}" =~ ^[0-9]+([.][0-9]+)?$ ]] \ + || ! [[ "${DRAIN_TIMEOUT}" =~ ^[1-9][0-9]*(s|m|h)$ ]]; then + echo "::error::FLUX_GHCR_SYNC_ATTEMPTS and FLUX_GHCR_SYNC_INTERVAL must be non-negative numbers, with at least one attempt; FLUX_GHCR_DRAIN_TIMEOUT must be a positive whole number of seconds, minutes, or hours." exit 64 fi @@ -82,6 +86,10 @@ fanout_api_resources="${work_dir}/fanout-api-resources.txt" talos_auth_patch_file="${work_dir}/talos-registry-auth.json" talos_revision_patch_file="${work_dir}/talos-registry-revision.json" talos_result_file="${work_dir}/talos-result.txt" +drain_result_file="${work_dir}/drain-result.txt" +reboot_result_file="${work_dir}/reboot-result.txt" +cordon_state_file="${work_dir}/cordon-state.json" +cordon_release_patch_file="${work_dir}/cordon-release-patch.json" talos_nodes_file="${work_dir}/talos-nodes.json" talos_node_targets="${work_dir}/talos-node-targets.tsv" @@ -167,42 +175,113 @@ verify_consumer_secret() { fi } -# 0 when the node is already cordoned, 1 when it is not, 2 when the cordon state -# could not be READ. `talosctl reboot --drain` cordons and drains, then UNCORDONS on -# the way out (Talos defers uncordonNodes), so a node an operator had deliberately -# cordoned would come back schedulable. Remember the pre-existing cordon and restore -# it after the reboot. -# -# Fail CLOSED on an unreadable state: swallowing a transient `kubectl get node` failure -# into an empty string would read as "not cordoned", so the post-reboot uncordon would -# silently make a deliberately-cordoned node schedulable again. Distinguish that failure -# (2) from a genuine not-cordoned answer (1) so the caller can abort rather than roll a -# node whose cordon it cannot preserve. Capture kubectl's status instead of testing it -# inside the `[[ ]]`, so an API failure is never conflated with an empty value. -node_is_cordoned() { - local node_name="$1" unschedulable - if ! unschedulable="$(kubectl \ +# Emit bounded, printable output only from operations that cannot contain the +# registry credential. Prefix each line so it cannot become a workflow command. +emit_safe_operation_output() { + local label="$1" result_file="$2" + [[ -s "${result_file}" ]] || return 0 + + LC_ALL=C tr -cd '\11\12\40-\176' < "${result_file}" \ + | tail -n 50 \ + | sed -e "s/^/${label}: /" >&2 \ + || true +} + +# Claim the right to reverse the cordon that the upcoming kubectl drain creates. +# The resource-version compare closes the read/claim race, and the annotation is +# later tested and removed atomically with the uncordon. Another actor taking +# over an already-cordoned node must replace this annotation: a second bare +# cordon is idempotent and cannot express new ownership in the Kubernetes API. +claim_node_cordon_ownership() { + local node_name="$1" owner_token="$2" state_file="$3" result_file="$4" + local resource_version + resource_version="$(jq -er '.metadata.resourceVersion' "${state_file}")" + + if ! kubectl \ --context "${KUBE_CONTEXT}" \ - get node "${node_name}" \ - --output jsonpath='{.spec.unschedulable}' 2>/dev/null)"; then - return 2 + annotate node "${node_name}" \ + "${CORDON_OWNER_ANNOTATION}=${owner_token}" \ + --resource-version="${resource_version}" \ + >"${result_file}" 2>&1; then + echo "::error::Could not claim cordon ownership for Talos node ${node_name}; refusing to drain it." + emit_safe_operation_output "cordon-claim" "${result_file}" + return 1 fi - [[ "${unschedulable}" == "true" ]] } -# Restore operator intent on both the normal and post-reboot readiness-failure paths. -restore_node_cordon_if_needed() { - local node_name="$1" was_cordoned="$2" result_file="$3" - [[ "${was_cordoned}" == "1" ]] || return 0 +# kubectl drain cordons the node. Restore schedulability only when this bridge +# owns that cordon; a pre-existing operator cordon must remain untouched. +restore_node_schedulability_if_needed() { + local node_name="$1" was_cordoned="$2" owner_token="$3" + local initial_node_uid="$4" initial_node_taints="$5" result_file="$6" + local current_resource_version + [[ "${was_cordoned}" == "0" ]] || return 0 + + if [[ -z "${owner_token}" ]]; then + echo "::error::Refusing to uncordon Talos node ${node_name} without a bridge ownership token." + return 1 + fi if ! kubectl \ --context "${KUBE_CONTEXT}" \ - cordon "${node_name}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2> "${result_file}"; then + echo "::error::Could not re-read Talos node ${node_name}; refusing to uncordon it." + emit_safe_operation_output "uncordon-read" "${result_file}" + return 1 + fi + if ! jq -e \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" \ + --argjson initial_taints "${initial_node_taints}" ' + .metadata.uid == $uid + and .metadata.deletionTimestamp == null + and .spec.unschedulable == true + and .metadata.annotations[$owner_annotation] == $owner + and (((.spec.taints // []) + | map(select(( + .key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")])) + == $initial_taints) + ' "${cordon_state_file}" >/dev/null; then + echo "::error::Cordon ownership changed or scheduling safety state changed for Talos node ${node_name}; refusing to uncordon it." + return 1 + fi + current_resource_version="$(jq -er \ + '.metadata.resourceVersion' "${cordon_state_file}")" + + jq -n \ + --arg path "${CORDON_OWNER_JSON_PATH}" \ + --arg owner "${owner_token}" \ + --arg resource_version "${current_resource_version}" ' + [ + {op: "test", path: $path, value: $owner}, + { + op: "test", + path: "/metadata/resourceVersion", + value: $resource_version + }, + {op: "add", path: "/spec/unschedulable", value: false}, + {op: "remove", path: $path} + ] + ' > "${cordon_release_patch_file}" + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + patch node "${node_name}" \ + --type=json \ + --patch-file="${cordon_release_patch_file}" \ >"${result_file}" 2>&1; then - echo "::error::Talos node ${node_name} was cordoned before its GHCR auth reboot but could not be re-cordoned afterwards." + echo "::error::Cordon ownership changed or could not be released for Talos node ${node_name}; refusing to uncordon it." + emit_safe_operation_output "uncordon" "${result_file}" return 1 fi - echo "Restored the pre-existing cordon on ${node_name}." + echo "Restored schedulability on ${node_name}." } # Talos returns gRPC NotFound with the exact image reference when that image is @@ -275,6 +354,10 @@ sync_talos_registry_auth() { : > "${talos_result_file}" chmod 600 "${talos_result_file}" + : > "${drain_result_file}" + chmod 600 "${drain_result_file}" + : > "${reboot_result_file}" + chmod 600 "${reboot_result_file}" # Targets are pre-sorted workers-first, and this loop is strictly sequential, # so the reboot below rolls one node at a time and control planes go last — # etcd keeps quorum throughout. @@ -349,28 +432,89 @@ sync_talos_registry_auth() { return 1 fi - # `reboot --drain` cordons, drains, and then UNCORDONS the node on the way - # out. A node an operator had already cordoned (maintenance, investigation, - # autoscaler scale-down) must not silently come back schedulable because we - # rebooted it for a credential refresh — remember the cordon and restore it. - local was_cordoned=0 cordon_rc=0 - node_is_cordoned "${node_name}" || cordon_rc=$? - if [[ "${cordon_rc}" -eq 2 ]]; then - echo "::error::Refusing to reboot ${node_name}: its cordon state could not be read, so the post-reboot uncordon could silently make a deliberately-cordoned node schedulable." + # Remember scheduling intent before kubectl drain cordons the node. Claim a + # schedulable node with a compare-and-swap annotation so cleanup can prove + # that no newer actor replaced our cordon ownership. + local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" + local initial_node_uid="" initial_node_taints="[]" + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}"; then + echo "::error::Refusing to reboot ${node_name}: its scheduling state could not be read." return 1 fi - if [[ "${cordon_rc}" -eq 0 ]]; then + if ! jq -e \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" ' + (.metadata.uid | type == "string" and length > 0) + and (.metadata.resourceVersion | type == "string" and length > 0) + and ((.spec.unschedulable // false) | type == "boolean") + and ((.metadata.annotations[$owner_annotation] // "") + | type == "string") + ' "${cordon_state_file}" >/dev/null; then + echo "::error::Refusing to reboot ${node_name}: its scheduling state was malformed." + return 1 + fi + initial_node_uid="$(jq -r '.metadata.uid' "${cordon_state_file}")" + initial_node_taints="$(jq -cS ' + (.spec.taints // []) + | map(select(( + .key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")]) + ' "${cordon_state_file}")" + existing_cordon_owner="$(jq -r \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + '.metadata.annotations[$owner_annotation] // ""' \ + "${cordon_state_file}")" + if [[ -n "${existing_cordon_owner}" ]]; then + echo "::error::Refusing to reboot ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." + return 1 + fi + if jq -e '.spec.unschedulable == true' \ + "${cordon_state_file}" >/dev/null; then was_cordoned=1 + else + cordon_owner_token="${desired_revision:0:16}-$$-${RANDOM}" + claim_node_cordon_ownership \ + "${node_name}" "${cordon_owner_token}" \ + "${cordon_state_file}" "${drain_result_file}" || return 1 fi - # --drain cordons the node and evicts its pods under PodDisruptionBudget - # control first; without it the workloads are killed along with the node. + # Drain through the Kubernetes context already proven by this deployment. + # Talos v1.13's integrated --drain path fetches a separate admin kubeconfig; + # this cluster's generated config targets an unreachable API endpoint. + # kubectl also retries PDB-protected evictions, giving CloudNativePG time to + # switch primaries and Longhorn time to enforce its data-safety policy. + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + drain "${node_name}" \ + --ignore-daemonsets \ + --delete-emptydir-data \ + --timeout="${DRAIN_TIMEOUT}" \ + >"${drain_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} could not be safely drained before its GHCR auth reboot." + emit_safe_operation_output "drain" "${drain_result_file}" + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 + return 1 + fi + + # The node is now cordoned and fully drained under PDB control, so a plain + # Talos reboot cannot terminate a workload behind Kubernetes' back. Keep + # --wait explicit so Kubernetes readiness is checked only after a new boot. if ! talosctl \ --nodes "${node_ip}" \ reboot \ - --drain \ - >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} did not drain+reboot to load the refreshed GHCR registry auth." + --wait \ + >"${reboot_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not reboot to load the refreshed GHCR registry auth; it remains cordoned because its reboot state is uncertain." + emit_safe_operation_output "reboot" "${reboot_result_file}" return 1 fi if ! kubectl \ @@ -379,16 +523,12 @@ sync_talos_registry_auth() { --for=condition=Ready \ "node/${node_name}" \ --timeout=10m \ - >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} did not return Ready after its GHCR auth reboot; refusing to roll the next node." - restore_node_cordon_if_needed \ - "${node_name}" "${was_cordoned}" "${talos_result_file}" || return 1 + >"${reboot_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not return Ready after its GHCR auth reboot; it remains cordoned and the next node will not be rolled." + emit_safe_operation_output "ready" "${reboot_result_file}" return 1 fi - restore_node_cordon_if_needed \ - "${node_name}" "${was_cordoned}" "${talos_result_file}" || return 1 - # A cached image can make a pull look healthy without proving that the # node's runtime can authenticate to GHCR. Remove the incoming exact target # first so the following pull must complete a registry round-trip. @@ -400,6 +540,10 @@ sync_talos_registry_auth() { if ! talos_image_remove_reports_absent \ "${talos_result_file}" "${operator_image}"; then echo "::error::Talos node ${node_name} could not remove the cached incoming KSail image before GHCR verification." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 return 1 fi fi @@ -412,9 +556,21 @@ sync_talos_registry_auth() { --namespace cri \ >"${talos_result_file}" 2>&1; then echo "::error::Talos node ${node_name} could not pull the exact incoming KSail image after its auth refresh." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 return 1 fi + # Restore original scheduling intent after runtime auth is proven but + # before recording success. If uncordon fails, a later run must retry this + # node instead of skipping one the bridge left unschedulable. + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 + # Recorded LAST, and only now: the marker means "this node's containerd has # provably loaded this credential revision", so it must not be written # before the reboot that makes that true. diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index a280ba8f7..c2c7726d7 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -254,10 +254,18 @@ def setUp(self) -> None: # A running containerd only adopts new registry auth by restarting, # and Talos refuses `service cri restart`, so the node must reboot. # The auth patch must already have landed, and the reboot must drain - # (cordon + evict under PDB) rather than killing the pods with it. + # only after kubectl has completed a PDB-respecting drain through + # the already-validated Kubernetes context. if [[ "$arguments" == *" reboot "* ]]; then test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - [[ "$arguments" == *" --drain "* ]] + if [[ "$arguments" == *" --drain "* ]]; then + echo 'integrated Talos drain is not allowed' >&2 + exit 54 + fi + if [[ "$arguments" != *" --wait "* ]]; then + echo 'Talos reboot must wait for the new boot' >&2 + exit 57 + fi printf 'talos-reboot:%s\n' "$node" >> "$TALOS_LOG" printf 'talos-reboot:%s\n' "$node" >> "$OPERATION_LOG" if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ @@ -546,12 +554,192 @@ def setUp(self) -> None: previous="$argument" done test -n "$node_target" + owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + if [[ "$arguments" == *" --output json "* ]]; then + owner="" + if [[ -f "$owner_file" ]]; then + owner="$(<"$owner_file")" + fi + cordoned=false + if [[ " ${FAKE_CORDONED_NODES:-} " == *" ${node_target} "* \ + || -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" ]]; then + cordoned=true + fi + autoscaler_intent=false + if [[ -f "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" ]]; then + autoscaler_intent=true + fi + jq -n \ + --arg owner "$owner" \ + --argjson cordoned "$cordoned" \ + --argjson autoscaler_intent "$autoscaler_intent" ' + { + metadata: { + uid: "node-uid", + resourceVersion: "10", + deletionTimestamp: null, + annotations: ( + if $owner == "" then {} + else { + "platform.devantler.tech/ghcr-auth-drain-owner": $owner + } end + ) + }, + spec: { + unschedulable: $cordoned, + taints: ( + (if $cordoned then [{ + key: "node.kubernetes.io/unschedulable", + effect: "NoSchedule" + }] else [] end) + + (if $autoscaler_intent then [{ + key: "ToBeDeletedByClusterAutoscaler", + effect: "NoSchedule" + }] else [] end) + ) + } + } + ' + exit 0 + fi if [[ " ${FAKE_CORDONED_NODES:-} " == *" ${node_target} "* ]]; then printf 'true' fi exit 0 fi + if [[ "$arguments" == *" annotate node "* \ + && "$arguments" == *" platform.devantler.tech/ghcr-auth-drain-owner="* ]]; then + node_target="" + owner="" + previous="" + for argument in "$@"; do + if [[ "$previous" == node ]]; then + node_target="$argument" + fi + case "$argument" in + platform.devantler.tech/ghcr-auth-drain-owner=*) + owner="${argument#*=}" + ;; + esac + previous="$argument" + done + test -n "$node_target" + test -n "$owner" + [[ "$arguments" == *" --resource-version=10 "* ]] + owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + test ! -e "$owner_file" + printf '%s' "$owner" > "$owner_file" + printf 'node-claim:%s\n' "$node_target" >> "$OPERATION_LOG" + exit 0 + fi + + if [[ "$arguments" == *" drain "* ]]; then + node_target="" + previous="" + for argument in "$@"; do + if [[ "$previous" == drain ]]; then + node_target="$argument" + break + fi + previous="$argument" + done + test -n "$node_target" + if [[ "$arguments" != *" --ignore-daemonsets "* \ + || "$arguments" != *" --delete-emptydir-data "* \ + || "$arguments" != *" --timeout=45m "* \ + || "$arguments" == *" --disable-eviction "* \ + || "$arguments" == *" --force "* ]]; then + echo 'unsafe or incomplete kubectl drain flags' >&2 + exit 55 + fi + printf 'node-drain:%s\n' "$node_target" >> "$OPERATION_LOG" + touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + if [[ "$node_target" == "${FAKE_CORDON_OWNER_REPLACED_NODE:-disabled}" ]]; then + printf 'operator-cordon' \ + > "$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + fi + if [[ "$node_target" == "${FAKE_AUTOSCALER_CORDON_NODE:-disabled}" ]]; then + touch "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" + fi + if [[ "$node_target" == "${FAKE_DRAIN_FAIL_NODE:-disabled}" ]]; then + echo 'cannot evict pod backstage-db-4: would violate PodDisruptionBudget backstage-db-primary' >&2 + exit 53 + fi + exit 0 + fi + + if [[ "$arguments" == *" uncordon "* ]]; then + node_target="" + previous="" + for argument in "$@"; do + if [[ "$previous" == uncordon ]]; then + node_target="$argument" + break + fi + previous="$argument" + done + test -n "$node_target" + if [[ "$node_target" == "${FAKE_CORDON_OWNER_REPLACED_NODE:-disabled}" \ + || "$node_target" == "${FAKE_UNCORDON_FAIL_NODE:-disabled}" ]]; then + echo 'cordon ownership changed; refusing to uncordon' >&2 + exit 56 + fi + printf 'node-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" + exit 0 + fi + + if [[ "$arguments" == *" patch node "* \ + && "$arguments" == *" --type=json "* ]]; then + node_target="" + previous="" + for argument in "$@"; do + if [[ "$previous" == node ]]; then + node_target="$argument" + break + fi + previous="$argument" + done + test -n "$node_target" + test -f "$patch_file" + owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + expected_owner="$(jq -er '.[0].value' "$patch_file")" + current_owner="" + if [[ -f "$owner_file" ]]; then + current_owner="$(<"$owner_file")" + fi + if [[ "$node_target" == "${FAKE_UNCORDON_FAIL_NODE:-disabled}" \ + || "$current_owner" != "$expected_owner" ]]; then + echo 'cordon ownership changed; refusing to uncordon' >&2 + exit 56 + fi + jq -e ' + .[0].op == "test" + and .[0].path == ( + "/metadata/annotations/platform.devantler.tech" + + "~1ghcr-auth-drain-owner" + ) + and any(.[]; + .op == "test" + and .path == "/metadata/resourceVersion" + and .value == "10") + and any(.[]; + .op == "add" + and .path == "/spec/unschedulable" + and .value == false) + and any(.[]; + .op == "remove" + and .path == ( + "/metadata/annotations/platform.devantler.tech" + + "~1ghcr-auth-drain-owner" + )) + ' "$patch_file" >/dev/null + printf 'node-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" + rm "$owner_file" + rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + exit 0 + fi + if [[ "$arguments" == *" cordon "* ]]; then node_target="" previous="" @@ -962,20 +1150,26 @@ def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", + "node-claim:prod-worker-1", + "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", "talos-remove:10.0.0.2:ghcr.io/devantler-tech/ksail:" f"v{KSAIL_OPERATOR_VERSION}", "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" f"v{KSAIL_OPERATOR_VERSION}", + "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "talos-auth:10.0.0.1", + "node-claim:prod-control-plane-1", + "node-drain:prod-control-plane-1", "talos-reboot:10.0.0.1", "node-ready:prod-control-plane-1", "talos-remove:10.0.0.1:ghcr.io/devantler-tech/ksail:" f"v{KSAIL_OPERATOR_VERSION}", "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:" f"v{KSAIL_OPERATOR_VERSION}", + "node-uncordon:prod-control-plane-1", "talos-revision:10.0.0.1", "root-patch", ], @@ -1054,10 +1248,9 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: """A node an operator cordoned must not come back schedulable. - `talosctl reboot --drain` cordons and drains, then UNCORDONS on the way - out. A node deliberately cordoned before this run (maintenance, - investigation, autoscaler scale-down) would silently become schedulable - again just because we rebooted it to refresh a credential. + A node deliberately cordoned before this run (maintenance, + investigation, autoscaler scale-down) must remain unschedulable after + the bridge completes its explicit Kubernetes drain and Talos reboot. """ result = self._run_helper( self._valid_config(), @@ -1066,24 +1259,142 @@ def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-cordon:prod-worker-1", operations) - # The cordon is restored only after the node is back, and only for the - # node that was cordoned to begin with. + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("node-claim:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertIn("node-claim:prod-control-plane-1", operations) + self.assertIn("node-uncordon:prod-control-plane-1", operations) + + def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: + """Restore scheduling after runtime proof, before recording success.""" + result = self._run_helper(self._valid_config()) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertLess( - operations.index("node-ready:prod-worker-1"), - operations.index("node-cordon:prod-worker-1"), + operations.index("node-claim:prod-worker-1"), + operations.index("node-drain:prod-worker-1"), + ) + self.assertLess( + operations.index("node-drain:prod-worker-1"), + operations.index("talos-reboot:10.0.0.2"), + ) + self.assertLess( + operations.index( + "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" + f"v{KSAIL_OPERATOR_VERSION}" + ), + operations.index("node-uncordon:prod-worker-1"), + ) + self.assertLess( + operations.index("node-uncordon:prod-worker-1"), + operations.index("talos-revision:10.0.0.2"), ) - self.assertNotIn("node-cordon:prod-control-plane-1", operations) - def test_uncordoned_node_is_not_cordoned_after_the_auth_reboot(self) -> None: - """Never leave a node unschedulable that was schedulable before.""" - result = self._run_helper(self._valid_config()) + def test_pdb_blocked_drain_restores_original_schedulability(self) -> None: + """A blocked eviction must be visible and must never reach reboot.""" + result = self._run_helper( + self._valid_config(), + FAKE_DRAIN_FAIL_NODE="prod-worker-1", + ) - self.assertEqual(result.returncode, 0, result.stderr) + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn( + "drain: cannot evict pod backstage-db-4: would violate " + "PodDisruptionBudget backstage-db-primary", + output, + ) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertFalse( - [entry for entry in operations if entry.startswith("node-cordon:")], - "a node that was schedulable before the reboot was left cordoned", + self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-drain:prod-worker-1", operations) + self.assertIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertNotIn("fixture-secret-token", output) + + def test_pdb_blocked_drain_preserves_pre_existing_cordon(self) -> None: + """Never uncordon a node that was already under operator control.""" + result = self._run_helper( + self._valid_config(), + FAKE_CORDONED_NODES="prod-worker-1", + FAKE_DRAIN_FAIL_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertNotIn("node-claim:prod-worker-1", operations) + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + + def test_revision_failure_does_not_leave_owned_cordon_behind(self) -> None: + """Restore scheduling before the final non-secret marker write.""" + result = self._run_helper( + self._valid_config(), + FAKE_TALOS_FAIL_NODE="10.0.0.2", + FAKE_TALOS_FAIL_OPERATION="revision", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-uncordon:prod-worker-1", operations) + self.assertLess( + operations.index("node-uncordon:prod-worker-1"), + operations.index("talos-revision:10.0.0.2"), + ) + self.assertNotIn("root-patch", operations) + + def test_changed_cordon_owner_is_never_uncordoned(self) -> None: + """Do not undo a newer operator's scheduling decision mid-roll.""" + result = self._run_helper( + self._valid_config(), + FAKE_CORDON_OWNER_REPLACED_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("ownership changed", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_autoscaler_taint_is_never_uncordoned(self) -> None: + """Honor standard scale-down intent even when our owner remains.""" + result = self._run_helper( + self._valid_config(), + FAKE_AUTOSCALER_CORDON_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("scheduling safety state changed", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_uncordon_failure_keeps_revision_marker_stale(self) -> None: + """A failed ownership release must force the next run to retry.""" + result = self._run_helper( + self._valid_config(), + FAKE_UNCORDON_FAIL_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertTrue( + (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() ) def test_unready_node_after_reboot_stops_the_roll(self) -> None: @@ -1106,6 +1417,8 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", + "node-claim:prod-worker-1", + "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", ], @@ -1114,7 +1427,7 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - def test_unready_node_restores_its_pre_existing_cordon(self) -> None: + def test_unready_node_preserves_its_pre_existing_cordon(self) -> None: """Keep an operator-cordoned node cordoned when readiness fails.""" result = self._run_helper( self._valid_config(), @@ -1125,11 +1438,7 @@ def test_unready_node_restores_its_pre_existing_cordon(self) -> None: self.assertNotEqual(result.returncode, 0) operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-ready:prod-worker-1", operations) - self.assertIn("node-cordon:prod-worker-1", operations) - self.assertLess( - operations.index("node-ready:prod-worker-1"), - operations.index("node-cordon:prod-worker-1"), - ) + self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) @@ -1156,6 +1465,13 @@ def test_talos_failure_after_safe_fanout_keeps_root_auth_unchanged(self) -> None "externalsecret/kyverno/ghcr-auth", ], ) + operations = self.operation_log.read_text( + encoding="utf-8" + ).splitlines() + if operation in ("remove", "pull"): + self.assertIn("node-uncordon:prod-worker-1", operations) + if operation == "reboot": + self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) def test_missing_cached_image_still_pulls_and_records_revision(self) -> None: @@ -1503,6 +1819,18 @@ def test_accepts_matching_explicit_and_encoded_auth(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) + def test_rejects_unsafe_drain_timeout_before_cluster_access(self) -> None: + """Keep the timeout value from becoming an injected kubectl option.""" + result = self._run_helper( + self._valid_config(), + FLUX_GHCR_DRAIN_TIMEOUT="45m --disable-eviction", + ) + + self.assertEqual(result.returncode, 64) + self.assertFalse(self.kubectl_called.exists()) + self.assertFalse(self.patch_capture.exists()) + self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_check_only_preflights_without_patching(self) -> None: """Keep registry preflight mode free of Kubernetes mutations.""" result = self._run_helper(self._valid_config(), ("--check-only",)) From 3f5a086a4172a1bc7ce82c9d65abab7f04341614 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 19:34:57 +0200 Subject: [PATCH 07/22] fix(deploy): make drain ownership atomic --- scripts/refresh-flux-ghcr-auth.sh | 67 ++++-- scripts/tests/test_refresh_flux_ghcr_auth.py | 212 +++++++++++++++---- 2 files changed, 222 insertions(+), 57 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 01a6dd71e..aa87195db 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -89,6 +89,7 @@ talos_result_file="${work_dir}/talos-result.txt" drain_result_file="${work_dir}/drain-result.txt" reboot_result_file="${work_dir}/reboot-result.txt" cordon_state_file="${work_dir}/cordon-state.json" +cordon_claim_patch_file="${work_dir}/cordon-claim-patch.json" cordon_release_patch_file="${work_dir}/cordon-release-patch.json" talos_nodes_file="${work_dir}/talos-nodes.json" talos_node_targets="${work_dir}/talos-node-targets.tsv" @@ -187,30 +188,68 @@ emit_safe_operation_output() { || true } -# Claim the right to reverse the cordon that the upcoming kubectl drain creates. -# The resource-version compare closes the read/claim race, and the annotation is -# later tested and removed atomically with the uncordon. Another actor taking -# over an already-cordoned node must replace this annotation: a second bare -# cordon is idempotent and cannot express new ownership in the Kubernetes API. +# Atomically claim the right to reverse the cordon and make the node +# unschedulable. Combining both mutations closes the gap where another actor +# could cordon after our ownership annotation but before kubectl drain. A bare +# cordon after this patch is an idempotent no-op; an actor taking over an +# already-cordoned node must replace the annotation to express new ownership. claim_node_cordon_ownership() { local node_name="$1" owner_token="$2" state_file="$3" result_file="$4" local resource_version resource_version="$(jq -er '.metadata.resourceVersion' "${state_file}")" + if jq -e '.metadata.annotations | type == "object"' \ + "${state_file}" >/dev/null; then + jq -n \ + --arg owner_path "${CORDON_OWNER_JSON_PATH}" \ + --arg owner "${owner_token}" \ + --arg resource_version "${resource_version}" ' + [ + { + op: "test", + path: "/metadata/resourceVersion", + value: $resource_version + }, + {op: "add", path: $owner_path, value: $owner}, + {op: "add", path: "/spec/unschedulable", value: true} + ] + ' > "${cordon_claim_patch_file}" + else + jq -n \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + --arg owner "${owner_token}" \ + --arg resource_version "${resource_version}" ' + [ + { + op: "test", + path: "/metadata/resourceVersion", + value: $resource_version + }, + { + op: "add", + path: "/metadata/annotations", + value: {($owner_annotation): $owner} + }, + {op: "add", path: "/spec/unschedulable", value: true} + ] + ' > "${cordon_claim_patch_file}" + fi + if ! kubectl \ --context "${KUBE_CONTEXT}" \ - annotate node "${node_name}" \ - "${CORDON_OWNER_ANNOTATION}=${owner_token}" \ - --resource-version="${resource_version}" \ + patch node "${node_name}" \ + --type=json \ + --patch-file="${cordon_claim_patch_file}" \ >"${result_file}" 2>&1; then - echo "::error::Could not claim cordon ownership for Talos node ${node_name}; refusing to drain it." + echo "::error::Could not atomically claim and cordon Talos node ${node_name}; refusing to drain it." emit_safe_operation_output "cordon-claim" "${result_file}" return 1 fi } -# kubectl drain cordons the node. Restore schedulability only when this bridge -# owns that cordon; a pre-existing operator cordon must remain untouched. +# The atomic claim cordons the node before kubectl drain. Restore schedulability +# only when this bridge owns that cordon; a pre-existing operator cordon must +# remain untouched. restore_node_schedulability_if_needed() { local node_name="$1" was_cordoned="$2" owner_token="$3" local initial_node_uid="$4" initial_node_taints="$5" result_file="$6" @@ -432,9 +471,9 @@ sync_talos_registry_auth() { return 1 fi - # Remember scheduling intent before kubectl drain cordons the node. Claim a - # schedulable node with a compare-and-swap annotation so cleanup can prove - # that no newer actor replaced our cordon ownership. + # Remember scheduling intent before any cordon. Atomically claim and cordon + # a schedulable node so cleanup can prove that no newer actor replaced our + # ownership, while a competing cordon that wins first makes the claim fail. local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" local initial_node_uid="" initial_node_taints="[]" if ! kubectl \ diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index c2c7726d7..bb22fc7bd 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -37,13 +37,43 @@ for line in KSAIL_OPERATOR_HELM_RELEASE.read_text(encoding="utf-8").splitlines() if line.strip().startswith("version:") ) + + +def _yaml_scalar_at_path(document: str, path: tuple[str, ...]) -> str: + """Return a scalar from a mapping-only YAML path without adding a dependency.""" + parents: list[tuple[int, str]] = [] + for raw_line in document.splitlines(): + line = raw_line.split("#", 1)[0].rstrip() + if not line.strip(): + continue + + indent = len(line) - len(line.lstrip(" ")) + key, separator, value = line.strip().partition(":") + if not separator: + continue + + while parents and parents[-1][0] >= indent: + parents.pop() + + current_path = tuple(parent_key for _, parent_key in parents) + (key,) + value = value.strip() + if current_path == path: + if not value: + break + return value.strip("\"'") + + if not value: + parents.append((indent, key)) + + raise ValueError(f"missing scalar YAML path: {'.'.join(path)}") + + # spec.cluster.talos.version is the source of truth the workflows' TALOS_VERSION # must track; deriving it here keeps the drift guard honest across version bumps. -KSAIL_PROD_TALOS_VERSION = next( - line.split(":", 1)[1].strip().lstrip("v") - for line in KSAIL_PROD_CONFIG.read_text(encoding="utf-8").splitlines() - if line.strip().startswith("version:") -) +KSAIL_PROD_TALOS_VERSION = _yaml_scalar_at_path( + KSAIL_PROD_CONFIG.read_text(encoding="utf-8"), + ("spec", "cluster", "talos", "version"), +).lstrip("v") PROVIDER_UPJET_UNIFI = ( ROOT / "k8s" @@ -59,6 +89,28 @@ ) +class KsailProdConfigTests(unittest.TestCase): + """Verify workflow-version guards read the intended nested config key.""" + + def test_talos_version_ignores_unrelated_version_fields(self) -> None: + """Select spec.cluster.talos.version even when another version appears first.""" + config = textwrap.dedent( + """ + spec: + version: v0.0.0 + cluster: + kubernetesVersion: v1.36.2 + talos: + version: v1.13.6 + """ + ) + + self.assertEqual( + "v1.13.6", + _yaml_scalar_at_path(config, ("spec", "cluster", "talos", "version")), + ) + + class RefreshFluxGhcrAuthTests(unittest.TestCase): """Exercise the helper with fake external commands and no real secrets.""" @@ -608,32 +660,6 @@ def setUp(self) -> None: exit 0 fi - if [[ "$arguments" == *" annotate node "* \ - && "$arguments" == *" platform.devantler.tech/ghcr-auth-drain-owner="* ]]; then - node_target="" - owner="" - previous="" - for argument in "$@"; do - if [[ "$previous" == node ]]; then - node_target="$argument" - fi - case "$argument" in - platform.devantler.tech/ghcr-auth-drain-owner=*) - owner="${argument#*=}" - ;; - esac - previous="$argument" - done - test -n "$node_target" - test -n "$owner" - [[ "$arguments" == *" --resource-version=10 "* ]] - owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" - test ! -e "$owner_file" - printf '%s' "$owner" > "$owner_file" - printf 'node-claim:%s\n' "$node_target" >> "$OPERATION_LOG" - exit 0 - fi - if [[ "$arguments" == *" drain "* ]]; then node_target="" previous="" @@ -654,7 +680,13 @@ def setUp(self) -> None: exit 55 fi printf 'node-drain:%s\n' "$node_target" >> "$OPERATION_LOG" - touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + if [[ " ${FAKE_CORDONED_NODES:-} " != *" ${node_target} "* ]]; then + test -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + fi + if [[ "$node_target" == "${FAKE_DRAIN_API_FAIL_NODE:-disabled}" ]]; then + echo 'could not list pods before eviction' >&2 + exit 54 + fi if [[ "$node_target" == "${FAKE_CORDON_OWNER_REPLACED_NODE:-disabled}" ]]; then printf 'operator-cordon' \ > "$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" @@ -703,6 +735,44 @@ def setUp(self) -> None: test -n "$node_target" test -f "$patch_file" owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + if jq -e ' + any(.[]; + .op == "add" + and .path == "/spec/unschedulable" + and .value == true) + ' "$patch_file" >/dev/null; then + if [[ "$node_target" == "${FAKE_CORDON_BEFORE_CLAIM_NODE:-disabled}" ]]; then + touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + printf 'operator-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" + echo 'resourceVersion test failed after concurrent cordon' >&2 + exit 56 + fi + expected_owner="$(jq -er ' + .[] + | select( + .op == "add" + and .path == ( + "/metadata/annotations/platform.devantler.tech" + + "~1ghcr-auth-drain-owner" + )) + | .value + ' "$patch_file")" + test -n "$expected_owner" + test ! -e "$owner_file" + jq -e ' + .[0].op == "test" + and .[0].path == "/metadata/resourceVersion" + and .[0].value == "10" + and any(.[]; + .op == "add" + and .path == "/spec/unschedulable" + and .value == true) + ' "$patch_file" >/dev/null + printf '%s' "$expected_owner" > "$owner_file" + touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + printf 'node-claim-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" + exit 0 + fi expected_owner="$(jq -er '.[0].value' "$patch_file")" current_owner="" if [[ -f "$owner_file" ]]; then @@ -759,6 +829,7 @@ def setUp(self) -> None: # must be handled before the namespace guard below. if [[ "$arguments" == *" wait "* ]]; then [[ "$arguments" == *" --for=condition=Ready "* ]] + [[ "$arguments" == *" --timeout=10m "* ]] node_target="" for argument in "$@"; do case "$argument" in @@ -1150,7 +1221,7 @@ def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", - "node-claim:prod-worker-1", + "node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", @@ -1161,7 +1232,7 @@ def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "talos-auth:10.0.0.1", - "node-claim:prod-control-plane-1", + "node-claim-cordon:prod-control-plane-1", "node-drain:prod-control-plane-1", "talos-reboot:10.0.0.1", "node-ready:prod-control-plane-1", @@ -1260,9 +1331,9 @@ def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-claim:prod-worker-1", operations) + self.assertNotIn("node-claim-cordon:prod-worker-1", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertIn("node-claim:prod-control-plane-1", operations) + self.assertIn("node-claim-cordon:prod-control-plane-1", operations) self.assertIn("node-uncordon:prod-control-plane-1", operations) def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: @@ -1272,7 +1343,7 @@ def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: self.assertEqual(result.returncode, 0, result.stderr) operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertLess( - operations.index("node-claim:prod-worker-1"), + operations.index("node-claim-cordon:prod-worker-1"), operations.index("node-drain:prod-worker-1"), ) self.assertLess( @@ -1291,6 +1362,39 @@ def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: operations.index("talos-revision:10.0.0.2"), ) + def test_schedulable_node_is_claimed_and_cordoned_atomically(self) -> None: + """Close the gap where a competing cordon could precede our drain.""" + result = self._run_helper(self._valid_config()) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim-cordon:prod-worker-1", operations) + self.assertNotIn("node-claim:prod-worker-1", operations) + self.assertLess( + operations.index("node-claim-cordon:prod-worker-1"), + operations.index("node-drain:prod-worker-1"), + ) + + def test_concurrent_cordon_before_atomic_claim_stops_the_roll(self) -> None: + """Never take ownership after another actor makes a node unschedulable.""" + result = self._run_helper( + self._valid_config(), + FAKE_CORDON_BEFORE_CLAIM_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("Could not atomically claim and cordon", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("operator-cordon:prod-worker-1", operations) + self.assertNotIn("node-claim-cordon:prod-worker-1", operations) + self.assertNotIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertFalse( + (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() + ) + def test_pdb_blocked_drain_restores_original_schedulability(self) -> None: """A blocked eviction must be visible and must never reach reboot.""" result = self._run_helper( @@ -1306,13 +1410,16 @@ def test_pdb_blocked_drain_restores_original_schedulability(self) -> None: output, ) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) self.assertIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", output) + self.assertFalse( + (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() + ) def test_pdb_blocked_drain_preserves_pre_existing_cordon(self) -> None: """Never uncordon a node that was already under operator control.""" @@ -1324,11 +1431,30 @@ def test_pdb_blocked_drain_preserves_pre_existing_cordon(self) -> None: self.assertNotEqual(result.returncode, 0) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("node-claim:prod-worker-1", operations) + self.assertNotIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-reboot:10.0.0.2", operations) + def test_drain_api_failure_releases_the_atomic_claim(self) -> None: + """A pre-eviction drain error must not strand our owned cordon.""" + result = self._run_helper( + self._valid_config(), + FAKE_DRAIN_API_FAIL_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim-cordon:prod-worker-1", operations) + self.assertIn("node-drain:prod-worker-1", operations) + self.assertIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + self.assertFalse( + (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() + ) + def test_revision_failure_does_not_leave_owned_cordon_behind(self) -> None: """Restore scheduling before the final non-secret marker write.""" result = self._run_helper( @@ -1357,7 +1483,7 @@ def test_changed_cordon_owner_is_never_uncordoned(self) -> None: output = result.stdout + result.stderr self.assertIn("ownership changed", output) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) @@ -1374,7 +1500,7 @@ def test_autoscaler_taint_is_never_uncordoned(self) -> None: output = result.stdout + result.stderr self.assertIn("scheduling safety state changed", output) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) @@ -1389,7 +1515,7 @@ def test_uncordon_failure_keeps_revision_marker_stale(self) -> None: self.assertNotEqual(result.returncode, 0) operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim:prod-worker-1", operations) + self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) @@ -1417,7 +1543,7 @@ def test_unready_node_after_reboot_stops_the_roll(self) -> None: "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", "talos-auth:10.0.0.2", - "node-claim:prod-worker-1", + "node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", From f2ec4270b43c5e8f914f3de2f817bec42281de4f Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 20:13:46 +0200 Subject: [PATCH 08/22] fix(deploy): close GHCR rollout safety gaps --- scripts/refresh-flux-ghcr-auth-safety.sh | 31 +++++++-- scripts/refresh-flux-ghcr-auth.sh | 26 ++++--- .../test-refresh-flux-ghcr-auth-safety.sh | 14 +++- scripts/tests/test_refresh_flux_ghcr_auth.py | 68 ++++++++++++++++++- scripts/tests/test_validate_alert_coverage.py | 32 +++++++++ scripts/validate-alert-coverage.sh | 31 ++++++++- 6 files changed, 177 insertions(+), 25 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh index ce9d5f1e4..54a280e9b 100644 --- a/scripts/refresh-flux-ghcr-auth-safety.sh +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -55,15 +55,12 @@ select_talos_node_targets() { rm -f "${unsorted_targets}" } -# Existing clusters must make every Kubernetes pull consumer safe before the -# first Talos reboot drains application pods. The root Flux Secret remains last -# so a failed node rollout cannot advance reconciliation onto a partial state. -stage_fanout_before_talos() { - local desired_revision="$1" - local operator_image="$2" +# Reapply and verify the complete live Kubernetes pull-consumer fanout. Flux and +# External Secrets reconcile independently, so a long Talos roll can overlap an +# hourly controller pass that restores the previous Git value. +sync_and_verify_kubernetes_fanout() { local namespace local rc - shift 2 patch_variables_base || { rc=$? @@ -83,10 +80,30 @@ stage_fanout_before_talos() { return "${rc}" } done +} + +# Existing clusters must make every Kubernetes pull consumer safe before the +# first Talos reboot drains application pods, then establish the same proof +# again after the roll. The root Flux Secret remains last so a failed node roll +# or a live-controller race cannot advance reconciliation onto a partial state. +stage_fanout_before_talos() { + local desired_revision="$1" + local operator_image="$2" + local rc + shift 2 + + sync_and_verify_kubernetes_fanout "$@" || { + rc=$? + return "${rc}" + } sync_talos_registry_auth "${desired_revision}" "${operator_image}" || { rc=$? return "${rc}" } + sync_and_verify_kubernetes_fanout "$@" || { + rc=$? + return "${rc}" + } patch_root_secret || { rc=$? return "${rc}" diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index aa87195db..7f63df054 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -544,6 +544,20 @@ sync_talos_registry_auth() { return 1 fi + # A PDB-respecting drain can legitimately take most of DRAIN_TIMEOUT. An + # etcd peer that was healthy before it began may fail while workloads move, + # so refresh the quorum proof at the last safe point before the reboot. + if [[ "${node_role}" == "1" ]] \ + && ! other_control_planes_safe_to_reboot \ + "${node_name}" "${KUBE_CONTEXT}" "${work_dir}"; then + echo "::error::Refusing to reboot control plane ${node_name} after its drain: another control plane is no longer Ready with healthy, alarm-free etcd, so rebooting this one risks quorum." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 + return 1 + fi + # The node is now cordoned and fully drained under PDB control, so a plain # Talos reboot cannot terminate a workload behind Kubernetes' back. Keep # --wait explicit so Kubernetes readiness is checked only after a new boot. @@ -578,11 +592,7 @@ sync_talos_registry_auth() { >"${talos_result_file}" 2>&1; then if ! talos_image_remove_reports_absent \ "${talos_result_file}" "${operator_image}"; then - echo "::error::Talos node ${node_name} could not remove the cached incoming KSail image before GHCR verification." - restore_node_schedulability_if_needed \ - "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ - "${initial_node_uid}" "${initial_node_taints}" \ - "${drain_result_file}" || return 1 + echo "::error::Talos node ${node_name} could not remove the cached incoming KSail image before GHCR verification; it remains cordoned because registry access is unproved." return 1 fi fi @@ -594,11 +604,7 @@ sync_talos_registry_auth() { image pull "${operator_image}" \ --namespace cri \ >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} could not pull the exact incoming KSail image after its auth refresh." - restore_node_schedulability_if_needed \ - "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ - "${initial_node_uid}" "${initial_node_taints}" \ - "${drain_result_file}" || return 1 + echo "::error::Talos node ${node_name} could not pull the exact incoming KSail image after its auth refresh; it remains cordoned because registry access is unproved." return 1 fi diff --git a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh index 609ac57d6..89693a967 100644 --- a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh +++ b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh @@ -138,14 +138,22 @@ if declare -F stage_fanout_before_talos >/dev/null; then force:externalsecret/kyverno/ghcr-auth \ verify:kyverno/ghcr-auth \ "talos:${DESIRED_REVISION}:${DESIRED_IMAGE}" \ + variables-patch \ + force:pushsecret/flux-system/seed-ghcr \ + force:externalsecret/wedding-app/ghcr-auth \ + verify:wedding-app/ghcr-auth \ + force:externalsecret/ascoachingogvaner/ghcr-auth \ + verify:ascoachingogvaner/ghcr-auth \ + force:externalsecret/kyverno/ghcr-auth \ + verify:kyverno/ghcr-auth \ root-patch)" if [[ "$(<"${operation_log}")" == "${expected_operations}" ]]; then - pass "verified tenant fanout completes before any Talos drain" + pass "verified tenant fanout brackets the Talos rollout" else - fail "verified tenant fanout completes before any Talos drain" + fail "verified tenant fanout brackets the Talos rollout" fi else - fail "verified tenant fanout completes before any Talos drain" + fail "verified tenant fanout brackets the Talos rollout" fi control_plane_inventory="${work_dir}/control-planes.json" diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index bb22fc7bd..327abc92d 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -237,6 +237,11 @@ def setUp(self) -> None: echo "etcd status failed" >&2 exit 51 fi + if [[ "$node" == "${FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE:-disabled}" \ + && -f "$FAKE_SYNC_STATE_DIR/drained-prod-control-plane-1" ]]; then + echo "etcd status failed after drain" >&2 + exit 51 + fi printf 'NODE MEMBER DB-SIZE\n%s member-id 1MB\n' "$node" exit 0 fi @@ -698,6 +703,7 @@ def setUp(self) -> None: echo 'cannot evict pod backstage-db-4: would violate PodDisruptionBudget backstage-db-primary' >&2 exit 53 fi + touch "$FAKE_SYNC_STATE_DIR/drained-${node_target}" exit 0 fi @@ -882,6 +888,13 @@ def setUp(self) -> None: [[ "$arguments" == *" --type=merge "* ]] test -n "$patch_file" cp "$patch_file" "$VARIABLES_PATCH_CAPTURE" + variables_patch_count_file="$FAKE_SYNC_STATE_DIR/variables-patch-count" + variables_patch_count=0 + if [[ -f "$variables_patch_count_file" ]]; then + variables_patch_count="$(<"$variables_patch_count_file")" + fi + variables_patch_count=$((variables_patch_count + 1)) + printf '%s' "$variables_patch_count" > "$variables_patch_count_file" printf 'variables-patch\n' >> "$OPERATION_LOG" echo 'secret/variables-base patched' exit 0 @@ -944,7 +957,13 @@ def setUp(self) -> None: fi if [[ "$arguments" == *" get secret ghcr-auth "* ]]; then - if [[ "$namespace" == "${FAKE_CONSUMER_MISMATCH_NAMESPACE:-disabled}" ]]; then + variables_patch_count=0 + if [[ -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" ]]; then + variables_patch_count="$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" + fi + if [[ "$namespace" == "${FAKE_CONSUMER_MISMATCH_NAMESPACE:-disabled}" \ + || ( "$namespace" == "${FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE:-disabled}" \ + && "$variables_patch_count" -ge 2 ) ]]; then encoded=$(printf '%s' '{"auths":{}}' | base64 | tr -d '\r\n') else encoded=$(jq -r '.data.ghcr_dockerconfigjson' "$VARIABLES_PATCH_CAPTURE") @@ -1172,6 +1191,10 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: "externalsecret/wedding-app/ghcr-auth", "externalsecret/ascoachingogvaner/ghcr-auth", "externalsecret/kyverno/ghcr-auth", + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", ], ) @@ -1242,6 +1265,11 @@ def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: f"v{KSAIL_OPERATOR_VERSION}", "node-uncordon:prod-control-plane-1", "talos-revision:10.0.0.1", + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", "root-patch", ], ) @@ -1316,6 +1344,22 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: self.assertNotIn("root-patch", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_control_plane_quorum_is_rechecked_after_the_drain(self) -> None: + """Abort before reboot when an etcd peer fails during a long drain.""" + result = self._run_helper( + self._valid_config(), + FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE="10.0.0.3", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("risks quorum", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-drain:prod-control-plane-1", operations) + self.assertIn("node-uncordon:prod-control-plane-1", operations) + self.assertNotIn("talos-reboot:10.0.0.1", operations) + self.assertNotIn("talos-revision:10.0.0.1", operations) + self.assertNotIn("root-patch", operations) + def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: """A node an operator cordoned must not come back schedulable. @@ -1595,11 +1639,31 @@ def test_talos_failure_after_safe_fanout_keeps_root_auth_unchanged(self) -> None encoding="utf-8" ).splitlines() if operation in ("remove", "pull"): - self.assertIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertTrue( + (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() + ) + self.assertTrue( + (self.sync_state_dir / "cordoned-prod-worker-1").exists() + ) if operation == "reboot": self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) + def test_second_fanout_verification_blocks_root_cutover(self) -> None: + """Recheck live tenant auth after Talos before changing root Flux auth.""" + result = self._run_helper( + self._valid_config(), + FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE="wedding-app", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("did not materialise", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-revision:10.0.0.1", operations) + self.assertEqual(operations.count("variables-patch"), 2) + self.assertNotIn("root-patch", operations) + def test_missing_cached_image_still_pulls_and_records_revision(self) -> None: """Treat a confirmed absent cache entry as ready for pull proof.""" result = self._run_helper( diff --git a/scripts/tests/test_validate_alert_coverage.py b/scripts/tests/test_validate_alert_coverage.py index 2d2feb8c6..cc0da5060 100644 --- a/scripts/tests/test_validate_alert_coverage.py +++ b/scripts/tests/test_validate_alert_coverage.py @@ -146,6 +146,38 @@ def test_named_event_source_does_not_cover_its_whole_namespace(self) -> None: self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) self.assertIn("does not watch every namespace", result.stdout + result.stderr) + def test_watched_resource_without_namespace_fails_closed(self) -> None: + """Reject malformed namespaced Flux resources instead of dropping them.""" + resource_file = self.workspace / LAYERS[0] / "resources.yaml" + for kind, name in ( + ("HelmRelease", "release-0"), + ("Kustomization", "layer-0"), + ): + with self.subTest(kind=kind): + self._write_layer(LAYERS[0], 0) + resources = resource_file.read_text(encoding="utf-8") + namespaced = ( + f"kind: {kind}\nmetadata:\n" + f" name: {name}\n namespace: flux-system" + ) + self.assertIn(namespaced, resources) + resource_file.write_text( + resources.replace( + namespaced, + f"kind: {kind}\nmetadata:\n name: {name}", + 1, + ), + encoding="utf-8", + ) + + result = self._run_validator() + + output = result.stdout + result.stderr + self.assertNotEqual(result.returncode, 0, output) + self.assertIn("missing metadata.namespace", output) + self.assertIn(kind, output) + self.assertIn(name, output) + def test_ci_runs_the_behavioral_regressions_when_they_change(self) -> None: """Keep the validator's failure-mode coverage in the required CI job.""" workflow = CI_WORKFLOW.read_text(encoding="utf-8") diff --git a/scripts/validate-alert-coverage.sh b/scripts/validate-alert-coverage.sh index c1e6c0009..217a808fe 100755 --- a/scripts/validate-alert-coverage.sh +++ b/scripts/validate-alert-coverage.sh @@ -66,6 +66,9 @@ done > "${rendered}" for kind in HelmRelease Kustomization; do declared="${work_dir}/declared-${kind}.txt" + declared_raw="${work_dir}/declared-${kind}-raw.txt" + missing_namespace="${work_dir}/missing-namespace-${kind}.txt" + missing_namespace_raw="${work_dir}/missing-namespace-${kind}-raw.txt" watched="${work_dir}/watched-${kind}.txt" # Only the Flux kinds count. A `kind: Kustomization` in the kustomize.config.k8s.io @@ -79,9 +82,31 @@ for kind in HelmRelease Kustomization; do ;; esac - yq ea "select(.kind == \"${kind}\" and (.apiVersion | test(\"^${group}/\"))) | .metadata.namespace" \ - "${rendered}" 2>/dev/null \ - | grep -vE '^null$|^---$|^$' | LC_ALL=C sort -u > "${declared}" + # These Flux APIs are namespaced and this render path has no targetNamespace + # fallback. Silently dropping an omitted namespace would make the coverage + # set look complete even though the resource itself is malformed. + yq ea -r " + select(.kind == \"${kind}\" and (.apiVersion | test(\"^${group}/\"))) + | select((.metadata.namespace // \"\") == \"\") + | (.kind + \"/\" + (.metadata.name // \"\")) + " "${rendered}" 2>/dev/null > "${missing_namespace_raw}" + awk 'NF && $0 != "---"' \ + "${missing_namespace_raw}" > "${missing_namespace}" + if [[ -s "${missing_namespace}" ]]; then + echo "::error::Rendered ${kind} resources are missing metadata.namespace; Alert coverage cannot be proven." + while read -r resource; do + [[ -n "${resource}" ]] && echo " ${resource}" + done < "${missing_namespace}" + exit 1 + fi + + yq ea -r " + select(.kind == \"${kind}\" and (.apiVersion | test(\"^${group}/\"))) + | .metadata.namespace + | select(. != null and . != \"\") + " "${rendered}" 2>/dev/null > "${declared_raw}" + awk 'NF && $0 != "---"' "${declared_raw}" \ + | LC_ALL=C sort -u > "${declared}" if [[ ! -s "${declared}" ]]; then echo "::error::Rendered no ${kind}s at all — the overlays failed to build, so coverage cannot be proven. Failing closed." From 02748d6734bcacb0efe8dba4c121732ae7c19aa5 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Thu, 16 Jul 2026 20:38:33 +0200 Subject: [PATCH 09/22] fix(deploy): tighten rollout safety validation --- scripts/refresh-flux-ghcr-auth-safety.sh | 39 ++++++++- .../test-refresh-flux-ghcr-auth-safety.sh | 62 +++++++++++++- scripts/tests/test_refresh_flux_ghcr_auth.py | 81 +++++++++++++++++-- scripts/tests/test_validate_alert_coverage.py | 28 ++++++- scripts/validate-alert-coverage.sh | 4 +- 5 files changed, 199 insertions(+), 15 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh index 54a280e9b..b284eb474 100644 --- a/scripts/refresh-flux-ghcr-auth-safety.sh +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -187,11 +187,42 @@ other_control_planes_safe_to_reboot() { return 1 fi if ! awk -v expected_node="${peer_ip}" ' - NR == 1 { header = ($1 == "NODE" && $2 == "MEMBER") } - NR > 1 && $1 == expected_node { rows++ } - END { exit !(header && rows == 1) } + NR == 1 { + common = ($1 == "NODE") + common = common && ($2 == "MEMBER") + common = common && ($3 == "DB") && ($4 == "SIZE") + common = common && ($5 == "IN") && ($6 == "USE") + common = common && ($7 == "LEADER") + common = common && ($8 == "RAFT") && ($9 == "INDEX") + common = common && ($10 == "RAFT") && ($11 == "TERM") + common = common && ($12 == "RAFT") && ($13 == "APPLIED") + common = common && ($14 == "INDEX") && ($15 == "LEARNER") + compact = (NF == 16) && ($16 == "ERRORS") + extended = (NF == 18) + extended = extended && ($16 == "PROTOCOL") + extended = extended && ($17 == "STORAGE") && ($18 == "ERRORS") + header = common && (compact || extended) + expected_data_fields = compact ? 12 : 14 + next + } + NF == 0 { next } + { + data_rows++ + if ($1 == expected_node) { + rows++ + # Talos emits either the compact 12-field row or a 14-field row with + # protocol/storage versions. LEARNER is field 12 in both, and any + # status error adds fields after the expected healthy row. + if (NF != expected_data_fields || $12 != "false") { + unsafe = 1 + } + } + } + END { + exit !(header && data_rows == 1 && rows == 1 && !unsafe) + } ' "${status_file}"; then - echo "Control-plane peer ${peer_name} returned an incomplete etcd status response." + echo "Control-plane peer ${peer_name} is an etcd learner, reports a status error, or returned an unrecognized status response." return 1 fi diff --git a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh index 89693a967..018321b33 100644 --- a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh +++ b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh @@ -215,7 +215,23 @@ talosctl() { if [[ "${node}" == "${ETCD_STATUS_FAIL_NODE:-}" ]]; then return 1 fi - printf 'NODE MEMBER DB-SIZE\n%s member-id 1MB\n' "${node}" + learner=false + status_error="" + if [[ "${node}" == "${ETCD_LEARNER_NODE:-}" ]]; then + learner=true + fi + if [[ "${node}" == "${ETCD_STATUS_ERROR_NODE:-}" ]]; then + status_error=" rpc-timeout" + fi + if [[ "${node}" == "${ETCD_COMPACT_STATUS_NODE:-}" ]]; then + printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER ERRORS\n' + printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s%s\n' \ + "${node}" "${learner}" "${status_error}" + else + printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER PROTOCOL STORAGE ERRORS\n' + printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s 3.6.4 3.6.0%s\n' \ + "${node}" "${learner}" "${status_error}" + fi return 0 fi @@ -232,6 +248,9 @@ talosctl() { if declare -F other_control_planes_safe_to_reboot >/dev/null; then ETCD_STATUS_FAIL_NODE="" + ETCD_COMPACT_STATUS_NODE="" + ETCD_LEARNER_NODE="" + ETCD_STATUS_ERROR_NODE="" ETCD_ALARM_NODE="" if other_control_planes_safe_to_reboot \ prod-control-plane-1 test-context "${work_dir}" >/dev/null; then @@ -240,7 +259,18 @@ if declare -F other_control_planes_safe_to_reboot >/dev/null; then fail "healthy alarm-free etcd peers permit a control-plane reboot" fi + ETCD_COMPACT_STATUS_NODE="10.0.0.2" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null; then + pass "compact healthy etcd status permits a control-plane reboot" + else + fail "compact healthy etcd status permits a control-plane reboot" + fi + ETCD_STATUS_FAIL_NODE="10.0.0.2" + ETCD_COMPACT_STATUS_NODE="" + ETCD_LEARNER_NODE="" + ETCD_STATUS_ERROR_NODE="" ETCD_ALARM_NODE="" if other_control_planes_safe_to_reboot \ prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then @@ -250,6 +280,9 @@ if declare -F other_control_planes_safe_to_reboot >/dev/null; then fi ETCD_STATUS_FAIL_NODE="" + ETCD_COMPACT_STATUS_NODE="" + ETCD_LEARNER_NODE="" + ETCD_STATUS_ERROR_NODE="" ETCD_ALARM_NODE="10.0.0.3" if other_control_planes_safe_to_reboot \ prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then @@ -257,10 +290,37 @@ if declare -F other_control_planes_safe_to_reboot >/dev/null; then else pass "an etcd peer alarm blocks a control-plane reboot" fi + + ETCD_STATUS_FAIL_NODE="" + ETCD_COMPACT_STATUS_NODE="" + ETCD_LEARNER_NODE="10.0.0.2" + ETCD_STATUS_ERROR_NODE="" + ETCD_ALARM_NODE="" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then + fail "a learner etcd peer blocks a control-plane reboot" + else + pass "a learner etcd peer blocks a control-plane reboot" + fi + + ETCD_STATUS_FAIL_NODE="" + ETCD_COMPACT_STATUS_NODE="" + ETCD_LEARNER_NODE="" + ETCD_STATUS_ERROR_NODE="10.0.0.3" + ETCD_ALARM_NODE="" + if other_control_planes_safe_to_reboot \ + prod-control-plane-1 test-context "${work_dir}" >/dev/null 2>&1; then + fail "an etcd status error blocks a control-plane reboot" + else + pass "an etcd status error blocks a control-plane reboot" + fi else fail "healthy alarm-free etcd peers permit a control-plane reboot" + fail "compact healthy etcd status permits a control-plane reboot" fail "unreadable etcd peer status blocks a control-plane reboot" fail "an etcd peer alarm blocks a control-plane reboot" + fail "a learner etcd peer blocks a control-plane reboot" + fail "an etcd status error blocks a control-plane reboot" fi if ((failures > 0)); then diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 327abc92d..6f0352b19 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -242,7 +242,23 @@ def setUp(self) -> None: echo "etcd status failed after drain" >&2 exit 51 fi - printf 'NODE MEMBER DB-SIZE\n%s member-id 1MB\n' "$node" + learner=false + status_error="" + if [[ "$node" == "${FAKE_ETCD_LEARNER_NODE:-disabled}" ]]; then + learner=true + fi + if [[ "$node" == "${FAKE_ETCD_STATUS_ERROR_NODE:-disabled}" ]]; then + status_error=" rpc-timeout" + fi + if [[ "$node" == "${FAKE_ETCD_COMPACT_STATUS_NODE:-disabled}" ]]; then + printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER ERRORS\n' + printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s%s\n' \ + "$node" "$learner" "$status_error" + else + printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER PROTOCOL STORAGE ERRORS\n' + printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s 3.6.4 3.6.0%s\n' \ + "$node" "$learner" "$status_error" + fi exit 0 fi @@ -612,6 +628,7 @@ def setUp(self) -> None: done test -n "$node_target" owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" if [[ "$arguments" == *" --output json "* ]]; then owner="" if [[ -f "$owner_file" ]]; then @@ -626,14 +643,19 @@ def setUp(self) -> None: if [[ -f "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" ]]; then autoscaler_intent=true fi + resource_version=10 + if [[ -f "$resource_version_file" ]]; then + resource_version="$(<"$resource_version_file")" + fi jq -n \ --arg owner "$owner" \ + --arg resource_version "$resource_version" \ --argjson cordoned "$cordoned" \ --argjson autoscaler_intent "$autoscaler_intent" ' { metadata: { uid: "node-uid", - resourceVersion: "10", + resourceVersion: $resource_version, deletionTimestamp: null, annotations: ( if $owner == "" then {} @@ -741,6 +763,11 @@ def setUp(self) -> None: test -n "$node_target" test -f "$patch_file" owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" + resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" + current_resource_version=10 + if [[ -f "$resource_version_file" ]]; then + current_resource_version="$(<"$resource_version_file")" + fi if jq -e ' any(.[]; .op == "add" @@ -765,10 +792,10 @@ def setUp(self) -> None: ' "$patch_file")" test -n "$expected_owner" test ! -e "$owner_file" - jq -e ' + jq -e --arg resource_version "$current_resource_version" ' .[0].op == "test" and .[0].path == "/metadata/resourceVersion" - and .[0].value == "10" + and .[0].value == $resource_version and any(.[]; .op == "add" and .path == "/spec/unschedulable" @@ -776,6 +803,8 @@ def setUp(self) -> None: ' "$patch_file" >/dev/null printf '%s' "$expected_owner" > "$owner_file" touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + printf '%s' "$((current_resource_version + 1))" \ + > "$resource_version_file" printf 'node-claim-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" exit 0 fi @@ -789,7 +818,7 @@ def setUp(self) -> None: echo 'cordon ownership changed; refusing to uncordon' >&2 exit 56 fi - jq -e ' + jq -e --arg resource_version "$current_resource_version" ' .[0].op == "test" and .[0].path == ( "/metadata/annotations/platform.devantler.tech" @@ -798,7 +827,7 @@ def setUp(self) -> None: and any(.[]; .op == "test" and .path == "/metadata/resourceVersion" - and .value == "10") + and .value == $resource_version) and any(.[]; .op == "add" and .path == "/spec/unschedulable" @@ -811,6 +840,8 @@ def setUp(self) -> None: )) ' "$patch_file" >/dev/null printf 'node-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" + printf '%s' "$((current_resource_version + 1))" \ + > "$resource_version_file" rm "$owner_file" rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" exit 0 @@ -1360,6 +1391,38 @@ def test_control_plane_quorum_is_rechecked_after_the_drain(self) -> None: self.assertNotIn("talos-revision:10.0.0.1", operations) self.assertNotIn("root-patch", operations) + def test_unsafe_etcd_member_status_blocks_control_plane_reboot(self) -> None: + """Reject non-voting learners and status rows that report errors.""" + for environment_name in ( + "FAKE_ETCD_LEARNER_NODE", + "FAKE_ETCD_STATUS_ERROR_NODE", + ): + with self.subTest(environment_name=environment_name): + result = self._run_helper( + self._valid_config(), + **{environment_name: "10.0.0.3"}, + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text( + encoding="utf-8" + ).splitlines() + self.assertIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-reboot:10.0.0.1", operations) + self.assertNotIn("root-patch", operations) + + def test_compact_healthy_etcd_status_permits_control_plane_reboot(self) -> None: + """Accept Talos's status table without protocol/storage columns.""" + result = self._run_helper( + self._valid_config(), + FAKE_ETCD_COMPACT_STATUS_NODE="10.0.0.3", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-reboot:10.0.0.1", operations) + self.assertIn("root-patch", operations) + def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: """A node an operator cordoned must not come back schedulable. @@ -1405,6 +1468,12 @@ def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: operations.index("node-uncordon:prod-worker-1"), operations.index("talos-revision:10.0.0.2"), ) + self.assertEqual( + ( + self.sync_state_dir / "resource-version-prod-worker-1" + ).read_text(encoding="utf-8"), + "12", + ) def test_schedulable_node_is_claimed_and_cordoned_atomically(self) -> None: """Close the gap where a competing cordon could precede our drain.""" diff --git a/scripts/tests/test_validate_alert_coverage.py b/scripts/tests/test_validate_alert_coverage.py index cc0da5060..a9906125e 100644 --- a/scripts/tests/test_validate_alert_coverage.py +++ b/scripts/tests/test_validate_alert_coverage.py @@ -110,12 +110,16 @@ def _write_alert(self, *, wildcard: bool) -> None: encoding="utf-8", ) - def _run_validator(self) -> subprocess.CompletedProcess[str]: + def _run_validator( + self, **environment_overrides: str + ) -> subprocess.CompletedProcess[str]: """Run the copied validator with the real local kubectl and yq.""" + environment = os.environ.copy() + environment.update(environment_overrides) return subprocess.run( ["bash", str(self.script)], cwd=self.workspace, - env=os.environ.copy(), + env=environment, check=False, capture_output=True, text=True, @@ -178,6 +182,26 @@ def test_watched_resource_without_namespace_fails_closed(self) -> None: self.assertIn(kind, output) self.assertIn(name, output) + def test_yq_diagnostics_remain_visible_on_query_failure(self) -> None: + """Keep the parser's own reason when a fail-closed query errors.""" + bin_dir = self.workspace / "bin" + bin_dir.mkdir() + yq = bin_dir / "yq" + yq.write_text( + "#!/usr/bin/env bash\n" + "echo fixture-yq-query-diagnostic >&2\n" + "exit 72\n", + encoding="utf-8", + ) + yq.chmod(0o755) + + result = self._run_validator( + PATH=f"{bin_dir}:{os.environ['PATH']}", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("fixture-yq-query-diagnostic", result.stderr) + def test_ci_runs_the_behavioral_regressions_when_they_change(self) -> None: """Keep the validator's failure-mode coverage in the required CI job.""" workflow = CI_WORKFLOW.read_text(encoding="utf-8") diff --git a/scripts/validate-alert-coverage.sh b/scripts/validate-alert-coverage.sh index 217a808fe..e959e9659 100755 --- a/scripts/validate-alert-coverage.sh +++ b/scripts/validate-alert-coverage.sh @@ -89,7 +89,7 @@ for kind in HelmRelease Kustomization; do select(.kind == \"${kind}\" and (.apiVersion | test(\"^${group}/\"))) | select((.metadata.namespace // \"\") == \"\") | (.kind + \"/\" + (.metadata.name // \"\")) - " "${rendered}" 2>/dev/null > "${missing_namespace_raw}" + " "${rendered}" > "${missing_namespace_raw}" awk 'NF && $0 != "---"' \ "${missing_namespace_raw}" > "${missing_namespace}" if [[ -s "${missing_namespace}" ]]; then @@ -104,7 +104,7 @@ for kind in HelmRelease Kustomization; do select(.kind == \"${kind}\" and (.apiVersion | test(\"^${group}/\"))) | .metadata.namespace | select(. != null and . != \"\") - " "${rendered}" 2>/dev/null > "${declared_raw}" + " "${rendered}" > "${declared_raw}" awk 'NF && $0 != "---"' "${declared_raw}" \ | LC_ALL=C sort -u > "${declared}" From d857f37569b09b8b461609a5e70ab644fb392a58 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 03:29:51 +0200 Subject: [PATCH 10/22] fix(deploy): close GHCR rollout safety gaps --- scripts/refresh-flux-ghcr-auth-safety.sh | 142 +++- scripts/refresh-flux-ghcr-auth.sh | 768 ++++++++++++++---- .../test-refresh-flux-ghcr-auth-safety.sh | 54 +- scripts/tests/test_refresh_flux_ghcr_auth.py | 631 +++++++++++++- 4 files changed, 1370 insertions(+), 225 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh index b284eb474..25fac08f5 100644 --- a/scripts/refresh-flux-ghcr-auth-safety.sh +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -7,11 +7,12 @@ readonly GHCR_PULL_VERIFIED_REVISION_ANNOTATION="platform.devantler.tech/ghcr-pull-verified-revision-v2" readonly GHCR_PULL_VERIFIED_IMAGE_ANNOTATION="platform.devantler.tech/ghcr-pull-verified-image-v2" -# Select nodes that have not completed the reboot-backed v2 proof for both the -# incoming credential revision and the exact image used for the registry pull. -# Legacy unversioned annotations deliberately do not satisfy this selector: the -# old bridge wrote them without rebooting containerd, so every such node must -# roll once after this contract lands. +# Select nodes that have not completed the v2 proof for the incoming credential +# revision or the exact image used for the registry pull. Credential-stale nodes +# require a reboot because containerd loads registry auth only at process start; +# image-only drift needs an uncached pull proof but must not reboot a node whose +# current credential revision is already proven. Legacy unversioned annotations +# deliberately select reboot mode once after this contract lands. select_talos_node_targets() { local nodes_file="$1" local desired_revision="$2" @@ -25,10 +26,9 @@ select_talos_node_targets() { --arg revision_annotation "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION}" \ --arg image_annotation "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION}" ' .items[] - | select( - (.metadata.annotations[$revision_annotation] // "") != $revision - or (.metadata.annotations[$image_annotation] // "") != $image - ) + | (.metadata.annotations[$revision_annotation] // "") as $verified_revision + | (.metadata.annotations[$image_annotation] // "") as $verified_image + | select($verified_revision != $revision or $verified_image != $image) | (.metadata.labels // {}) as $labels | [ (if (($labels | has("node-role.kubernetes.io/control-plane")) @@ -37,9 +37,9 @@ select_talos_node_targets() { .metadata.name, ([.status.addresses[] | select(.type == "InternalIP") | .address][0]), - (.metadata.annotations[ - "platform.devantler.tech/ghcr-pull-desired-revision" - ] // "") + (if $verified_revision != $revision + then "reboot" else "image-only" end), + (.metadata.uid // "") ] | @tsv ' "${nodes_file}" > "${unsorted_targets}"; then @@ -55,6 +55,72 @@ select_talos_node_targets() { rm -f "${unsorted_targets}" } +# Validate the scheduling state captured immediately before a Talos reboot. +# The bridge must still own a cordon it created; a pre-existing cordon must stay +# ownerless, and neither path may proceed after UID, taint, deletion, or +# schedulability drift. The unschedulable taint mirrors spec.unschedulable and is +# excluded from the comparison; all other scheduling intent is preserved. +node_scheduling_state_is_safe_to_reboot() { + local state_file="$1" + local was_cordoned="$2" + local owner_token="$3" + local initial_node_uid="$4" + local initial_node_taints="$5" + + jq -e \ + --arg owner_annotation \ + "platform.devantler.tech/ghcr-auth-drain-owner" \ + --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" \ + --argjson was_cordoned "${was_cordoned}" \ + --argjson initial_taints "${initial_node_taints}" ' + .metadata.uid == $uid + and .metadata.deletionTimestamp == null + and .spec.unschedulable == true + and (if $was_cordoned == 0 then + .metadata.annotations[$owner_annotation] == $owner + else + (.metadata.annotations[$owner_annotation] // "") == "" + end) + and (((.spec.taints // []) + | map(select(( + .key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")])) + == $initial_taints) + ' "${state_file}" >/dev/null +} + +# Bind a selected node name to the same UID, InternalIP, and role immediately +# before any Talos API mutation. Names and addresses can be reused when an +# autoscaler replaces a node between inventory reads; the immutable UID keeps +# the bridge from patching or rebooting the wrong machine. +selected_node_identity_is_current() { + local state_file="$1" + local expected_name="$2" + local expected_uid="$3" + local expected_ip="$4" + local expected_role="$5" + + jq -e \ + --arg name "${expected_name}" \ + --arg uid "${expected_uid}" \ + --arg ip "${expected_ip}" \ + --arg role "${expected_role}" ' + .metadata.name == $name + and .metadata.uid == $uid + and .metadata.deletionTimestamp == null + and ([.status.addresses[]? + | select(.type == "InternalIP") | .address] == [$ip]) + and ((((.metadata.labels // {}) + | has("node-role.kubernetes.io/control-plane")) + or (((.metadata.labels // {}) + | has("node-role.kubernetes.io/master")))) == ($role == "1")) + ' "${state_file}" >/dev/null +} + # Reapply and verify the complete live Kubernetes pull-consumer fanout. Flux and # External Secrets reconcile independently, so a long Talos roll can overlap an # hourly controller pass that restores the previous Git value. @@ -89,25 +155,43 @@ sync_and_verify_kubernetes_fanout() { stage_fanout_before_talos() { local desired_revision="$1" local operator_image="$2" + local talos_sync_result="$3" + local stage_attempt=0 + local stage_attempts="${TALOS_CONVERGENCE_ATTEMPTS:-3}" local rc - shift 2 + shift 3 - sync_and_verify_kubernetes_fanout "$@" || { - rc=$? - return "${rc}" - } - sync_talos_registry_auth "${desired_revision}" "${operator_image}" || { - rc=$? - return "${rc}" - } - sync_and_verify_kubernetes_fanout "$@" || { - rc=$? - return "${rc}" - } - patch_root_secret || { - rc=$? - return "${rc}" - } + while ((stage_attempt < stage_attempts)); do + stage_attempt=$((stage_attempt + 1)) + sync_and_verify_kubernetes_fanout "$@" || { + rc=$? + return "${rc}" + } + sync_talos_registry_auth \ + "${desired_revision}" \ + "${operator_image}" \ + "${talos_sync_result}" || { + rc=$? + return "${rc}" + } + if grep -Fxq -- clean "${talos_sync_result}"; then + patch_root_secret || { + rc=$? + return "${rc}" + } + return 0 + fi + if ! grep -Fxq -- processed "${talos_sync_result}"; then + echo "::error::Talos synchronization returned an invalid convergence result; root Flux auth remains unchanged." + return 1 + fi + # A node mutation can overlap another controller reconciliation. Re-prove + # every consumer, then require a whole node convergence pass with no + # mutations before root cutover. This closes both race domains together. + done + + echo "::error::Kubernetes pull-consumer and Talos node state did not converge after ${stage_attempts} transaction rounds; root Flux auth remains unchanged." + return 1 } # Prove every OTHER production control-plane member is Kubernetes-Ready, diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 7f63df054..1f8a8f99e 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -37,6 +37,7 @@ readonly SECRET_FILE="${FLUX_GHCR_SECRET_FILE:-k8s/bases/bootstrap/secret.enc.ya readonly KUBE_CONTEXT="${KUBE_CONTEXT:-admin@prod}" readonly SYNC_ATTEMPTS="${FLUX_GHCR_SYNC_ATTEMPTS:-60}" readonly SYNC_INTERVAL="${FLUX_GHCR_SYNC_INTERVAL:-2}" +readonly TALOS_CONVERGENCE_ATTEMPTS="${FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS:-${SYNC_ATTEMPTS}}" readonly DRAIN_TIMEOUT="${FLUX_GHCR_DRAIN_TIMEOUT:-45m}" readonly CORDON_OWNER_ANNOTATION="platform.devantler.tech/ghcr-auth-drain-owner" readonly CORDON_OWNER_JSON_PATH="/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" @@ -56,6 +57,13 @@ readonly -a REQUIRED_PULL_TARGETS=( "devantler-tech/ksail:v${KSAIL_OPERATOR_VERSION}" "devantler-tech/provider-upjet-unifi:v0.1.0" ) +# These packages are intentionally private and have independent ACLs. A public +# image (including KSail itself) can prove registry reachability but cannot +# prove that containerd loaded a working credential. +readonly -a RUNTIME_CREDENTIAL_PROBE_IMAGES=( + "ghcr.io/devantler-tech/wedding-app:latest" + "ghcr.io/devantler-tech/ascoachingogvaner:latest" +) readonly -a FANOUT_NAMESPACES=( "wedding-app" "ascoachingogvaner" @@ -63,22 +71,43 @@ readonly -a FANOUT_NAMESPACES=( ) if ! [[ "${SYNC_ATTEMPTS}" =~ ^[1-9][0-9]*$ ]] \ + || ! [[ "${TALOS_CONVERGENCE_ATTEMPTS}" =~ ^[3-9]$|^[1-9][0-9]+$ ]] \ || ! [[ "${SYNC_INTERVAL}" =~ ^[0-9]+([.][0-9]+)?$ ]] \ || ! [[ "${DRAIN_TIMEOUT}" =~ ^[1-9][0-9]*(s|m|h)$ ]]; then - echo "::error::FLUX_GHCR_SYNC_ATTEMPTS and FLUX_GHCR_SYNC_INTERVAL must be non-negative numbers, with at least one attempt; FLUX_GHCR_DRAIN_TIMEOUT must be a positive whole number of seconds, minutes, or hours." + echo "::error::FLUX_GHCR_SYNC_ATTEMPTS must be positive, FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS must be at least 3, FLUX_GHCR_SYNC_INTERVAL must be non-negative, and FLUX_GHCR_DRAIN_TIMEOUT must be a positive whole number of seconds, minutes, or hours." exit 64 fi work_dir="$(mktemp -d)" -trap 'rm -rf "${work_dir}"' EXIT chmod 700 "${work_dir}" umask 077 +active_runtime_probe="" + +cleanup_refresh_work() { + if [[ -n "${active_runtime_probe}" ]]; then + kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + delete pod "${active_runtime_probe}" \ + --ignore-not-found \ + --wait=false \ + >/dev/null 2>&1 || true + fi + rm -rf "${work_dir}" +} +trap cleanup_refresh_work EXIT docker_config="${work_dir}/config.json" credentials_file="${work_dir}/credentials.json" basic_curl_config="${work_dir}/curl-basic.config" bearer_curl_config="${work_dir}/curl-bearer.config" token_response="${work_dir}/token.json" +current_root_secret_file="${work_dir}/current-root-secret.json" +current_root_docker_config="${work_dir}/current-root-config.json" +current_root_credentials_file="${work_dir}/current-root-credentials.json" +current_root_basic_curl_config="${work_dir}/current-root-curl-basic.config" +current_root_token_response="${work_dir}/current-root-token.json" +current_root_bearer_curl_config="${work_dir}/current-root-curl-bearer.config" patch_file="${work_dir}/patch.json" variables_patch_file="${work_dir}/variables-patch.json" expected_normalized="${work_dir}/expected-normalized.json" @@ -93,6 +122,16 @@ cordon_claim_patch_file="${work_dir}/cordon-claim-patch.json" cordon_release_patch_file="${work_dir}/cordon-release-patch.json" talos_nodes_file="${work_dir}/talos-nodes.json" talos_node_targets="${work_dir}/talos-node-targets.tsv" +talos_pending_targets="${work_dir}/talos-pending-targets.tsv" +talos_processed_targets="${work_dir}/talos-processed-targets.tsv" +talos_stage_result_file="${work_dir}/talos-stage-result.txt" +runtime_probe_nodes_file="${work_dir}/runtime-probe-nodes.json" +runtime_probe_targets_file="${work_dir}/runtime-probe-targets.tsv" +runtime_proved_targets_file="${work_dir}/runtime-proved-targets.txt" +runtime_probe_manifest_file="${work_dir}/runtime-probe-pod.json" +runtime_probe_state_file="${work_dir}/runtime-probe-state.json" +runtime_probe_result_file="${work_dir}/runtime-probe-result.txt" +runtime_probe_sequence=0 # Force an ESO resource to reconcile and observe a post-annotation Ready edge. force_sync_resource() { @@ -188,6 +227,299 @@ emit_safe_operation_output() { || true } +# Prove a Docker credential with real manifest reads for every package this +# deployment can pull. Callers provide mode-0600 curl config/temp paths so the +# credential never appears in argv or output. This serves both the incoming +# SOPS credential and the still-live root credential whose overlap keeps peers +# safe while the first stale node drains. +verify_ghcr_pull_credential() { + local basic_config="$1" + local token_file="$2" + local bearer_config="$3" + local credential_label="$4" + local target repository reference http_status + + for target in "${REQUIRED_PULL_TARGETS[@]}"; do + repository="${target%:*}" + reference="${target##*:}" + if ! http_status="$(curl --disable \ + --config "${basic_config}" \ + --silent \ + --show-error \ + --output "${token_file}" \ + --write-out '%{http_code}' \ + --get \ + --data-urlencode 'service=ghcr.io' \ + --data-urlencode "scope=repository:${repository}:pull" \ + 'https://ghcr.io/token')"; then + echo "::error::Could not request a GHCR pull token for ${repository} with the ${credential_label}; root Flux auth was not changed." + return 1 + fi + if [[ "${http_status}" != "200" ]] || ! jq -e ' + (.token // .access_token // "") + | type == "string" and length > 0 + ' "${token_file}" >/dev/null; then + echo "::error::The ${credential_label} could not obtain a pull token for ${repository} (GHCR HTTP ${http_status}); root Flux auth was not changed." + return 1 + fi + + jq -r ' + (.token // .access_token) as $token + | "header = " + (("Authorization: Bearer " + $token) | @json) + ' "${token_file}" > "${bearer_config}" + chmod 600 "${bearer_config}" + + if ! http_status="$(curl --disable \ + --config "${bearer_config}" \ + --silent \ + --show-error \ + --output /dev/null \ + --write-out '%{http_code}' \ + --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ + "https://ghcr.io/v2/${repository}/manifests/${reference}")"; then + echo "::error::Could not read the GHCR manifest for ${target} with the ${credential_label}; root Flux auth was not changed." + return 1 + fi + if [[ "${http_status}" != "200" ]]; then + echo "::error::The ${credential_label} cannot read ${target} (GHCR HTTP ${http_status}); root Flux auth was not changed." + return 1 + fi + done +} + +# Before the first credential-stale node is drained, prove that the credential +# still stored in the live root Secret remains accepted by every GHCR package. +# Peers have not rebooted onto the incoming credential yet, so a revoked old +# credential would make them unsafe eviction destinations. Root auth stays old +# until the complete Talos convergence succeeds. +verify_current_root_credential_overlap() { + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + get secret ksail-registry-credentials \ + -o json \ + > "${current_root_secret_file}"; then + echo "::error::Could not read the current root GHCR credential; refusing to drain onto peers whose runtime credential cannot be proved." + return 1 + fi + if ! jq -er '.data[".dockerconfigjson"] | @base64d' \ + "${current_root_secret_file}" \ + > "${current_root_docker_config}" 2>/dev/null \ + || ! jq -e . "${current_root_docker_config}" >/dev/null 2>&1; then + echo "::error::The current root GHCR credential is malformed; refusing to drain onto unproved peers." + return 1 + fi + if ! write_flux_ghcr_credentials \ + "${current_root_docker_config}" \ + "${current_root_credentials_file}"; then + echo "::error::The current root GHCR credential cannot be parsed; refusing to drain onto unproved peers." + return 1 + fi + jq -r ' + "user = " + ((.username + ":" + .password) | @json) + ' "${current_root_credentials_file}" \ + > "${current_root_basic_curl_config}" + chmod 600 \ + "${current_root_docker_config}" \ + "${current_root_credentials_file}" \ + "${current_root_basic_curl_config}" + + verify_ghcr_pull_credential \ + "${current_root_basic_curl_config}" \ + "${current_root_token_response}" \ + "${current_root_bearer_curl_config}" \ + "current root GHCR credential" +} + +delete_runtime_pull_probe() { + local probe_name="$1" + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + delete pod "${probe_name}" \ + --ignore-not-found \ + --wait=false \ + > "${runtime_probe_result_file}" 2>&1; then + echo "::error::Could not remove runtime pull probe ${probe_name}; root Flux auth remains unchanged." + emit_safe_operation_output "runtime-probe-delete" \ + "${runtime_probe_result_file}" + return 1 + fi + if [[ "${active_runtime_probe}" == "${probe_name}" ]]; then + active_runtime_probe="" + fi +} + +# Exercise each possible eviction destination through kubelet/containerd with +# no imagePullSecret. A valid live root Secret is not sufficient evidence: in +# the legacy outage state, machine config already held the new token while the +# running runtime still presented a revoked predecessor. imagePullPolicy Always +# forces a registry resolution even when the exact private image is cached. +probe_node_runtime_pull() { + local node_name="$1" + local probe_image="$2" + local probe_name + local attempt image_id waiting_reason + + runtime_probe_sequence=$((runtime_probe_sequence + 1)) + probe_name="ghcr-runtime-probe-$$-${RANDOM}-${runtime_probe_sequence}" + jq -n \ + --arg name "${probe_name}" \ + --arg node "${node_name}" \ + --arg image "${probe_image}" ' + { + apiVersion: "v1", + kind: "Pod", + metadata: { + name: $name, + namespace: "ksail-operator", + labels: { + "app.kubernetes.io/name": "ghcr-runtime-probe", + "app.kubernetes.io/component": "credential-verification", + "app.kubernetes.io/managed-by": "refresh-flux-ghcr-auth" + } + }, + spec: { + nodeName: $node, + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: "Never", + terminationGracePeriodSeconds: 0, + securityContext: { + runAsNonRoot: true, + runAsUser: 65532, + runAsGroup: 65532, + seccompProfile: {type: "RuntimeDefault"} + }, + containers: [{ + name: "pull-probe", + image: $image, + imagePullPolicy: "Always", + args: ["--version"], + resources: { + requests: {cpu: "10m", memory: "16Mi"}, + limits: {cpu: "100m", memory: "64Mi"} + }, + securityContext: { + allowPrivilegeEscalation: false, + readOnlyRootFilesystem: true, + capabilities: {drop: ["ALL"]} + } + }] + } + } + ' > "${runtime_probe_manifest_file}" + + active_runtime_probe="${probe_name}" + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + create --filename "${runtime_probe_manifest_file}" \ + -o name \ + > "${runtime_probe_result_file}" 2>&1; then + echo "::error::Could not create a kubelet/containerd GHCR pull probe on ${node_name}; refusing to drain onto an unproved runtime." + emit_safe_operation_output "runtime-probe-create" \ + "${runtime_probe_result_file}" + return 1 + fi + + for ((attempt = 1; attempt <= SYNC_ATTEMPTS; attempt++)); do + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + get pod "${probe_name}" \ + -o json \ + > "${runtime_probe_state_file}" 2> "${runtime_probe_result_file}"; then + echo "::error::Could not read the kubelet/containerd GHCR pull probe on ${node_name}; refusing to drain onto an unproved runtime." + emit_safe_operation_output "runtime-probe-read" \ + "${runtime_probe_result_file}" + delete_runtime_pull_probe "${probe_name}" || true + return 1 + fi + if ! jq -e \ + '(.spec.imagePullSecrets // [] | length) == 0' \ + "${runtime_probe_state_file}" >/dev/null; then + delete_runtime_pull_probe "${probe_name}" || true + echo "::error::Runtime probe on ${node_name} received an imagePullSecret, so it did not prove the running containerd credential; refusing the drain." + return 1 + fi + image_id="$(jq -r \ + '.status.containerStatuses[0].imageID // ""' \ + "${runtime_probe_state_file}")" + if [[ -n "${image_id}" ]]; then + delete_runtime_pull_probe "${probe_name}" || return 1 + return 0 + fi + waiting_reason="$(jq -r \ + '.status.containerStatuses[0].state.waiting.reason // ""' \ + "${runtime_probe_state_file}")" + case "${waiting_reason}" in + ErrImagePull|ImagePullBackOff|InvalidImageName) + delete_runtime_pull_probe "${probe_name}" || true + echo "::error::The running containerd on ${node_name} could not pull ${probe_image} (${waiting_reason}); refusing to drain workloads onto peers with unproved runtime auth." + return 1 + ;; + esac + sleep "${SYNC_INTERVAL}" + done + + delete_runtime_pull_probe "${probe_name}" || true + echo "::error::Timed out proving the running containerd GHCR credential on ${node_name}; refusing to drain workloads onto an unproved runtime." + return 1 +} + +verify_peer_runtime_pull_overlap() { + local draining_node="$1" + local peer_name peer_uid + local probe_image + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get nodes \ + -o json \ + > "${runtime_probe_nodes_file}"; then + echo "::error::Could not list eviction destinations for runtime GHCR proof; refusing to drain ${draining_node}." + return 1 + fi + if ! validate_talos_node_inventory "${runtime_probe_nodes_file}"; then + echo "::error::Eviction-destination inventory was ambiguous during runtime GHCR proof; refusing to drain ${draining_node}." + return 1 + fi + if ! jq -r \ + --arg draining "${draining_node}" ' + .items[] + | select(.metadata.name != $draining) + | select(.metadata.deletionTimestamp == null) + | select((.spec.unschedulable // false) == false) + | select(any(.status.conditions[]?; + .type == "Ready" and .status == "True")) + | [.metadata.name, .metadata.uid] + | @tsv + ' "${runtime_probe_nodes_file}" > "${runtime_probe_targets_file}"; then + echo "::error::Could not select eviction destinations for runtime GHCR proof; refusing to drain ${draining_node}." + return 1 + fi + if [[ ! -s "${runtime_probe_targets_file}" ]]; then + echo "::error::No Ready schedulable peer can receive workloads while ${draining_node} reboots; refusing the drain." + return 1 + fi + + while IFS=$'\t' read -r peer_name peer_uid; do + [[ -n "${peer_name}" && -n "${peer_uid}" ]] || { + echo "::error::Eviction-destination identity was empty during runtime GHCR proof; refusing to drain ${draining_node}." + return 1 + } + if grep -Fqx -- "${peer_uid}" "${runtime_proved_targets_file}"; then + continue + fi + for probe_image in "${RUNTIME_CREDENTIAL_PROBE_IMAGES[@]}"; do + probe_node_runtime_pull "${peer_name}" "${probe_image}" || return 1 + done + printf '%s\n' "${peer_uid}" >> "${runtime_proved_targets_file}" + done < "${runtime_probe_targets_file}" +} + # Atomically claim the right to reverse the cordon and make the node # unschedulable. Combining both mutations closes the gap where another actor # could cordon after our ownership annotation but before kubectl drain. A bare @@ -270,24 +602,12 @@ restore_node_schedulability_if_needed() { emit_safe_operation_output "uncordon-read" "${result_file}" return 1 fi - if ! jq -e \ - --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ - --arg owner "${owner_token}" \ - --arg uid "${initial_node_uid}" \ - --argjson initial_taints "${initial_node_taints}" ' - .metadata.uid == $uid - and .metadata.deletionTimestamp == null - and .spec.unschedulable == true - and .metadata.annotations[$owner_annotation] == $owner - and (((.spec.taints // []) - | map(select(( - .key == "node.kubernetes.io/unschedulable" - and .effect == "NoSchedule" - and (.value // "") == "" - ) | not)) - | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")])) - == $initial_taints) - ' "${cordon_state_file}" >/dev/null; then + if ! node_scheduling_state_is_safe_to_reboot \ + "${cordon_state_file}" \ + "${was_cordoned}" \ + "${owner_token}" \ + "${initial_node_uid}" \ + "${initial_node_taints}"; then echo "::error::Cordon ownership changed or scheduling safety state changed for Talos node ${node_name}; refusing to uncordon it." return 1 fi @@ -323,6 +643,41 @@ restore_node_schedulability_if_needed() { echo "Restored schedulability on ${node_name}." } +# Close every post-cordon scheduling race. A drain, reboot, readiness wait, or +# image proof can outlive an operator/autoscaler change, so re-read before each +# destructive Talos edge and fail closed when the captured guard no longer holds. +revalidate_node_scheduling_guard() { + local node_name="$1" was_cordoned="$2" owner_token="$3" + local initial_node_uid="$4" initial_node_taints="$5" result_file="$6" + local selected_node_ip="$7" selected_node_role="$8" + local operation="$9" + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2> "${result_file}"; then + echo "::error::Could not re-read Talos node ${node_name} immediately before ${operation}; refusing the mutation." + emit_safe_operation_output "scheduling-guard" "${result_file}" + return 1 + fi + if ! selected_node_identity_is_current \ + "${cordon_state_file}" \ + "${node_name}" \ + "${initial_node_uid}" \ + "${selected_node_ip}" \ + "${selected_node_role}" \ + || ! node_scheduling_state_is_safe_to_reboot \ + "${cordon_state_file}" \ + "${was_cordoned}" \ + "${owner_token}" \ + "${initial_node_uid}" \ + "${initial_node_taints}"; then + echo "::error::Talos node ${node_name} identity changed, cordon ownership changed, or scheduling safety state changed before ${operation}; refusing the mutation." + return 1 + fi +} + # Talos returns gRPC NotFound with the exact image reference when that image is # already absent from the selected runtime namespace. Match both so transport, # authorization, and unrelated removal failures remain fatal. @@ -335,72 +690,52 @@ talos_image_remove_reports_absent() { "${result_file}" } -# Apply Git/SOPS auth to stale Talos nodes, reboot them so containerd actually -# adopts the credential, prove an uncached pull of the declared incoming image, -# and only then record its non-secret revision+image proof markers so either -# credential or target changes trigger verification. -sync_talos_registry_auth() { - local desired_revision="$1" - local operator_image="$2" - local node_name - local node_ip - local node_role - local _node_desired +revalidate_selected_node_identity_before_mutation() { + local node_name="$1" node_uid="$2" node_ip="$3" node_role="$4" if ! kubectl \ --context "${KUBE_CONTEXT}" \ - get nodes \ - -o json \ - > "${talos_nodes_file}"; then - echo "::error::Could not list Talos nodes; refusing to mutate any Kubernetes credential consumers." + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2> "${talos_result_file}"; then + echo "::error::Could not re-read Talos node ${node_name} before mutation; refusing to target a stale address." + emit_safe_operation_output "node-identity" "${talos_result_file}" return 1 fi - # talosctl connects to the public control-plane endpoints in talosconfig and - # proxies --nodes targets through them. Target addresses therefore must be - # the stable InternalIPs as seen by those endpoint servers, not client-facing - # ExternalIPs (Talos v1.13 "Endpoints and nodes"). - if ! jq -e ' - (.items | length) > 0 - and all(.items[]; - ([.status.addresses[]? | select(.type == "InternalIP") | .address] - | length) == 1 - and (([.status.addresses[]? - | select(.type == "InternalIP") | .address][0]) - | type == "string" and length > 0)) - and (([.items[] - | [.status.addresses[]? - | select(.type == "InternalIP") | .address][0]] - | unique | length) == (.items | length)) - ' "${talos_nodes_file}" >/dev/null; then - echo "::error::Every Talos node must expose exactly one non-empty, unique InternalIP before GHCR auth can be synchronized." + if ! selected_node_identity_is_current \ + "${cordon_state_file}" \ + "${node_name}" \ + "${node_uid}" \ + "${node_ip}" \ + "${node_role}"; then + echo "::error::Talos node ${node_name} identity changed after inventory selection; refusing to patch, drain, or reboot it." return 1 fi +} - if ! select_talos_node_targets \ - "${talos_nodes_file}" \ - "${desired_revision}" \ - "${operator_image}" \ - "${talos_node_targets}"; then - echo "::error::Could not select Talos nodes requiring the GHCR auth revision." - return 1 - fi +# Apply Git/SOPS auth to stale Talos nodes, reboot them so containerd actually +# adopts the credential, prove an uncached pull of the declared incoming image, +# and only then record its non-secret revision+image proof markers so either +# credential or target changes trigger verification. +process_talos_node_target() { + local desired_revision="$1" + local operator_image="$2" + local node_role="$3" + local node_name="$4" + local node_ip="$5" + local node_mode="$6" + local node_uid="$7" + local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" + local initial_node_uid="" initial_node_taints="[]" - # Normal deploys should not regain an all-node Talos API dependency once the - # current ciphertext revision has been proved on every node. - if [[ ! -s "${talos_node_targets}" ]]; then - return 0 + if [[ "${node_mode}" != "reboot" && "${node_mode}" != "image-only" ]]; then + echo "::error::Unknown Talos GHCR synchronization mode '${node_mode}' for ${node_name}." + return 1 fi + revalidate_selected_node_identity_before_mutation \ + "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 - : > "${talos_result_file}" - chmod 600 "${talos_result_file}" - : > "${drain_result_file}" - chmod 600 "${drain_result_file}" - : > "${reboot_result_file}" - chmod 600 "${reboot_result_file}" - # Targets are pre-sorted workers-first, and this loop is strictly sequential, - # so the reboot below rolls one node at a time and control planes go last — - # etcd keeps quorum throughout. - while IFS=$'\t' read -r node_role node_name node_ip _node_desired; do + if [[ "${node_mode}" == "reboot" ]]; then if ! talosctl \ --nodes "${node_ip}" \ patch machineconfig \ @@ -437,27 +772,10 @@ sync_talos_registry_auth() { # The pull check proves the CREDENTIAL is good; only the reboot proves # CONTAINERD is using it. # - # The reboot is UNCONDITIONAL for every selected node, which does mean a - # freshly-autoscaled node — whose containerd already booted with the current - # credential — gets one avoidable reboot on the next deploy. That is - # deliberate, and the tempting optimisation is a trap: - # - # The obvious way to spot a "fresh" node is its ghcr-pull-desired-revision - # marker (mark-ghcr-pull-revision.yaml). But that marker tracks MACHINE - # CONFIG state, and machine-config state being decoupled from what containerd - # actually loaded IS the bug this whole function exists to fix. The deploy - # runs this bridge twice — once before `ksail cluster update` and once after - # (deploy-prod action) — and `cluster update` stamps the current desired - # marker onto every node, including ones whose containerd is still holding - # the old credential. Any node the manual runbook path updates ahead of the - # bridge lands in the same state. Skipping a reboot on that evidence would - # silently re-create the exact 2026-07-14 outage. - # - # Correctness beats one wasted reboot: an over-eager reboot costs ~90s on a - # drained node, a skipped one costs a day of silent ImagePullBackOff. Skipping - # provably-fresh nodes needs evidence about CONTAINERD (its start time versus - # when the credential landed), not about the machine config — tracked - # separately. + # Credential-revision drift always takes this reboot path; a desired-machine + # marker is not evidence that the running containerd loaded the credential. + # A node whose v2 credential proof is already current but whose declared + # image changed takes the image-only path below and is never rebooted. # # etcd tolerates exactly one control plane down in a 3-member cluster. This # loop is serial and control planes sort last, but a peer can be @@ -470,21 +788,26 @@ sync_talos_registry_auth() { echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready with healthy, alarm-free etcd, so rebooting this one risks quorum." return 1 fi + fi - # Remember scheduling intent before any cordon. Atomically claim and cordon - # a schedulable node so cleanup can prove that no newer actor replaced our - # ownership, while a competing cordon that wins first makes the claim fail. - local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" - local initial_node_uid="" initial_node_taints="[]" + # Remember scheduling intent before any cordon. Both reboot and image-only + # verification exclude new placements while the exact target is removed; + # only the reboot path drains existing workloads. if ! kubectl \ --context "${KUBE_CONTEXT}" \ get node "${node_name}" \ --output json \ > "${cordon_state_file}"; then - echo "::error::Refusing to reboot ${node_name}: its scheduling state could not be read." + echo "::error::Refusing to synchronize ${node_name}: its scheduling state could not be read." return 1 fi - if ! jq -e \ + if ! selected_node_identity_is_current \ + "${cordon_state_file}" \ + "${node_name}" \ + "${node_uid}" \ + "${node_ip}" \ + "${node_role}" \ + || ! jq -e \ --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" ' (.metadata.uid | type == "string" and length > 0) and (.metadata.resourceVersion | type == "string" and length > 0) @@ -492,7 +815,7 @@ sync_talos_registry_auth() { and ((.metadata.annotations[$owner_annotation] // "") | type == "string") ' "${cordon_state_file}" >/dev/null; then - echo "::error::Refusing to reboot ${node_name}: its scheduling state was malformed." + echo "::error::Refusing to synchronize ${node_name}: its identity changed or scheduling state was malformed." return 1 fi initial_node_uid="$(jq -r '.metadata.uid' "${cordon_state_file}")" @@ -510,7 +833,7 @@ sync_talos_registry_auth() { '.metadata.annotations[$owner_annotation] // ""' \ "${cordon_state_file}")" if [[ -n "${existing_cordon_owner}" ]]; then - echo "::error::Refusing to reboot ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." + echo "::error::Refusing to synchronize ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." return 1 fi if jq -e '.spec.unschedulable == true' \ @@ -523,6 +846,7 @@ sync_talos_registry_auth() { "${cordon_state_file}" "${drain_result_file}" || return 1 fi + if [[ "${node_mode}" == "reboot" ]]; then # Drain through the Kubernetes context already proven by this deployment. # Talos v1.13's integrated --drain path fetches a separate admin kubeconfig; # this cluster's generated config targets an unreachable API endpoint. @@ -558,6 +882,16 @@ sync_talos_registry_auth() { return 1 fi + if ! revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" "${node_ip}" "${node_role}" "reboot"; then + # Scheduling intent changed after the PDB-respecting drain. Never reboot + # or undo the newer actor's decision; leave the node in its observed state + # for an operator or the next run to reconcile explicitly. + return 1 + fi + # The node is now cordoned and fully drained under PDB control, so a plain # Talos reboot cannot terminate a workload behind Kubernetes' back. Keep # --wait explicit so Kubernetes readiness is checked only after a new boot. @@ -581,6 +915,16 @@ sync_talos_registry_auth() { emit_safe_operation_output "ready" "${reboot_result_file}" return 1 fi + fi + + # A reboot/readiness wait or even a short image-only cordon can outlive a + # replacement, uncordon, taint, or owner change. Rebind identity and the + # scheduling guard at the final Talos edge before touching the image cache. + revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${talos_result_file}" "${node_ip}" "${node_role}" \ + "image verification" || return 1 # A cached image can make a pull look healthy without proving that the # node's runtime can authenticate to GHCR. Remove the incoming exact target @@ -608,9 +952,9 @@ sync_talos_registry_auth() { return 1 fi - # Restore original scheduling intent after runtime auth is proven but - # before recording success. If uncordon fails, a later run must retry this - # node instead of skipping one the bridge left unschedulable. + # Restore original scheduling intent only after the uncached pull succeeds. + # On failure, a cordon claimed by this bridge remains as a fail-closed signal + # that the node must not receive new pods until registry access is repaired. restore_node_schedulability_if_needed \ "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ "${initial_node_uid}" "${initial_node_taints}" \ @@ -628,7 +972,151 @@ sync_talos_registry_auth() { echo "::error::Talos node ${node_name} proved GHCR access but could not record the synchronized credential revision." return 1 fi - done < "${talos_node_targets}" +} + +validate_talos_node_inventory() { + local nodes_file="$1" + + # talosctl proxies node targets through the public control-plane endpoints, + # so use the stable, unique InternalIP. UID is part of convergence identity: + # an autoscaler replacement may reuse a name or address and still needs proof. + jq -e ' + (.items | length) > 0 + and all(.items[]; + (.metadata.name | type == "string" and test("^[^\\t\\r\\n]+$")) + and (.metadata.uid | type == "string" and test("^[^\\t\\r\\n]+$")) + and ([.status.addresses[]? + | select(.type == "InternalIP") | .address] | length) == 1 + and (([.status.addresses[]? + | select(.type == "InternalIP") | .address][0]) + | type == "string" and test("^[^\\t\\r\\n]+$"))) + and (([.items[].metadata.uid] | unique | length) == (.items | length)) + and (([.items[] + | [.status.addresses[]? + | select(.type == "InternalIP") | .address][0]] + | unique | length) == (.items | length)) + ' "${nodes_file}" >/dev/null +} + +# Converge the live node set, rather than trusting one inventory captured before +# a potentially long roll. Completed node UIDs are not rolled twice while their +# Kubernetes annotations propagate; newly autoscaled/replaced nodes are picked +# up in the next pass. Two consecutive clean inventories close the common +# cutover race, and the bounded loop fails before root auth changes if the set +# never stabilizes. +sync_talos_registry_auth() { + local desired_revision="$1" + local operator_image="$2" + local sync_result_file="$3" + local convergence_attempt=0 + local consecutive_clean_inventories=0 + local processed_any_node=0 + local node_role node_name node_ip node_mode node_uid + + : > "${talos_result_file}" + : > "${drain_result_file}" + : > "${reboot_result_file}" + : > "${talos_processed_targets}" + : > "${sync_result_file}" + : > "${runtime_proved_targets_file}" + chmod 600 \ + "${talos_result_file}" \ + "${drain_result_file}" \ + "${reboot_result_file}" \ + "${talos_processed_targets}" \ + "${sync_result_file}" \ + "${runtime_proved_targets_file}" + + while ((convergence_attempt < TALOS_CONVERGENCE_ATTEMPTS)); do + convergence_attempt=$((convergence_attempt + 1)) + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get nodes \ + -o json \ + > "${talos_nodes_file}"; then + echo "::error::Could not list Talos nodes; refusing to mutate any Kubernetes credential consumers." + return 1 + fi + if ! validate_talos_node_inventory "${talos_nodes_file}"; then + echo "::error::Every Talos node must expose a non-empty unique UID and exactly one non-empty unique InternalIP before GHCR auth can be synchronized." + return 1 + fi + if ! select_talos_node_targets \ + "${talos_nodes_file}" \ + "${desired_revision}" \ + "${operator_image}" \ + "${talos_node_targets}"; then + echo "::error::Could not select Talos nodes requiring GHCR synchronization." + return 1 + fi + + : > "${talos_pending_targets}" + while IFS=$'\t' read -r \ + node_role node_name node_ip node_mode node_uid; do + [[ -n "${node_name}" ]] || continue + if grep -Fqx -- "${node_uid}" "${talos_processed_targets}"; then + continue + fi + printf '%s\t%s\t%s\t%s\t%s\n' \ + "${node_role}" "${node_name}" "${node_ip}" \ + "${node_mode}" "${node_uid}" \ + >> "${talos_pending_targets}" + done < "${talos_node_targets}" + + if [[ ! -s "${talos_pending_targets}" ]]; then + if [[ ! -s "${talos_node_targets}" ]]; then + consecutive_clean_inventories=$((consecutive_clean_inventories + 1)) + if ((consecutive_clean_inventories >= 2)); then + if ((processed_any_node == 1)); then + printf '%s\n' processed > "${sync_result_file}" + else + printf '%s\n' clean > "${sync_result_file}" + fi + return 0 + fi + else + # A completed node can remain in the selector briefly while Talos node + # annotations propagate back to Kubernetes. Wait; never re-roll it. + consecutive_clean_inventories=0 + fi + else + consecutive_clean_inventories=0 + # A valid previous credential is the safety bridge for the first drain: + # not-yet-rebooted peers must still be able to pull evicted workloads. + # Re-prove overlap for every newly discovered reboot batch. + if awk -F '\t' '$4 == "reboot" { found = 1 } END { exit !found }' \ + "${talos_pending_targets}"; then + verify_current_root_credential_overlap || return 1 + fi + + # Targets are sorted workers-first and processed strictly sequentially, + # so only one node is down and control planes go last. + while IFS=$'\t' read -r \ + node_role node_name node_ip node_mode node_uid; do + if [[ "${node_mode}" == "reboot" ]]; then + verify_peer_runtime_pull_overlap \ + "${node_name}" || return 1 + fi + process_talos_node_target \ + "${desired_revision}" \ + "${operator_image}" \ + "${node_role}" \ + "${node_name}" \ + "${node_ip}" \ + "${node_mode}" \ + "${node_uid}" || return 1 + processed_any_node=1 + printf '%s\n' "${node_uid}" >> "${talos_processed_targets}" + done < "${talos_pending_targets}" + fi + + if ((convergence_attempt < TALOS_CONVERGENCE_ATTEMPTS)); then + sleep "${SYNC_INTERVAL}" + fi + done + + echo "::error::Talos node inventory did not converge after ${TALOS_CONVERGENCE_ATTEMPTS} checks; root Flux auth remains unchanged." + return 1 } # KSail embeds SOPS, so the deploy uses the same pinned toolchain as workload @@ -646,59 +1134,14 @@ jq -r ' ' "${credentials_file}" > "${basic_curl_config}" chmod 600 "${basic_curl_config}" -# The same pull credential fans out to Flux OCI sources and private tenant -# workloads. GHCR permissions are package-granular, and the token endpoint can -# return only the intersection of requested and granted scopes. Therefore a -# token HTTP 200 is not proof of pull access: exchange it for a bearer token, -# then perform a real registry manifest GET for every package. Both credentials -# stay in mode-0600 files. --disable must remain curl's first argument so an -# ambient ~/.curlrc cannot enable tracing, add URLs, or otherwise expose auth. -for target in "${REQUIRED_PULL_TARGETS[@]}"; do - repository="${target%:*}" - reference="${target##*:}" - if ! http_status="$(curl --disable \ - --config "${basic_curl_config}" \ - --silent \ - --show-error \ - --output "${token_response}" \ - --write-out '%{http_code}' \ - --get \ - --data-urlencode 'service=ghcr.io' \ - --data-urlencode "scope=repository:${repository}:pull" \ - 'https://ghcr.io/token')"; then - echo "::error::Could not request a GHCR pull token for ${repository}; the root Flux Secret was not changed." - exit 1 - fi - if [[ "${http_status}" != "200" ]] || ! jq -e ' - (.token // .access_token // "") - | type == "string" and length > 0 - ' "${token_response}" >/dev/null; then - echo "::error::The SOPS GHCR credential could not obtain a pull token for ${repository} (GHCR HTTP ${http_status}); the root Flux Secret was not changed." - exit 1 - fi - - jq -r ' - (.token // .access_token) as $token - | "header = " + (("Authorization: Bearer " + $token) | @json) - ' "${token_response}" > "${bearer_curl_config}" - chmod 600 "${bearer_curl_config}" - - if ! http_status="$(curl --disable \ - --config "${bearer_curl_config}" \ - --silent \ - --show-error \ - --output /dev/null \ - --write-out '%{http_code}' \ - --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json' \ - "https://ghcr.io/v2/${repository}/manifests/${reference}")"; then - echo "::error::Could not read the GHCR manifest for ${target}; the root Flux Secret was not changed." - exit 1 - fi - if [[ "${http_status}" != "200" ]]; then - echo "::error::The SOPS GHCR pull credential cannot read ${target} (GHCR HTTP ${http_status}); the root Flux Secret was not changed." - exit 1 - fi -done +# GHCR permissions are package-granular, so a token response alone is not proof +# of access. Exchange and read every required manifest with the incoming SOPS +# credential before touching any cluster consumer. +verify_ghcr_pull_credential \ + "${basic_curl_config}" \ + "${token_response}" \ + "${bearer_curl_config}" \ + "SOPS GHCR credential" || exit 1 if [[ "${check_only}" == "true" ]]; then echo "✅ Validated every required GHCR package pull from Git/SOPS." @@ -860,6 +1303,7 @@ fi stage_fanout_before_talos \ "${pull_revision}" \ "${KSAIL_OPERATOR_IMAGE}" \ + "${talos_stage_result_file}" \ "${FANOUT_NAMESPACES[@]}" echo "✅ Synchronised every existing consumer and refreshed root Flux GHCR auth from Git/SOPS." diff --git a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh index 018321b33..1a86069f9 100644 --- a/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh +++ b/scripts/tests/test-refresh-flux-ghcr-auth-safety.sh @@ -31,6 +31,8 @@ legacy_nodes="${work_dir}/legacy-nodes.json" legacy_targets="${work_dir}/legacy-targets.tsv" current_nodes="${work_dir}/current-nodes.json" current_targets="${work_dir}/current-targets.tsv" +image_only_nodes="${work_dir}/image-only-nodes.json" +image_only_targets="${work_dir}/image-only-targets.tsv" readonly DESIRED_REVISION="ciphertext-revision" readonly DESIRED_IMAGE="ghcr.io/devantler-tech/ksail:v7.170.1" @@ -60,10 +62,10 @@ if declare -F select_talos_node_targets >/dev/null; then "${DESIRED_REVISION}" \ "${DESIRED_IMAGE}" \ "${legacy_targets}" \ - && [[ -s "${legacy_targets}" ]]; then - pass "legacy verification markers force a one-time reboot" + && [[ "$(cut -f4 "${legacy_targets}")" == "reboot" ]]; then + pass "legacy verification markers select reboot mode" else - fail "legacy verification markers force a one-time reboot" + fail "legacy verification markers select reboot mode" fi jq -n \ @@ -100,9 +102,44 @@ if declare -F select_talos_node_targets >/dev/null; then else fail "v2 post-reboot markers suppress an already-proved reboot" fi + + jq -n \ + --arg revision "${DESIRED_REVISION}" \ + --arg previous_image "ghcr.io/devantler-tech/ksail:v7.170.0" \ + --arg revision_key "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION:-}" \ + --arg image_key "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION:-}" ' + { + items: [{ + metadata: { + name: "prod-worker-1", + uid: "worker-1-uid", + labels: {}, + annotations: { + ($revision_key): $revision, + ($image_key): $previous_image + } + }, + status: {addresses: [ + {type: "InternalIP", address: "10.0.0.4"} + ]} + }] + } + ' > "${image_only_nodes}" + + if select_talos_node_targets \ + "${image_only_nodes}" \ + "${DESIRED_REVISION}" \ + "${DESIRED_IMAGE}" \ + "${image_only_targets}" \ + && [[ "$(cut -f4 "${image_only_targets}")" == "image-only" ]]; then + pass "a changed image with current credentials selects image-only mode" + else + fail "a changed image with current credentials selects image-only mode" + fi else - fail "legacy verification markers force a one-time reboot" + fail "legacy verification markers select reboot mode" fail "v2 post-reboot markers suppress an already-proved reboot" + fail "a changed image with current credentials selects image-only mode" fi operation_log="${work_dir}/operations.log" @@ -116,7 +153,13 @@ verify_consumer_secret() { printf 'verify:%s/ghcr-auth\n' "$1" >> "${operation_log}" } sync_talos_registry_auth() { + talos_sync_call_count=$((talos_sync_call_count + 1)) printf 'talos:%s:%s\n' "$1" "$2" >> "${operation_log}" + if ((talos_sync_call_count == 1)); then + printf '%s\n' processed > "$3" + else + printf '%s\n' clean > "$3" + fi } patch_root_secret() { printf '%s\n' root-patch >> "${operation_log}" @@ -124,9 +167,11 @@ patch_root_secret() { if declare -F stage_fanout_before_talos >/dev/null; then : > "${operation_log}" + talos_sync_call_count=0 stage_fanout_before_talos \ "${DESIRED_REVISION}" \ "${DESIRED_IMAGE}" \ + "${work_dir}/talos-stage-result.txt" \ wedding-app ascoachingogvaner kyverno expected_operations="$(printf '%s\n' \ variables-patch \ @@ -146,6 +191,7 @@ if declare -F stage_fanout_before_talos >/dev/null; then verify:ascoachingogvaner/ghcr-auth \ force:externalsecret/kyverno/ghcr-auth \ verify:kyverno/ghcr-auth \ + "talos:${DESIRED_REVISION}:${DESIRED_IMAGE}" \ root-patch)" if [[ "$(<"${operation_log}")" == "${expected_operations}" ]]; then pass "verified tenant fanout brackets the Talos rollout" diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 6f0352b19..78dbaad4b 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -299,8 +299,13 @@ def setUp(self) -> None: exit 0 fi - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + else + # Image-only verification is allowed only when the fixture + # says this credential revision was already reboot-proved. + test "${FAKE_TALOS_NODES_CURRENT:-false}" = true + fi test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" test -f "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" jq -e --arg revision "$EXPECTED_GHCR_REVISION" ' @@ -321,6 +326,13 @@ def setUp(self) -> None: exit 48 fi touch "$FAKE_SYNC_STATE_DIR/talos-revision-${node}" + if [[ "$node" == 10.0.0.5 \ + && -n "${FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE:-}" ]]; then + touch "$FAKE_SYNC_STATE_DIR/consumer-reverted-${FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE}" + printf 'consumer-revert:%s\n' \ + "$FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE" \ + >> "$OPERATION_LOG" + fi exit 0 fi @@ -351,10 +363,12 @@ def setUp(self) -> None: fi if [[ "$arguments" == *" image remove "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - # The cache is only worth clearing once containerd has reloaded the - # credential — i.e. after the reboot. - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then + # Credential-stale nodes must reboot before cache proof. + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + else + test "${FAKE_TALOS_NODES_CURRENT:-false}" = true + fi [[ "$arguments" == *" --namespace cri "* ]] image="" previous="" @@ -383,11 +397,13 @@ def setUp(self) -> None: fi if [[ "$arguments" == *" image pull "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - # The pull check is only meaningful once containerd has actually - # reloaded the credential (the reboot) and the cached copy is gone - # (the remove), so the pull must complete a real registry round-trip. - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" + else + test "${FAKE_TALOS_NODES_CURRENT:-false}" = true + fi + # The cached copy is always removed first, so this is a registry + # round-trip in both reboot and image-only modes. test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" [[ "$arguments" == *" --namespace cri "* ]] image="" @@ -463,6 +479,12 @@ def setUp(self) -> None: if [[ "$url" == https://ghcr.io/token ]]; then test -n "$scope" grep -q '^user = ' "$config" + if [[ "${FAKE_REVOKE_CURRENT_ROOT_TOKEN:-false}" == true ]] \ + && grep -qF 'previous-runtime-token' "$config"; then + printf '{}' > "$output" + printf '403' + exit 0 + fi printf '{"token":"fixture-registry-token"}' > "$output" printf '200' exit 0 @@ -491,14 +513,19 @@ def setUp(self) -> None: [[ "$arguments" == *" --context admin@prod "* ]] namespace="" patch_file="" + manifest_file="" previous="" for argument in "$@"; do if [[ "$previous" == --namespace ]]; then namespace="$argument" fi + if [[ "$previous" == --filename || "$previous" == -f ]]; then + manifest_file="$argument" + fi case "$argument" in --namespace=*) namespace="${argument#*=}" ;; --patch-file=*) patch_file="${argument#*=}" ;; + --filename=*) manifest_file="${argument#*=}" ;; esac previous="$argument" done @@ -513,7 +540,7 @@ def setUp(self) -> None: printf '%s\n' "$FAKE_NODE_JSON" exit 0 fi - jq -n \ + nodes_json="$(jq -n \ --arg revision "$EXPECTED_GHCR_REVISION" \ --arg current "${FAKE_TALOS_NODES_CURRENT:-false}" \ --arg verified_image \ @@ -524,6 +551,7 @@ def setUp(self) -> None: { metadata: { name: "prod-worker-1", + uid: "prod-worker-1-uid", labels: {}, annotations: { "platform.devantler.tech/ghcr-pull-desired-revision": @@ -538,6 +566,7 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-1", + uid: "prod-control-plane-1-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -554,6 +583,7 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-2", + uid: "prod-control-plane-2-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -577,6 +607,7 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-3", + uid: "prod-control-plane-3-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -607,7 +638,78 @@ def setUp(self) -> None: "platform.devantler.tech/ghcr-pull-verified-image-v2" ] = $verified_image else . end - ' + ')" + + # Talos nodeAnnotations propagate back to Kubernetes after the + # marker patch. Reflect completed nodes so the convergence loop + # waits for proof without rolling the same UID twice. + for completed in \ + '0:10.0.0.2' \ + '1:10.0.0.1'; do + item_index="${completed%%:*}" + item_ip="${completed#*:}" + if [[ -f "$FAKE_SYNC_STATE_DIR/talos-revision-${item_ip}" ]]; then + nodes_json="$(jq \ + --argjson item_index "$item_index" \ + --arg revision "$EXPECTED_GHCR_REVISION" \ + --arg image "$EXPECTED_KSAIL_TARGET_IMAGE" ' + .items[$item_index].metadata.annotations[ + "platform.devantler.tech/ghcr-pull-verified-revision-v2" + ] = $revision + | .items[$item_index].metadata.annotations[ + "platform.devantler.tech/ghcr-pull-verified-image-v2" + ] = $image + ' <<<"$nodes_json")" + fi + done + + # Simulate autoscaler nodes that appear either during the first + # roll or during the potentially long second fanout. Each stays + # stale until this bridge processes and marks its distinct UID. + new_node_name="" + if [[ -n "${FAKE_NODE_APPEARS_AFTER_ROLL:-}" \ + && -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.2" \ + && -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.1" ]]; then + new_node_name="$FAKE_NODE_APPEARS_AFTER_ROLL" + elif [[ -n "${FAKE_NODE_APPEARS_DURING_SECOND_FANOUT:-}" \ + && -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" \ + && "$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" -ge 2 ]]; then + new_node_name="$FAKE_NODE_APPEARS_DURING_SECOND_FANOUT" + fi + if [[ -n "$new_node_name" ]]; then + new_node_revision="" + new_node_image="" + if [[ -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.5" ]]; then + new_node_revision="$EXPECTED_GHCR_REVISION" + new_node_image="$EXPECTED_KSAIL_TARGET_IMAGE" + fi + nodes_json="$(jq \ + --arg name "$new_node_name" \ + --arg revision "$EXPECTED_GHCR_REVISION" \ + --arg verified_revision "$new_node_revision" \ + --arg verified_image "$new_node_image" ' + .items += [{ + metadata: { + name: $name, + uid: ($name + "-uid"), + labels: {}, + annotations: { + "platform.devantler.tech/ghcr-pull-desired-revision": + $revision, + "platform.devantler.tech/ghcr-pull-verified-revision-v2": + $verified_revision, + "platform.devantler.tech/ghcr-pull-verified-image-v2": + $verified_image + } + }, + status: {addresses: [ + {type: "InternalIP", address: "10.0.0.5"}, + {type: "ExternalIP", address: "198.51.100.5"} + ]} + }] + ' <<<"$nodes_json")" + fi + printf '%s\n' "$nodes_json" exit 0 fi @@ -630,6 +732,40 @@ def setUp(self) -> None: owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" if [[ "$arguments" == *" --output json "* ]]; then + node_uid="${node_target}-uid" + is_control_plane=false + case "$node_target" in + prod-worker-1) node_ip=10.0.0.2 ;; + prod-control-plane-1) + node_ip=10.0.0.1 + is_control_plane=true + ;; + prod-control-plane-2) + node_ip=10.0.0.3 + is_control_plane=true + ;; + prod-control-plane-3) + node_ip=10.0.0.4 + is_control_plane=true + ;; + *) node_ip=10.0.0.5 ;; + esac + if [[ "$node_target" == \ + "${FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE:-disabled}" ]]; then + node_uid="${node_target}-replacement-uid" + node_ip=10.0.0.99 + fi + if [[ "$node_target" == \ + "${FAKE_NODE_REPLACED_AFTER_READY_NODE:-disabled}" \ + && -f "$FAKE_SYNC_STATE_DIR/ready-${node_target}" ]]; then + node_uid="${node_target}-replacement-uid" + node_ip=10.0.0.99 + fi + if [[ "$node_target" == \ + "${FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE:-disabled}" \ + && -f "$FAKE_SYNC_STATE_DIR/drained-${node_target}" ]]; then + node_ip=10.0.0.99 + fi owner="" if [[ -f "$owner_file" ]]; then owner="$(<"$owner_file")" @@ -639,6 +775,11 @@ def setUp(self) -> None: || -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" ]]; then cordoned=true fi + if [[ "$node_target" == \ + "${FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE:-disabled}" \ + && -f "$FAKE_SYNC_STATE_DIR/ready-${node_target}" ]]; then + cordoned=false + fi autoscaler_intent=false if [[ -f "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" ]]; then autoscaler_intent=true @@ -648,13 +789,23 @@ def setUp(self) -> None: resource_version="$(<"$resource_version_file")" fi jq -n \ + --arg name "$node_target" \ + --arg uid "$node_uid" \ + --arg node_ip "$node_ip" \ + --argjson is_control_plane "$is_control_plane" \ --arg owner "$owner" \ --arg resource_version "$resource_version" \ --argjson cordoned "$cordoned" \ --argjson autoscaler_intent "$autoscaler_intent" ' { metadata: { - uid: "node-uid", + name: $name, + uid: $uid, + labels: ( + if $is_control_plane then { + "node-role.kubernetes.io/control-plane": "" + } else {} end + ), resourceVersion: $resource_version, deletionTimestamp: null, annotations: ( @@ -676,7 +827,11 @@ def setUp(self) -> None: effect: "NoSchedule" }] else [] end) ) - } + }, + status: {addresses: [{ + type: "InternalIP", + address: $node_ip + }]} } ' exit 0 @@ -726,6 +881,10 @@ def setUp(self) -> None: exit 53 fi touch "$FAKE_SYNC_STATE_DIR/drained-${node_target}" + if [[ "$node_target" == "${FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE:-disabled}" ]]; then + rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + printf 'operator-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" + fi exit 0 fi @@ -879,11 +1038,108 @@ def setUp(self) -> None: echo 'node did not become ready' >&2 exit 50 fi + touch "$FAKE_SYNC_STATE_DIR/ready-${node_target}" exit 0 fi test -n "$namespace" + if [[ "$arguments" == *" create "* \ + && -n "$manifest_file" ]]; then + test "$namespace" = ksail-operator + test -f "$manifest_file" + probe_name="$(jq -er '.metadata.name' "$manifest_file")" + probe_node="$(jq -er '.spec.nodeName' "$manifest_file")" + jq -e ' + .kind == "Pod" + and .metadata.namespace == "ksail-operator" + and .spec.automountServiceAccountToken == false + and (.spec.imagePullSecrets // [] | length) == 0 + and (.spec.containers[0].image == + "ghcr.io/devantler-tech/wedding-app:latest" + or .spec.containers[0].image == + "ghcr.io/devantler-tech/ascoachingogvaner:latest") + and .spec.containers[0].imagePullPolicy == "Always" + and .spec.containers[0].securityContext.allowPrivilegeEscalation + == false + ' "$manifest_file" >/dev/null + probe_image="$(jq -er '.spec.containers[0].image' "$manifest_file")" + printf '%s\n%s\n' "$probe_node" "$probe_image" \ + > "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}" + printf 'pod/%s\n' "$probe_name" + exit 0 + fi + + if [[ "$arguments" == *" get pod "* ]]; then + probe_name="" + previous="" + for argument in "$@"; do + if [[ "$previous" == pod ]]; then + probe_name="$argument" + break + fi + previous="$argument" + done + test -n "$probe_name" + probe_node="$(sed -n '1p' \ + "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}")" + probe_image="$(sed -n '2p' \ + "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}")" + pull_secrets='[]' + if [[ " ${FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES:-} " \ + == *" ${probe_node} "* ]]; then + pull_secrets='[{"name":"injected-pull-secret"}]' + fi + if [[ " ${FAKE_RUNTIME_PULL_FAIL_NODES:-} " \ + == *" ${probe_node} "* \ + || " ${FAKE_RUNTIME_PULL_FAIL_IMAGES:-} " \ + == *" ${probe_image} "* ]]; then + jq -n --argjson pull_secrets "$pull_secrets" \ + '{spec:{imagePullSecrets:$pull_secrets},status:{containerStatuses:[{state:{waiting:{ + reason:"ImagePullBackOff" + }}}]}}' + else + jq -n --argjson pull_secrets "$pull_secrets" \ + '{spec:{imagePullSecrets:$pull_secrets},status:{containerStatuses:[{ + imageID:"ghcr.io/private@sha256:runtime-probe", + state:{terminated:{reason:"Completed", exitCode:0}} + }]}}' + fi + exit 0 + fi + + if [[ "$arguments" == *" delete pod "* ]]; then + probe_name="" + previous="" + for argument in "$@"; do + if [[ "$previous" == pod ]]; then + probe_name="$argument" + break + fi + previous="$argument" + done + test -n "$probe_name" + rm -f "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}" + printf 'pod "%s" deleted\n' "$probe_name" + exit 0 + fi + + if [[ "$arguments" == *" get secret ksail-registry-credentials "* \ + && "$arguments" == *" -o json "* ]]; then + current_config="$(jq -nc \ + --arg token "${FAKE_CURRENT_ROOT_TOKEN:-previous-runtime-token}" ' + {auths:{"ghcr.io":{ + username:"devantler", + password:$token + }}} + ')" + encoded="$(printf '%s' "$current_config" \ + | base64 | tr -d '\r\n')" + jq -n --arg encoded "$encoded" \ + '{data:{".dockerconfigjson":$encoded}}' + exit 0 + fi + if [[ "$arguments" == *" api-resources "* ]]; then [[ "$arguments" == *" --api-group=external-secrets.io "* ]] if [[ "${FAKE_FANOUT_CRDS_ABSENT:-false}" != true ]]; then @@ -992,12 +1248,18 @@ def setUp(self) -> None: if [[ -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" ]]; then variables_patch_count="$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" fi + consumer_reverted_file="$FAKE_SYNC_STATE_DIR/consumer-reverted-${namespace}" if [[ "$namespace" == "${FAKE_CONSUMER_MISMATCH_NAMESPACE:-disabled}" \ || ( "$namespace" == "${FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE:-disabled}" \ - && "$variables_patch_count" -ge 2 ) ]]; then + && "$variables_patch_count" -ge 2 ) \ + || ( -f "$consumer_reverted_file" \ + && "$variables_patch_count" -lt 3 ) ]]; then encoded=$(printf '%s' '{"auths":{}}' | base64 | tr -d '\r\n') else encoded=$(jq -r '.data.ghcr_dockerconfigjson' "$VARIABLES_PATCH_CAPTURE") + if [[ "$variables_patch_count" -ge 3 ]]; then + rm -f "$consumer_reverted_file" + fi fi jq -n --arg encoded "$encoded" '{data:{".dockerconfigjson":$encoded}}' exit 0 @@ -1109,6 +1371,7 @@ def _run_helper( "FAKE_SYNC_STATE_DIR": str(self.sync_state_dir), "FLUX_GHCR_SYNC_ATTEMPTS": "2", "FLUX_GHCR_SYNC_INTERVAL": "0", + "FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS": "6", } ) environment.update(environment_overrides) @@ -1203,9 +1466,7 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: temporary_config = Path(self.output_path_log.read_text(encoding="utf-8")) self.assertFalse(temporary_config.exists()) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - self.assertEqual( - self.registry_read_log.read_text(encoding="utf-8").splitlines(), - [ + required_registry_reads = [ "devantler-tech/platform/manifests:latest", "devantler-tech/wedding-app/manifests:latest", "devantler-tech/ascoachingogvaner/manifests:latest", @@ -1213,7 +1474,10 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: "devantler-tech/ascoachingogvaner:latest", f"devantler-tech/ksail:v{KSAIL_OPERATOR_VERSION}", "devantler-tech/provider-upjet-unifi:v0.1.0", - ], + ] + self.assertEqual( + self.registry_read_log.read_text(encoding="utf-8").splitlines(), + required_registry_reads * 2, ) self.assertEqual( self.fanout_log.read_text(encoding="utf-8").splitlines(), @@ -1323,7 +1587,11 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: inventory = { "items": [ { - "metadata": {"name": "prod-worker-1", "labels": {}}, + "metadata": { + "name": "prod-worker-1", + "uid": "prod-worker-1-uid", + "labels": {}, + }, "status": { "addresses": [{"type": "InternalIP", "address": "10.0.0.2"}], "conditions": ready, @@ -1332,6 +1600,7 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: { "metadata": { "name": "prod-control-plane-1", + "uid": "prod-control-plane-1-uid", "labels": {"node-role.kubernetes.io/control-plane": ""}, }, "status": { @@ -1344,6 +1613,7 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: # not itself a sync target, it is just a quorum member. "metadata": { "name": "prod-control-plane-2", + "uid": "prod-control-plane-2-uid", "labels": {"node-role.kubernetes.io/control-plane": ""}, "annotations": { "platform.devantler.tech/ghcr-pull-verified-revision-v2": @@ -1598,6 +1868,7 @@ def test_changed_cordon_owner_is_never_uncordoned(self) -> None: operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) @@ -1615,10 +1886,98 @@ def test_autoscaler_taint_is_never_uncordoned(self) -> None: operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) + def test_external_uncordon_after_drain_blocks_reboot(self) -> None: + """Re-read scheduling state after drain and before Talos reboot.""" + result = self._run_helper( + self._valid_config(), + FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("scheduling safety state changed", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_changed_internal_ip_after_drain_blocks_reboot(self) -> None: + """Never reboot an inventory-time address after its Node UID moves.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("identity changed", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_replacement_after_ready_blocks_image_mutation(self) -> None: + """Rebind UID and IP after reboot before touching the image cache.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_REPLACED_AFTER_READY_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("identity changed", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-remove:10.0.0.2", "\n".join(operations)) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("root-patch", operations) + + def test_external_uncordon_after_ready_blocks_image_mutation(self) -> None: + """Require the scheduling guard through the final cache mutation.""" + result = self._run_helper( + self._valid_config(), + FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("scheduling safety state changed", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("talos-remove:10.0.0.2", "\n".join(operations)) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("root-patch", operations) + + def test_replaced_node_is_rejected_before_talos_mutation(self) -> None: + """Bind every Talos mutation to the UID and IP selected together.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("identity changed", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertNotIn("talos-auth:10.0.0.2", operations) + self.assertNotIn("node-drain:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_talos_convergence_budget_requires_target_and_two_clean_reads( + self, + ) -> None: + """Reject a budget that cannot process a target plus prove stability.""" + result = self._run_helper( + self._valid_config(), + FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS="2", + ) + + self.assertEqual(result.returncode, 64) + self.assertIn("must be at least 3", result.stdout + result.stderr) + self.assertFalse(self.kubectl_called.exists()) + def test_uncordon_failure_keeps_revision_marker_stale(self) -> None: """A failed ownership release must force the next run to retry.""" result = self._run_helper( @@ -1764,7 +2123,7 @@ def test_current_talos_nodes_skip_talos_api(self) -> None: self.assertTrue(self.patch_capture.exists()) def test_matching_revision_revalidates_changed_declared_image(self) -> None: - """Do not trust a current credential marker for a new target image.""" + """Re-prove a changed image without rebooting current credentials.""" previous_image = "ghcr.io/devantler-tech/ksail:v7.166.0" target_image = ( "ghcr.io/devantler-tech/ksail:" @@ -1786,20 +2145,206 @@ def test_matching_revision_revalidates_changed_declared_image(self) -> None: self.assertEqual( operations, [ - "talos-auth:10.0.0.2", - "talos-reboot:10.0.0.2", f"talos-remove:10.0.0.2:{target_image}", f"talos-pull:10.0.0.2:{target_image}", "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", - "talos-reboot:10.0.0.1", f"talos-remove:10.0.0.1:{target_image}", f"talos-pull:10.0.0.1:{target_image}", "talos-revision:10.0.0.1", ], ) + operation_log = self.operation_log.read_text(encoding="utf-8") + self.assertNotIn("node-drain:", operation_log) + self.assertNotIn("talos-reboot:", operation_log) self.assertNotIn(previous_image, "\n".join(operations)) + def test_failed_image_only_pull_keeps_node_cordoned(self) -> None: + """Exclude new pods after cache removal until pull proof succeeds.""" + result = self._run_helper( + self._valid_config(), + FAKE_TALOS_NODES_CURRENT="true", + FAKE_TALOS_VERIFIED_IMAGE="ghcr.io/devantler-tech/ksail:v7.166.0", + FAKE_TALOS_FAIL_NODE="10.0.0.2", + FAKE_TALOS_FAIL_OPERATION="pull", + ) + + self.assertNotEqual(result.returncode, 0) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-claim-cordon:prod-worker-1", operations) + self.assertNotIn("node-drain:prod-worker-1", operations) + self.assertNotIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-reboot:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + + def test_node_added_mid_roll_is_processed_before_root_cutover(self) -> None: + """Converge a newly autoscaled stale node before changing root auth.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_APPEARS_AFTER_ROLL="prod-worker-2", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("talos-auth:10.0.0.5", operations) + self.assertIn("node-drain:prod-worker-2", operations) + self.assertIn("talos-reboot:10.0.0.5", operations) + self.assertIn("talos-revision:10.0.0.5", operations) + self.assertLess( + operations.index("talos-revision:10.0.0.5"), + operations.index("root-patch"), + ) + + def test_node_added_during_second_fanout_is_processed_before_cutover( + self, + ) -> None: + """Re-converge after the potentially long post-roll fanout.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_APPEARS_DURING_SECOND_FANOUT="prod-worker-2", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + second_fanout_start = [ + index + for index, operation in enumerate(operations) + if operation == "variables-patch" + ][1] + late_revision = operations.index("talos-revision:10.0.0.5") + root_cutover = operations.index("root-patch") + self.assertLess(second_fanout_start, late_revision) + self.assertLess(late_revision, root_cutover) + + def test_late_node_roll_reproves_fanout_before_root_cutover(self) -> None: + """Converge fanout and nodes as one bounded transaction.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_APPEARS_DURING_SECOND_FANOUT="prod-worker-2", + FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE="wedding-app", + ) + + self.assertEqual(result.returncode, 0, result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + fanout_starts = [ + index + for index, operation in enumerate(operations) + if operation == "variables-patch" + ] + self.assertEqual(len(fanout_starts), 3) + consumer_revert = operations.index("consumer-revert:wedding-app") + root_cutover = operations.index("root-patch") + self.assertLess(consumer_revert, fanout_starts[2]) + self.assertLess(fanout_starts[2], root_cutover) + + def test_revoked_previous_credential_blocks_first_drain(self) -> None: + """Require overlap while workloads can land on not-yet-rebooted peers.""" + result = self._run_helper( + self._valid_config(), + FAKE_REVOKE_CURRENT_ROOT_TOKEN="true", + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("current root GHCR credential", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertNotIn("node-drain:", "\n".join(operations)) + self.assertNotIn("talos-reboot:", "\n".join(operations)) + self.assertNotIn("root-patch", operations) + self.assertNotIn("previous-runtime-token", output) + + def test_valid_root_token_does_not_substitute_for_peer_runtime_proof( + self, + ) -> None: + """Exercise kubelet/containerd on every possible eviction destination.""" + ready = [{"type": "Ready", "status": "True"}] + inventory = { + "items": [ + { + "metadata": { + "name": "prod-worker-1", + "uid": "prod-worker-1-uid", + "labels": {}, + }, + "status": { + "addresses": [ + {"type": "InternalIP", "address": "10.0.0.2"} + ], + "conditions": ready, + }, + }, + *[ + { + "metadata": { + "name": f"prod-control-plane-{index}", + "uid": f"prod-control-plane-{index}-uid", + "labels": { + "node-role.kubernetes.io/control-plane": "" + }, + }, + "status": { + "addresses": [ + { + "type": "InternalIP", + "address": f"10.0.0.{index * 2 - 1}", + } + ], + "conditions": ready, + }, + } + for index in (1, 2, 3) + ], + ] + } + + result = self._run_helper( + self._valid_config(), + FAKE_NODE_JSON=json.dumps(inventory), + FAKE_RUNTIME_PULL_FAIL_NODES=( + "prod-control-plane-1 prod-control-plane-2 " + "prod-control-plane-3" + ), + ) + + self.assertNotEqual(result.returncode, 0) + output = result.stdout + result.stderr + self.assertIn("running containerd", output) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertNotIn("node-drain:", "\n".join(operations)) + self.assertNotIn("root-patch", operations) + + def test_runtime_probe_rejects_injected_image_pull_secret(self) -> None: + """Require the probe to exercise node runtime auth, not a Pod secret.""" + result = self._run_helper( + self._valid_config(), + FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES="prod-control-plane-2", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("imagePullSecret", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertNotIn("node-drain:", "\n".join(operations)) + self.assertNotIn("root-patch", operations) + + def test_each_private_runtime_package_acl_must_pass(self) -> None: + """Do not substitute a public image or one package ACL for another.""" + for probe_image in ( + "ghcr.io/devantler-tech/wedding-app:latest", + "ghcr.io/devantler-tech/ascoachingogvaner:latest", + ): + with self.subTest(probe_image=probe_image): + result = self._run_helper( + self._valid_config(), + FAKE_RUNTIME_PULL_FAIL_IMAGES=probe_image, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn(probe_image, result.stdout + result.stderr) + operations = self.operation_log.read_text( + encoding="utf-8" + ).splitlines() + self.assertNotIn("node-drain:", "\n".join(operations)) + self.assertNotIn("root-patch", operations) + def test_dr_without_fanout_does_not_drain_nodes(self) -> None: """Never drain workloads before a DR cluster has its pull fanout.""" result = self._run_helper( @@ -1819,7 +2364,7 @@ def test_invalid_node_inventory_fails_closed(self) -> None: { "items": [ { - "metadata": {"name": "one"}, + "metadata": {"name": "one", "uid": "uid-one"}, "status": {"addresses": []}, } ] @@ -1827,7 +2372,7 @@ def test_invalid_node_inventory_fails_closed(self) -> None: { "items": [ { - "metadata": {"name": "one"}, + "metadata": {"name": "one", "uid": "uid-one"}, "status": { "addresses": [ {"type": "InternalIP", "address": "10.0.0.1"}, @@ -1840,19 +2385,45 @@ def test_invalid_node_inventory_fails_closed(self) -> None: { "items": [ { - "metadata": {"name": "one"}, + "metadata": {"name": "one", "uid": "uid-one"}, "status": {"addresses": [ {"type": "InternalIP", "address": "10.0.0.1"} ]}, }, { - "metadata": {"name": "two"}, + "metadata": {"name": "two", "uid": "uid-two"}, "status": {"addresses": [ {"type": "InternalIP", "address": "10.0.0.1"} ]}, }, ] }, + { + "items": [ + { + "metadata": {"name": "one"}, + "status": {"addresses": [ + {"type": "InternalIP", "address": "10.0.0.1"} + ]}, + } + ] + }, + { + "items": [ + { + "metadata": {"name": "one", "uid": "duplicate"}, + "status": {"addresses": [ + {"type": "InternalIP", "address": "10.0.0.1"} + ]}, + }, + { + "metadata": {"name": "two", "uid": "duplicate"}, + "status": {"addresses": [ + {"type": "InternalIP", "address": "10.0.0.2"} + ]}, + }, + ] + }, ] for inventory in invalid_inventories: with self.subTest(inventory=inventory): From 78eefe955e73d1e6a1d93114a35d47698f9c7e0e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 03:44:16 +0200 Subject: [PATCH 11/22] fix(deploy): revalidate node before proof marker --- scripts/refresh-flux-ghcr-auth.sh | 6 ++++++ scripts/tests/test_refresh_flux_ghcr_auth.py | 21 ++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 1f8a8f99e..fa85e9d69 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -960,6 +960,12 @@ process_talos_node_target() { "${initial_node_uid}" "${initial_node_taints}" \ "${drain_result_file}" || return 1 + # The scheduling release is a Kubernetes mutation and therefore another + # autoscaler replacement boundary. Rebind the selected UID and InternalIP + # at the last possible point before the final Talos proof-marker write. + revalidate_selected_node_identity_before_mutation \ + "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 + # Recorded LAST, and only now: the marker means "this node's containerd has # provably loaded this credential revision", so it must not be written # before the reboot that makes that true. diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 78dbaad4b..6f718ab3e 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -761,6 +761,12 @@ def setUp(self) -> None: node_uid="${node_target}-replacement-uid" node_ip=10.0.0.99 fi + if [[ "$node_target" == \ + "${FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE:-disabled}" \ + && -f "$FAKE_SYNC_STATE_DIR/uncordoned-${node_target}" ]]; then + node_uid="${node_target}-replacement-uid" + node_ip=10.0.0.99 + fi if [[ "$node_target" == \ "${FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE:-disabled}" \ && -f "$FAKE_SYNC_STATE_DIR/drained-${node_target}" ]]; then @@ -1003,6 +1009,7 @@ def setUp(self) -> None: > "$resource_version_file" rm "$owner_file" rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" + touch "$FAKE_SYNC_STATE_DIR/uncordoned-${node_target}" exit 0 fi @@ -1950,6 +1957,20 @@ def test_external_uncordon_after_ready_blocks_image_mutation(self) -> None: self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("root-patch", operations) + def test_replacement_after_uncordon_blocks_revision_marker(self) -> None: + """Rebind UID and IP immediately before the final Talos marker write.""" + result = self._run_helper( + self._valid_config(), + FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE="prod-worker-1", + ) + + self.assertNotEqual(result.returncode, 0) + self.assertIn("identity changed", result.stdout + result.stderr) + operations = self.operation_log.read_text(encoding="utf-8").splitlines() + self.assertIn("node-uncordon:prod-worker-1", operations) + self.assertNotIn("talos-revision:10.0.0.2", operations) + self.assertNotIn("root-patch", operations) + def test_replaced_node_is_rejected_before_talos_mutation(self) -> None: """Bind every Talos mutation to the UID and IP selected together.""" result = self._run_helper( From 4b69185a568bcc1d75f4c502073d1e8c846e0204 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 03:53:57 +0200 Subject: [PATCH 12/22] refactor(test): remove Python harness additions --- scripts/tests/test_refresh_flux_ghcr_auth.py | 652 +------------------ 1 file changed, 30 insertions(+), 622 deletions(-) diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py index 6f718ab3e..6f0352b19 100644 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ b/scripts/tests/test_refresh_flux_ghcr_auth.py @@ -299,13 +299,8 @@ def setUp(self) -> None: exit 0 fi - if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - else - # Image-only verification is allowed only when the fixture - # says this credential revision was already reboot-proved. - test "${FAKE_TALOS_NODES_CURRENT:-false}" = true - fi + test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" test -f "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" jq -e --arg revision "$EXPECTED_GHCR_REVISION" ' @@ -326,13 +321,6 @@ def setUp(self) -> None: exit 48 fi touch "$FAKE_SYNC_STATE_DIR/talos-revision-${node}" - if [[ "$node" == 10.0.0.5 \ - && -n "${FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE:-}" ]]; then - touch "$FAKE_SYNC_STATE_DIR/consumer-reverted-${FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE}" - printf 'consumer-revert:%s\n' \ - "$FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE" \ - >> "$OPERATION_LOG" - fi exit 0 fi @@ -363,12 +351,10 @@ def setUp(self) -> None: fi if [[ "$arguments" == *" image remove "* ]]; then - if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then - # Credential-stale nodes must reboot before cache proof. - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - else - test "${FAKE_TALOS_NODES_CURRENT:-false}" = true - fi + test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + # The cache is only worth clearing once containerd has reloaded the + # credential — i.e. after the reboot. + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" [[ "$arguments" == *" --namespace cri "* ]] image="" previous="" @@ -397,13 +383,11 @@ def setUp(self) -> None: fi if [[ "$arguments" == *" image pull "* ]]; then - if [[ -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - else - test "${FAKE_TALOS_NODES_CURRENT:-false}" = true - fi - # The cached copy is always removed first, so this is a registry - # round-trip in both reboot and image-only modes. + test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" + # The pull check is only meaningful once containerd has actually + # reloaded the credential (the reboot) and the cached copy is gone + # (the remove), so the pull must complete a real registry round-trip. + test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" [[ "$arguments" == *" --namespace cri "* ]] image="" @@ -479,12 +463,6 @@ def setUp(self) -> None: if [[ "$url" == https://ghcr.io/token ]]; then test -n "$scope" grep -q '^user = ' "$config" - if [[ "${FAKE_REVOKE_CURRENT_ROOT_TOKEN:-false}" == true ]] \ - && grep -qF 'previous-runtime-token' "$config"; then - printf '{}' > "$output" - printf '403' - exit 0 - fi printf '{"token":"fixture-registry-token"}' > "$output" printf '200' exit 0 @@ -513,19 +491,14 @@ def setUp(self) -> None: [[ "$arguments" == *" --context admin@prod "* ]] namespace="" patch_file="" - manifest_file="" previous="" for argument in "$@"; do if [[ "$previous" == --namespace ]]; then namespace="$argument" fi - if [[ "$previous" == --filename || "$previous" == -f ]]; then - manifest_file="$argument" - fi case "$argument" in --namespace=*) namespace="${argument#*=}" ;; --patch-file=*) patch_file="${argument#*=}" ;; - --filename=*) manifest_file="${argument#*=}" ;; esac previous="$argument" done @@ -540,7 +513,7 @@ def setUp(self) -> None: printf '%s\n' "$FAKE_NODE_JSON" exit 0 fi - nodes_json="$(jq -n \ + jq -n \ --arg revision "$EXPECTED_GHCR_REVISION" \ --arg current "${FAKE_TALOS_NODES_CURRENT:-false}" \ --arg verified_image \ @@ -551,7 +524,6 @@ def setUp(self) -> None: { metadata: { name: "prod-worker-1", - uid: "prod-worker-1-uid", labels: {}, annotations: { "platform.devantler.tech/ghcr-pull-desired-revision": @@ -566,7 +538,6 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-1", - uid: "prod-control-plane-1-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -583,7 +554,6 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-2", - uid: "prod-control-plane-2-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -607,7 +577,6 @@ def setUp(self) -> None: { metadata: { name: "prod-control-plane-3", - uid: "prod-control-plane-3-uid", labels: { "node-role.kubernetes.io/control-plane": "" }, @@ -638,78 +607,7 @@ def setUp(self) -> None: "platform.devantler.tech/ghcr-pull-verified-image-v2" ] = $verified_image else . end - ')" - - # Talos nodeAnnotations propagate back to Kubernetes after the - # marker patch. Reflect completed nodes so the convergence loop - # waits for proof without rolling the same UID twice. - for completed in \ - '0:10.0.0.2' \ - '1:10.0.0.1'; do - item_index="${completed%%:*}" - item_ip="${completed#*:}" - if [[ -f "$FAKE_SYNC_STATE_DIR/talos-revision-${item_ip}" ]]; then - nodes_json="$(jq \ - --argjson item_index "$item_index" \ - --arg revision "$EXPECTED_GHCR_REVISION" \ - --arg image "$EXPECTED_KSAIL_TARGET_IMAGE" ' - .items[$item_index].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-revision-v2" - ] = $revision - | .items[$item_index].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-image-v2" - ] = $image - ' <<<"$nodes_json")" - fi - done - - # Simulate autoscaler nodes that appear either during the first - # roll or during the potentially long second fanout. Each stays - # stale until this bridge processes and marks its distinct UID. - new_node_name="" - if [[ -n "${FAKE_NODE_APPEARS_AFTER_ROLL:-}" \ - && -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.2" \ - && -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.1" ]]; then - new_node_name="$FAKE_NODE_APPEARS_AFTER_ROLL" - elif [[ -n "${FAKE_NODE_APPEARS_DURING_SECOND_FANOUT:-}" \ - && -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" \ - && "$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" -ge 2 ]]; then - new_node_name="$FAKE_NODE_APPEARS_DURING_SECOND_FANOUT" - fi - if [[ -n "$new_node_name" ]]; then - new_node_revision="" - new_node_image="" - if [[ -f "$FAKE_SYNC_STATE_DIR/talos-revision-10.0.0.5" ]]; then - new_node_revision="$EXPECTED_GHCR_REVISION" - new_node_image="$EXPECTED_KSAIL_TARGET_IMAGE" - fi - nodes_json="$(jq \ - --arg name "$new_node_name" \ - --arg revision "$EXPECTED_GHCR_REVISION" \ - --arg verified_revision "$new_node_revision" \ - --arg verified_image "$new_node_image" ' - .items += [{ - metadata: { - name: $name, - uid: ($name + "-uid"), - labels: {}, - annotations: { - "platform.devantler.tech/ghcr-pull-desired-revision": - $revision, - "platform.devantler.tech/ghcr-pull-verified-revision-v2": - $verified_revision, - "platform.devantler.tech/ghcr-pull-verified-image-v2": - $verified_image - } - }, - status: {addresses: [ - {type: "InternalIP", address: "10.0.0.5"}, - {type: "ExternalIP", address: "198.51.100.5"} - ]} - }] - ' <<<"$nodes_json")" - fi - printf '%s\n' "$nodes_json" + ' exit 0 fi @@ -732,46 +630,6 @@ def setUp(self) -> None: owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" if [[ "$arguments" == *" --output json "* ]]; then - node_uid="${node_target}-uid" - is_control_plane=false - case "$node_target" in - prod-worker-1) node_ip=10.0.0.2 ;; - prod-control-plane-1) - node_ip=10.0.0.1 - is_control_plane=true - ;; - prod-control-plane-2) - node_ip=10.0.0.3 - is_control_plane=true - ;; - prod-control-plane-3) - node_ip=10.0.0.4 - is_control_plane=true - ;; - *) node_ip=10.0.0.5 ;; - esac - if [[ "$node_target" == \ - "${FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE:-disabled}" ]]; then - node_uid="${node_target}-replacement-uid" - node_ip=10.0.0.99 - fi - if [[ "$node_target" == \ - "${FAKE_NODE_REPLACED_AFTER_READY_NODE:-disabled}" \ - && -f "$FAKE_SYNC_STATE_DIR/ready-${node_target}" ]]; then - node_uid="${node_target}-replacement-uid" - node_ip=10.0.0.99 - fi - if [[ "$node_target" == \ - "${FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE:-disabled}" \ - && -f "$FAKE_SYNC_STATE_DIR/uncordoned-${node_target}" ]]; then - node_uid="${node_target}-replacement-uid" - node_ip=10.0.0.99 - fi - if [[ "$node_target" == \ - "${FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE:-disabled}" \ - && -f "$FAKE_SYNC_STATE_DIR/drained-${node_target}" ]]; then - node_ip=10.0.0.99 - fi owner="" if [[ -f "$owner_file" ]]; then owner="$(<"$owner_file")" @@ -781,11 +639,6 @@ def setUp(self) -> None: || -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" ]]; then cordoned=true fi - if [[ "$node_target" == \ - "${FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE:-disabled}" \ - && -f "$FAKE_SYNC_STATE_DIR/ready-${node_target}" ]]; then - cordoned=false - fi autoscaler_intent=false if [[ -f "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" ]]; then autoscaler_intent=true @@ -795,23 +648,13 @@ def setUp(self) -> None: resource_version="$(<"$resource_version_file")" fi jq -n \ - --arg name "$node_target" \ - --arg uid "$node_uid" \ - --arg node_ip "$node_ip" \ - --argjson is_control_plane "$is_control_plane" \ --arg owner "$owner" \ --arg resource_version "$resource_version" \ --argjson cordoned "$cordoned" \ --argjson autoscaler_intent "$autoscaler_intent" ' { metadata: { - name: $name, - uid: $uid, - labels: ( - if $is_control_plane then { - "node-role.kubernetes.io/control-plane": "" - } else {} end - ), + uid: "node-uid", resourceVersion: $resource_version, deletionTimestamp: null, annotations: ( @@ -833,11 +676,7 @@ def setUp(self) -> None: effect: "NoSchedule" }] else [] end) ) - }, - status: {addresses: [{ - type: "InternalIP", - address: $node_ip - }]} + } } ' exit 0 @@ -887,10 +726,6 @@ def setUp(self) -> None: exit 53 fi touch "$FAKE_SYNC_STATE_DIR/drained-${node_target}" - if [[ "$node_target" == "${FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE:-disabled}" ]]; then - rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - printf 'operator-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" - fi exit 0 fi @@ -1009,7 +844,6 @@ def setUp(self) -> None: > "$resource_version_file" rm "$owner_file" rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - touch "$FAKE_SYNC_STATE_DIR/uncordoned-${node_target}" exit 0 fi @@ -1045,108 +879,11 @@ def setUp(self) -> None: echo 'node did not become ready' >&2 exit 50 fi - touch "$FAKE_SYNC_STATE_DIR/ready-${node_target}" exit 0 fi test -n "$namespace" - if [[ "$arguments" == *" create "* \ - && -n "$manifest_file" ]]; then - test "$namespace" = ksail-operator - test -f "$manifest_file" - probe_name="$(jq -er '.metadata.name' "$manifest_file")" - probe_node="$(jq -er '.spec.nodeName' "$manifest_file")" - jq -e ' - .kind == "Pod" - and .metadata.namespace == "ksail-operator" - and .spec.automountServiceAccountToken == false - and (.spec.imagePullSecrets // [] | length) == 0 - and (.spec.containers[0].image == - "ghcr.io/devantler-tech/wedding-app:latest" - or .spec.containers[0].image == - "ghcr.io/devantler-tech/ascoachingogvaner:latest") - and .spec.containers[0].imagePullPolicy == "Always" - and .spec.containers[0].securityContext.allowPrivilegeEscalation - == false - ' "$manifest_file" >/dev/null - probe_image="$(jq -er '.spec.containers[0].image' "$manifest_file")" - printf '%s\n%s\n' "$probe_node" "$probe_image" \ - > "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}" - printf 'pod/%s\n' "$probe_name" - exit 0 - fi - - if [[ "$arguments" == *" get pod "* ]]; then - probe_name="" - previous="" - for argument in "$@"; do - if [[ "$previous" == pod ]]; then - probe_name="$argument" - break - fi - previous="$argument" - done - test -n "$probe_name" - probe_node="$(sed -n '1p' \ - "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}")" - probe_image="$(sed -n '2p' \ - "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}")" - pull_secrets='[]' - if [[ " ${FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES:-} " \ - == *" ${probe_node} "* ]]; then - pull_secrets='[{"name":"injected-pull-secret"}]' - fi - if [[ " ${FAKE_RUNTIME_PULL_FAIL_NODES:-} " \ - == *" ${probe_node} "* \ - || " ${FAKE_RUNTIME_PULL_FAIL_IMAGES:-} " \ - == *" ${probe_image} "* ]]; then - jq -n --argjson pull_secrets "$pull_secrets" \ - '{spec:{imagePullSecrets:$pull_secrets},status:{containerStatuses:[{state:{waiting:{ - reason:"ImagePullBackOff" - }}}]}}' - else - jq -n --argjson pull_secrets "$pull_secrets" \ - '{spec:{imagePullSecrets:$pull_secrets},status:{containerStatuses:[{ - imageID:"ghcr.io/private@sha256:runtime-probe", - state:{terminated:{reason:"Completed", exitCode:0}} - }]}}' - fi - exit 0 - fi - - if [[ "$arguments" == *" delete pod "* ]]; then - probe_name="" - previous="" - for argument in "$@"; do - if [[ "$previous" == pod ]]; then - probe_name="$argument" - break - fi - previous="$argument" - done - test -n "$probe_name" - rm -f "$FAKE_SYNC_STATE_DIR/runtime-probe-${probe_name}" - printf 'pod "%s" deleted\n' "$probe_name" - exit 0 - fi - - if [[ "$arguments" == *" get secret ksail-registry-credentials "* \ - && "$arguments" == *" -o json "* ]]; then - current_config="$(jq -nc \ - --arg token "${FAKE_CURRENT_ROOT_TOKEN:-previous-runtime-token}" ' - {auths:{"ghcr.io":{ - username:"devantler", - password:$token - }}} - ')" - encoded="$(printf '%s' "$current_config" \ - | base64 | tr -d '\r\n')" - jq -n --arg encoded "$encoded" \ - '{data:{".dockerconfigjson":$encoded}}' - exit 0 - fi - if [[ "$arguments" == *" api-resources "* ]]; then [[ "$arguments" == *" --api-group=external-secrets.io "* ]] if [[ "${FAKE_FANOUT_CRDS_ABSENT:-false}" != true ]]; then @@ -1255,18 +992,12 @@ def setUp(self) -> None: if [[ -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" ]]; then variables_patch_count="$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" fi - consumer_reverted_file="$FAKE_SYNC_STATE_DIR/consumer-reverted-${namespace}" if [[ "$namespace" == "${FAKE_CONSUMER_MISMATCH_NAMESPACE:-disabled}" \ || ( "$namespace" == "${FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE:-disabled}" \ - && "$variables_patch_count" -ge 2 ) \ - || ( -f "$consumer_reverted_file" \ - && "$variables_patch_count" -lt 3 ) ]]; then + && "$variables_patch_count" -ge 2 ) ]]; then encoded=$(printf '%s' '{"auths":{}}' | base64 | tr -d '\r\n') else encoded=$(jq -r '.data.ghcr_dockerconfigjson' "$VARIABLES_PATCH_CAPTURE") - if [[ "$variables_patch_count" -ge 3 ]]; then - rm -f "$consumer_reverted_file" - fi fi jq -n --arg encoded "$encoded" '{data:{".dockerconfigjson":$encoded}}' exit 0 @@ -1378,7 +1109,6 @@ def _run_helper( "FAKE_SYNC_STATE_DIR": str(self.sync_state_dir), "FLUX_GHCR_SYNC_ATTEMPTS": "2", "FLUX_GHCR_SYNC_INTERVAL": "0", - "FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS": "6", } ) environment.update(environment_overrides) @@ -1473,7 +1203,9 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: temporary_config = Path(self.output_path_log.read_text(encoding="utf-8")) self.assertFalse(temporary_config.exists()) self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - required_registry_reads = [ + self.assertEqual( + self.registry_read_log.read_text(encoding="utf-8").splitlines(), + [ "devantler-tech/platform/manifests:latest", "devantler-tech/wedding-app/manifests:latest", "devantler-tech/ascoachingogvaner/manifests:latest", @@ -1481,10 +1213,7 @@ def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: "devantler-tech/ascoachingogvaner:latest", f"devantler-tech/ksail:v{KSAIL_OPERATOR_VERSION}", "devantler-tech/provider-upjet-unifi:v0.1.0", - ] - self.assertEqual( - self.registry_read_log.read_text(encoding="utf-8").splitlines(), - required_registry_reads * 2, + ], ) self.assertEqual( self.fanout_log.read_text(encoding="utf-8").splitlines(), @@ -1594,11 +1323,7 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: inventory = { "items": [ { - "metadata": { - "name": "prod-worker-1", - "uid": "prod-worker-1-uid", - "labels": {}, - }, + "metadata": {"name": "prod-worker-1", "labels": {}}, "status": { "addresses": [{"type": "InternalIP", "address": "10.0.0.2"}], "conditions": ready, @@ -1607,7 +1332,6 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: { "metadata": { "name": "prod-control-plane-1", - "uid": "prod-control-plane-1-uid", "labels": {"node-role.kubernetes.io/control-plane": ""}, }, "status": { @@ -1620,7 +1344,6 @@ def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: # not itself a sync target, it is just a quorum member. "metadata": { "name": "prod-control-plane-2", - "uid": "prod-control-plane-2-uid", "labels": {"node-role.kubernetes.io/control-plane": ""}, "annotations": { "platform.devantler.tech/ghcr-pull-verified-revision-v2": @@ -1875,7 +1598,6 @@ def test_changed_cordon_owner_is_never_uncordoned(self) -> None: operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) @@ -1893,112 +1615,10 @@ def test_autoscaler_taint_is_never_uncordoned(self) -> None: operations = self.operation_log.read_text(encoding="utf-8").splitlines() self.assertIn("node-claim-cordon:prod-worker-1", operations) self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) self.assertNotIn("node-uncordon:prod-worker-1", operations) self.assertNotIn("talos-revision:10.0.0.2", operations) self.assertNotIn("root-patch", operations) - def test_external_uncordon_after_drain_blocks_reboot(self) -> None: - """Re-read scheduling state after drain and before Talos reboot.""" - result = self._run_helper( - self._valid_config(), - FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("scheduling safety state changed", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_changed_internal_ip_after_drain_blocks_reboot(self) -> None: - """Never reboot an inventory-time address after its Node UID moves.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("identity changed", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_replacement_after_ready_blocks_image_mutation(self) -> None: - """Rebind UID and IP after reboot before touching the image cache.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_REPLACED_AFTER_READY_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("identity changed", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-remove:10.0.0.2", "\n".join(operations)) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("root-patch", operations) - - def test_external_uncordon_after_ready_blocks_image_mutation(self) -> None: - """Require the scheduling guard through the final cache mutation.""" - result = self._run_helper( - self._valid_config(), - FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("scheduling safety state changed", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-remove:10.0.0.2", "\n".join(operations)) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("root-patch", operations) - - def test_replacement_after_uncordon_blocks_revision_marker(self) -> None: - """Rebind UID and IP immediately before the final Talos marker write.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("identity changed", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_replaced_node_is_rejected_before_talos_mutation(self) -> None: - """Bind every Talos mutation to the UID and IP selected together.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("identity changed", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("talos-auth:10.0.0.2", operations) - self.assertNotIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_talos_convergence_budget_requires_target_and_two_clean_reads( - self, - ) -> None: - """Reject a budget that cannot process a target plus prove stability.""" - result = self._run_helper( - self._valid_config(), - FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS="2", - ) - - self.assertEqual(result.returncode, 64) - self.assertIn("must be at least 3", result.stdout + result.stderr) - self.assertFalse(self.kubectl_called.exists()) - def test_uncordon_failure_keeps_revision_marker_stale(self) -> None: """A failed ownership release must force the next run to retry.""" result = self._run_helper( @@ -2144,7 +1764,7 @@ def test_current_talos_nodes_skip_talos_api(self) -> None: self.assertTrue(self.patch_capture.exists()) def test_matching_revision_revalidates_changed_declared_image(self) -> None: - """Re-prove a changed image without rebooting current credentials.""" + """Do not trust a current credential marker for a new target image.""" previous_image = "ghcr.io/devantler-tech/ksail:v7.166.0" target_image = ( "ghcr.io/devantler-tech/ksail:" @@ -2166,206 +1786,20 @@ def test_matching_revision_revalidates_changed_declared_image(self) -> None: self.assertEqual( operations, [ + "talos-auth:10.0.0.2", + "talos-reboot:10.0.0.2", f"talos-remove:10.0.0.2:{target_image}", f"talos-pull:10.0.0.2:{target_image}", "talos-revision:10.0.0.2", + "talos-auth:10.0.0.1", + "talos-reboot:10.0.0.1", f"talos-remove:10.0.0.1:{target_image}", f"talos-pull:10.0.0.1:{target_image}", "talos-revision:10.0.0.1", ], ) - operation_log = self.operation_log.read_text(encoding="utf-8") - self.assertNotIn("node-drain:", operation_log) - self.assertNotIn("talos-reboot:", operation_log) self.assertNotIn(previous_image, "\n".join(operations)) - def test_failed_image_only_pull_keeps_node_cordoned(self) -> None: - """Exclude new pods after cache removal until pull proof succeeds.""" - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_NODES_CURRENT="true", - FAKE_TALOS_VERIFIED_IMAGE="ghcr.io/devantler-tech/ksail:v7.166.0", - FAKE_TALOS_FAIL_NODE="10.0.0.2", - FAKE_TALOS_FAIL_OPERATION="pull", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertNotIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_node_added_mid_roll_is_processed_before_root_cutover(self) -> None: - """Converge a newly autoscaled stale node before changing root auth.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_APPEARS_AFTER_ROLL="prod-worker-2", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-auth:10.0.0.5", operations) - self.assertIn("node-drain:prod-worker-2", operations) - self.assertIn("talos-reboot:10.0.0.5", operations) - self.assertIn("talos-revision:10.0.0.5", operations) - self.assertLess( - operations.index("talos-revision:10.0.0.5"), - operations.index("root-patch"), - ) - - def test_node_added_during_second_fanout_is_processed_before_cutover( - self, - ) -> None: - """Re-converge after the potentially long post-roll fanout.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_APPEARS_DURING_SECOND_FANOUT="prod-worker-2", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - second_fanout_start = [ - index - for index, operation in enumerate(operations) - if operation == "variables-patch" - ][1] - late_revision = operations.index("talos-revision:10.0.0.5") - root_cutover = operations.index("root-patch") - self.assertLess(second_fanout_start, late_revision) - self.assertLess(late_revision, root_cutover) - - def test_late_node_roll_reproves_fanout_before_root_cutover(self) -> None: - """Converge fanout and nodes as one bounded transaction.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_APPEARS_DURING_SECOND_FANOUT="prod-worker-2", - FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE="wedding-app", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - fanout_starts = [ - index - for index, operation in enumerate(operations) - if operation == "variables-patch" - ] - self.assertEqual(len(fanout_starts), 3) - consumer_revert = operations.index("consumer-revert:wedding-app") - root_cutover = operations.index("root-patch") - self.assertLess(consumer_revert, fanout_starts[2]) - self.assertLess(fanout_starts[2], root_cutover) - - def test_revoked_previous_credential_blocks_first_drain(self) -> None: - """Require overlap while workloads can land on not-yet-rebooted peers.""" - result = self._run_helper( - self._valid_config(), - FAKE_REVOKE_CURRENT_ROOT_TOKEN="true", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("current root GHCR credential", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("node-drain:", "\n".join(operations)) - self.assertNotIn("talos-reboot:", "\n".join(operations)) - self.assertNotIn("root-patch", operations) - self.assertNotIn("previous-runtime-token", output) - - def test_valid_root_token_does_not_substitute_for_peer_runtime_proof( - self, - ) -> None: - """Exercise kubelet/containerd on every possible eviction destination.""" - ready = [{"type": "Ready", "status": "True"}] - inventory = { - "items": [ - { - "metadata": { - "name": "prod-worker-1", - "uid": "prod-worker-1-uid", - "labels": {}, - }, - "status": { - "addresses": [ - {"type": "InternalIP", "address": "10.0.0.2"} - ], - "conditions": ready, - }, - }, - *[ - { - "metadata": { - "name": f"prod-control-plane-{index}", - "uid": f"prod-control-plane-{index}-uid", - "labels": { - "node-role.kubernetes.io/control-plane": "" - }, - }, - "status": { - "addresses": [ - { - "type": "InternalIP", - "address": f"10.0.0.{index * 2 - 1}", - } - ], - "conditions": ready, - }, - } - for index in (1, 2, 3) - ], - ] - } - - result = self._run_helper( - self._valid_config(), - FAKE_NODE_JSON=json.dumps(inventory), - FAKE_RUNTIME_PULL_FAIL_NODES=( - "prod-control-plane-1 prod-control-plane-2 " - "prod-control-plane-3" - ), - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("running containerd", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("node-drain:", "\n".join(operations)) - self.assertNotIn("root-patch", operations) - - def test_runtime_probe_rejects_injected_image_pull_secret(self) -> None: - """Require the probe to exercise node runtime auth, not a Pod secret.""" - result = self._run_helper( - self._valid_config(), - FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES="prod-control-plane-2", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("imagePullSecret", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("node-drain:", "\n".join(operations)) - self.assertNotIn("root-patch", operations) - - def test_each_private_runtime_package_acl_must_pass(self) -> None: - """Do not substitute a public image or one package ACL for another.""" - for probe_image in ( - "ghcr.io/devantler-tech/wedding-app:latest", - "ghcr.io/devantler-tech/ascoachingogvaner:latest", - ): - with self.subTest(probe_image=probe_image): - result = self._run_helper( - self._valid_config(), - FAKE_RUNTIME_PULL_FAIL_IMAGES=probe_image, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn(probe_image, result.stdout + result.stderr) - operations = self.operation_log.read_text( - encoding="utf-8" - ).splitlines() - self.assertNotIn("node-drain:", "\n".join(operations)) - self.assertNotIn("root-patch", operations) - def test_dr_without_fanout_does_not_drain_nodes(self) -> None: """Never drain workloads before a DR cluster has its pull fanout.""" result = self._run_helper( @@ -2385,7 +1819,7 @@ def test_invalid_node_inventory_fails_closed(self) -> None: { "items": [ { - "metadata": {"name": "one", "uid": "uid-one"}, + "metadata": {"name": "one"}, "status": {"addresses": []}, } ] @@ -2393,7 +1827,7 @@ def test_invalid_node_inventory_fails_closed(self) -> None: { "items": [ { - "metadata": {"name": "one", "uid": "uid-one"}, + "metadata": {"name": "one"}, "status": { "addresses": [ {"type": "InternalIP", "address": "10.0.0.1"}, @@ -2403,22 +1837,6 @@ def test_invalid_node_inventory_fails_closed(self) -> None: } ] }, - { - "items": [ - { - "metadata": {"name": "one", "uid": "uid-one"}, - "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"} - ]}, - }, - { - "metadata": {"name": "two", "uid": "uid-two"}, - "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"} - ]}, - }, - ] - }, { "items": [ { @@ -2426,21 +1844,11 @@ def test_invalid_node_inventory_fails_closed(self) -> None: "status": {"addresses": [ {"type": "InternalIP", "address": "10.0.0.1"} ]}, - } - ] - }, - { - "items": [ - { - "metadata": {"name": "one", "uid": "duplicate"}, - "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"} - ]}, }, { - "metadata": {"name": "two", "uid": "duplicate"}, + "metadata": {"name": "two"}, "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.2"} + {"type": "InternalIP", "address": "10.0.0.1"} ]}, }, ] From 60c7b14411699e6a2bc8e04dff4e57266d0a23fa Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 04:33:16 +0200 Subject: [PATCH 13/22] refactor(test): replace Python rollout harnesses with Go --- .github/workflows/ci.yaml | 11 +- .../refresh-flux-ghcr-auth/contracts_test.go | 454 +++ .../fake_commands_test.go | 490 +++ .../fake_contracts_test.go | 121 + .../fake_kubectl_test.go | 688 +++++ .../refresh-flux-ghcr-auth/fixture_test.go | 381 +++ .../rollout_convergence_test.go | 282 ++ .../rollout_core_test.go | 362 +++ .../rollout_safety_test.go | 214 ++ .../refresh-flux-ghcr-auth/wrapper_test.go | 471 +++ scripts/tests/test_refresh_flux_ghcr_auth.py | 2666 ----------------- scripts/tests/test_validate_alert_coverage.py | 217 -- .../validate-alert-coverage/validator_test.go | 315 ++ 13 files changed, 3784 insertions(+), 2888 deletions(-) create mode 100644 scripts/tests/refresh-flux-ghcr-auth/contracts_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/fixture_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go create mode 100644 scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go delete mode 100644 scripts/tests/test_refresh_flux_ghcr_auth.py delete mode 100644 scripts/tests/test_validate_alert_coverage.py create mode 100644 scripts/tests/validate-alert-coverage/validator_test.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ccc40e116..39026bb58 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -59,7 +59,7 @@ jobs: - 'scripts/tests/test-cilium-bandwidth-manager-component.sh' - 'tests/cilium-bandwidth-manager-bbr/**' - 'scripts/validate-alert-coverage.sh' - - 'scripts/tests/test_validate_alert_coverage.py' + - 'scripts/tests/validate-alert-coverage/**' - 'scripts/refresh-flux-ghcr-auth.sh' - 'scripts/refresh-flux-ghcr-auth-safety.sh' - 'scripts/ghcr-auth-lib.sh' @@ -73,7 +73,7 @@ jobs: - 'scripts/ghcr-auth-lib.sh' - 'scripts/run-ksail-prod-with-pull-auth.sh' - 'scripts/tests/test-refresh-flux-ghcr-auth-safety.sh' - - 'scripts/tests/test_refresh_flux_ghcr_auth.py' + - 'scripts/tests/refresh-flux-ghcr-auth/**' - 'docs/dr/runbook.md' - '.github/actions/deploy-prod/**' - '.github/workflows/dr-rebuild.yaml' @@ -129,7 +129,7 @@ jobs: scripts/run-ksail-prod-with-pull-auth.sh \ scripts/tests/test-refresh-flux-ghcr-auth-safety.sh bash scripts/tests/test-refresh-flux-ghcr-auth-safety.sh - python3 -m unittest scripts.tests.test_refresh_flux_ghcr_auth + go test ./scripts/tests/refresh-flux-ghcr-auth - name: 🧩 Validate embedded JSON blobs # ConfigMaps that embed whole JSON documents as block scalars (e.g. the @@ -155,9 +155,10 @@ jobs: # every namespace holding a HelmRelease. A namespace missing from that list # is invisible: the release fails, rolls back, reports Ready, and nothing # pages (ksail-operator, 2026-07-14). Drift is a build failure, not a - # comment. Bash + the runner's kubectl/yq; no cluster or secrets required. + # comment. Go behavioral tests + Bash with the runner's kubectl/yq; no + # cluster or secrets required. run: | - python3 -m unittest scripts.tests.test_validate_alert_coverage + go test ./scripts/tests/validate-alert-coverage shellcheck scripts/validate-alert-coverage.sh bash scripts/validate-alert-coverage.sh diff --git a/scripts/tests/refresh-flux-ghcr-auth/contracts_test.go b/scripts/tests/refresh-flux-ghcr-auth/contracts_test.go new file mode 100644 index 000000000..48763053c --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/contracts_test.go @@ -0,0 +1,454 @@ +package refreshfluxghcrauth + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +type yamlParent struct { + indent int + key string +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate contract test source") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..")) +} + +func readRepositoryFile(t *testing.T, relativePath string) string { + t.Helper() + + contents, err := os.ReadFile(filepath.Join(repositoryRoot(t), relativePath)) + if err != nil { + t.Fatalf("read %s: %v", relativePath, err) + } + return string(contents) +} + +func yamlScalarAtPath(document string, path ...string) (string, error) { + parents := make([]yamlParent, 0, len(path)) + for rawLine := range strings.SplitSeq(document, "\n") { + line, _, _ := strings.Cut(rawLine, "#") + line = strings.TrimRight(line, " \t\r") + if strings.TrimSpace(line) == "" { + continue + } + + trimmed := strings.TrimLeft(line, " ") + indent := len(line) - len(trimmed) + key, value, found := strings.Cut(strings.TrimSpace(line), ":") + if !found { + continue + } + + for len(parents) > 0 && parents[len(parents)-1].indent >= indent { + parents = parents[:len(parents)-1] + } + + currentPath := make([]string, 0, len(parents)+1) + for _, parent := range parents { + currentPath = append(currentPath, parent.key) + } + currentPath = append(currentPath, key) + value = strings.TrimSpace(value) + if slicesEqual(currentPath, path) { + if value == "" { + break + } + return strings.Trim(value, "\"'"), nil + } + + if value == "" { + parents = append(parents, yamlParent{indent: indent, key: key}) + } + } + + return "", fmt.Errorf("missing scalar YAML path: %s", strings.Join(path, ".")) +} + +func slicesEqual(left, right []string) bool { + if len(left) != len(right) { + return false + } + for index := range left { + if left[index] != right[index] { + return false + } + } + return true +} + +func requireContains(t *testing.T, document, expected string) { + t.Helper() + if !strings.Contains(document, expected) { + t.Errorf("expected document to contain %q", expected) + } +} + +func requireNotContains(t *testing.T, document, unexpected string) { + t.Helper() + if strings.Contains(document, unexpected) { + t.Errorf("expected document not to contain %q", unexpected) + } +} + +func requireIndex(t *testing.T, document, needle string) int { + t.Helper() + index := strings.Index(document, needle) + if index < 0 { + t.Fatalf("expected document to contain %q", needle) + } + return index +} + +func requireBefore(t *testing.T, earlier, later int, message string) { + t.Helper() + if earlier >= later { + t.Errorf("expected %s: positions %d >= %d", message, earlier, later) + } +} + +func TestKsailProdConfigTalosVersionIgnoresUnrelatedVersionFields(t *testing.T) { + config := ` +spec: + version: v0.0.0 + cluster: + kubernetesVersion: v1.36.2 + talos: + version: v1.13.6 +` + + version, err := yamlScalarAtPath(config, "spec", "cluster", "talos", "version") + if err != nil { + t.Fatalf("read Talos version: %v", err) + } + if version != "v1.13.6" { + t.Errorf("Talos version = %q, want %q", version, "v1.13.6") + } +} + +func TestDeployActionClusterLifecycleUsesSOPSAuthButPublishKeepsActionsToken(t *testing.T) { + action := readRepositoryFile(t, ".github/actions/deploy-prod/action.yml") + workflow := readRepositoryFile(t, ".github/workflows/dr-rebuild.yaml") + wrapper := "./scripts/run-ksail-prod-with-pull-auth.sh" + + actionReconcile := requireIndex(t, action, "id: reconcile") + actionUpdate := requireIndex(t, action, "name: 🔄 Update cluster") + actionReassert := requireIndex(t, action, "id: reassert_flux_ghcr_auth") + requireContains(t, action[actionReconcile:actionUpdate], "run: "+wrapper+" workload reconcile") + requireNotContains(t, action[actionReconcile:actionUpdate], "GHCR_TOKEN:") + requireContains(t, action[actionUpdate:actionReassert], "run: "+wrapper+" cluster update") + requireNotContains(t, action[actionUpdate:actionReassert], "GHCR_TOKEN:") + + actionPush := requireIndex(t, action, "name: 📦 Push manifests to GHCR") + actionSign := requireIndex(t, action, "name: ⚙️ Install cosign") + requireContains(t, action[actionPush:actionSign], "run: "+wrapper+" workload push") + requireContains(t, action[actionPush:actionSign], "GHCR_TOKEN: ${{ inputs.ghcr-token }}") + + drCreate := requireIndex(t, workflow, "name: 🏗️ Create cluster") + drStage := requireIndex(t, workflow, "id: stage_flux_ghcr_auth") + requireContains(t, workflow[drCreate:drStage], "run: "+wrapper+" cluster create") + requireNotContains(t, workflow[drCreate:drStage], "GHCR_TOKEN:") + + drPush := requireIndex(t, workflow, "name: 📦 Push manifests to GHCR") + drVerify := requireIndex(t, workflow, "id: verify_flux_ghcr_auth_after_push") + requireContains(t, workflow[drPush:drVerify], "run: "+wrapper+" workload push") + requireContains(t, workflow[drPush:drVerify], "GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}") + + drReconcile := requireIndex(t, workflow, "name: 🔁 Trigger Flux reconciliation") + drWait := requireIndex(t, workflow, "name: ⏳ Wait for Flux to settle") + requireContains(t, workflow[drReconcile:drWait], "run: "+wrapper+" workload reconcile") + requireNotContains(t, workflow[drReconcile:drWait], "GHCR_TOKEN:") +} + +func TestDeployActionTalosctlIsInstalledBeforeAnyMutatingBridge(t *testing.T) { + action := readRepositoryFile(t, ".github/actions/deploy-prod/action.yml") + workflow := readRepositoryFile(t, ".github/workflows/dr-rebuild.yaml") + ksailConfig := readRepositoryFile(t, "ksail.prod.yaml") + talosVersion, err := yamlScalarAtPath(ksailConfig, "spec", "cluster", "talos", "version") + if err != nil { + t.Fatalf("read production Talos version: %v", err) + } + talosVersion = strings.TrimPrefix(talosVersion, "v") + + tests := []struct { + name string + document string + requireRestore bool + }{ + {name: "deploy action", document: action, requireRestore: true}, + {name: "DR workflow", document: workflow}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + setup := requireIndex(t, test.document, "name: ⚙️ Setup talosctl") + stage := requireIndex(t, test.document, "id: stage_flux_ghcr_auth") + requireBefore(t, setup, stage, "talosctl setup before credential staging") + setupStep := test.document[setup:stage] + requireContains(t, setupStep, fmt.Sprintf("TALOS_VERSION: %q", talosVersion)) + requireContains(t, setupStep, "talosctl-linux-amd64") + if test.requireRestore { + restore := requireIndex(t, test.document, "name: 🔑 Restore talosconfig") + requireBefore(t, restore, stage, "talosconfig restore before credential staging") + } + }) + } +} + +func TestDeployActionConsumerStagingPrecedesPublishAndIsReassertedAfterUpdate(t *testing.T) { + action := readRepositoryFile(t, ".github/actions/deploy-prod/action.yml") + wrapper := "./scripts/run-ksail-prod-with-pull-auth.sh" + + firstRefresh := requireIndex(t, action, "id: stage_flux_ghcr_auth") + push := requireIndex(t, action, "run: "+wrapper+" workload push") + postPushRefresh := requireIndex(t, action, "id: verify_flux_ghcr_auth_after_push") + reconcile := requireIndex(t, action, "id: reconcile") + clusterUpdate := requireIndex(t, action, "run: "+wrapper+" cluster update") + finalRefresh := requireIndex(t, action, "id: reassert_flux_ghcr_auth\n") + + requireBefore(t, firstRefresh, push, "initial refresh before publish") + requireBefore(t, push, postPushRefresh, "publish before post-publish refresh") + requireBefore(t, postPushRefresh, reconcile, "post-publish refresh before reconcile") + requireBefore(t, reconcile, clusterUpdate, "reconcile before cluster update") + requireBefore(t, clusterUpdate, finalRefresh, "cluster update before final refresh") + requireContains(t, action[firstRefresh:push], "run: ./scripts/refresh-flux-ghcr-auth.sh\n") + requireNotContains(t, action[firstRefresh:push], "--check-only") + requireContains(t, action[postPushRefresh:reconcile], "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only") + finalRefreshStep := action[finalRefresh:] + requireContains(t, finalRefreshStep, "always() &&") + requireContains(t, finalRefreshStep, "steps.verify_flux_ghcr_auth_after_push.outcome == 'success'") + requireContains(t, finalRefreshStep, "steps.reconcile.outcome == 'success'") + if count := strings.Count(action, "scripts/refresh-flux-ghcr-auth.sh"); count != 3 { + t.Errorf("refresh helper references = %d, want 3", count) + } +} + +func TestDeployActionDisasterRebuildPreflightsThenStagesBeforePublish(t *testing.T) { + workflow := readRepositoryFile(t, ".github/workflows/dr-rebuild.yaml") + wrapper := "./scripts/run-ksail-prod-with-pull-auth.sh" + + preflight := requireIndex(t, workflow, "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only") + clusterCreate := requireIndex(t, workflow, "run: "+wrapper+" cluster create") + stage := requireIndex(t, workflow, "id: stage_flux_ghcr_auth") + push := requireIndex(t, workflow, "run: "+wrapper+" workload push") + verify := requireIndex(t, workflow, "id: verify_flux_ghcr_auth_after_push") + fanoutVerify := requireIndex(t, workflow, "id: verify_flux_ghcr_fanout") + openbaoRestore := requireIndex(t, workflow, "name: 🔐 Restore OpenBao from the R2 snapshot mirror") + postRestoreVerify := requireIndex(t, workflow, "id: reassert_flux_ghcr_after_restore") + reconcile := requireIndex(t, workflow, "run: "+wrapper+" workload reconcile") + + requireBefore(t, preflight, clusterCreate, "preflight before cluster create") + requireBefore(t, clusterCreate, stage, "cluster create before credential staging") + requireBefore(t, stage, push, "credential staging before publish") + requireBefore(t, push, verify, "publish before verification") + requireBefore(t, verify, reconcile, "verification before reconcile") + requireBefore(t, reconcile, fanoutVerify, "reconcile before fanout verification") + requireBefore(t, fanoutVerify, openbaoRestore, "fanout verification before OpenBao restore") + requireBefore(t, openbaoRestore, postRestoreVerify, "OpenBao restore before post-restore verification") + requireContains(t, workflow[stage:push], "run: ./scripts/refresh-flux-ghcr-auth.sh --allow-incomplete-fanout") + requireContains(t, workflow[verify:reconcile], "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only") + requireContains(t, workflow[fanoutVerify:], "run: ./scripts/refresh-flux-ghcr-auth.sh\n") + requireContains(t, workflow[postRestoreVerify:], "if: ${{ !cancelled() && inputs.restore && steps.verify_flux_ghcr_fanout.outcome == 'success' }}") + if count := strings.Count(workflow, "scripts/refresh-flux-ghcr-auth.sh"); count != 5 { + t.Errorf("refresh helper references = %d, want 5", count) + } +} + +func TestDeployActionManualDRWaitsForFluxBeforeFullBridge(t *testing.T) { + runbook := readRepositoryFile(t, "docs/dr/runbook.md") + ciWorkflow := readRepositoryFile(t, ".github/workflows/ci.yaml") + manualStart := requireIndex(t, runbook, "# 2. Prove the Git/SOPS pull credential") + manualEnd := requireIndex(t, runbook, "# 6. ONLY if the OpenBao raft-snapshot") + manual := runbook[manualStart:manualEnd] + + bootstrap := requireIndex(t, manual, "./scripts/refresh-flux-ghcr-auth.sh --allow-incomplete-fanout") + reconcile := requireIndex(t, manual, "./scripts/run-ksail-prod-with-pull-auth.sh workload reconcile") + wait := requireIndex(t, manual, "kubectl --context admin@prod -n flux-system wait") + fullBridge := requireIndex(t, manual, "./scripts/refresh-flux-ghcr-auth.sh # prove completed fan-out") + + requireBefore(t, bootstrap, reconcile, "bootstrap before reconcile") + requireBefore(t, reconcile, wait, "reconcile before Flux wait") + requireBefore(t, wait, fullBridge, "Flux wait before full bridge") + requireContains(t, runbook, "workload reconciliation also requires the SOPS key") + requireContains(t, ciWorkflow, "- 'docs/dr/runbook.md'") +} + +func TestDeployActionBridgeDocsAndTestsValidateWithoutDeploying(t *testing.T) { + workflow := readRepositoryFile(t, ".github/workflows/ci.yaml") + filtersStart := requireIndex(t, workflow, " filters: |") + filtersEnd := requireIndex(t, workflow, "\n validate:") + filters := workflow[filtersStart:filtersEnd] + k8sStart := requireIndex(t, filters, " k8s:") + k8sEnd := requireIndex(t, filters, " bridge_validation:") + k8sFilter := filters[k8sStart:k8sEnd] + bridgeStart := requireIndex(t, filters, " bridge_validation:") + bridgeEnd := requireIndex(t, filters, " talos:") + bridgeFilter := filters[bridgeStart:bridgeEnd] + + for _, validationOnlyPath := range []string{ + "scripts/tests/test-refresh-flux-ghcr-auth-safety.sh", + "scripts/tests/refresh-flux-ghcr-auth/**", + "docs/dr/runbook.md", + } { + t.Run(validationOnlyPath, func(t *testing.T) { + quotedPath := "- '" + validationOnlyPath + "'" + requireNotContains(t, k8sFilter, quotedPath) + requireContains(t, bridgeFilter, quotedPath) + }) + } + + requireContains(t, workflow, "bridge_validation: ${{ steps.filter.outputs.bridge_validation }}") + validateStart := requireIndex(t, workflow, " validate:") + validateEnd := requireIndex(t, workflow, " naming:") + validate := workflow[validateStart:validateEnd] + requireContains(t, validate, "needs.changes.outputs.bridge_validation == 'true'") + deployStart := requireIndex(t, workflow, " deploy-prod:") + deployEnd := requireIndex(t, workflow, " heal-prod-on-failure:") + deploy := workflow[deployStart:deployEnd] + healStart := requireIndex(t, workflow, " heal-prod-on-failure:") + healEnd := requireIndex(t, workflow, " ci-required-checks:") + heal := workflow[healStart:healEnd] + for _, productionJob := range []struct { + name string + body string + }{ + {name: "deploy", body: deploy}, + {name: "heal", body: heal}, + } { + t.Run(productionJob.name, func(t *testing.T) { + requireContains(t, productionJob.body, "needs.changes.outputs.k8s == 'true'") + requireNotContains(t, productionJob.body, "bridge_validation") + }) + } +} + +func TestManualBridgePrerequisitesYQV4IsDocumentedAndPreflighted(t *testing.T) { + instructions := readRepositoryFile(t, "AGENTS.md") + runbook := readRepositoryFile(t, "docs/dr/runbook.md") + library := readRepositoryFile(t, "scripts/ghcr-auth-lib.sh") + + requireContains(t, instructions, "yq v4") + scenarioOne := requireIndex(t, runbook, "## Scenario 1") + requireContains(t, runbook[:scenarioOne], "yq v4") + requireContains(t, library, "require_flux_ghcr_yaml_tool()") + for _, entrypoint := range []string{ + "scripts/refresh-flux-ghcr-auth.sh", + "scripts/run-ksail-prod-with-pull-auth.sh", + } { + t.Run(filepath.Base(entrypoint), func(t *testing.T) { + requireContains(t, readRepositoryFile(t, entrypoint), "require_flux_ghcr_yaml_tool") + }) + } +} + +func TestManualBridgePrerequisitesYAMLToolPreflightRejectsMissingOrIncompatibleYQ(t *testing.T) { + for _, testCase := range []string{"missing", "incompatible"} { + t.Run(testCase, func(t *testing.T) { + binDirectory := t.TempDir() + if testCase == "incompatible" { + fakeYQ := filepath.Join(binDirectory, "yq") + if err := os.WriteFile(fakeYQ, []byte("#!/bin/bash\necho 'yq 3.4.3'\n"), 0o755); err != nil { + t.Fatalf("write incompatible yq: %v", err) + } + } + + library := filepath.Join(repositoryRoot(t), "scripts", "ghcr-auth-lib.sh") + command := exec.Command("/bin/bash", "-c", `source "$1"; require_flux_ghcr_yaml_tool`, "bash", library) + command.Env = environmentWithPath(os.Environ(), binDirectory) + var stderr bytes.Buffer + command.Stderr = &stderr + err := command.Run() + if err == nil { + t.Error("YAML tool preflight succeeded, want non-zero exit") + } + requireContains(t, stderr.String(), "yq v4 is required") + }) + } +} + +func environmentWithPath(environment []string, path string) []string { + result := make([]string, 0, len(environment)+1) + for _, variable := range environment { + if strings.HasPrefix(variable, "PATH=") { + continue + } + result = append(result, variable) + } + return append(result, "PATH="+path) +} + +func TestRequiredPackageCoverageProviderUpjetUnifiReferenceIsPreflighted(t *testing.T) { + manifest := readRepositoryFile(t, "k8s/providers/hetzner/infrastructure/crossplane/provider-upjet-unifi.yaml") + helper := readRepositoryFile(t, "scripts/refresh-flux-ghcr-auth.sh") + + packageLine := "" + for line := range strings.SplitSeq(manifest, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "package: ghcr.io/") { + packageLine = line + break + } + } + if packageLine == "" { + t.Fatal("find private provider package reference") + } + packageReference := strings.TrimPrefix(packageLine, "package: ghcr.io/") + requireContains(t, helper, `"`+packageReference+`"`) +} + +func TestRequiredPackageCoverageExactDeclaredKsailOperatorImageIsPreflighted(t *testing.T) { + helper := readRepositoryFile(t, "scripts/refresh-flux-ghcr-auth.sh") + helmRelease := readRepositoryFile(t, "k8s/bases/infrastructure/controllers/ksail-operator/helm-release.yaml") + + ksailOperatorVersion := "" + for line := range strings.SplitSeq(helmRelease, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "version:") { + ksailOperatorVersion = strings.TrimSpace(strings.TrimPrefix(line, "version:")) + break + } + } + requireContains(t, helper, `"devantler-tech/ksail:v${KSAIL_OPERATOR_VERSION}"`) + requireContains(t, helper, ".spec.chart.spec.version") + if ksailOperatorVersion == "" { + t.Error("declared KSail operator version is empty") + } +} + +func TestTalosRegistryAuthStaticDesiredRevisionCannotClaimVerifiedPull(t *testing.T) { + staticRevision := readRepositoryFile(t, "talos/cluster/mark-ghcr-pull-revision.yaml") + helper := readRepositoryFile(t, "scripts/refresh-flux-ghcr-auth.sh") + staticConfigLines := make([]string, 0) + for line := range strings.SplitSeq(staticRevision, "\n") { + if strings.HasPrefix(strings.TrimLeft(line, " \t"), "#") { + continue + } + staticConfigLines = append(staticConfigLines, line) + } + staticConfig := strings.Join(staticConfigLines, "\n") + + requireContains(t, staticConfig, "ghcr-pull-desired-revision") + requireNotContains(t, staticConfig, "ghcr-pull-verified-revision") + requireContains(t, helper, "ghcr-pull-verified-revision") +} + +func TestTalosRegistryAuthUsesSupportedTalosDocument(t *testing.T) { + registryAuth := readRepositoryFile(t, "talos/cluster/authenticate-ghcr-pulls.yaml") + + requireContains(t, registryAuth, "kind: RegistryAuthConfig") + requireContains(t, registryAuth, "username: ${GHCR_USERNAME}") + requireContains(t, registryAuth, "password: ${GHCR_TOKEN}") + requireNotContains(t, registryAuth, "machine:\n") + requireNotContains(t, registryAuth, "\n---\n") +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go new file mode 100644 index 000000000..4ab38ac48 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go @@ -0,0 +1,490 @@ +package refreshfluxghcrauth + +import ( + "encoding/json" + "fmt" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "gopkg.in/yaml.v3" +) + +func fakeKSail(args []string) int { + if containsSequence(args, "workload", "cipher", "decrypt") { + selector := `["stringData"]["ghcr_dockerconfigjson"]` + if !containsAdjacent(args, "--extract", selector) { + return commandFailure(92, "missing exact encrypted-secret selector") + } + output := flagValue(args, "--output") + if output == "" { + return commandFailure(92, "missing decrypt output") + } + mustWriteEnvFile("KSAIL_OUTPUT_PATH_LOG", output) + if err := copyFile(os.Getenv("FAKE_DECRYPTED_CONFIG"), output); err != nil { + return commandFailure(92, "copy decrypted fixture: %v", err) + } + return 0 + } + + isLifecycle := containsSequence(args, "cluster", "create") || + containsSequence(args, "cluster", "update") || + containsSequence(args, "workload", "push") || + containsSequence(args, "workload", "reconcile") + if !isLifecycle { + return commandFailure(92, "unexpected ksail invocation") + } + configPath := flagValue(args, "--config") + config, err := os.ReadFile(configPath) + if err != nil { + return commandFailure(92, "read KSail config: %v", err) + } + var parsedConfig struct { + Spec struct { + Cluster struct { + LocalRegistry struct { + Registry string `yaml:"registry"` + } `yaml:"localRegistry"` + } `yaml:"cluster"` + } `yaml:"spec"` + } + if err := yaml.Unmarshal(config, &parsedConfig); err != nil { + return commandFailure(92, "parse KSail config: %v", err) + } + registry := parsedConfig.Spec.Cluster.LocalRegistry.Registry + expectedRegistry := `devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests` + if registry != expectedRegistry { + return commandFailure(92, "protected registry template changed") + } + registryOverride := os.Getenv("KSAIL_SPEC_CLUSTER_LOCALREGISTRY_REGISTRY") + if containsSequence(args, "workload", "push") { + if registryOverride != "" { + return commandFailure(92, "publish unexpectedly overrides registry") + } + } else if registryOverride != `${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests` { + return commandFailure(92, "lifecycle registry override missing") + } + if os.Getenv("GHCR_TOKEN") == "" || os.Getenv("GHCR_USERNAME") == "" || os.Getenv("GHCR_PULL_REVISION") == "" { + return commandFailure(92, "missing lifecycle environment") + } + mustWriteEnvFile("KSAIL_TOKEN_CAPTURE", os.Getenv("GHCR_TOKEN")) + mustWriteEnvFile("KSAIL_USERNAME_CAPTURE", os.Getenv("GHCR_USERNAME")) + mustWriteEnvFile("KSAIL_REVISION_CAPTURE", os.Getenv("GHCR_PULL_REVISION")) + mustWriteEnvFile("KSAIL_COMMAND_CAPTURE", strings.Join(args, " ")+"\n") + mustWriteEnvFile("KSAIL_CONFIG_PATH_CAPTURE", configPath) + mustWriteEnvFile("KSAIL_REGISTRY_CAPTURE", registry) + mustWriteEnvFile("KSAIL_REGISTRY_OVERRIDE_CAPTURE", registryOverride) + return 0 +} + +func fakeCurl(args []string) int { + if len(args) == 0 || args[0] != "--disable" { + return commandFailure(90, "curl must disable user config") + } + var configPath, outputPath, scope, requestURL string + for index := 1; index < len(args); { + switch args[index] { + case "--config": + configPath = requiredNext(args, &index) + case "--output": + outputPath = requiredNext(args, &index) + case "--data-urlencode": + value := requiredNext(args, &index) + if strings.HasPrefix(value, "scope=") { + scope = strings.TrimPrefix(value, "scope=") + } + case "--write-out", "--header": + _ = requiredNext(args, &index) + case "--silent", "--show-error", "--get": + index++ + default: + if strings.HasPrefix(args[index], "https://") { + requestURL = args[index] + index++ + continue + } + return commandFailure(90, "unexpected curl argument: %s", args[index]) + } + } + config := mustReadCommandFile(configPath) + if outputPath == "" || requestURL == "" { + return commandFailure(90, "incomplete curl invocation") + } + if requestURL == "https://ghcr.io/token" { + if scope == "" || !hasCurlUserConfig(config) { + return commandFailure(90, "invalid token exchange") + } + if os.Getenv("FAKE_REVOKE_CURRENT_ROOT_TOKEN") == "true" && strings.Contains(config, "previous-runtime-token") { + mustWriteCommandFile(outputPath, `{}`) + fmt.Print("403") + return 0 + } + mustWriteCommandFile(outputPath, `{"token":"fixture-registry-token"}`) + fmt.Print("200") + return 0 + } + if !strings.Contains(config, "Authorization: Bearer fixture-registry-token") { + return commandFailure(90, "manifest read lacks bearer token") + } + parsed, err := url.Parse(requestURL) + if err != nil { + return commandFailure(90, "parse request URL: %v", err) + } + manifestPath := strings.TrimPrefix(parsed.Path, "/v2/") + manifestSeparator := strings.LastIndex(manifestPath, "/manifests/") + if manifestSeparator < 0 { + return commandFailure(90, "invalid manifest URL") + } + repository := manifestPath[:manifestSeparator] + reference := manifestPath[manifestSeparator+len("/manifests/"):] + appendEnvFile("REGISTRY_READ_LOG", repository+":"+reference+"\n") + if repository == os.Getenv("FAKE_CURL_DENY_REPOSITORY") { + fmt.Print("403") + } else { + fmt.Print("200") + } + return 0 +} + +func fakeTalosctl(args []string) int { + node := flagValue(args, "--nodes") + if node == "" { + for _, argument := range args { + if strings.HasPrefix(argument, "--nodes=") { + node = strings.TrimPrefix(argument, "--nodes=") + } + } + } + if node == "" { + return commandFailure(93, "talosctl node missing") + } + + if containsSequence(args, "etcd", "status") { + if node == os.Getenv("FAKE_ETCD_STATUS_FAIL_NODE") || + (node == os.Getenv("FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE") && markerExists("drained-prod-control-plane-1")) { + return commandFailure(51, "etcd status failed") + } + learner := node == os.Getenv("FAKE_ETCD_LEARNER_NODE") + statusError := "" + if node == os.Getenv("FAKE_ETCD_STATUS_ERROR_NODE") { + statusError = " rpc-timeout" + } + if node == os.Getenv("FAKE_ETCD_COMPACT_STATUS_NODE") { + fmt.Println("NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER ERRORS") + fmt.Printf("%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %t%s\n", node, learner, statusError) + } else { + fmt.Println("NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER PROTOCOL STORAGE ERRORS") + fmt.Printf("%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %t 3.6.4 3.6.0%s\n", node, learner, statusError) + } + return 0 + } + if containsSequence(args, "etcd", "alarm", "list") { + if node == os.Getenv("FAKE_ETCD_ALARM_READ_FAIL_NODE") { + return commandFailure(52, "etcd alarm read failed") + } + fmt.Println("NODE MEMBER ALARM") + if node == os.Getenv("FAKE_ETCD_ALARM_NODE") { + fmt.Printf("%s member-id NOSPACE\n", node) + } + return 0 + } + + if containsSequence(args, "patch", "machineconfig") { + if !containsArg(args, "--mode=no-reboot") { + return commandFailure(93, "Talos patch may not reboot implicitly") + } + patchPath := "" + for _, argument := range args { + if strings.HasPrefix(argument, "--patch-file=") { + patchPath = strings.TrimPrefix(argument, "--patch-file=") + } + } + if patchPath == "" { + patchPath = flagValue(args, "--patch-file") + } + mustWriteEnvFile("TALOS_PATCH_PATH_LOG", patchPath) + var patch map[string]any + if err := json.Unmarshal([]byte(mustReadCommandFile(patchPath)), &patch); err != nil { + return commandFailure(93, "parse Talos patch: %v", err) + } + if patch["kind"] == "RegistryAuthConfig" { + if patch["apiVersion"] != "v1alpha1" || patch["name"] != "ghcr.io" || + patch["username"] != os.Getenv("EXPECTED_PULL_USERNAME") || patch["password"] != os.Getenv("EXPECTED_PULL_TOKEN") { + return commandFailure(93, "invalid RegistryAuthConfig") + } + appendTalosOperation("talos-auth:" + node) + if talosFailure(node, "auth") { + return commandFailure(45, "talos auth failed with %s", os.Getenv("EXPECTED_PULL_TOKEN")) + } + touchMarker("talos-auth-" + node) + return 0 + } + if markerExists("talos-auth-" + node) { + if !markerExists("talos-reboot-" + node) { + return commandFailure(93, "revision preceded reboot") + } + } else if os.Getenv("FAKE_TALOS_NODES_CURRENT") != "true" { + return commandFailure(93, "image-only proof lacks current credential") + } + if !markerExists("talos-remove-"+node) || !markerExists("talos-pull-"+node) { + return commandFailure(93, "revision preceded registry pull proof") + } + machine, _ := patch["machine"].(map[string]any) + annotations, _ := machine["nodeAnnotations"].(map[string]any) + if annotations["platform.devantler.tech/ghcr-pull-verified-revision-v2"] != os.Getenv("EXPECTED_GHCR_REVISION") || + annotations["platform.devantler.tech/ghcr-pull-verified-image-v2"] != os.Getenv("EXPECTED_KSAIL_TARGET_IMAGE") { + return commandFailure(93, "invalid verified pull marker") + } + appendTalosOperation("talos-revision:" + node) + if talosFailure(node, "revision") { + return commandFailure(48, "talos revision failed") + } + touchMarker("talos-revision-" + node) + if node == "10.0.0.5" && os.Getenv("FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE") != "" { + namespace := os.Getenv("FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE") + touchMarker("consumer-reverted-" + namespace) + appendEnvFile("OPERATION_LOG", "consumer-revert:"+namespace+"\n") + } + return 0 + } + + if containsArg(args, "reboot") { + if !markerExists("talos-auth-"+node) || containsArg(args, "--drain") || !containsArg(args, "--wait") { + return commandFailure(54, "unsafe Talos reboot") + } + appendTalosOperation("talos-reboot:" + node) + if talosFailure(node, "reboot") { + return commandFailure(49, "talos reboot failed") + } + touchMarker("talos-reboot-" + node) + return 0 + } + if containsSequence(args, "image", "remove") { + if !containsAdjacent(args, "--namespace", "cri") { + return commandFailure(93, "Talos image remove must use the cri namespace") + } + if markerExists("talos-auth-"+node) && !markerExists("talos-reboot-"+node) { + return commandFailure(93, "credential-stale cache mutation") + } + if !markerExists("talos-auth-"+node) && os.Getenv("FAKE_TALOS_NODES_CURRENT") != "true" { + return commandFailure(93, "image-only cache mutation lacks proof") + } + image := argumentAfter(args, "remove") + operation := "talos-remove:" + node + ":" + image + appendTalosOperation(operation) + if node == os.Getenv("FAKE_TALOS_IMAGE_ABSENT_NODE") { + touchMarker("talos-remove-" + node) + return commandFailure(1, "rpc error: code = NotFound desc = image %s not found", image) + } + if talosFailure(node, "remove") { + return commandFailure(49, "talos remove failed") + } + touchMarker("talos-remove-" + node) + return 0 + } + if containsSequence(args, "image", "pull") { + if !containsAdjacent(args, "--namespace", "cri") { + return commandFailure(93, "Talos image pull must use the cri namespace") + } + if markerExists("talos-auth-"+node) && !markerExists("talos-reboot-"+node) { + return commandFailure(93, "credential-stale pull") + } + if !markerExists("talos-auth-"+node) && os.Getenv("FAKE_TALOS_NODES_CURRENT") != "true" { + return commandFailure(93, "image-only pull lacks proof") + } + if !markerExists("talos-remove-" + node) { + return commandFailure(93, "cached image not removed") + } + image := argumentAfter(args, "pull") + operation := "talos-pull:" + node + ":" + image + appendTalosOperation(operation) + if talosFailure(node, "pull") { + return commandFailure(47, "talos pull failed with %s", os.Getenv("EXPECTED_PULL_TOKEN")) + } + touchMarker("talos-pull-" + node) + return 0 + } + return commandFailure(93, "unexpected talosctl invocation") +} + +func fakeKubectl(args []string) int { + return fakeKubectlImplementation(args) +} + +func hasCurlUserConfig(config string) bool { + for _, line := range strings.Split(config, "\n") { + if strings.HasPrefix(line, "user = ") { + return true + } + } + return false +} + +func appendTalosOperation(operation string) { + appendEnvFile("TALOS_LOG", operation+"\n") + appendEnvFile("OPERATION_LOG", operation+"\n") +} + +func talosFailure(node, operation string) bool { + return node == os.Getenv("FAKE_TALOS_FAIL_NODE") && operation == defaultString(os.Getenv("FAKE_TALOS_FAIL_OPERATION"), "auth") +} + +func containsSequence(args []string, sequence ...string) bool { + if len(sequence) == 0 || len(sequence) > len(args) { + return false + } + for start := 0; start <= len(args)-len(sequence); start++ { + matches := true + for offset := range sequence { + if args[start+offset] != sequence[offset] { + matches = false + break + } + } + if matches { + return true + } + } + return false +} + +func containsAdjacent(args []string, key, value string) bool { + return containsSequence(args, key, value) +} + +func containsArg(args []string, target string) bool { + for _, argument := range args { + if argument == target { + return true + } + } + return false +} + +func flagValue(args []string, flag string) string { + for index, argument := range args { + if argument == flag && index+1 < len(args) { + return args[index+1] + } + if strings.HasPrefix(argument, flag+"=") { + return strings.TrimPrefix(argument, flag+"=") + } + } + return "" +} + +func argumentAfter(args []string, target string) string { + for index, argument := range args { + if argument == target && index+1 < len(args) { + return args[index+1] + } + } + return "" +} + +func requiredNext(args []string, index *int) string { + if *index+1 >= len(args) { + *index = len(args) + return "" + } + value := args[*index+1] + *index += 2 + return value +} + +func commandFailure(code int, format string, values ...any) int { + fmt.Fprintf(os.Stderr, format+"\n", values...) + return code +} + +func mustWriteEnvFile(name, content string) { + mustWriteCommandFile(os.Getenv(name), content) +} + +func appendEnvFile(name, content string) { + path := os.Getenv(name) + file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + panic(fmt.Sprintf("open %s: %v", path, err)) + } + defer func() { + if err := file.Close(); err != nil { + panic(fmt.Sprintf("close %s: %v", path, err)) + } + }() + if _, err := file.WriteString(content); err != nil { + panic(fmt.Sprintf("append %s: %v", path, err)) + } +} + +func mustWriteCommandFile(path, content string) { + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + panic(fmt.Sprintf("write %s: %v", path, err)) + } +} + +func mustReadCommandFile(path string) string { + content, err := os.ReadFile(path) + if err != nil { + panic(fmt.Sprintf("read %s: %v", path, err)) + } + return string(content) +} + +func copyFile(source, destination string) error { + content, err := os.ReadFile(source) + if err != nil { + return err + } + return os.WriteFile(destination, content, 0o600) +} + +func touchMarker(name string) { + mustWriteCommandFile(filepath.Join(os.Getenv("FAKE_SYNC_STATE_DIR"), name), "") +} + +func markerExists(name string) bool { + _, err := os.Stat(filepath.Join(os.Getenv("FAKE_SYNC_STATE_DIR"), name)) + return err == nil +} + +func markerContent(name string) string { + content, err := os.ReadFile(filepath.Join(os.Getenv("FAKE_SYNC_STATE_DIR"), name)) + if err != nil { + return "" + } + return string(content) +} + +func setMarkerContent(name, content string) { + mustWriteCommandFile(filepath.Join(os.Getenv("FAKE_SYNC_STATE_DIR"), name), content) +} + +func removeMarker(name string) { + _ = os.Remove(filepath.Join(os.Getenv("FAKE_SYNC_STATE_DIR"), name)) +} + +func defaultString(value, fallback string) string { + if value == "" { + return fallback + } + return value +} + +func encodeJSON(value any) string { + content, err := json.Marshal(value) + if err != nil { + panic(err) + } + return string(content) +} + +func parseInt(value string, fallback int) int { + parsed, err := strconv.Atoi(value) + if err != nil { + return fallback + } + return parsed +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go new file mode 100644 index 000000000..9b9eabf64 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go @@ -0,0 +1,121 @@ +package refreshfluxghcrauth + +import ( + "os" + "path/filepath" + "testing" +) + +func TestFakeKSailReadsTheConfiguredRegistryField(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "ksail.yaml") + config := `spec: + cluster: + localRegistry: + registry: ghcr.io/example/wrong +# devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests +` + if err := os.WriteFile(configPath, []byte(config), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + setFakeKSailEnvironment(t, workspace) + + exitCode := fakeKSail([]string{"cluster", "create", "--config", configPath}) + + if exitCode == 0 { + t.Fatal("fake KSail accepted the protected registry literal outside spec.cluster.localRegistry.registry") + } +} + +func TestFakeTalosImageOperationsRequireCRINamespace(t *testing.T) { + for _, operation := range []string{"remove", "pull"} { + t.Run(operation, func(t *testing.T) { + workspace := t.TempDir() + t.Setenv("FAKE_SYNC_STATE_DIR", workspace) + t.Setenv("FAKE_TALOS_NODES_CURRENT", "true") + t.Setenv("TALOS_LOG", filepath.Join(workspace, "talos.log")) + t.Setenv("OPERATION_LOG", filepath.Join(workspace, "operations.log")) + if operation == "pull" { + touchMarker("talos-remove-10.0.0.2") + } + + exitCode := fakeTalosctl([]string{ + "--nodes", "10.0.0.2", "image", operation, + "ghcr.io/devantler-tech/ksail:v1.2.3", + }) + + if exitCode == 0 { + t.Fatalf("fake Talos %s accepted an image operation without --namespace cri", operation) + } + }) + } +} + +func TestFakeCurlRequiresScopeDataPrefix(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "curl.config") + outputPath := filepath.Join(workspace, "response.json") + if err := os.WriteFile(configPath, []byte("user = fixture:token\n"), 0o600); err != nil { + t.Fatalf("write curl config: %v", err) + } + + exitCode := fakeCurl([]string{ + "--disable", + "--config", configPath, + "--output", outputPath, + "--data-urlencode", "not-scope=repository:devantler-tech/ksail:pull", + "--write-out", "%{http_code}", + "--silent", + "--show-error", + "--get", + "https://ghcr.io/token", + }) + + if exitCode == 0 { + t.Fatal("fake curl accepted token scope data without the scope= prefix") + } +} + +func TestFakeCurlRequiresAnchoredUserConfig(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "curl.config") + outputPath := filepath.Join(workspace, "response.json") + if err := os.WriteFile(configPath, []byte(`header = "x-user = disguised"`), 0o600); err != nil { + t.Fatalf("write curl config: %v", err) + } + + exitCode := fakeCurl([]string{ + "--disable", + "--config", configPath, + "--output", outputPath, + "--data-urlencode", "scope=repository:devantler-tech/ksail:pull", + "--write-out", "%{http_code}", + "--silent", + "--show-error", + "--get", + "https://ghcr.io/token", + }) + + if exitCode == 0 { + t.Fatal("fake curl accepted an embedded user setting instead of an anchored user line") + } +} + +func setFakeKSailEnvironment(t *testing.T, workspace string) { + t.Helper() + for _, name := range []string{ + "KSAIL_TOKEN_CAPTURE", + "KSAIL_USERNAME_CAPTURE", + "KSAIL_REVISION_CAPTURE", + "KSAIL_COMMAND_CAPTURE", + "KSAIL_CONFIG_PATH_CAPTURE", + "KSAIL_REGISTRY_CAPTURE", + "KSAIL_REGISTRY_OVERRIDE_CAPTURE", + } { + t.Setenv(name, filepath.Join(workspace, name)) + } + t.Setenv("KSAIL_SPEC_CLUSTER_LOCALREGISTRY_REGISTRY", `${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests`) + t.Setenv("GHCR_TOKEN", "fixture-token") + t.Setenv("GHCR_USERNAME", "devantler") + t.Setenv("GHCR_PULL_REVISION", "fixture-revision") +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go new file mode 100644 index 000000000..48ff7f391 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -0,0 +1,688 @@ +package refreshfluxghcrauth + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" +) + +type jsonPatchOperation struct { + Operation string `json:"op"` + Path string `json:"path"` + Value any `json:"value"` +} + +func fakeKubectlImplementation(args []string) int { + if flagValue(args, "--context") != "admin@prod" { + return commandFailure(91, "kubectl must use the production context") + } + if calledFile := os.Getenv("KUBECTL_CALLED"); calledFile != "" { + mustWriteCommandFile(calledFile, "") + } + + namespace := flagValue(args, "--namespace") + patchFile := flagValue(args, "--patch-file") + manifestFile := flagValue(args, "--filename") + if manifestFile == "" { + manifestFile = flagValue(args, "-f") + } + + switch { + case containsSequence(args, "get", "nodes"): + return fakeKubectlGetNodes() + case containsSequence(args, "get", "node"): + return fakeKubectlGetNode(args) + case containsArg(args, "drain"): + return fakeKubectlDrain(args) + case containsArg(args, "uncordon"): + return fakeKubectlUncordon(args) + case containsSequence(args, "patch", "node") && containsArg(args, "--type=json"): + return fakeKubectlPatchNode(args, patchFile) + case containsArg(args, "cordon"): + return fakeKubectlCordon(args) + case containsArg(args, "wait"): + return fakeKubectlWaitForNode(args) + case containsArg(args, "create") && manifestFile != "": + return fakeKubectlCreateRuntimeProbe(namespace, manifestFile) + case containsSequence(args, "get", "pod"): + return fakeKubectlGetRuntimeProbe(args) + case containsSequence(args, "delete", "pod"): + return fakeKubectlDeleteRuntimeProbe(args) + } + + if namespace == "" { + return commandFailure(91, "namespaced kubectl invocation omitted namespace") + } + + switch { + case containsSequence(args, "get", "secret", "ksail-registry-credentials") && containsSequence(args, "-o", "json"): + return fakeKubectlGetRootSecret() + case containsArg(args, "api-resources"): + return fakeKubectlAPIResources(args) + case containsSequence(args, "patch", "secret", "ksail-registry-credentials"): + return fakeKubectlPatchRootSecret(args, patchFile) + case containsSequence(args, "get", "secret", "variables-base"): + return fakeKubectlGetVariablesBase(args) + case containsSequence(args, "patch", "secret", "variables-base"): + return fakeKubectlPatchVariablesBase(args, patchFile) + } + + kind, name := fanoutResource(args) + if kind != "" { + return fakeKubectlFanoutResource(args, namespace, kind, name) + } + if containsSequence(args, "get", "secret", "ghcr-auth") { + return fakeKubectlGetConsumerSecret(namespace) + } + + return commandFailure(91, "unexpected kubectl invocation: %s", strings.Join(args, " ")) +} + +func fakeKubectlGetNodes() int { + if os.Getenv("FAKE_NODE_DISCOVERY_FAIL") == "true" { + return commandFailure(46, "node discovery failed") + } + if custom := os.Getenv("FAKE_NODE_JSON"); custom != "" { + fmt.Println(custom) + return 0 + } + + revision := os.Getenv("EXPECTED_GHCR_REVISION") + image := os.Getenv("EXPECTED_KSAIL_TARGET_IMAGE") + verifiedImage := defaultString(os.Getenv("FAKE_TALOS_VERIFIED_IMAGE"), image) + nodes := []any{ + fakeInventoryNode("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", "198.51.100.2", false, revision, "", "", true), + fakeInventoryNode("prod-control-plane-1", "prod-control-plane-1-uid", "10.0.0.1", "198.51.100.1", true, revision, "", "", true), + fakeInventoryNode("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", "198.51.100.3", true, revision, revision, image, false), + fakeInventoryNode("prod-control-plane-3", "prod-control-plane-3-uid", "10.0.0.4", "198.51.100.4", true, revision, revision, image, false), + } + if os.Getenv("FAKE_TALOS_NODES_CURRENT") == "true" { + setInventoryProof(nodes[0], revision, verifiedImage) + setInventoryProof(nodes[1], revision, verifiedImage) + } + if markerExists("talos-revision-10.0.0.2") { + setInventoryProof(nodes[0], revision, image) + } + if markerExists("talos-revision-10.0.0.1") { + setInventoryProof(nodes[1], revision, image) + } + + newNodeName := "" + if configured := os.Getenv("FAKE_NODE_APPEARS_AFTER_ROLL"); configured != "" && + markerExists("talos-revision-10.0.0.2") && markerExists("talos-revision-10.0.0.1") { + newNodeName = configured + } else if configured := os.Getenv("FAKE_NODE_APPEARS_DURING_SECOND_FANOUT"); configured != "" && + parseInt(markerContent("variables-patch-count"), 0) >= 2 { + newNodeName = configured + } + if newNodeName != "" { + verifiedRevision := "" + newNodeImage := "" + if markerExists("talos-revision-10.0.0.5") { + verifiedRevision = revision + newNodeImage = image + } + nodes = append(nodes, fakeInventoryNode( + newNodeName, + newNodeName+"-uid", + "10.0.0.5", + "198.51.100.5", + false, + revision, + verifiedRevision, + newNodeImage, + true, + )) + } + + fmt.Println(encodeJSON(map[string]any{"items": nodes})) + return 0 +} + +func fakeInventoryNode( + name string, + uid string, + internalIP string, + externalIP string, + controlPlane bool, + desiredRevision string, + verifiedRevision string, + verifiedImage string, + omitReady bool, +) map[string]any { + labels := map[string]any{} + if controlPlane { + labels["node-role.kubernetes.io/control-plane"] = "" + } + annotations := map[string]any{ + "platform.devantler.tech/ghcr-pull-desired-revision": desiredRevision, + } + if verifiedRevision != "" { + annotations["platform.devantler.tech/ghcr-pull-verified-revision-v2"] = verifiedRevision + } + if verifiedImage != "" { + annotations["platform.devantler.tech/ghcr-pull-verified-image-v2"] = verifiedImage + } + status := map[string]any{ + "addresses": []any{ + map[string]any{"type": "InternalIP", "address": internalIP}, + map[string]any{"type": "ExternalIP", "address": externalIP}, + }, + } + if !omitReady { + status["conditions"] = []any{map[string]any{"type": "Ready", "status": "True"}} + } + return map[string]any{ + "metadata": map[string]any{ + "name": name, + "uid": uid, + "labels": labels, + "annotations": annotations, + }, + "status": status, + } +} + +func setInventoryProof(node any, revision, image string) { + nodeMap := node.(map[string]any) + metadata := nodeMap["metadata"].(map[string]any) + annotations := metadata["annotations"].(map[string]any) + annotations["platform.devantler.tech/ghcr-pull-verified-revision-v2"] = revision + annotations["platform.devantler.tech/ghcr-pull-verified-image-v2"] = image +} + +func fakeKubectlGetNode(args []string) int { + nodeName := argumentAfter(args, "node") + if nodeName == "" { + return commandFailure(91, "node target missing") + } + if !containsSequence(args, "--output", "json") && !containsArg(args, "--output=json") { + if wordListContains(os.Getenv("FAKE_CORDONED_NODES"), nodeName) { + fmt.Print("true") + } + return 0 + } + + nodeUID := nodeName + "-uid" + nodeIP, controlPlane := fakeNodeAddress(nodeName) + if nodeName == os.Getenv("FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE") { + nodeUID = nodeName + "-replacement-uid" + nodeIP = "10.0.0.99" + } + if nodeName == os.Getenv("FAKE_NODE_REPLACED_AFTER_READY_NODE") && markerExists("ready-"+nodeName) { + nodeUID = nodeName + "-replacement-uid" + nodeIP = "10.0.0.99" + } + if nodeName == os.Getenv("FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE") && markerExists("uncordoned-"+nodeName) { + nodeUID = nodeName + "-replacement-uid" + nodeIP = "10.0.0.99" + } + if nodeName == os.Getenv("FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE") && markerExists("drained-"+nodeName) { + nodeIP = "10.0.0.99" + } + + labels := map[string]any{} + if controlPlane { + labels["node-role.kubernetes.io/control-plane"] = "" + } + annotations := map[string]any{} + if owner := markerContent("cordon-owner-" + nodeName); owner != "" { + annotations["platform.devantler.tech/ghcr-auth-drain-owner"] = owner + } + cordoned := wordListContains(os.Getenv("FAKE_CORDONED_NODES"), nodeName) || markerExists("cordoned-"+nodeName) + if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE") && markerExists("ready-"+nodeName) { + cordoned = false + } + taints := make([]any, 0, 2) + if cordoned { + taints = append(taints, map[string]any{ + "key": "node.kubernetes.io/unschedulable", + "effect": "NoSchedule", + }) + } + if markerExists("autoscaler-cordon-" + nodeName) { + taints = append(taints, map[string]any{ + "key": "ToBeDeletedByClusterAutoscaler", + "effect": "NoSchedule", + }) + } + resourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") + node := map[string]any{ + "metadata": map[string]any{ + "name": nodeName, + "uid": nodeUID, + "labels": labels, + "resourceVersion": resourceVersion, + "deletionTimestamp": nil, + "annotations": annotations, + }, + "spec": map[string]any{ + "unschedulable": cordoned, + "taints": taints, + }, + "status": map[string]any{ + "addresses": []any{map[string]any{"type": "InternalIP", "address": nodeIP}}, + }, + } + fmt.Println(encodeJSON(node)) + return 0 +} + +func fakeNodeAddress(nodeName string) (string, bool) { + switch nodeName { + case "prod-worker-1": + return "10.0.0.2", false + case "prod-control-plane-1": + return "10.0.0.1", true + case "prod-control-plane-2": + return "10.0.0.3", true + case "prod-control-plane-3": + return "10.0.0.4", true + default: + return "10.0.0.5", false + } +} + +func fakeKubectlDrain(args []string) int { + nodeName := argumentAfter(args, "drain") + if nodeName == "" || !containsArg(args, "--ignore-daemonsets") || + !containsArg(args, "--delete-emptydir-data") || !containsArg(args, "--timeout=45m") || + containsArg(args, "--disable-eviction") || containsArg(args, "--force") { + return commandFailure(55, "unsafe or incomplete kubectl drain flags") + } + appendEnvFile("OPERATION_LOG", "node-drain:"+nodeName+"\n") + if !wordListContains(os.Getenv("FAKE_CORDONED_NODES"), nodeName) && !markerExists("cordoned-"+nodeName) { + return commandFailure(55, "drain target was not cordoned") + } + if nodeName == os.Getenv("FAKE_DRAIN_API_FAIL_NODE") { + return commandFailure(54, "could not list pods before eviction") + } + if nodeName == os.Getenv("FAKE_CORDON_OWNER_REPLACED_NODE") { + setMarkerContent("cordon-owner-"+nodeName, "operator-cordon") + } + if nodeName == os.Getenv("FAKE_AUTOSCALER_CORDON_NODE") { + touchMarker("autoscaler-cordon-" + nodeName) + } + if nodeName == os.Getenv("FAKE_DRAIN_FAIL_NODE") { + return commandFailure(53, "cannot evict pod backstage-db-4: would violate PodDisruptionBudget backstage-db-primary") + } + touchMarker("drained-" + nodeName) + if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE") { + removeMarker("cordoned-" + nodeName) + appendEnvFile("OPERATION_LOG", "operator-uncordon:"+nodeName+"\n") + } + return 0 +} + +func fakeKubectlUncordon(args []string) int { + nodeName := argumentAfter(args, "uncordon") + if nodeName == "" { + return commandFailure(91, "uncordon target missing") + } + if nodeName == os.Getenv("FAKE_CORDON_OWNER_REPLACED_NODE") || nodeName == os.Getenv("FAKE_UNCORDON_FAIL_NODE") { + return commandFailure(56, "cordon ownership changed; refusing to uncordon") + } + appendEnvFile("OPERATION_LOG", "node-uncordon:"+nodeName+"\n") + return 0 +} + +func fakeKubectlPatchNode(args []string, patchFile string) int { + nodeName := argumentAfter(args, "node") + if nodeName == "" || patchFile == "" { + return commandFailure(91, "node patch target or patch file missing") + } + var patch []jsonPatchOperation + if err := json.Unmarshal([]byte(mustReadCommandFile(patchFile)), &patch); err != nil { + return commandFailure(91, "parse node patch: %v", err) + } + currentResourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") + isClaim := hasPatchOperation(patch, "add", "/spec/unschedulable", true) + if isClaim { + if nodeName == os.Getenv("FAKE_CORDON_BEFORE_CLAIM_NODE") { + touchMarker("cordoned-" + nodeName) + appendEnvFile("OPERATION_LOG", "operator-cordon:"+nodeName+"\n") + return commandFailure(56, "resourceVersion test failed after concurrent cordon") + } + owner := patchValueString(patch, "add", "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner") + if owner == "" || markerExists("cordon-owner-"+nodeName) || + len(patch) == 0 || patch[0].Operation != "test" || + patch[0].Path != "/metadata/resourceVersion" || fmt.Sprint(patch[0].Value) != currentResourceVersion { + return commandFailure(56, "invalid atomic cordon claim") + } + setMarkerContent("cordon-owner-"+nodeName, owner) + touchMarker("cordoned-" + nodeName) + setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) + appendEnvFile("OPERATION_LOG", "node-claim-cordon:"+nodeName+"\n") + return 0 + } + + expectedOwner := "" + if len(patch) > 0 { + expectedOwner = fmt.Sprint(patch[0].Value) + } + if nodeName == os.Getenv("FAKE_UNCORDON_FAIL_NODE") || markerContent("cordon-owner-"+nodeName) != expectedOwner { + return commandFailure(56, "cordon ownership changed; refusing to uncordon") + } + if len(patch) == 0 || patch[0].Operation != "test" || + patch[0].Path != "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" || + !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) || + !hasPatchOperation(patch, "add", "/spec/unschedulable", false) || + !hasPatchPath(patch, "remove", "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner") { + return commandFailure(56, "invalid atomic cordon release") + } + appendEnvFile("OPERATION_LOG", "node-uncordon:"+nodeName+"\n") + setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) + removeMarker("cordon-owner-" + nodeName) + removeMarker("cordoned-" + nodeName) + touchMarker("uncordoned-" + nodeName) + return 0 +} + +func hasPatchOperation(patch []jsonPatchOperation, operation, path string, value any) bool { + for _, item := range patch { + if item.Operation == operation && item.Path == path && fmt.Sprint(item.Value) == fmt.Sprint(value) { + return true + } + } + return false +} + +func hasPatchPath(patch []jsonPatchOperation, operation, path string) bool { + for _, item := range patch { + if item.Operation == operation && item.Path == path { + return true + } + } + return false +} + +func patchValueString(patch []jsonPatchOperation, operation, path string) string { + for _, item := range patch { + if item.Operation == operation && item.Path == path { + return fmt.Sprint(item.Value) + } + } + return "" +} + +func incrementDecimal(value string) string { + parsed, err := strconv.Atoi(value) + if err != nil { + return value + } + return strconv.Itoa(parsed + 1) +} + +func fakeKubectlCordon(args []string) int { + nodeName := argumentAfter(args, "cordon") + if nodeName == "" { + return commandFailure(91, "cordon target missing") + } + appendEnvFile("OPERATION_LOG", "node-cordon:"+nodeName+"\n") + return 0 +} + +func fakeKubectlWaitForNode(args []string) int { + if !containsArg(args, "--for=condition=Ready") || !containsArg(args, "--timeout=10m") { + return commandFailure(91, "unsafe node readiness wait") + } + nodeName := "" + for _, argument := range args { + if strings.HasPrefix(argument, "node/") { + nodeName = strings.TrimPrefix(argument, "node/") + } + } + if nodeName == "" { + return commandFailure(91, "readiness target missing") + } + appendEnvFile("OPERATION_LOG", "node-ready:"+nodeName+"\n") + if nodeName == os.Getenv("FAKE_NODE_READY_FAIL_NODE") { + return commandFailure(50, "node did not become ready") + } + touchMarker("ready-" + nodeName) + return 0 +} + +func fakeKubectlCreateRuntimeProbe(namespace, manifestFile string) int { + if namespace != "ksail-operator" || manifestFile == "" { + return commandFailure(91, "invalid runtime probe namespace or manifest") + } + var manifest map[string]any + if err := json.Unmarshal([]byte(mustReadCommandFile(manifestFile)), &manifest); err != nil { + return commandFailure(91, "parse runtime probe: %v", err) + } + metadata, _ := manifest["metadata"].(map[string]any) + spec, _ := manifest["spec"].(map[string]any) + containers, _ := spec["containers"].([]any) + if manifest["kind"] != "Pod" || metadata["namespace"] != "ksail-operator" || + spec["automountServiceAccountToken"] != false || len(containers) != 1 || + len(anySlice(spec["imagePullSecrets"])) != 0 { + return commandFailure(91, "unsafe runtime probe manifest") + } + container, _ := containers[0].(map[string]any) + securityContext, _ := container["securityContext"].(map[string]any) + image, _ := container["image"].(string) + if (image != "ghcr.io/devantler-tech/wedding-app:latest" && image != "ghcr.io/devantler-tech/ascoachingogvaner:latest") || + container["imagePullPolicy"] != "Always" || securityContext["allowPrivilegeEscalation"] != false { + return commandFailure(91, "runtime probe does not prove a private package pull") + } + probeName, _ := metadata["name"].(string) + probeNode, _ := spec["nodeName"].(string) + if probeName == "" || probeNode == "" { + return commandFailure(91, "runtime probe name or node missing") + } + setMarkerContent("runtime-probe-"+probeName, probeNode+"\n"+image+"\n") + fmt.Printf("pod/%s\n", probeName) + return 0 +} + +func fakeKubectlGetRuntimeProbe(args []string) int { + probeName := argumentAfter(args, "pod") + contents := markerContent("runtime-probe-" + probeName) + lines := strings.Split(strings.TrimSuffix(contents, "\n"), "\n") + if probeName == "" || len(lines) < 2 { + return commandFailure(91, "runtime probe state missing") + } + probeNode, probeImage := lines[0], lines[1] + pullSecrets := []any{} + if wordListContains(os.Getenv("FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES"), probeNode) { + pullSecrets = append(pullSecrets, map[string]any{"name": "injected-pull-secret"}) + } + status := map[string]any{} + if wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_NODES"), probeNode) || + wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_IMAGES"), probeImage) { + status["containerStatuses"] = []any{map[string]any{ + "state": map[string]any{"waiting": map[string]any{"reason": "ImagePullBackOff"}}, + }} + } else { + status["containerStatuses"] = []any{map[string]any{ + "imageID": "ghcr.io/private@sha256:runtime-probe", + "state": map[string]any{ + "terminated": map[string]any{"reason": "Completed", "exitCode": 0}, + }, + }} + } + fmt.Println(encodeJSON(map[string]any{ + "spec": map[string]any{"imagePullSecrets": pullSecrets}, + "status": status, + })) + return 0 +} + +func fakeKubectlDeleteRuntimeProbe(args []string) int { + probeName := argumentAfter(args, "pod") + if probeName == "" { + return commandFailure(91, "runtime probe delete target missing") + } + removeMarker("runtime-probe-" + probeName) + fmt.Printf("pod %q deleted\n", probeName) + return 0 +} + +func fakeKubectlGetRootSecret() int { + token := defaultString(os.Getenv("FAKE_CURRENT_ROOT_TOKEN"), "previous-runtime-token") + config := encodeJSON(map[string]any{ + "auths": map[string]any{ + "ghcr.io": map[string]any{"username": "devantler", "password": token}, + }, + }) + encoded := base64.StdEncoding.EncodeToString([]byte(config)) + fmt.Println(encodeJSON(map[string]any{ + "data": map[string]any{".dockerconfigjson": encoded}, + })) + return 0 +} + +func fakeKubectlAPIResources(args []string) int { + if flagValue(args, "--api-group") != "external-secrets.io" { + return commandFailure(91, "unexpected api-resources group") + } + if os.Getenv("FAKE_FANOUT_CRDS_ABSENT") != "true" { + fmt.Println("externalsecrets.external-secrets.io") + fmt.Println("pushsecrets.external-secrets.io") + } + return 0 +} + +func fakeKubectlPatchRootSecret(args []string, patchFile string) int { + if !containsArg(args, "--type=merge") || patchFile == "" { + return commandFailure(91, "invalid root secret patch") + } + if err := copyFile(patchFile, os.Getenv("PATCH_CAPTURE")); err != nil { + return commandFailure(91, "capture root patch: %v", err) + } + appendEnvFile("OPERATION_LOG", "root-patch\n") + if os.Getenv("FAKE_KUBECTL_FAIL") == "true" { + return commandFailure(43, "cluster patch failed") + } + fmt.Println("secret/ksail-registry-credentials patched") + return 0 +} + +func fakeKubectlGetVariablesBase(args []string) int { + if !containsArg(args, "--ignore-not-found") { + return commandFailure(91, "variables-base lookup must tolerate a fresh cluster") + } + if os.Getenv("FAKE_VARIABLES_BASE_ABSENT") != "true" { + fmt.Println("secret/variables-base") + } + return 0 +} + +func fakeKubectlPatchVariablesBase(args []string, patchFile string) int { + if !containsArg(args, "--type=merge") || patchFile == "" { + return commandFailure(91, "invalid variables-base patch") + } + if err := copyFile(patchFile, os.Getenv("VARIABLES_PATCH_CAPTURE")); err != nil { + return commandFailure(91, "capture variables-base patch: %v", err) + } + count := parseInt(markerContent("variables-patch-count"), 0) + 1 + setMarkerContent("variables-patch-count", strconv.Itoa(count)) + appendEnvFile("OPERATION_LOG", "variables-patch\n") + fmt.Println("secret/variables-base patched") + return 0 +} + +func fanoutResource(args []string) (string, string) { + if containsSequence(args, "pushsecret", "seed-ghcr") { + return "pushsecret", "seed-ghcr" + } + if containsSequence(args, "externalsecret", "ghcr-auth") { + return "externalsecret", "ghcr-auth" + } + return "", "" +} + +func fakeKubectlFanoutResource(args []string, namespace, kind, name string) int { + resource := kind + "/" + namespace + "/" + name + missingResource := os.Getenv("FAKE_MISSING_FANOUT_RESOURCE") + if containsArg(args, "--ignore-not-found") && containsSequence(args, "get", kind, name) { + if resource != missingResource { + fmt.Printf("%s/%s\n", kind, name) + } + return 0 + } + if resource == missingResource { + return commandFailure(44, "%s/%s not found", kind, name) + } + if containsSequence(args, "get", kind, name) { + markerName := kind + "-" + namespace + "-" + name + refreshTime := "2026-07-13T00:00:00Z" + resourceVersion := "1" + if markerExists(markerName + "-annotated") { + resourceVersion = "2" + } + if markerExists(markerName) && os.Getenv("FAKE_SYNC_SAME_REFRESH_TIME") != "true" { + refreshTime = "2026-07-13T00:00:01Z" + } + if markerExists(markerName) { + resourceVersion = "3" + } + fmt.Println(encodeJSON(map[string]any{ + "metadata": map[string]any{"resourceVersion": resourceVersion}, + "status": map[string]any{ + "refreshTime": refreshTime, + "conditions": []any{map[string]any{"type": "Ready", "status": "True"}}, + }, + })) + return 0 + } + if containsSequence(args, "annotate", kind, name) { + appendEnvFile("FANOUT_LOG", resource+"\n") + appendEnvFile("OPERATION_LOG", "fanout:"+resource+"\n") + markerName := kind + "-" + namespace + "-" + name + touchMarker(markerName + "-annotated") + if resource != os.Getenv("FAKE_SYNC_STALL_RESOURCE") { + touchMarker(markerName) + } + fmt.Println(`{"metadata":{"resourceVersion":"2"}}`) + return 0 + } + return commandFailure(91, "unexpected fanout resource invocation") +} + +func fakeKubectlGetConsumerSecret(namespace string) int { + variablesPatchCount := parseInt(markerContent("variables-patch-count"), 0) + revertedMarker := "consumer-reverted-" + namespace + mismatch := namespace == os.Getenv("FAKE_CONSUMER_MISMATCH_NAMESPACE") || + (namespace == os.Getenv("FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE") && variablesPatchCount >= 2) || + (markerExists(revertedMarker) && variablesPatchCount < 3) + encoded := "" + if mismatch { + encoded = base64.StdEncoding.EncodeToString([]byte(`{"auths":{}}`)) + } else { + var patch map[string]any + if err := json.Unmarshal([]byte(mustReadCommandFile(os.Getenv("VARIABLES_PATCH_CAPTURE"))), &patch); err != nil { + return commandFailure(91, "parse variables-base patch: %v", err) + } + data, _ := patch["data"].(map[string]any) + encoded, _ = data["ghcr_dockerconfigjson"].(string) + if variablesPatchCount >= 3 { + removeMarker(revertedMarker) + } + } + fmt.Println(encodeJSON(map[string]any{ + "data": map[string]any{".dockerconfigjson": encoded}, + })) + return 0 +} + +func wordListContains(list, target string) bool { + for _, item := range strings.Fields(list) { + if item == target { + return true + } + } + return false +} + +func anySlice(value any) []any { + if value == nil { + return nil + } + items, _ := value.([]any) + return items +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go b/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go new file mode 100644 index 000000000..80b05aab7 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go @@ -0,0 +1,381 @@ +package refreshfluxghcrauth + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "sort" + "strings" + "testing" +) + +var ( + rootPath = findRepoRoot() + helperPath = filepath.Join(rootPath, "scripts", "refresh-flux-ghcr-auth.sh") + ksailPullWrapperPath = filepath.Join(rootPath, "scripts", "run-ksail-prod-with-pull-auth.sh") + ksailOperatorVersion = readKSailOperatorVersion() + ksailTargetImage = "ghcr.io/devantler-tech/ksail:v" + ksailOperatorVersion +) + +type commandResult struct { + exitCode int + stdout string + stderr string +} + +type fixture struct { + t *testing.T + workspace string + binDir string + decryptedConfig string + encryptedSecret string + patchCapture string + variablesPatchCapture string + kubectlCalled string + outputPathLog string + registryReadLog string + fanoutLog string + talosLog string + talosPatchPathLog string + operationLog string + ksailTokenCapture string + ksailUsernameCapture string + ksailRevisionCapture string + ksailCommandCapture string + ksailConfigPathCapture string + ksailRegistryCapture string + ksailRegistryOverrideCapture string + syncStateDir string + encryptedCiphertext string +} + +func TestMain(m *testing.M) { + var code int + switch filepath.Base(os.Args[0]) { + case "ksail": + code = fakeKSail(os.Args[1:]) + case "talosctl": + code = fakeTalosctl(os.Args[1:]) + case "curl": + code = fakeCurl(os.Args[1:]) + case "kubectl": + code = fakeKubectl(os.Args[1:]) + default: + code = m.Run() + } + os.Exit(code) +} + +func findRepoRoot() string { + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("cannot locate fixture source") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..")) +} + +func readKSailOperatorVersion() string { + path := filepath.Join(rootPath, "k8s", "bases", "infrastructure", "controllers", "ksail-operator", "helm-release.yaml") + for _, line := range strings.Split(mustReadFile(path), "\n") { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "version:") { + return strings.TrimSpace(strings.TrimPrefix(trimmed, "version:")) + } + } + panic("KSail operator chart version not found") +} + +func newFixture(t *testing.T) *fixture { + t.Helper() + workspace := t.TempDir() + f := &fixture{ + t: t, + workspace: workspace, + binDir: filepath.Join(workspace, "bin"), + decryptedConfig: filepath.Join(workspace, "decrypted-config.json"), + encryptedSecret: filepath.Join(workspace, "secret.enc.yaml"), + patchCapture: filepath.Join(workspace, "patch.json"), + variablesPatchCapture: filepath.Join(workspace, "variables-patch.json"), + kubectlCalled: filepath.Join(workspace, "kubectl-called"), + outputPathLog: filepath.Join(workspace, "ksail-output-path"), + registryReadLog: filepath.Join(workspace, "registry-reads"), + fanoutLog: filepath.Join(workspace, "fanout-log"), + talosLog: filepath.Join(workspace, "talos-log"), + talosPatchPathLog: filepath.Join(workspace, "talos-patch-path"), + operationLog: filepath.Join(workspace, "operation-log"), + ksailTokenCapture: filepath.Join(workspace, "ksail-token"), + ksailUsernameCapture: filepath.Join(workspace, "ksail-username"), + ksailRevisionCapture: filepath.Join(workspace, "ksail-revision"), + ksailCommandCapture: filepath.Join(workspace, "ksail-command"), + ksailConfigPathCapture: filepath.Join(workspace, "ksail-config-path"), + ksailRegistryCapture: filepath.Join(workspace, "ksail-registry"), + ksailRegistryOverrideCapture: filepath.Join(workspace, "ksail-registry-override"), + syncStateDir: filepath.Join(workspace, "sync-state"), + } + mustMkdir(f.binDir) + mustMkdir(f.syncStateDir) + testExecutable, err := os.Executable() + if err != nil { + t.Fatalf("resolve test executable: %v", err) + } + for _, name := range []string{"ksail", "talosctl", "curl", "kubectl"} { + if err := os.Symlink(testExecutable, filepath.Join(f.binDir, name)); err != nil { + t.Fatalf("link fake %s: %v", name, err) + } + } + f.writeEncryptedSecret("ENC[AES256_GCM,data:fixture-one]") + return f +} + +func (f *fixture) writeEncryptedSecret(ciphertext string) { + f.t.Helper() + f.encryptedCiphertext = ciphertext + mustWriteJSON(f.t, f.encryptedSecret, map[string]any{ + "stringData": map[string]any{"ghcr_dockerconfigjson": ciphertext}, + }) +} + +func (f *fixture) expectedRevision() string { + digest := sha256.Sum256([]byte(f.encryptedCiphertext + "\n")) + return hex.EncodeToString(digest[:]) +} + +func expectedCredentials(config any) (string, string) { + root, ok := config.(map[string]any) + if !ok { + return "unused", "unused" + } + auths, ok := root["auths"].(map[string]any) + if !ok { + return "unused", "unused" + } + registry, ok := auths["ghcr.io"].(map[string]any) + if !ok { + return "unused", "unused" + } + username, usernameOK := registry["username"].(string) + password, passwordOK := registry["password"].(string) + if usernameOK && passwordOK && username != "" && password != "" { + return username, password + } + encoded, ok := registry["auth"].(string) + if !ok { + return "unused", "unused" + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + return "unused", "unused" + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "unused", "unused" + } + return parts[0], parts[1] +} + +func validConfig() map[string]any { + return map[string]any{ + "auths": map[string]any{ + "ghcr.io": map[string]any{ + "username": "devantler", + "password": "fixture-secret-token", + }, + }, + } +} + +func (f *fixture) runHelper(config any, helperArgs []string, overrides map[string]string) commandResult { + f.t.Helper() + mustWriteJSON(f.t, f.decryptedConfig, config) + f.clearRunState(true) + username, token := expectedCredentials(config) + env := f.baseEnvironment() + env["EXPECTED_PULL_USERNAME"] = username + env["EXPECTED_PULL_TOKEN"] = token + env["EXPECTED_GHCR_REVISION"] = f.expectedRevision() + env["EXPECTED_KSAIL_TARGET_IMAGE"] = ksailTargetImage + env["FLUX_GHCR_SYNC_ATTEMPTS"] = "2" + env["FLUX_GHCR_SYNC_INTERVAL"] = "0" + env["FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS"] = "6" + for key, value := range overrides { + env[key] = value + } + return runCaptured(f.t, rootPath, env, helperPath, helperArgs...) +} + +func (f *fixture) runKSailPullWrapper(config any, command []string, overrides map[string]string) commandResult { + f.t.Helper() + mustWriteJSON(f.t, f.decryptedConfig, config) + f.clearRunState(false) + username, token := expectedCredentials(config) + env := f.baseEnvironment() + env["EXPECTED_PULL_USERNAME"] = username + env["EXPECTED_PULL_TOKEN"] = token + env["EXPECTED_GHCR_REVISION"] = f.expectedRevision() + for key, value := range overrides { + env[key] = value + } + return runCaptured(f.t, rootPath, env, ksailPullWrapperPath, command...) +} + +func (f *fixture) baseEnvironment() map[string]string { + env := environmentMap(os.Environ()) + env["PATH"] = f.binDir + string(os.PathListSeparator) + env["PATH"] + env["FAKE_DECRYPTED_CONFIG"] = f.decryptedConfig + env["FLUX_GHCR_SECRET_FILE"] = f.encryptedSecret + env["PATCH_CAPTURE"] = f.patchCapture + env["VARIABLES_PATCH_CAPTURE"] = f.variablesPatchCapture + env["KUBECTL_CALLED"] = f.kubectlCalled + env["KSAIL_OUTPUT_PATH_LOG"] = f.outputPathLog + env["REGISTRY_READ_LOG"] = f.registryReadLog + env["FANOUT_LOG"] = f.fanoutLog + env["TALOS_LOG"] = f.talosLog + env["TALOS_PATCH_PATH_LOG"] = f.talosPatchPathLog + env["OPERATION_LOG"] = f.operationLog + env["KSAIL_TOKEN_CAPTURE"] = f.ksailTokenCapture + env["KSAIL_USERNAME_CAPTURE"] = f.ksailUsernameCapture + env["KSAIL_REVISION_CAPTURE"] = f.ksailRevisionCapture + env["KSAIL_COMMAND_CAPTURE"] = f.ksailCommandCapture + env["KSAIL_CONFIG_PATH_CAPTURE"] = f.ksailConfigPathCapture + env["KSAIL_REGISTRY_CAPTURE"] = f.ksailRegistryCapture + env["KSAIL_REGISTRY_OVERRIDE_CAPTURE"] = f.ksailRegistryOverrideCapture + env["FAKE_SYNC_STATE_DIR"] = f.syncStateDir + return env +} + +func (f *fixture) clearRunState(helper bool) { + f.t.Helper() + paths := []string{ + f.outputPathLog, + f.ksailTokenCapture, + f.ksailUsernameCapture, + f.ksailRevisionCapture, + f.ksailCommandCapture, + f.ksailConfigPathCapture, + f.ksailRegistryCapture, + f.ksailRegistryOverrideCapture, + } + if helper { + paths = append(paths, + f.patchCapture, + f.variablesPatchCapture, + f.kubectlCalled, + f.registryReadLog, + f.fanoutLog, + f.talosLog, + f.talosPatchPathLog, + f.operationLog, + ) + } + for _, path := range paths { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + f.t.Fatalf("clear %s: %v", path, err) + } + } + if helper { + entries, err := os.ReadDir(f.syncStateDir) + if err != nil { + f.t.Fatalf("read sync state: %v", err) + } + for _, entry := range entries { + if err := os.Remove(filepath.Join(f.syncStateDir, entry.Name())); err != nil { + f.t.Fatalf("clear sync marker %s: %v", entry.Name(), err) + } + } + } +} + +func runCaptured(t *testing.T, cwd string, environment map[string]string, executable string, args ...string) commandResult { + t.Helper() + cmd := exec.Command(executable, args...) + cmd.Dir = cwd + cmd.Env = environmentSlice(environment) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + exitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("run %s: %v", executable, err) + } + exitCode = exitError.ExitCode() + } + return commandResult{exitCode: exitCode, stdout: stdout.String(), stderr: stderr.String()} +} + +func environmentMap(values []string) map[string]string { + result := make(map[string]string, len(values)) + for _, value := range values { + key, item, ok := strings.Cut(value, "=") + if ok { + result[key] = item + } + } + return result +} + +func environmentSlice(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + result := make([]string, 0, len(keys)) + for _, key := range keys { + result = append(result, key+"="+values[key]) + } + return result +} + +func mustWriteJSON(t *testing.T, path string, value any) { + t.Helper() + encoded, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal %s: %v", path, err) + } + if err := os.WriteFile(path, encoded, 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func mustRead(path string) string { + return mustReadFile(path) +} + +func mustReadFile(path string) string { + content, err := os.ReadFile(path) + if err != nil { + panic(fmt.Sprintf("read %s: %v", path, err)) + } + return string(content) +} + +func readLines(path string) []string { + content := strings.TrimSuffix(mustRead(path), "\n") + if content == "" { + return nil + } + return strings.Split(content, "\n") +} + +func pathExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +func mustMkdir(path string) { + if err := os.MkdirAll(path, 0o700); err != nil { + panic(fmt.Sprintf("mkdir %s: %v", path, err)) + } +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go new file mode 100644 index 000000000..3137ad611 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -0,0 +1,282 @@ +package refreshfluxghcrauth + +import ( + "strings" + "testing" +) + +func TestSecondFanoutVerificationBlocksRootCutover(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE": "wedding-app"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "did not materialise") + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-revision:10.0.0.1") + count := 0 + for _, operation := range operations { + if operation == "variables-patch" { + count++ + } + } + if count != 2 { + t.Errorf("variables patch count = %d, want 2", count) + } + requireNoLine(t, operations, "root-patch") +} + +func TestMissingCachedImageStillPullsAndRecordsRevision(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_TALOS_IMAGE_ABSENT_NODE": "10.0.0.2"}) + requireSuccessResult(t, result) + operations := readLines(f.talosLog) + requireLine(t, operations, "talos-remove:10.0.0.2:"+ksailTargetImage) + requireLine(t, operations, "talos-pull:10.0.0.2:"+ksailTargetImage) + requireLine(t, operations, "talos-revision:10.0.0.2") + if !pathExists(f.patchCapture) { + t.Error("root patch missing after successful pull proof") + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") +} + +func TestCurrentTalosNodesSkipTalosAPI(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_TALOS_NODES_CURRENT": "true"}) + requireSuccessResult(t, result) + if pathExists(f.talosLog) { + t.Error("current nodes unexpectedly invoked Talos") + } + if !pathExists(f.patchCapture) { + t.Error("root patch missing") + } +} + +func TestMatchingRevisionRevalidatesChangedDeclaredImage(t *testing.T) { + f := newFixture(t) + previousImage := "ghcr.io/devantler-tech/ksail:v7.166.0" + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TALOS_NODES_CURRENT": "true", + "FAKE_TALOS_VERIFIED_IMAGE": previousImage, + }) + requireSuccessResult(t, result) + if !pathExists(f.talosLog) { + t.Fatal("matching revision incorrectly skipped changed-image proof") + } + operations := readLines(f.talosLog) + requireLinesEqual(t, operations, []string{ + "talos-remove:10.0.0.2:" + ksailTargetImage, + "talos-pull:10.0.0.2:" + ksailTargetImage, + "talos-revision:10.0.0.2", + "talos-remove:10.0.0.1:" + ksailTargetImage, + "talos-pull:10.0.0.1:" + ksailTargetImage, + "talos-revision:10.0.0.1", + }) + operationLog := mustRead(f.operationLog) + requireNotContains(t, operationLog, "node-drain:") + requireNotContains(t, operationLog, "talos-reboot:") + requireNotContains(t, strings.Join(operations, "\n"), previousImage) +} + +func TestFailedImageOnlyPullKeepsNodeCordoned(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TALOS_NODES_CURRENT": "true", + "FAKE_TALOS_VERIFIED_IMAGE": "ghcr.io/devantler-tech/ksail:v7.166.0", + "FAKE_TALOS_FAIL_NODE": "10.0.0.2", + "FAKE_TALOS_FAIL_OPERATION": "pull", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + for _, unexpected := range []string{"node-drain:prod-worker-1", "node-uncordon:prod-worker-1", "talos-reboot:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } +} + +func TestNodeAddedMidRollIsProcessedBeforeRootCutover(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_APPEARS_AFTER_ROLL": "prod-worker-2"}) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + for _, expected := range []string{"talos-auth:10.0.0.5", "node-drain:prod-worker-2", "talos-reboot:10.0.0.5", "talos-revision:10.0.0.5"} { + requireLine(t, operations, expected) + } + if lineIndex(t, operations, "talos-revision:10.0.0.5") >= lineIndex(t, operations, "root-patch") { + t.Error("root cutover preceded late-node proof") + } +} + +func TestNodeAddedDuringSecondFanoutIsProcessedBeforeCutover(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_APPEARS_DURING_SECOND_FANOUT": "prod-worker-2"}) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + variables := lineIndices(operations, "variables-patch") + if len(variables) < 2 { + t.Fatalf("variables fanout passes = %d, want at least 2", len(variables)) + } + lateRevision := lineIndex(t, operations, "talos-revision:10.0.0.5") + rootCutover := lineIndex(t, operations, "root-patch") + if variables[1] >= lateRevision || lateRevision >= rootCutover { + t.Errorf("unsafe late-node ordering: fanout=%d revision=%d root=%d", variables[1], lateRevision, rootCutover) + } +} + +func TestLateNodeRollReprovesFanoutBeforeRootCutover(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_NODE_APPEARS_DURING_SECOND_FANOUT": "prod-worker-2", + "FAKE_CONSUMER_REVERT_DURING_LATE_NODE_NAMESPACE": "wedding-app", + }) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + fanoutStarts := lineIndices(operations, "variables-patch") + if len(fanoutStarts) != 3 { + t.Fatalf("fanout pass count = %d, want 3", len(fanoutStarts)) + } + consumerRevert := lineIndex(t, operations, "consumer-revert:wedding-app") + rootCutover := lineIndex(t, operations, "root-patch") + if consumerRevert >= fanoutStarts[2] || fanoutStarts[2] >= rootCutover { + t.Errorf("unsafe re-proof ordering: revert=%d third-fanout=%d root=%d", consumerRevert, fanoutStarts[2], rootCutover) + } +} + +func lineIndices(lines []string, target string) []int { + var result []int + for index, line := range lines { + if line == target { + result = append(result, index) + } + } + return result +} + +func TestRevokedPreviousCredentialBlocksFirstDrain(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true"}) + requireFailureResult(t, result) + output := result.stdout + result.stderr + requireContains(t, output, "current root GHCR credential") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNotContains(t, strings.Join(operations, "\n"), "talos-reboot:") + requireNoLine(t, operations, "root-patch") + requireNotContains(t, output, "previous-runtime-token") +} + +func TestValidRootTokenDoesNotSubstituteForPeerRuntimeProof(t *testing.T) { + f := newFixture(t) + ready := []any{map[string]any{"type": "Ready", "status": "True"}} + inventory := map[string]any{"items": []any{ + nodeFixture("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", false, ready, nil), + nodeFixture("prod-control-plane-1", "prod-control-plane-1-uid", "10.0.0.1", true, ready, nil), + nodeFixture("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", true, ready, nil), + nodeFixture("prod-control-plane-3", "prod-control-plane-3-uid", "10.0.0.5", true, ready, nil), + }} + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_NODE_JSON": encodeJSON(inventory), + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "running containerd") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + +func TestRuntimeProbeRejectsInjectedImagePullSecret(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES": "prod-control-plane-2"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "imagePullSecret") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + +func TestEachPrivateRuntimePackageACLMustPass(t *testing.T) { + for _, image := range []string{ + "ghcr.io/devantler-tech/wedding-app:latest", + "ghcr.io/devantler-tech/ascoachingogvaner:latest", + } { + t.Run(image, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_RUNTIME_PULL_FAIL_IMAGES": image}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, image) + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") + }) + } +} + +func TestDRWithoutFanoutDoesNotDrainNodes(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), []string{"--allow-incomplete-fanout"}, map[string]string{"FAKE_VARIABLES_BASE_ABSENT": "true"}) + requireSuccessResult(t, result) + if pathExists(f.talosLog) { + t.Error("DR without fanout invoked Talos") + } + if !pathExists(f.patchCapture) { + t.Error("DR root repair patch missing") + } +} + +func TestInvalidNodeInventoryFailsClosed(t *testing.T) { + invalidInventories := []any{ + map[string]any{"items": []any{}}, + map[string]any{"items": []any{map[string]any{ + "metadata": map[string]any{"name": "one", "uid": "uid-one"}, + "status": map[string]any{"addresses": []any{}}, + }}}, + map[string]any{"items": []any{map[string]any{ + "metadata": map[string]any{"name": "one", "uid": "uid-one"}, + "status": map[string]any{"addresses": []any{ + map[string]any{"type": "InternalIP", "address": "10.0.0.1"}, + map[string]any{"type": "InternalIP", "address": "10.0.0.2"}, + }}, + }}}, + map[string]any{"items": []any{ + nodeFixture("one", "uid-one", "10.0.0.1", false, nil, nil), + nodeFixture("two", "uid-two", "10.0.0.1", false, nil, nil), + }}, + map[string]any{"items": []any{map[string]any{ + "metadata": map[string]any{"name": "one"}, + "status": map[string]any{"addresses": []any{ + map[string]any{"type": "InternalIP", "address": "10.0.0.1"}, + }}, + }}}, + map[string]any{"items": []any{ + nodeFixture("one", "duplicate", "10.0.0.1", false, nil, nil), + nodeFixture("two", "duplicate", "10.0.0.2", false, nil, nil), + }}, + } + for index, inventory := range invalidInventories { + t.Run(string(rune('A'+index)), func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_JSON": encodeJSON(inventory)}) + requireFailureResult(t, result) + if pathExists(f.talosLog) { + t.Error("invalid inventory invoked Talos") + } + if pathExists(f.patchCapture) { + t.Error("invalid inventory changed root auth") + } + }) + } +} + +func TestNodeDiscoveryFailureAfterSafeFanoutKeepsRootUnchanged(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_DISCOVERY_FAIL": "true"}) + requireFailureResult(t, result) + if pathExists(f.talosLog) { + t.Error("failed discovery invoked Talos") + } + if !pathExists(f.variablesPatchCapture) || !pathExists(f.fanoutLog) { + t.Error("failed discovery did not occur after safe fanout") + } + if pathExists(f.patchCapture) { + t.Error("failed discovery changed root auth") + } +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go new file mode 100644 index 000000000..a314f5729 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go @@ -0,0 +1,362 @@ +package refreshfluxghcrauth + +import ( + "encoding/base64" + "encoding/json" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func requireSuccessResult(t *testing.T, result commandResult) { + t.Helper() + if result.exitCode != 0 { + t.Fatalf("command exit = %d, want 0\nstdout:\n%s\nstderr:\n%s", result.exitCode, result.stdout, result.stderr) + } +} + +func requireFailureResult(t *testing.T, result commandResult) { + t.Helper() + if result.exitCode == 0 { + t.Fatalf("command unexpectedly succeeded\nstdout:\n%s\nstderr:\n%s", result.stdout, result.stderr) + } +} + +func requireLinesEqual(t *testing.T, actual, expected []string) { + t.Helper() + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("lines differ\nactual: %#v\nexpected: %#v", actual, expected) + } +} + +func lineIndex(t *testing.T, lines []string, target string) int { + t.Helper() + for index, line := range lines { + if line == target { + return index + } + } + t.Fatalf("line %q not found in %#v", target, lines) + return -1 +} + +func requireLine(t *testing.T, lines []string, target string) { + t.Helper() + _ = lineIndex(t, lines, target) +} + +func requireNoLine(t *testing.T, lines []string, target string) { + t.Helper() + for _, line := range lines { + if line == target { + t.Errorf("unexpected line %q in %#v", target, lines) + } + } +} + +func TestRefreshesRootAndFanoutWithoutLeakingPlaintext(t *testing.T) { + f := newFixture(t) + config := validConfig() + result := f.runHelper(config, nil, nil) + requireSuccessResult(t, result) + + var patch map[string]any + if err := json.Unmarshal([]byte(mustRead(f.patchCapture)), &patch); err != nil { + t.Fatalf("decode root patch: %v", err) + } + encoded := patch["data"].(map[string]any)[".dockerconfigjson"].(string) + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decode root credentials: %v", err) + } + var decodedConfig any + if err := json.Unmarshal(decoded, &decodedConfig); err != nil { + t.Fatalf("parse root credentials: %v", err) + } + if !reflect.DeepEqual(decodedConfig, config) { + t.Errorf("root credentials = %#v, want %#v", decodedConfig, config) + } + + var variablesPatch map[string]any + if err := json.Unmarshal([]byte(mustRead(f.variablesPatchCapture)), &variablesPatch); err != nil { + t.Fatalf("decode variables patch: %v", err) + } + variablesEncoded := variablesPatch["data"].(map[string]any)["ghcr_dockerconfigjson"].(string) + variablesDecoded, err := base64.StdEncoding.DecodeString(variablesEncoded) + if err != nil { + t.Fatalf("decode variables credentials: %v", err) + } + var decodedVariables any + if err := json.Unmarshal(variablesDecoded, &decodedVariables); err != nil { + t.Fatalf("parse variables credentials: %v", err) + } + if !reflect.DeepEqual(decodedVariables, config) { + t.Errorf("variables credentials = %#v, want %#v", decodedVariables, config) + } + temporaryConfig := strings.TrimSpace(mustRead(f.outputPathLog)) + if pathExists(temporaryConfig) { + t.Errorf("temporary decrypted config still exists: %s", temporaryConfig) + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") + + requiredRegistryReads := []string{ + "devantler-tech/platform/manifests:latest", + "devantler-tech/wedding-app/manifests:latest", + "devantler-tech/ascoachingogvaner/manifests:latest", + "devantler-tech/wedding-app:latest", + "devantler-tech/ascoachingogvaner:latest", + "devantler-tech/ksail:v" + ksailOperatorVersion, + "devantler-tech/provider-upjet-unifi:v0.1.0", + } + requireLinesEqual(t, readLines(f.registryReadLog), append(append([]string{}, requiredRegistryReads...), requiredRegistryReads...)) + requireLinesEqual(t, readLines(f.fanoutLog), []string{ + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", + }) +} + +func TestStagesKubernetesConsumersBeforeTalosDrains(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, nil) + requireSuccessResult(t, result) + target := ksailTargetImage + requireLinesEqual(t, readLines(f.talosLog), []string{ + "talos-auth:10.0.0.2", + "talos-reboot:10.0.0.2", + "talos-remove:10.0.0.2:" + target, + "talos-pull:10.0.0.2:" + target, + "talos-revision:10.0.0.2", + "talos-auth:10.0.0.1", + "talos-reboot:10.0.0.1", + "talos-remove:10.0.0.1:" + target, + "talos-pull:10.0.0.1:" + target, + "talos-revision:10.0.0.1", + }) + requireLinesEqual(t, readLines(f.operationLog), []string{ + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", + "talos-auth:10.0.0.2", + "node-claim-cordon:prod-worker-1", + "node-drain:prod-worker-1", + "talos-reboot:10.0.0.2", + "node-ready:prod-worker-1", + "talos-remove:10.0.0.2:" + target, + "talos-pull:10.0.0.2:" + target, + "node-uncordon:prod-worker-1", + "talos-revision:10.0.0.2", + "talos-auth:10.0.0.1", + "node-claim-cordon:prod-control-plane-1", + "node-drain:prod-control-plane-1", + "talos-reboot:10.0.0.1", + "node-ready:prod-control-plane-1", + "talos-remove:10.0.0.1:" + target, + "talos-pull:10.0.0.1:" + target, + "node-uncordon:prod-control-plane-1", + "talos-revision:10.0.0.1", + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", + "root-patch", + }) + temporaryPatch := strings.TrimSpace(mustRead(f.talosPatchPathLog)) + if pathExists(temporaryPatch) { + t.Errorf("temporary Talos patch still exists: %s", temporaryPatch) + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") +} + +func TestUnhealthyControlPlaneBlocksTheControlPlaneReboot(t *testing.T) { + f := newFixture(t) + ready := []any{map[string]any{"type": "Ready", "status": "True"}} + inventory := map[string]any{"items": []any{ + nodeFixture("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", false, ready, nil), + nodeFixture("prod-control-plane-1", "prod-control-plane-1-uid", "10.0.0.1", true, ready, nil), + nodeFixture("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", true, + []any{map[string]any{"type": "Ready", "status": "False"}}, + map[string]any{ + "platform.devantler.tech/ghcr-pull-verified-revision-v2": f.expectedRevision(), + "platform.devantler.tech/ghcr-pull-verified-image-v2": ksailTargetImage, + }), + }} + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_JSON": encodeJSON(inventory)}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "risks quorum") + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.2") + requireNoLine(t, operations, "talos-reboot:10.0.0.1") + requireNoLine(t, operations, "root-patch") + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") +} + +func nodeFixture(name, uid, internalIP string, controlPlane bool, conditions []any, annotations map[string]any) map[string]any { + labels := map[string]any{} + if controlPlane { + labels["node-role.kubernetes.io/control-plane"] = "" + } + metadata := map[string]any{"name": name, "uid": uid, "labels": labels} + if annotations != nil { + metadata["annotations"] = annotations + } + return map[string]any{ + "metadata": metadata, + "status": map[string]any{ + "addresses": []any{map[string]any{"type": "InternalIP", "address": internalIP}}, + "conditions": conditions, + }, + } +} + +func TestControlPlaneQuorumIsRecheckedAfterTheDrain(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE": "10.0.0.3"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "risks quorum") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-drain:prod-control-plane-1") + requireLine(t, operations, "node-uncordon:prod-control-plane-1") + requireNoLine(t, operations, "talos-reboot:10.0.0.1") + requireNoLine(t, operations, "talos-revision:10.0.0.1") + requireNoLine(t, operations, "root-patch") +} + +func TestUnsafeEtcdMemberStatusBlocksControlPlaneReboot(t *testing.T) { + for _, variable := range []string{"FAKE_ETCD_LEARNER_NODE", "FAKE_ETCD_STATUS_ERROR_NODE"} { + t.Run(variable, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{variable: "10.0.0.3"}) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.2") + requireNoLine(t, operations, "talos-reboot:10.0.0.1") + requireNoLine(t, operations, "root-patch") + }) + } +} + +func TestCompactHealthyEtcdStatusPermitsControlPlaneReboot(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_ETCD_COMPACT_STATUS_NODE": "10.0.0.3"}) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.1") + requireLine(t, operations, "root-patch") +} + +func TestPreExistingCordonSurvivesTheAuthReboot(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_CORDONED_NODES": "prod-worker-1"}) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "node-claim-cordon:prod-worker-1") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireLine(t, operations, "node-claim-cordon:prod-control-plane-1") + requireLine(t, operations, "node-uncordon:prod-control-plane-1") +} + +func TestSchedulableNodeIsUncordonedAfterTheAuthReboot(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, nil) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + claim := lineIndex(t, operations, "node-claim-cordon:prod-worker-1") + drain := lineIndex(t, operations, "node-drain:prod-worker-1") + pull := lineIndex(t, operations, "talos-pull:10.0.0.2:"+ksailTargetImage) + uncordon := lineIndex(t, operations, "node-uncordon:prod-worker-1") + revision := lineIndex(t, operations, "talos-revision:10.0.0.2") + if claim >= drain || drain >= pull || pull >= uncordon || uncordon >= revision { + t.Errorf("unsafe worker ordering: claim=%d drain=%d pull=%d uncordon=%d revision=%d", claim, drain, pull, uncordon, revision) + } + if actual := mustRead(filepath.Join(f.syncStateDir, "resource-version-prod-worker-1")); actual != "12" { + t.Errorf("resource version = %q, want 12", actual) + } +} + +func TestSchedulableNodeIsClaimedAndCordonedAtomically(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, nil) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + claim := lineIndex(t, operations, "node-claim-cordon:prod-worker-1") + drain := lineIndex(t, operations, "node-drain:prod-worker-1") + if claim >= drain { + t.Errorf("claim index %d is not before drain index %d", claim, drain) + } + requireNoLine(t, operations, "node-claim:prod-worker-1") +} + +func TestConcurrentCordonBeforeAtomicClaimStopsTheRoll(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_CORDON_BEFORE_CLAIM_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "Could not atomically claim and cordon") + operations := readLines(f.operationLog) + requireLine(t, operations, "operator-cordon:prod-worker-1") + for _, unexpected := range []string{"node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Error("failed atomic claim left an owner marker") + } +} + +func TestPDBBlockedDrainRestoresOriginalSchedulability(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_DRAIN_FAIL_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + output := result.stdout + result.stderr + requireContains(t, output, "drain: cannot evict pod backstage-db-4: would violate PodDisruptionBudget backstage-db-primary") + operations := readLines(f.operationLog) + for _, expected := range []string{"node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "node-uncordon:prod-worker-1"} { + requireLine(t, operations, expected) + } + for _, unexpected := range []string{"talos-reboot:10.0.0.2", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } + requireNotContains(t, output, "fixture-secret-token") + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Error("PDB failure left an owner marker") + } +} + +func TestPDBBlockedDrainPreservesPreExistingCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CORDONED_NODES": "prod-worker-1", + "FAKE_DRAIN_FAIL_NODE": "prod-worker-1", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireNoLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "talos-reboot:10.0.0.2") +} + +func TestDrainAPIFailureReleasesTheAtomicClaim(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_DRAIN_API_FAIL_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + for _, expected := range []string{"node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "node-uncordon:prod-worker-1"} { + requireLine(t, operations, expected) + } + for _, unexpected := range []string{"talos-reboot:10.0.0.2", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Error("drain API failure left an owner marker") + } +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go new file mode 100644 index 000000000..4376f7f95 --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go @@ -0,0 +1,214 @@ +package refreshfluxghcrauth + +import ( + "path/filepath" + "strings" + "testing" +) + +func TestRevisionFailureDoesNotLeaveOwnedCordonBehind(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TALOS_FAIL_NODE": "10.0.0.2", + "FAKE_TALOS_FAIL_OPERATION": "revision", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + uncordon := lineIndex(t, operations, "node-uncordon:prod-worker-1") + revision := lineIndex(t, operations, "talos-revision:10.0.0.2") + if uncordon >= revision { + t.Errorf("uncordon index %d is not before revision index %d", uncordon, revision) + } + requireNoLine(t, operations, "root-patch") +} + +func TestChangedCordonOwnerIsNeverUncordoned(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_CORDON_OWNER_REPLACED_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "ownership changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "node-drain:prod-worker-1") + for _, unexpected := range []string{"talos-reboot:10.0.0.2", "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } +} + +func TestAutoscalerTaintIsNeverUncordoned(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_AUTOSCALER_CORDON_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "scheduling safety state changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "node-drain:prod-worker-1") + for _, unexpected := range []string{"talos-reboot:10.0.0.2", "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } +} + +func TestExternalUncordonAfterDrainBlocksReboot(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_EXTERNAL_UNCORDON_AFTER_DRAIN_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "scheduling safety state changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "talos-reboot:10.0.0.2") + requireNoLine(t, operations, "root-patch") +} + +func TestChangedInternalIPAfterDrainBlocksReboot(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_IP_CHANGED_AFTER_DRAIN_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "identity changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "talos-reboot:10.0.0.2") + requireNoLine(t, operations, "root-patch") +} + +func TestReplacementAfterReadyBlocksImageMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_AFTER_READY_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "identity changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.2") + requireNotContains(t, strings.Join(operations, "\n"), "talos-remove:10.0.0.2") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "root-patch") +} + +func TestExternalUncordonAfterReadyBlocksImageMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "scheduling safety state changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.2") + requireNotContains(t, strings.Join(operations, "\n"), "talos-remove:10.0.0.2") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "root-patch") +} + +func TestReplacementAfterUncordonBlocksRevisionMarker(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "identity changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "talos-revision:10.0.0.2") + requireNoLine(t, operations, "root-patch") +} + +func TestReplacedNodeIsRejectedBeforeTalosMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_BEFORE_PROCESS_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "identity changed") + operations := readLines(f.operationLog) + for _, unexpected := range []string{"talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } +} + +func TestTalosConvergenceBudgetRequiresTargetAndTwoCleanReads(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS": "2"}) + if result.exitCode != 64 { + t.Errorf("exit = %d, want 64\n%s%s", result.exitCode, result.stdout, result.stderr) + } + requireContains(t, result.stdout+result.stderr, "must be at least 3") + if pathExists(f.kubectlCalled) { + t.Error("unsafe convergence budget reached kubectl") + } +} + +func TestUncordonFailureKeepsRevisionMarkerStale(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_UNCORDON_FAIL_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + for _, unexpected := range []string{"node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Error("failed uncordon did not preserve owner marker") + } +} + +func TestUnreadyNodeAfterRebootStopsTheRoll(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_READY_FAIL_NODE": "prod-worker-1"}) + requireFailureResult(t, result) + requireLinesEqual(t, readLines(f.operationLog), []string{ + "variables-patch", + "fanout:pushsecret/flux-system/seed-ghcr", + "fanout:externalsecret/wedding-app/ghcr-auth", + "fanout:externalsecret/ascoachingogvaner/ghcr-auth", + "fanout:externalsecret/kyverno/ghcr-auth", + "talos-auth:10.0.0.2", + "node-claim-cordon:prod-worker-1", + "node-drain:prod-worker-1", + "talos-reboot:10.0.0.2", + "node-ready:prod-worker-1", + }) + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") +} + +func TestUnreadyNodePreservesItsPreExistingCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CORDONED_NODES": "prod-worker-1", + "FAKE_NODE_READY_FAIL_NODE": "prod-worker-1", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-ready:prod-worker-1") + for _, unexpected := range []string{"node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "root-patch"} { + requireNoLine(t, operations, unexpected) + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") +} + +func TestTalosFailureAfterSafeFanoutKeepsRootAuthUnchanged(t *testing.T) { + for _, operation := range []string{"auth", "reboot", "remove", "pull", "revision"} { + t.Run(operation, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TALOS_FAIL_NODE": "10.0.0.2", + "FAKE_TALOS_FAIL_OPERATION": operation, + }) + requireFailureResult(t, result) + if !pathExists(f.variablesPatchCapture) { + t.Error("safe fanout variables patch missing") + } + if pathExists(f.patchCapture) { + t.Error("root credential changed after Talos failure") + } + requireLinesEqual(t, readLines(f.fanoutLog), []string{ + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", + }) + operations := readLines(f.operationLog) + if operation == "remove" || operation == "pull" { + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Error("cache proof failure did not preserve the owned cordon") + } + } + if operation == "reboot" { + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") + }) + } +} diff --git a/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go b/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go new file mode 100644 index 000000000..eeba6f01b --- /dev/null +++ b/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go @@ -0,0 +1,471 @@ +package refreshfluxghcrauth + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "reflect" + "regexp" + "strings" + "testing" +) + +var sha256HexPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func requireWrapperExitCode(t *testing.T, result commandResult, expected int) { + t.Helper() + if result.exitCode != expected { + t.Fatalf("exit code = %d, want %d\nstdout:\n%s\nstderr:\n%s", result.exitCode, expected, result.stdout, result.stderr) + } +} + +func requireWrapperPathExists(t *testing.T, path string, expected bool) { + t.Helper() + if actual := pathExists(path); actual != expected { + t.Errorf("pathExists(%q) = %t, want %t", path, actual, expected) + } +} + +func requireWrapperFileEquals(t *testing.T, path, expected string) { + t.Helper() + if actual := mustRead(path); actual != expected { + t.Errorf("%s = %q, want %q", path, actual, expected) + } +} + +func requireWrapperSecretAbsent(t *testing.T, result commandResult, secret string) { + t.Helper() + if strings.Contains(result.stdout+result.stderr, secret) { + t.Errorf("command output exposed secret %q", secret) + } +} + +func TestKSailLifecycleWrapperUsesOnlySOPSPullToken(t *testing.T) { + info, err := os.Stat(ksailPullWrapperPath) + if err != nil { + t.Fatalf("stat production KSail wrapper: %v", err) + } + if !info.Mode().IsRegular() { + t.Fatalf("production KSail wrapper %q is not a regular file", ksailPullWrapperPath) + } + + commands := [][]string{ + {"cluster", "create"}, + {"workload", "reconcile"}, + {"cluster", "update"}, + } + for _, command := range commands { + command := command + t.Run(strings.Join(command, " "), func(t *testing.T) { + f := newFixture(t) + result := f.runKSailPullWrapper(validConfig(), command, nil) + + requireWrapperExitCode(t, result, 0) + requireWrapperFileEquals(t, f.ksailTokenCapture, "fixture-secret-token") + requireWrapperFileEquals(t, f.ksailUsernameCapture, "devantler") + wantCommand := append([]string{"--config", "ksail.prod.yaml"}, command...) + if got := strings.Fields(strings.TrimSpace(mustRead(f.ksailCommandCapture))); !slicesEqual(got, wantCommand) { + t.Errorf("KSail command = %q, want %q", got, wantCommand) + } + requireWrapperFileEquals(t, f.ksailConfigPathCapture, "ksail.prod.yaml") + requireWrapperFileEquals(t, f.ksailRegistryCapture, "devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests") + requireWrapperFileEquals(t, f.ksailRegistryOverrideCapture, "${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests") + if revision := mustRead(f.ksailRevisionCapture); !sha256HexPattern.MatchString(revision) { + t.Errorf("KSail pull revision = %q, want 64 lowercase hex characters", revision) + } + requireWrapperSecretAbsent(t, result, "fixture-secret-token") + temporaryConfig := mustRead(f.outputPathLog) + requireWrapperPathExists(t, temporaryConfig, false) + }) + } +} + +func TestKSailPublishWrapperPreservesActionsWriteToken(t *testing.T) { + f := newFixture(t) + result := f.runKSailPullWrapper( + validConfig(), + []string{"workload", "push"}, + map[string]string{ + "GITHUB_ACTOR": "fixture-publisher", + "GHCR_TOKEN": "fixture-actions-write-token", + }, + ) + + requireWrapperExitCode(t, result, 0) + requireWrapperFileEquals(t, f.ksailTokenCapture, "fixture-actions-write-token") + requireWrapperFileEquals(t, f.ksailUsernameCapture, "fixture-publisher") + requireWrapperFileEquals(t, f.ksailConfigPathCapture, "ksail.prod.yaml") + requireWrapperFileEquals(t, f.ksailRegistryCapture, "devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests") + requireWrapperFileEquals(t, f.ksailRegistryOverrideCapture, "") + if revision := mustRead(f.ksailRevisionCapture); !sha256HexPattern.MatchString(revision) { + t.Errorf("KSail pull revision = %q, want 64 lowercase hex characters", revision) + } + requireWrapperSecretAbsent(t, result, "fixture-actions-write-token") + requireWrapperPathExists(t, f.outputPathLog, false) +} + +func TestProductionConfigKeepsProtectedRegistryTemplate(t *testing.T) { + config := readRepositoryFile(t, "ksail.prod.yaml") + requireContains(t, config, `registry: "devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests"`) + requireNotContains(t, config, "${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io") +} + +func TestLifecyclePreservesUsernameFromSOPSDockerConfig(t *testing.T) { + f := newFixture(t) + config := validConfig() + config["auths"].(map[string]any)["ghcr.io"].(map[string]any)["username"] = "pull-robot" + + result := f.runKSailPullWrapper(config, []string{"cluster", "update"}, nil) + + requireWrapperExitCode(t, result, 0) + requireWrapperFileEquals(t, f.ksailUsernameCapture, "pull-robot") +} + +func TestCiphertextRotationChangesRevisionWithoutHashingToken(t *testing.T) { + f := newFixture(t) + config := validConfig() + first := f.runKSailPullWrapper(config, []string{"cluster", "update"}, nil) + requireWrapperExitCode(t, first, 0) + firstRevision := mustRead(f.ksailRevisionCapture) + + f.writeEncryptedSecret("ENC[AES256_GCM,data:fixture-two]") + second := f.runKSailPullWrapper(config, []string{"cluster", "update"}, nil) + requireWrapperExitCode(t, second, 0) + secondRevision := mustRead(f.ksailRevisionCapture) + + normalizedPlaintext, err := json.Marshal(config) + if err != nil { + t.Fatalf("marshal normalized plaintext fixture: %v", err) + } + plaintextDigest := sha256.Sum256(append(normalizedPlaintext, '\n')) + plaintextHash := fmt.Sprintf("%x", plaintextDigest) + if firstRevision == secondRevision { + t.Errorf("ciphertext rotation left revision unchanged at %q", firstRevision) + } + if firstRevision == plaintextHash { + t.Errorf("first revision unexpectedly hashes normalized plaintext: %q", plaintextHash) + } + if secondRevision == plaintextHash { + t.Errorf("second revision unexpectedly hashes normalized plaintext: %q", plaintextHash) + } +} + +func TestWrapperRejectsArbitraryCommands(t *testing.T) { + f := newFixture(t) + result := f.runKSailPullWrapper(validConfig(), []string{"workload", "delete"}, nil) + + requireWrapperExitCode(t, result, 64) + requireWrapperPathExists(t, f.ksailTokenCapture, false) +} + +func TestPlaintextRevisionSourceFailsClosed(t *testing.T) { + f := newFixture(t) + f.writeEncryptedSecret("accidentally-plaintext") + + result := f.runKSailPullWrapper(validConfig(), []string{"cluster", "update"}, nil) + + if result.exitCode == 0 { + t.Fatalf("plaintext revision source unexpectedly succeeded") + } + requireWrapperPathExists(t, f.ksailTokenCapture, false) +} + +func TestAcceptsStandardAuthOnlyDockerConfig(t *testing.T) { + f := newFixture(t) + auth := base64.StdEncoding.EncodeToString([]byte("devantler:fixture-secret-token")) + config := map[string]any{ + "auths": map[string]any{ + "ghcr.io": map[string]any{"auth": auth}, + }, + } + + result := f.runHelper(config, nil, nil) + + requireWrapperExitCode(t, result, 0) + var patch map[string]any + if err := json.Unmarshal([]byte(mustRead(f.patchCapture)), &patch); err != nil { + t.Fatalf("decode root credential patch: %v", err) + } + encoded, ok := patch["data"].(map[string]any)[".dockerconfigjson"].(string) + if !ok { + t.Fatalf("root credential patch does not contain encoded .dockerconfigjson: %#v", patch) + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("decode patched Docker config: %v", err) + } + var patchedConfig any + if err := json.Unmarshal(decoded, &patchedConfig); err != nil { + t.Fatalf("decode patched Docker config JSON: %v", err) + } + if !reflect.DeepEqual(patchedConfig, config) { + t.Errorf("patched Docker config = %#v, want %#v", patchedConfig, config) + } +} + +func TestAcceptsMatchingExplicitAndEncodedAuth(t *testing.T) { + f := newFixture(t) + config := validConfig() + config["auths"].(map[string]any)["ghcr.io"].(map[string]any)["auth"] = base64.StdEncoding.EncodeToString([]byte("devantler:fixture-secret-token")) + + result := f.runHelper(config, nil, nil) + + requireWrapperExitCode(t, result, 0) +} + +func TestRejectsUnsafeDrainTimeoutBeforeClusterAccess(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FLUX_GHCR_DRAIN_TIMEOUT": "45m --disable-eviction", + }) + + requireWrapperExitCode(t, result, 64) + requireWrapperPathExists(t, f.kubectlCalled, false) + requireWrapperPathExists(t, f.patchCapture, false) + requireWrapperSecretAbsent(t, result, "fixture-secret-token") +} + +func TestCheckOnlyPreflightsWithoutPatching(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), []string{"--check-only"}, nil) + + requireWrapperExitCode(t, result, 0) + requireWrapperPathExists(t, f.kubectlCalled, false) + requireWrapperPathExists(t, f.patchCapture, false) +} + +func TestMissingOrMalformedRegistryAuthFailsClosed(t *testing.T) { + conflictingAuth := base64.StdEncoding.EncodeToString([]byte("devantler:different-token")) + tests := []struct { + name string + config any + }{ + { + name: "missing password", + config: map[string]any{ + "auths": map[string]any{"ghcr.io": map[string]any{"username": "devantler"}}, + }, + }, + { + name: "empty username", + config: map[string]any{ + "auths": map[string]any{"ghcr.io": map[string]any{"username": "", "password": "token"}}, + }, + }, + { + name: "malformed encoded auth", + config: map[string]any{ + "auths": map[string]any{"ghcr.io": map[string]any{"auth": "not-base64"}}, + }, + }, + { + name: "contradictory explicit and encoded auth", + config: map[string]any{ + "auths": map[string]any{ + "ghcr.io": map[string]any{ + "username": "devantler", + "password": "fixture-secret-token", + "auth": conflictingAuth, + }, + }, + }, + }, + {name: "missing registry", config: map[string]any{"auths": map[string]any{}}}, + {name: "not a Docker config", config: "not-a-docker-config"}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(test.config, nil, nil) + if result.exitCode == 0 { + t.Errorf("invalid Docker config unexpectedly succeeded") + } + requireWrapperPathExists(t, f.kubectlCalled, false) + }) + } +} + +func TestRegistryDenialPreventsClusterPatch(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CURL_DENY_REPOSITORY": "devantler-tech/platform/manifests", + }) + + if result.exitCode == 0 { + t.Fatalf("registry denial unexpectedly succeeded") + } + requireWrapperPathExists(t, f.kubectlCalled, false) +} + +func TestTokenSuccessWithoutRegistryReadAccessPreventsClusterPatch(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CURL_DENY_REPOSITORY": "devantler-tech/wedding-app", + }) + + if result.exitCode == 0 { + t.Fatalf("missing package read access unexpectedly succeeded") + } + requireWrapperPathExists(t, f.kubectlCalled, false) +} + +func TestClusterPatchFailureIsNotHidden(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_KUBECTL_FAIL": "true"}) + + requireWrapperExitCode(t, result, 43) + requireWrapperPathExists(t, f.kubectlCalled, true) +} + +func TestFreshClusterWithoutVariablesBaseSkipsExistingFanout(t *testing.T) { + f := newFixture(t) + result := f.runHelper( + validConfig(), + []string{"--allow-incomplete-fanout"}, + map[string]string{"FAKE_VARIABLES_BASE_ABSENT": "true"}, + ) + + requireWrapperExitCode(t, result, 0) + requireWrapperPathExists(t, f.patchCapture, true) + requireWrapperPathExists(t, f.variablesPatchCapture, false) + requireWrapperPathExists(t, f.fanoutLog, false) +} + +func TestMissingVariablesBaseFailsClosedWithoutBootstrapMode(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_VARIABLES_BASE_ABSENT": "true"}) + + if result.exitCode == 0 { + t.Fatalf("missing variables-base unexpectedly succeeded without bootstrap mode") + } + requireWrapperPathExists(t, f.patchCapture, false) +} + +func TestPartialBootstrapRepairsRootWithoutForcingMissingFanout(t *testing.T) { + missingResources := []string{ + "pushsecret/flux-system/seed-ghcr", + "externalsecret/wedding-app/ghcr-auth", + "externalsecret/ascoachingogvaner/ghcr-auth", + "externalsecret/kyverno/ghcr-auth", + } + for _, resource := range missingResources { + resource := resource + t.Run(resource, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper( + validConfig(), + []string{"--allow-incomplete-fanout"}, + map[string]string{"FAKE_MISSING_FANOUT_RESOURCE": resource}, + ) + + requireWrapperExitCode(t, result, 0) + requireWrapperPathExists(t, f.variablesPatchCapture, true) + requireWrapperPathExists(t, f.patchCapture, true) + requireWrapperPathExists(t, f.fanoutLog, false) + requireContains(t, result.stdout, "first reconcile will complete") + operations := readLines(f.operationLog) + if len(operations) < 3 { + t.Fatalf("operation log has %d lines, want at least 3: %q", len(operations), operations) + } + gotTail := operations[len(operations)-3:] + wantTail := []string{"root-patch", "variables-patch", "root-patch"} + if !slicesEqual(gotTail, wantTail) { + t.Errorf("final operations = %q, want %q", gotTail, wantTail) + } + }) + } +} + +func TestPartialBootstrapRepairsRootBeforeStagingVariables(t *testing.T) { + f := newFixture(t) + result := f.runHelper( + validConfig(), + []string{"--allow-incomplete-fanout"}, + map[string]string{ + "FAKE_MISSING_FANOUT_RESOURCE": "pushsecret/flux-system/seed-ghcr", + "FAKE_KUBECTL_FAIL": "true", + }, + ) + + requireWrapperExitCode(t, result, 43) + requireWrapperPathExists(t, f.variablesPatchCapture, false) +} + +func TestPartialFanoutFailsClosedWithoutBootstrapMode(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_MISSING_FANOUT_RESOURCE": "externalsecret/kyverno/ghcr-auth", + }) + + if result.exitCode == 0 { + t.Fatalf("partial fanout unexpectedly succeeded without bootstrap mode") + } + requireWrapperPathExists(t, f.variablesPatchCapture, false) + requireWrapperPathExists(t, f.patchCapture, false) + requireWrapperPathExists(t, f.fanoutLog, false) +} + +func TestMissingESOCRDsFailsWithoutStagingVariables(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_FANOUT_CRDS_ABSENT": "true"}) + + if result.exitCode == 0 { + t.Fatalf("missing External Secrets CRDs unexpectedly succeeded") + } + requireWrapperPathExists(t, f.variablesPatchCapture, false) + requireWrapperPathExists(t, f.patchCapture, false) +} + +func TestPartialBootstrapWithoutESOCRDsRepairsRoot(t *testing.T) { + f := newFixture(t) + result := f.runHelper( + validConfig(), + []string{"--allow-incomplete-fanout"}, + map[string]string{"FAKE_FANOUT_CRDS_ABSENT": "true"}, + ) + + requireWrapperExitCode(t, result, 0) + requireWrapperPathExists(t, f.variablesPatchCapture, true) + requireWrapperPathExists(t, f.patchCapture, true) + requireWrapperPathExists(t, f.fanoutLog, false) +} + +func TestPushSecretSyncFailureIsNotHidden(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_SYNC_STALL_RESOURCE": "pushsecret/flux-system/seed-ghcr", + }) + + if result.exitCode == 0 { + t.Fatalf("stalled PushSecret sync unexpectedly succeeded") + } + requireWrapperPathExists(t, f.patchCapture, false) + requireWrapperPathExists(t, f.variablesPatchCapture, true) + requireWrapperSecretAbsent(t, result, "fixture-secret-token") +} + +func TestSameSecondSyncAcceptsControllerResourceVersionEdge(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_SYNC_SAME_REFRESH_TIME": "true"}) + + requireWrapperExitCode(t, result, 0) + requireWrapperPathExists(t, f.patchCapture, true) +} + +func TestMaterialisedConsumerMismatchIsNotHidden(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CONSUMER_MISMATCH_NAMESPACE": "wedding-app", + }) + + if result.exitCode == 0 { + t.Fatalf("stale materialised consumer unexpectedly succeeded") + } + requireContains(t, result.stdout+result.stderr, "wedding-app/ghcr-auth did not materialise") + requireWrapperPathExists(t, f.patchCapture, false) + requireWrapperSecretAbsent(t, result, "fixture-secret-token") +} diff --git a/scripts/tests/test_refresh_flux_ghcr_auth.py b/scripts/tests/test_refresh_flux_ghcr_auth.py deleted file mode 100644 index 6f0352b19..000000000 --- a/scripts/tests/test_refresh_flux_ghcr_auth.py +++ /dev/null @@ -1,2666 +0,0 @@ -"""Regression tests for the production Flux GHCR credential bridge.""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -from pathlib import Path -import subprocess -import tempfile -import textwrap -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -AGENT_INSTRUCTIONS = ROOT / "AGENTS.md" -HELPER = ROOT / "scripts" / "refresh-flux-ghcr-auth.sh" -GHCR_AUTH_LIB = ROOT / "scripts" / "ghcr-auth-lib.sh" -KSAIL_PULL_WRAPPER = ROOT / "scripts" / "run-ksail-prod-with-pull-auth.sh" -KSAIL_PROD_CONFIG = ROOT / "ksail.prod.yaml" -ACTION = ROOT / ".github" / "actions" / "deploy-prod" / "action.yml" -DR_REBUILD = ROOT / ".github" / "workflows" / "dr-rebuild.yaml" -CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yaml" -DR_RUNBOOK = ROOT / "docs" / "dr" / "runbook.md" -KSAIL_OPERATOR_HELM_RELEASE = ( - ROOT - / "k8s" - / "bases" - / "infrastructure" - / "controllers" - / "ksail-operator" - / "helm-release.yaml" -) -KSAIL_OPERATOR_VERSION = next( - line.split(":", 1)[1].strip() - for line in KSAIL_OPERATOR_HELM_RELEASE.read_text(encoding="utf-8").splitlines() - if line.strip().startswith("version:") -) - - -def _yaml_scalar_at_path(document: str, path: tuple[str, ...]) -> str: - """Return a scalar from a mapping-only YAML path without adding a dependency.""" - parents: list[tuple[int, str]] = [] - for raw_line in document.splitlines(): - line = raw_line.split("#", 1)[0].rstrip() - if not line.strip(): - continue - - indent = len(line) - len(line.lstrip(" ")) - key, separator, value = line.strip().partition(":") - if not separator: - continue - - while parents and parents[-1][0] >= indent: - parents.pop() - - current_path = tuple(parent_key for _, parent_key in parents) + (key,) - value = value.strip() - if current_path == path: - if not value: - break - return value.strip("\"'") - - if not value: - parents.append((indent, key)) - - raise ValueError(f"missing scalar YAML path: {'.'.join(path)}") - - -# spec.cluster.talos.version is the source of truth the workflows' TALOS_VERSION -# must track; deriving it here keeps the drift guard honest across version bumps. -KSAIL_PROD_TALOS_VERSION = _yaml_scalar_at_path( - KSAIL_PROD_CONFIG.read_text(encoding="utf-8"), - ("spec", "cluster", "talos", "version"), -).lstrip("v") -PROVIDER_UPJET_UNIFI = ( - ROOT - / "k8s" - / "providers" - / "hetzner" - / "infrastructure" - / "crossplane" - / "provider-upjet-unifi.yaml" -) -TALOS_GHCR_AUTH = ROOT / "talos" / "cluster" / "authenticate-ghcr-pulls.yaml" -TALOS_GHCR_REVISION = ( - ROOT / "talos" / "cluster" / "mark-ghcr-pull-revision.yaml" -) - - -class KsailProdConfigTests(unittest.TestCase): - """Verify workflow-version guards read the intended nested config key.""" - - def test_talos_version_ignores_unrelated_version_fields(self) -> None: - """Select spec.cluster.talos.version even when another version appears first.""" - config = textwrap.dedent( - """ - spec: - version: v0.0.0 - cluster: - kubernetesVersion: v1.36.2 - talos: - version: v1.13.6 - """ - ) - - self.assertEqual( - "v1.13.6", - _yaml_scalar_at_path(config, ("spec", "cluster", "talos", "version")), - ) - - -class RefreshFluxGhcrAuthTests(unittest.TestCase): - """Exercise the helper with fake external commands and no real secrets.""" - - def setUp(self) -> None: - """Create isolated command fakes and capture files for each test.""" - self.temp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.temp_dir.cleanup) - self.workspace = Path(self.temp_dir.name) - self.bin_dir = self.workspace / "bin" - self.bin_dir.mkdir() - self.decrypted_config = self.workspace / "decrypted-config.json" - self.encrypted_secret = self.workspace / "secret.enc.yaml" - self.patch_capture = self.workspace / "patch.json" - self.variables_patch_capture = self.workspace / "variables-patch.json" - self.kubectl_called = self.workspace / "kubectl-called" - self.output_path_log = self.workspace / "ksail-output-path" - self.registry_read_log = self.workspace / "registry-reads" - self.fanout_log = self.workspace / "fanout-log" - self.talos_log = self.workspace / "talos-log" - self.talos_patch_path_log = self.workspace / "talos-patch-path" - self.operation_log = self.workspace / "operation-log" - self.ksail_token_capture = self.workspace / "ksail-token" - self.ksail_username_capture = self.workspace / "ksail-username" - self.ksail_revision_capture = self.workspace / "ksail-revision" - self.ksail_command_capture = self.workspace / "ksail-command" - self.ksail_config_path_capture = self.workspace / "ksail-config-path" - self.ksail_registry_capture = self.workspace / "ksail-registry" - self.ksail_registry_override_capture = ( - self.workspace / "ksail-registry-override" - ) - self.sync_state_dir = self.workspace / "sync-state" - self.sync_state_dir.mkdir() - self._write_encrypted_secret("ENC[AES256_GCM,data:fixture-one]") - - self._write_executable( - "ksail", - """ - #!/usr/bin/env bash - set -euo pipefail - arguments=" $* " - selector='["stringData"]["ghcr_dockerconfigjson"]' - if [[ "$arguments" == *" workload cipher decrypt "* ]]; then - [[ "$arguments" == *" --extract $selector "* ]] - output="" - while (($#)); do - case "$1" in - --output) - output="$2" - shift 2 - ;; - *) shift ;; - esac - done - test -n "$output" - printf '%s' "$output" > "$KSAIL_OUTPUT_PATH_LOG" - cp "$FAKE_DECRYPTED_CONFIG" "$output" - exit 0 - fi - - if [[ "$arguments" == *" cluster create "* \ - || "$arguments" == *" cluster update "* \ - || "$arguments" == *" workload push "* \ - || "$arguments" == *" workload reconcile "* ]]; then - config="" - previous="" - for argument in "$@"; do - if [[ "$previous" == --config ]]; then - config="$argument" - break - fi - previous="$argument" - done - test -f "$config" - registry="$(yq -er '.spec.cluster.localRegistry.registry' "$config")" - test "$registry" = \ - 'devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests' - registry_override="${KSAIL_SPEC_CLUSTER_LOCALREGISTRY_REGISTRY:-}" - if [[ "$arguments" == *" workload push "* ]]; then - test -z "$registry_override" - else - test "$registry_override" = \ - '${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests' - fi - test -n "${GHCR_TOKEN:-}" - test -n "${GHCR_USERNAME:-}" - test "${GHCR_PULL_REVISION:-}" != "" - printf '%s' "$GHCR_TOKEN" > "$KSAIL_TOKEN_CAPTURE" - printf '%s' "$GHCR_USERNAME" > "$KSAIL_USERNAME_CAPTURE" - printf '%s' "$GHCR_PULL_REVISION" > "$KSAIL_REVISION_CAPTURE" - printf '%s\n' "$*" > "$KSAIL_COMMAND_CAPTURE" - printf '%s' "$config" > "$KSAIL_CONFIG_PATH_CAPTURE" - printf '%s' "$registry" > "$KSAIL_REGISTRY_CAPTURE" - printf '%s' "$registry_override" > "$KSAIL_REGISTRY_OVERRIDE_CAPTURE" - exit 0 - fi - - echo "unexpected ksail invocation" >&2 - exit 92 - """, - ) - self._write_executable( - "talosctl", - """ - #!/usr/bin/env bash - set -euo pipefail - arguments=" $* " - node="" - patch_file="" - previous="" - for argument in "$@"; do - if [[ "$previous" == --nodes ]]; then - node="$argument" - fi - case "$argument" in - --nodes=*) node="${argument#*=}" ;; - --patch-file=*) patch_file="${argument#*=}" ;; - esac - previous="$argument" - done - test -n "$node" - - if [[ "$arguments" == *" etcd status "* ]]; then - if [[ "$node" == "${FAKE_ETCD_STATUS_FAIL_NODE:-disabled}" ]]; then - echo "etcd status failed" >&2 - exit 51 - fi - if [[ "$node" == "${FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE:-disabled}" \ - && -f "$FAKE_SYNC_STATE_DIR/drained-prod-control-plane-1" ]]; then - echo "etcd status failed after drain" >&2 - exit 51 - fi - learner=false - status_error="" - if [[ "$node" == "${FAKE_ETCD_LEARNER_NODE:-disabled}" ]]; then - learner=true - fi - if [[ "$node" == "${FAKE_ETCD_STATUS_ERROR_NODE:-disabled}" ]]; then - status_error=" rpc-timeout" - fi - if [[ "$node" == "${FAKE_ETCD_COMPACT_STATUS_NODE:-disabled}" ]]; then - printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER ERRORS\n' - printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s%s\n' \ - "$node" "$learner" "$status_error" - else - printf 'NODE MEMBER DB SIZE IN USE LEADER RAFT INDEX RAFT TERM RAFT APPLIED INDEX LEARNER PROTOCOL STORAGE ERRORS\n' - printf '%s member-id 1.0 MB 0.5 MB (50.00%%) leader-id 100 2 100 %s 3.6.4 3.6.0%s\n' \ - "$node" "$learner" "$status_error" - fi - exit 0 - fi - - if [[ "$arguments" == *" etcd alarm list "* ]]; then - if [[ "$node" == "${FAKE_ETCD_ALARM_READ_FAIL_NODE:-disabled}" ]]; then - echo "etcd alarm read failed" >&2 - exit 52 - fi - printf 'NODE MEMBER ALARM\n' - if [[ "$node" == "${FAKE_ETCD_ALARM_NODE:-disabled}" ]]; then - printf '%s member-id NOSPACE\n' "$node" - fi - exit 0 - fi - - if [[ "$arguments" == *" patch machineconfig "* ]]; then - [[ "$arguments" == *" --mode=no-reboot "* ]] - test -f "$patch_file" - printf '%s' "$patch_file" > "$TALOS_PATCH_PATH_LOG" - if jq -e '.kind == "RegistryAuthConfig"' "$patch_file" >/dev/null; then - jq -e \ - --arg username "$EXPECTED_PULL_USERNAME" \ - --arg token "$EXPECTED_PULL_TOKEN" ' - .apiVersion == "v1alpha1" - and .kind == "RegistryAuthConfig" - and .name == "ghcr.io" - and .username == $username - and .password == $token - ' "$patch_file" >/dev/null - printf 'talos-auth:%s\n' "$node" >> "$TALOS_LOG" - printf 'talos-auth:%s\n' "$node" >> "$OPERATION_LOG" - if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ - && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == auth ]]; then - echo "talos auth failed with $EXPECTED_PULL_TOKEN" >&2 - exit 45 - fi - touch "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - exit 0 - fi - - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" - test -f "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" - jq -e --arg revision "$EXPECTED_GHCR_REVISION" ' - .machine.nodeAnnotations[ - "platform.devantler.tech/ghcr-pull-verified-revision-v2" - ] == $revision - ' "$patch_file" >/dev/null - jq -e --arg image "$EXPECTED_KSAIL_TARGET_IMAGE" ' - .machine.nodeAnnotations[ - "platform.devantler.tech/ghcr-pull-verified-image-v2" - ] == $image - ' "$patch_file" >/dev/null - printf 'talos-revision:%s\n' "$node" >> "$TALOS_LOG" - printf 'talos-revision:%s\n' "$node" >> "$OPERATION_LOG" - if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ - && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == revision ]]; then - echo "talos revision failed" >&2 - exit 48 - fi - touch "$FAKE_SYNC_STATE_DIR/talos-revision-${node}" - exit 0 - fi - - # A running containerd only adopts new registry auth by restarting, - # and Talos refuses `service cri restart`, so the node must reboot. - # The auth patch must already have landed, and the reboot must drain - # only after kubectl has completed a PDB-respecting drain through - # the already-validated Kubernetes context. - if [[ "$arguments" == *" reboot "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - if [[ "$arguments" == *" --drain "* ]]; then - echo 'integrated Talos drain is not allowed' >&2 - exit 54 - fi - if [[ "$arguments" != *" --wait "* ]]; then - echo 'Talos reboot must wait for the new boot' >&2 - exit 57 - fi - printf 'talos-reboot:%s\n' "$node" >> "$TALOS_LOG" - printf 'talos-reboot:%s\n' "$node" >> "$OPERATION_LOG" - if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ - && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == reboot ]]; then - echo "talos reboot failed" >&2 - exit 49 - fi - touch "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - exit 0 - fi - - if [[ "$arguments" == *" image remove "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - # The cache is only worth clearing once containerd has reloaded the - # credential — i.e. after the reboot. - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - [[ "$arguments" == *" --namespace cri "* ]] - image="" - previous="" - for argument in "$@"; do - if [[ "$previous" == remove ]]; then - image="$argument" - break - fi - previous="$argument" - done - test -n "$image" - printf 'talos-remove:%s:%s\n' "$node" "$image" >> "$TALOS_LOG" - printf 'talos-remove:%s:%s\n' "$node" "$image" >> "$OPERATION_LOG" - if [[ "$node" == "${FAKE_TALOS_IMAGE_ABSENT_NODE:-disabled}" ]]; then - touch "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" - echo "rpc error: code = NotFound desc = image ${image} not found" >&2 - exit 1 - fi - if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ - && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == remove ]]; then - echo "talos remove failed" >&2 - exit 49 - fi - touch "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" - exit 0 - fi - - if [[ "$arguments" == *" image pull "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/talos-auth-${node}" - # The pull check is only meaningful once containerd has actually - # reloaded the credential (the reboot) and the cached copy is gone - # (the remove), so the pull must complete a real registry round-trip. - test -f "$FAKE_SYNC_STATE_DIR/talos-reboot-${node}" - test -f "$FAKE_SYNC_STATE_DIR/talos-remove-${node}" - [[ "$arguments" == *" --namespace cri "* ]] - image="" - previous="" - for argument in "$@"; do - if [[ "$previous" == pull ]]; then - image="$argument" - break - fi - previous="$argument" - done - test -n "$image" - printf 'talos-pull:%s:%s\n' "$node" "$image" >> "$TALOS_LOG" - printf 'talos-pull:%s:%s\n' "$node" "$image" >> "$OPERATION_LOG" - if [[ "$node" == "${FAKE_TALOS_FAIL_NODE:-disabled}" \ - && "${FAKE_TALOS_FAIL_OPERATION:-auth}" == pull ]]; then - echo "talos pull failed with $EXPECTED_PULL_TOKEN" >&2 - exit 47 - fi - touch "$FAKE_SYNC_STATE_DIR/talos-pull-${node}" - exit 0 - fi - - echo "unexpected talosctl invocation" >&2 - exit 93 - """, - ) - self._write_executable( - "curl", - """ - #!/usr/bin/env bash - set -euo pipefail - test "$1" = --disable - shift - config="" - output="" - scope="" - url="" - while (($#)); do - case "$1" in - --config) - config="$2" - shift 2 - ;; - --output) - output="$2" - shift 2 - ;; - --data-urlencode) - case "$2" in scope=*) scope="${2#scope=}" ;; esac - shift 2 - ;; - --write-out|--header) - shift 2 - ;; - --silent|--show-error|--get) - shift - ;; - https://*) - url="$1" - shift - ;; - *) - echo "unexpected curl argument: $1" >&2 - exit 90 - ;; - esac - done - test -f "$config" - test -n "$output" - test -n "$url" - - if [[ "$url" == https://ghcr.io/token ]]; then - test -n "$scope" - grep -q '^user = ' "$config" - printf '{"token":"fixture-registry-token"}' > "$output" - printf '200' - exit 0 - fi - - manifest_path="${url#https://ghcr.io/v2/}" - repository="${manifest_path%/manifests/*}" - reference="${manifest_path##*/manifests/}" - test "$repository" != "$manifest_path" - test "$reference" != "$manifest_path" - grep -q 'Authorization: Bearer fixture-registry-token' "$config" - printf '%s:%s\n' "$repository" "$reference" >> "$REGISTRY_READ_LOG" - if [[ "$repository" == "${FAKE_CURL_DENY_REPOSITORY:-disabled}" ]]; then - printf '403' - else - printf '200' - fi - """, - ) - self._write_executable( - "kubectl", - """ - #!/usr/bin/env bash - set -euo pipefail - arguments=" $* " - [[ "$arguments" == *" --context admin@prod "* ]] - namespace="" - patch_file="" - previous="" - for argument in "$@"; do - if [[ "$previous" == --namespace ]]; then - namespace="$argument" - fi - case "$argument" in - --namespace=*) namespace="${argument#*=}" ;; - --patch-file=*) patch_file="${argument#*=}" ;; - esac - previous="$argument" - done - touch "$KUBECTL_CALLED" - - if [[ "$arguments" == *" get nodes "* ]]; then - if [[ "${FAKE_NODE_DISCOVERY_FAIL:-false}" == true ]]; then - echo 'node discovery failed' >&2 - exit 46 - fi - if [[ -n "${FAKE_NODE_JSON:-}" ]]; then - printf '%s\n' "$FAKE_NODE_JSON" - exit 0 - fi - jq -n \ - --arg revision "$EXPECTED_GHCR_REVISION" \ - --arg current "${FAKE_TALOS_NODES_CURRENT:-false}" \ - --arg verified_image \ - "${FAKE_TALOS_VERIFIED_IMAGE:-$EXPECTED_KSAIL_TARGET_IMAGE}" \ - --arg target_image "$EXPECTED_KSAIL_TARGET_IMAGE" ' - { - items: [ - { - metadata: { - name: "prod-worker-1", - labels: {}, - annotations: { - "platform.devantler.tech/ghcr-pull-desired-revision": - $revision - } - }, - status: {addresses: [ - {type: "InternalIP", address: "10.0.0.2"}, - {type: "ExternalIP", address: "198.51.100.2"} - ]} - }, - { - metadata: { - name: "prod-control-plane-1", - labels: { - "node-role.kubernetes.io/control-plane": "" - }, - annotations: { - "platform.devantler.tech/ghcr-pull-desired-revision": - $revision - } - }, - status: {addresses: [ - {type: "InternalIP", address: "10.0.0.1"}, - {type: "ExternalIP", address: "198.51.100.1"} - ]} - }, - { - metadata: { - name: "prod-control-plane-2", - labels: { - "node-role.kubernetes.io/control-plane": "" - }, - annotations: { - "platform.devantler.tech/ghcr-pull-desired-revision": - $revision, - "platform.devantler.tech/ghcr-pull-verified-revision-v2": - $revision, - "platform.devantler.tech/ghcr-pull-verified-image-v2": - $target_image - } - }, - status: { - addresses: [ - {type: "InternalIP", address: "10.0.0.3"}, - {type: "ExternalIP", address: "198.51.100.3"} - ], - conditions: [{type: "Ready", status: "True"}] - } - }, - { - metadata: { - name: "prod-control-plane-3", - labels: { - "node-role.kubernetes.io/control-plane": "" - }, - annotations: { - "platform.devantler.tech/ghcr-pull-desired-revision": - $revision, - "platform.devantler.tech/ghcr-pull-verified-revision-v2": - $revision, - "platform.devantler.tech/ghcr-pull-verified-image-v2": - $target_image - } - }, - status: { - addresses: [ - {type: "InternalIP", address: "10.0.0.4"}, - {type: "ExternalIP", address: "198.51.100.4"} - ], - conditions: [{type: "Ready", status: "True"}] - } - } - ] - } - | if $current == "true" then - .items[0:2][].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-revision-v2" - ] = $revision - | .items[0:2][].metadata.annotations[ - "platform.devantler.tech/ghcr-pull-verified-image-v2" - ] = $verified_image - else . end - ' - exit 0 - fi - - # Cordon bookkeeping around a GHCR-auth reboot. Cluster-scoped, so - # both must be handled before the namespace guard below. - # FAKE_CORDONED_NODES is a space-separated list of already-cordoned - # node names, i.e. nodes an operator had deliberately made - # unschedulable before this run. - if [[ "$arguments" == *" get node "* ]]; then - node_target="" - previous="" - for argument in "$@"; do - if [[ "$previous" == node ]]; then - node_target="$argument" - break - fi - previous="$argument" - done - test -n "$node_target" - owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" - resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" - if [[ "$arguments" == *" --output json "* ]]; then - owner="" - if [[ -f "$owner_file" ]]; then - owner="$(<"$owner_file")" - fi - cordoned=false - if [[ " ${FAKE_CORDONED_NODES:-} " == *" ${node_target} "* \ - || -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" ]]; then - cordoned=true - fi - autoscaler_intent=false - if [[ -f "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" ]]; then - autoscaler_intent=true - fi - resource_version=10 - if [[ -f "$resource_version_file" ]]; then - resource_version="$(<"$resource_version_file")" - fi - jq -n \ - --arg owner "$owner" \ - --arg resource_version "$resource_version" \ - --argjson cordoned "$cordoned" \ - --argjson autoscaler_intent "$autoscaler_intent" ' - { - metadata: { - uid: "node-uid", - resourceVersion: $resource_version, - deletionTimestamp: null, - annotations: ( - if $owner == "" then {} - else { - "platform.devantler.tech/ghcr-auth-drain-owner": $owner - } end - ) - }, - spec: { - unschedulable: $cordoned, - taints: ( - (if $cordoned then [{ - key: "node.kubernetes.io/unschedulable", - effect: "NoSchedule" - }] else [] end) - + (if $autoscaler_intent then [{ - key: "ToBeDeletedByClusterAutoscaler", - effect: "NoSchedule" - }] else [] end) - ) - } - } - ' - exit 0 - fi - if [[ " ${FAKE_CORDONED_NODES:-} " == *" ${node_target} "* ]]; then - printf 'true' - fi - exit 0 - fi - - if [[ "$arguments" == *" drain "* ]]; then - node_target="" - previous="" - for argument in "$@"; do - if [[ "$previous" == drain ]]; then - node_target="$argument" - break - fi - previous="$argument" - done - test -n "$node_target" - if [[ "$arguments" != *" --ignore-daemonsets "* \ - || "$arguments" != *" --delete-emptydir-data "* \ - || "$arguments" != *" --timeout=45m "* \ - || "$arguments" == *" --disable-eviction "* \ - || "$arguments" == *" --force "* ]]; then - echo 'unsafe or incomplete kubectl drain flags' >&2 - exit 55 - fi - printf 'node-drain:%s\n' "$node_target" >> "$OPERATION_LOG" - if [[ " ${FAKE_CORDONED_NODES:-} " != *" ${node_target} "* ]]; then - test -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - fi - if [[ "$node_target" == "${FAKE_DRAIN_API_FAIL_NODE:-disabled}" ]]; then - echo 'could not list pods before eviction' >&2 - exit 54 - fi - if [[ "$node_target" == "${FAKE_CORDON_OWNER_REPLACED_NODE:-disabled}" ]]; then - printf 'operator-cordon' \ - > "$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" - fi - if [[ "$node_target" == "${FAKE_AUTOSCALER_CORDON_NODE:-disabled}" ]]; then - touch "$FAKE_SYNC_STATE_DIR/autoscaler-cordon-${node_target}" - fi - if [[ "$node_target" == "${FAKE_DRAIN_FAIL_NODE:-disabled}" ]]; then - echo 'cannot evict pod backstage-db-4: would violate PodDisruptionBudget backstage-db-primary' >&2 - exit 53 - fi - touch "$FAKE_SYNC_STATE_DIR/drained-${node_target}" - exit 0 - fi - - if [[ "$arguments" == *" uncordon "* ]]; then - node_target="" - previous="" - for argument in "$@"; do - if [[ "$previous" == uncordon ]]; then - node_target="$argument" - break - fi - previous="$argument" - done - test -n "$node_target" - if [[ "$node_target" == "${FAKE_CORDON_OWNER_REPLACED_NODE:-disabled}" \ - || "$node_target" == "${FAKE_UNCORDON_FAIL_NODE:-disabled}" ]]; then - echo 'cordon ownership changed; refusing to uncordon' >&2 - exit 56 - fi - printf 'node-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" - exit 0 - fi - - if [[ "$arguments" == *" patch node "* \ - && "$arguments" == *" --type=json "* ]]; then - node_target="" - previous="" - for argument in "$@"; do - if [[ "$previous" == node ]]; then - node_target="$argument" - break - fi - previous="$argument" - done - test -n "$node_target" - test -f "$patch_file" - owner_file="$FAKE_SYNC_STATE_DIR/cordon-owner-${node_target}" - resource_version_file="$FAKE_SYNC_STATE_DIR/resource-version-${node_target}" - current_resource_version=10 - if [[ -f "$resource_version_file" ]]; then - current_resource_version="$(<"$resource_version_file")" - fi - if jq -e ' - any(.[]; - .op == "add" - and .path == "/spec/unschedulable" - and .value == true) - ' "$patch_file" >/dev/null; then - if [[ "$node_target" == "${FAKE_CORDON_BEFORE_CLAIM_NODE:-disabled}" ]]; then - touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - printf 'operator-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" - echo 'resourceVersion test failed after concurrent cordon' >&2 - exit 56 - fi - expected_owner="$(jq -er ' - .[] - | select( - .op == "add" - and .path == ( - "/metadata/annotations/platform.devantler.tech" - + "~1ghcr-auth-drain-owner" - )) - | .value - ' "$patch_file")" - test -n "$expected_owner" - test ! -e "$owner_file" - jq -e --arg resource_version "$current_resource_version" ' - .[0].op == "test" - and .[0].path == "/metadata/resourceVersion" - and .[0].value == $resource_version - and any(.[]; - .op == "add" - and .path == "/spec/unschedulable" - and .value == true) - ' "$patch_file" >/dev/null - printf '%s' "$expected_owner" > "$owner_file" - touch "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - printf '%s' "$((current_resource_version + 1))" \ - > "$resource_version_file" - printf 'node-claim-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" - exit 0 - fi - expected_owner="$(jq -er '.[0].value' "$patch_file")" - current_owner="" - if [[ -f "$owner_file" ]]; then - current_owner="$(<"$owner_file")" - fi - if [[ "$node_target" == "${FAKE_UNCORDON_FAIL_NODE:-disabled}" \ - || "$current_owner" != "$expected_owner" ]]; then - echo 'cordon ownership changed; refusing to uncordon' >&2 - exit 56 - fi - jq -e --arg resource_version "$current_resource_version" ' - .[0].op == "test" - and .[0].path == ( - "/metadata/annotations/platform.devantler.tech" - + "~1ghcr-auth-drain-owner" - ) - and any(.[]; - .op == "test" - and .path == "/metadata/resourceVersion" - and .value == $resource_version) - and any(.[]; - .op == "add" - and .path == "/spec/unschedulable" - and .value == false) - and any(.[]; - .op == "remove" - and .path == ( - "/metadata/annotations/platform.devantler.tech" - + "~1ghcr-auth-drain-owner" - )) - ' "$patch_file" >/dev/null - printf 'node-uncordon:%s\n' "$node_target" >> "$OPERATION_LOG" - printf '%s' "$((current_resource_version + 1))" \ - > "$resource_version_file" - rm "$owner_file" - rm -f "$FAKE_SYNC_STATE_DIR/cordoned-${node_target}" - exit 0 - fi - - if [[ "$arguments" == *" cordon "* ]]; then - node_target="" - previous="" - for argument in "$@"; do - if [[ "$previous" == cordon ]]; then - node_target="$argument" - break - fi - previous="$argument" - done - test -n "$node_target" - printf 'node-cordon:%s\n' "$node_target" >> "$OPERATION_LOG" - exit 0 - fi - - # Node readiness gate after a GHCR-auth reboot. Cluster-scoped, so it - # must be handled before the namespace guard below. - if [[ "$arguments" == *" wait "* ]]; then - [[ "$arguments" == *" --for=condition=Ready "* ]] - [[ "$arguments" == *" --timeout=10m "* ]] - node_target="" - for argument in "$@"; do - case "$argument" in - node/*) node_target="${argument#node/}" ;; - esac - done - test -n "$node_target" - printf 'node-ready:%s\n' "$node_target" >> "$OPERATION_LOG" - if [[ "$node_target" == "${FAKE_NODE_READY_FAIL_NODE:-disabled}" ]]; then - echo 'node did not become ready' >&2 - exit 50 - fi - exit 0 - fi - - test -n "$namespace" - - if [[ "$arguments" == *" api-resources "* ]]; then - [[ "$arguments" == *" --api-group=external-secrets.io "* ]] - if [[ "${FAKE_FANOUT_CRDS_ABSENT:-false}" != true ]]; then - printf '%s\n' \ - 'externalsecrets.external-secrets.io' \ - 'pushsecrets.external-secrets.io' - fi - exit 0 - fi - - if [[ "$arguments" == *" patch secret ksail-registry-credentials "* ]]; then - [[ "$arguments" == *" --type=merge "* ]] - test -n "$patch_file" - cp "$patch_file" "$PATCH_CAPTURE" - printf 'root-patch\n' >> "$OPERATION_LOG" - if [[ "${FAKE_KUBECTL_FAIL:-false}" == true ]]; then - echo 'cluster patch failed' >&2 - exit 43 - fi - echo 'secret/ksail-registry-credentials patched' - exit 0 - fi - - if [[ "$arguments" == *" get secret variables-base "* ]]; then - [[ "$arguments" == *" --ignore-not-found "* ]] - if [[ "${FAKE_VARIABLES_BASE_ABSENT:-false}" != true ]]; then - echo 'secret/variables-base' - fi - exit 0 - fi - - if [[ "$arguments" == *" patch secret variables-base "* ]]; then - [[ "$arguments" == *" --type=merge "* ]] - test -n "$patch_file" - cp "$patch_file" "$VARIABLES_PATCH_CAPTURE" - variables_patch_count_file="$FAKE_SYNC_STATE_DIR/variables-patch-count" - variables_patch_count=0 - if [[ -f "$variables_patch_count_file" ]]; then - variables_patch_count="$(<"$variables_patch_count_file")" - fi - variables_patch_count=$((variables_patch_count + 1)) - printf '%s' "$variables_patch_count" > "$variables_patch_count_file" - printf 'variables-patch\n' >> "$OPERATION_LOG" - echo 'secret/variables-base patched' - exit 0 - fi - - kind="" - name="" - if [[ "$arguments" == *" pushsecret seed-ghcr "* ]]; then - kind=pushsecret - name=seed-ghcr - elif [[ "$arguments" == *" externalsecret ghcr-auth "* ]]; then - kind=externalsecret - name=ghcr-auth - fi - - if [[ -n "$kind" && "$arguments" == *" get $kind $name "* \ - && "$arguments" == *" --ignore-not-found "* ]]; then - resource="$kind/$namespace/$name" - if [[ "$resource" != "${FAKE_MISSING_FANOUT_RESOURCE:-disabled}" ]]; then - echo "$kind/$name" - fi - exit 0 - fi - - if [[ -n "$kind" \ - && "$kind/$namespace/$name" == "${FAKE_MISSING_FANOUT_RESOURCE:-disabled}" ]]; then - echo "$kind/$name not found" >&2 - exit 44 - fi - - if [[ -n "$kind" && "$arguments" == *" get $kind $name "* ]]; then - marker="$FAKE_SYNC_STATE_DIR/${kind}-${namespace}-${name}" - annotated_marker="${marker}-annotated" - refresh_time=2026-07-13T00:00:00Z - resource_version=1 - if [[ -f "$annotated_marker" ]]; then - resource_version=2 - fi - if [[ -f "$marker" && "${FAKE_SYNC_SAME_REFRESH_TIME:-false}" != true ]]; then - refresh_time=2026-07-13T00:00:01Z - fi - if [[ -f "$marker" ]]; then - resource_version=3 - fi - printf '{"metadata":{"resourceVersion":"%s"},"status":{"refreshTime":"%s","conditions":[{"type":"Ready","status":"True"}]}}\n' "$resource_version" "$refresh_time" - exit 0 - fi - - if [[ -n "$kind" && "$arguments" == *" annotate $kind $name "* ]]; then - resource="$kind/$namespace/$name" - printf '%s\n' "$resource" >> "$FANOUT_LOG" - printf 'fanout:%s\n' "$resource" >> "$OPERATION_LOG" - marker="$FAKE_SYNC_STATE_DIR/${kind}-${namespace}-${name}" - touch "${marker}-annotated" - if [[ "$resource" != "${FAKE_SYNC_STALL_RESOURCE:-disabled}" ]]; then - touch "$marker" - fi - printf '{"metadata":{"resourceVersion":"2"}}\n' - exit 0 - fi - - if [[ "$arguments" == *" get secret ghcr-auth "* ]]; then - variables_patch_count=0 - if [[ -f "$FAKE_SYNC_STATE_DIR/variables-patch-count" ]]; then - variables_patch_count="$(<"$FAKE_SYNC_STATE_DIR/variables-patch-count")" - fi - if [[ "$namespace" == "${FAKE_CONSUMER_MISMATCH_NAMESPACE:-disabled}" \ - || ( "$namespace" == "${FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE:-disabled}" \ - && "$variables_patch_count" -ge 2 ) ]]; then - encoded=$(printf '%s' '{"auths":{}}' | base64 | tr -d '\r\n') - else - encoded=$(jq -r '.data.ghcr_dockerconfigjson' "$VARIABLES_PATCH_CAPTURE") - fi - jq -n --arg encoded "$encoded" '{data:{".dockerconfigjson":$encoded}}' - exit 0 - fi - - echo "unexpected kubectl invocation: $arguments" >&2 - exit 91 - """, - ) - - def _write_executable(self, name: str, body: str) -> None: - """Install an executable command fake in the isolated test PATH.""" - path = self.bin_dir / name - path.write_text(textwrap.dedent(body).lstrip(), encoding="utf-8") - path.chmod(0o755) - - def _write_encrypted_secret(self, ciphertext: str) -> None: - """Write a non-secret SOPS ciphertext fixture for revision tests.""" - self.encrypted_ciphertext = ciphertext - self.encrypted_secret.write_text( - json.dumps( - {"stringData": {"ghcr_dockerconfigjson": ciphertext}} - ), - encoding="utf-8", - ) - - @staticmethod - def _expected_credentials(config: object) -> tuple[str, str]: - """Extract expected credentials from a valid Docker config fixture.""" - try: - registry = config["auths"]["ghcr.io"] # type: ignore[index] - username = registry.get("username") - password = registry.get("password") - if username and password: - return str(username), str(password) - decoded = base64.b64decode(registry["auth"]).decode() - decoded_username, decoded_password = decoded.split(":", 1) - return decoded_username, decoded_password - except (KeyError, TypeError, ValueError): - return "unused", "unused" - - def _expected_revision(self) -> str: - """Match the helper's SHA-256 of the yq-emitted ciphertext scalar.""" - return hashlib.sha256( - f"{self.encrypted_ciphertext}\n".encode() - ).hexdigest() - - def _run_helper( - self, - config: object, - helper_args: tuple[str, ...] = (), - **environment_overrides: str, - ) -> subprocess.CompletedProcess[str]: - """Run the credential bridge against a supplied Docker config fixture.""" - self.decrypted_config.write_text(json.dumps(config), encoding="utf-8") - for marker in ( - self.patch_capture, - self.variables_patch_capture, - self.kubectl_called, - self.output_path_log, - self.registry_read_log, - self.fanout_log, - self.talos_log, - self.talos_patch_path_log, - self.operation_log, - self.ksail_token_capture, - self.ksail_username_capture, - self.ksail_revision_capture, - self.ksail_command_capture, - self.ksail_config_path_capture, - self.ksail_registry_capture, - self.ksail_registry_override_capture, - ): - marker.unlink(missing_ok=True) - for marker in self.sync_state_dir.iterdir(): - marker.unlink() - expected_username, expected_token = self._expected_credentials(config) - environment = os.environ.copy() - environment.update( - { - "PATH": f"{self.bin_dir}:{environment['PATH']}", - "FAKE_DECRYPTED_CONFIG": str(self.decrypted_config), - "FLUX_GHCR_SECRET_FILE": str(self.encrypted_secret), - "PATCH_CAPTURE": str(self.patch_capture), - "VARIABLES_PATCH_CAPTURE": str(self.variables_patch_capture), - "KUBECTL_CALLED": str(self.kubectl_called), - "KSAIL_OUTPUT_PATH_LOG": str(self.output_path_log), - "REGISTRY_READ_LOG": str(self.registry_read_log), - "FANOUT_LOG": str(self.fanout_log), - "TALOS_LOG": str(self.talos_log), - "TALOS_PATCH_PATH_LOG": str(self.talos_patch_path_log), - "OPERATION_LOG": str(self.operation_log), - "KSAIL_TOKEN_CAPTURE": str(self.ksail_token_capture), - "KSAIL_USERNAME_CAPTURE": str(self.ksail_username_capture), - "KSAIL_REVISION_CAPTURE": str(self.ksail_revision_capture), - "KSAIL_COMMAND_CAPTURE": str(self.ksail_command_capture), - "KSAIL_CONFIG_PATH_CAPTURE": str(self.ksail_config_path_capture), - "KSAIL_REGISTRY_CAPTURE": str(self.ksail_registry_capture), - "KSAIL_REGISTRY_OVERRIDE_CAPTURE": str( - self.ksail_registry_override_capture - ), - "EXPECTED_PULL_USERNAME": expected_username, - "EXPECTED_PULL_TOKEN": expected_token, - "EXPECTED_GHCR_REVISION": self._expected_revision(), - "EXPECTED_KSAIL_TARGET_IMAGE": ( - "ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}" - ), - "FAKE_SYNC_STATE_DIR": str(self.sync_state_dir), - "FLUX_GHCR_SYNC_ATTEMPTS": "2", - "FLUX_GHCR_SYNC_INTERVAL": "0", - } - ) - environment.update(environment_overrides) - return subprocess.run( - [str(HELPER), *helper_args], - cwd=ROOT, - env=environment, - text=True, - capture_output=True, - check=False, - ) - - def _run_ksail_pull_wrapper( - self, - config: object, - command: tuple[str, ...], - **environment_overrides: str, - ) -> subprocess.CompletedProcess[str]: - """Run a production KSail command through the SOPS pull-auth wrapper.""" - self.decrypted_config.write_text(json.dumps(config), encoding="utf-8") - for marker in ( - self.output_path_log, - self.ksail_token_capture, - self.ksail_username_capture, - self.ksail_revision_capture, - self.ksail_command_capture, - self.ksail_config_path_capture, - self.ksail_registry_capture, - self.ksail_registry_override_capture, - ): - marker.unlink(missing_ok=True) - expected_username, expected_token = self._expected_credentials(config) - environment = os.environ.copy() - environment.update( - { - "PATH": f"{self.bin_dir}:{environment['PATH']}", - "FAKE_DECRYPTED_CONFIG": str(self.decrypted_config), - "FLUX_GHCR_SECRET_FILE": str(self.encrypted_secret), - "KSAIL_OUTPUT_PATH_LOG": str(self.output_path_log), - "KSAIL_TOKEN_CAPTURE": str(self.ksail_token_capture), - "KSAIL_USERNAME_CAPTURE": str(self.ksail_username_capture), - "KSAIL_REVISION_CAPTURE": str(self.ksail_revision_capture), - "KSAIL_COMMAND_CAPTURE": str(self.ksail_command_capture), - "KSAIL_CONFIG_PATH_CAPTURE": str(self.ksail_config_path_capture), - "KSAIL_REGISTRY_CAPTURE": str(self.ksail_registry_capture), - "KSAIL_REGISTRY_OVERRIDE_CAPTURE": str( - self.ksail_registry_override_capture - ), - "EXPECTED_PULL_USERNAME": expected_username, - "EXPECTED_PULL_TOKEN": expected_token, - "EXPECTED_GHCR_REVISION": self._expected_revision(), - } - ) - environment.update(environment_overrides) - return subprocess.run( - [str(KSAIL_PULL_WRAPPER), *command], - cwd=ROOT, - env=environment, - text=True, - capture_output=True, - check=False, - ) - - @staticmethod - def _valid_config() -> dict[str, object]: - """Return a valid explicit GHCR Docker authentication fixture.""" - return { - "auths": { - "ghcr.io": { - "username": "devantler", - "password": "fixture-secret-token", - } - } - } - - def test_refreshes_root_and_fanout_without_leaking_plaintext(self) -> None: - """Refresh every credential consumer without exposing the pull token.""" - config = self._valid_config() - - result = self._run_helper(config) - - self.assertEqual(result.returncode, 0, result.stderr) - patch = json.loads(self.patch_capture.read_text(encoding="utf-8")) - encoded = patch["data"][".dockerconfigjson"] - - self.assertEqual(json.loads(base64.b64decode(encoded)), config) - variables_patch = json.loads( - self.variables_patch_capture.read_text(encoding="utf-8") - ) - variables_encoded = variables_patch["data"]["ghcr_dockerconfigjson"] - self.assertEqual(json.loads(base64.b64decode(variables_encoded)), config) - temporary_config = Path(self.output_path_log.read_text(encoding="utf-8")) - self.assertFalse(temporary_config.exists()) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - self.assertEqual( - self.registry_read_log.read_text(encoding="utf-8").splitlines(), - [ - "devantler-tech/platform/manifests:latest", - "devantler-tech/wedding-app/manifests:latest", - "devantler-tech/ascoachingogvaner/manifests:latest", - "devantler-tech/wedding-app:latest", - "devantler-tech/ascoachingogvaner:latest", - f"devantler-tech/ksail:v{KSAIL_OPERATOR_VERSION}", - "devantler-tech/provider-upjet-unifi:v0.1.0", - ], - ) - self.assertEqual( - self.fanout_log.read_text(encoding="utf-8").splitlines(), - [ - "pushsecret/flux-system/seed-ghcr", - "externalsecret/wedding-app/ghcr-auth", - "externalsecret/ascoachingogvaner/ghcr-auth", - "externalsecret/kyverno/ghcr-auth", - "pushsecret/flux-system/seed-ghcr", - "externalsecret/wedding-app/ghcr-auth", - "externalsecret/ascoachingogvaner/ghcr-auth", - "externalsecret/kyverno/ghcr-auth", - ], - ) - - def test_stages_kubernetes_consumers_before_talos_drains(self) -> None: - """Verify fanout, then patch, REBOOT, drop cache, and pull workers first. - - The reboot is load-bearing: containerd reads registry auth from its - static config only at process start, and Talos refuses - `service cri restart`, so a --mode=no-reboot patch leaves the running - containerd on the OLD credential. Without the reboot every ghcr.io pull - on the node keeps failing 403 while the node still reports "verified". - Removing the cached copy first is what makes the pull prove a real - registry round-trip rather than a cache hit. - """ - result = self._run_helper(self._valid_config()) - - self.assertEqual(result.returncode, 0, result.stderr) - expected_talos_operations = [ - "talos-auth:10.0.0.2", - "talos-reboot:10.0.0.2", - "talos-remove:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", - "talos-reboot:10.0.0.1", - "talos-remove:10.0.0.1:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-revision:10.0.0.1", - ] - self.assertEqual( - self.talos_log.read_text(encoding="utf-8").splitlines(), - expected_talos_operations, - ) - # Every Kubernetes pull consumer is refreshed and verified before the - # first drain. Each node then completes its full sequence before the - # next node is touched, and root Flux auth remains the final mutation. - self.assertEqual( - self.operation_log.read_text(encoding="utf-8").splitlines(), - [ - "variables-patch", - "fanout:pushsecret/flux-system/seed-ghcr", - "fanout:externalsecret/wedding-app/ghcr-auth", - "fanout:externalsecret/ascoachingogvaner/ghcr-auth", - "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", - "node-claim-cordon:prod-worker-1", - "node-drain:prod-worker-1", - "talos-reboot:10.0.0.2", - "node-ready:prod-worker-1", - "talos-remove:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "node-uncordon:prod-worker-1", - "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", - "node-claim-cordon:prod-control-plane-1", - "node-drain:prod-control-plane-1", - "talos-reboot:10.0.0.1", - "node-ready:prod-control-plane-1", - "talos-remove:10.0.0.1:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "talos-pull:10.0.0.1:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}", - "node-uncordon:prod-control-plane-1", - "talos-revision:10.0.0.1", - "variables-patch", - "fanout:pushsecret/flux-system/seed-ghcr", - "fanout:externalsecret/wedding-app/ghcr-auth", - "fanout:externalsecret/ascoachingogvaner/ghcr-auth", - "fanout:externalsecret/kyverno/ghcr-auth", - "root-patch", - ], - ) - temporary_patch = Path( - self.talos_patch_path_log.read_text(encoding="utf-8") - ) - self.assertFalse(temporary_patch.exists()) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_unhealthy_control_plane_blocks_the_control_plane_reboot(self) -> None: - """Never take a SECOND control plane down — etcd would lose quorum. - - The reboot roll is serial and control planes go last, but a control plane - that was ALREADY unhealthy before this run makes our reboot the second one - down. The worker still syncs (it cannot affect quorum); the control-plane - reboot is refused after the Kubernetes credential fanout is made safe. - """ - revision = self._expected_revision() - ready = [{"type": "Ready", "status": "True"}] - inventory = { - "items": [ - { - "metadata": {"name": "prod-worker-1", "labels": {}}, - "status": { - "addresses": [{"type": "InternalIP", "address": "10.0.0.2"}], - "conditions": ready, - }, - }, - { - "metadata": { - "name": "prod-control-plane-1", - "labels": {"node-role.kubernetes.io/control-plane": ""}, - }, - "status": { - "addresses": [{"type": "InternalIP", "address": "10.0.0.1"}], - "conditions": ready, - }, - }, - { - # Already down, and already at the desired revision — so it is - # not itself a sync target, it is just a quorum member. - "metadata": { - "name": "prod-control-plane-2", - "labels": {"node-role.kubernetes.io/control-plane": ""}, - "annotations": { - "platform.devantler.tech/ghcr-pull-verified-revision-v2": - revision, - "platform.devantler.tech/ghcr-pull-verified-image-v2": ( - "ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}" - ), - }, - }, - "status": { - "addresses": [{"type": "InternalIP", "address": "10.0.0.3"}], - "conditions": [{"type": "Ready", "status": "False"}], - }, - }, - ] - } - - result = self._run_helper( - self._valid_config(), - FAKE_NODE_JSON=json.dumps(inventory), - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("risks quorum", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-reboot:10.0.0.1", operations) - self.assertNotIn("root-patch", operations) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_control_plane_quorum_is_rechecked_after_the_drain(self) -> None: - """Abort before reboot when an etcd peer fails during a long drain.""" - result = self._run_helper( - self._valid_config(), - FAKE_ETCD_STATUS_FAIL_AFTER_DRAIN_NODE="10.0.0.3", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("risks quorum", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-drain:prod-control-plane-1", operations) - self.assertIn("node-uncordon:prod-control-plane-1", operations) - self.assertNotIn("talos-reboot:10.0.0.1", operations) - self.assertNotIn("talos-revision:10.0.0.1", operations) - self.assertNotIn("root-patch", operations) - - def test_unsafe_etcd_member_status_blocks_control_plane_reboot(self) -> None: - """Reject non-voting learners and status rows that report errors.""" - for environment_name in ( - "FAKE_ETCD_LEARNER_NODE", - "FAKE_ETCD_STATUS_ERROR_NODE", - ): - with self.subTest(environment_name=environment_name): - result = self._run_helper( - self._valid_config(), - **{environment_name: "10.0.0.3"}, - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text( - encoding="utf-8" - ).splitlines() - self.assertIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-reboot:10.0.0.1", operations) - self.assertNotIn("root-patch", operations) - - def test_compact_healthy_etcd_status_permits_control_plane_reboot(self) -> None: - """Accept Talos's status table without protocol/storage columns.""" - result = self._run_helper( - self._valid_config(), - FAKE_ETCD_COMPACT_STATUS_NODE="10.0.0.3", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-reboot:10.0.0.1", operations) - self.assertIn("root-patch", operations) - - def test_pre_existing_cordon_survives_the_auth_reboot(self) -> None: - """A node an operator cordoned must not come back schedulable. - - A node deliberately cordoned before this run (maintenance, - investigation, autoscaler scale-down) must remain unschedulable after - the bridge completes its explicit Kubernetes drain and Talos reboot. - """ - result = self._run_helper( - self._valid_config(), - FAKE_CORDONED_NODES="prod-worker-1", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-claim-cordon:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertIn("node-claim-cordon:prod-control-plane-1", operations) - self.assertIn("node-uncordon:prod-control-plane-1", operations) - - def test_schedulable_node_is_uncordoned_after_the_auth_reboot(self) -> None: - """Restore scheduling after runtime proof, before recording success.""" - result = self._run_helper(self._valid_config()) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertLess( - operations.index("node-claim-cordon:prod-worker-1"), - operations.index("node-drain:prod-worker-1"), - ) - self.assertLess( - operations.index("node-drain:prod-worker-1"), - operations.index("talos-reboot:10.0.0.2"), - ) - self.assertLess( - operations.index( - "talos-pull:10.0.0.2:ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}" - ), - operations.index("node-uncordon:prod-worker-1"), - ) - self.assertLess( - operations.index("node-uncordon:prod-worker-1"), - operations.index("talos-revision:10.0.0.2"), - ) - self.assertEqual( - ( - self.sync_state_dir / "resource-version-prod-worker-1" - ).read_text(encoding="utf-8"), - "12", - ) - - def test_schedulable_node_is_claimed_and_cordoned_atomically(self) -> None: - """Close the gap where a competing cordon could precede our drain.""" - result = self._run_helper(self._valid_config()) - - self.assertEqual(result.returncode, 0, result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertNotIn("node-claim:prod-worker-1", operations) - self.assertLess( - operations.index("node-claim-cordon:prod-worker-1"), - operations.index("node-drain:prod-worker-1"), - ) - - def test_concurrent_cordon_before_atomic_claim_stops_the_roll(self) -> None: - """Never take ownership after another actor makes a node unschedulable.""" - result = self._run_helper( - self._valid_config(), - FAKE_CORDON_BEFORE_CLAIM_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("Could not atomically claim and cordon", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("operator-cordon:prod-worker-1", operations) - self.assertNotIn("node-claim-cordon:prod-worker-1", operations) - self.assertNotIn("node-drain:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertFalse( - (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() - ) - - def test_pdb_blocked_drain_restores_original_schedulability(self) -> None: - """A blocked eviction must be visible and must never reach reboot.""" - result = self._run_helper( - self._valid_config(), - FAKE_DRAIN_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn( - "drain: cannot evict pod backstage-db-4: would violate " - "PodDisruptionBudget backstage-db-primary", - output, - ) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertIn("node-drain:prod-worker-1", operations) - self.assertIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertNotIn("fixture-secret-token", output) - self.assertFalse( - (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() - ) - - def test_pdb_blocked_drain_preserves_pre_existing_cordon(self) -> None: - """Never uncordon a node that was already under operator control.""" - result = self._run_helper( - self._valid_config(), - FAKE_CORDONED_NODES="prod-worker-1", - FAKE_DRAIN_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertNotIn("node-claim-cordon:prod-worker-1", operations) - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - - def test_drain_api_failure_releases_the_atomic_claim(self) -> None: - """A pre-eviction drain error must not strand our owned cordon.""" - result = self._run_helper( - self._valid_config(), - FAKE_DRAIN_API_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertIn("node-drain:prod-worker-1", operations) - self.assertIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-reboot:10.0.0.2", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertFalse( - (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() - ) - - def test_revision_failure_does_not_leave_owned_cordon_behind(self) -> None: - """Restore scheduling before the final non-secret marker write.""" - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_FAIL_NODE="10.0.0.2", - FAKE_TALOS_FAIL_OPERATION="revision", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-uncordon:prod-worker-1", operations) - self.assertLess( - operations.index("node-uncordon:prod-worker-1"), - operations.index("talos-revision:10.0.0.2"), - ) - self.assertNotIn("root-patch", operations) - - def test_changed_cordon_owner_is_never_uncordoned(self) -> None: - """Do not undo a newer operator's scheduling decision mid-roll.""" - result = self._run_helper( - self._valid_config(), - FAKE_CORDON_OWNER_REPLACED_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("ownership changed", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_autoscaler_taint_is_never_uncordoned(self) -> None: - """Honor standard scale-down intent even when our owner remains.""" - result = self._run_helper( - self._valid_config(), - FAKE_AUTOSCALER_CORDON_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - output = result.stdout + result.stderr - self.assertIn("scheduling safety state changed", output) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertIn("node-drain:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - - def test_uncordon_failure_keeps_revision_marker_stale(self) -> None: - """A failed ownership release must force the next run to retry.""" - result = self._run_helper( - self._valid_config(), - FAKE_UNCORDON_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-claim-cordon:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertTrue( - (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() - ) - - def test_unready_node_after_reboot_stops_the_roll(self) -> None: - """Never roll the next node while the rebooted one is still down.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_READY_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - # The fanout is already safe. The worker rebooted but never came back, - # so the control plane is left alone and root Flux auth is unchanged. - self.assertEqual( - operations, - [ - "variables-patch", - "fanout:pushsecret/flux-system/seed-ghcr", - "fanout:externalsecret/wedding-app/ghcr-auth", - "fanout:externalsecret/ascoachingogvaner/ghcr-auth", - "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", - "node-claim-cordon:prod-worker-1", - "node-drain:prod-worker-1", - "talos-reboot:10.0.0.2", - "node-ready:prod-worker-1", - ], - ) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_unready_node_preserves_its_pre_existing_cordon(self) -> None: - """Keep an operator-cordoned node cordoned when readiness fails.""" - result = self._run_helper( - self._valid_config(), - FAKE_CORDONED_NODES="prod-worker-1", - FAKE_NODE_READY_FAIL_NODE="prod-worker-1", - ) - - self.assertNotEqual(result.returncode, 0) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("node-ready:prod-worker-1", operations) - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("talos-revision:10.0.0.2", operations) - self.assertNotIn("root-patch", operations) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_talos_failure_after_safe_fanout_keeps_root_auth_unchanged(self) -> None: - """Keep root Flux auth unchanged when the post-fanout node roll fails.""" - for operation in ("auth", "reboot", "remove", "pull", "revision"): - with self.subTest(operation=operation): - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_FAIL_NODE="10.0.0.2", - FAKE_TALOS_FAIL_OPERATION=operation, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertTrue(self.variables_patch_capture.exists()) - self.assertFalse(self.patch_capture.exists()) - self.assertEqual( - self.fanout_log.read_text(encoding="utf-8").splitlines(), - [ - "pushsecret/flux-system/seed-ghcr", - "externalsecret/wedding-app/ghcr-auth", - "externalsecret/ascoachingogvaner/ghcr-auth", - "externalsecret/kyverno/ghcr-auth", - ], - ) - operations = self.operation_log.read_text( - encoding="utf-8" - ).splitlines() - if operation in ("remove", "pull"): - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertTrue( - (self.sync_state_dir / "cordon-owner-prod-worker-1").exists() - ) - self.assertTrue( - (self.sync_state_dir / "cordoned-prod-worker-1").exists() - ) - if operation == "reboot": - self.assertNotIn("node-uncordon:prod-worker-1", operations) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_second_fanout_verification_blocks_root_cutover(self) -> None: - """Recheck live tenant auth after Talos before changing root Flux auth.""" - result = self._run_helper( - self._valid_config(), - FAKE_CONSUMER_MISMATCH_ON_SECOND_PASS_NAMESPACE="wedding-app", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("did not materialise", result.stdout + result.stderr) - operations = self.operation_log.read_text(encoding="utf-8").splitlines() - self.assertIn("talos-revision:10.0.0.1", operations) - self.assertEqual(operations.count("variables-patch"), 2) - self.assertNotIn("root-patch", operations) - - def test_missing_cached_image_still_pulls_and_records_revision(self) -> None: - """Treat a confirmed absent cache entry as ready for pull proof.""" - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_IMAGE_ABSENT_NODE="10.0.0.2", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - target_image = ( - "ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}" - ) - operations = self.talos_log.read_text(encoding="utf-8").splitlines() - self.assertIn(f"talos-remove:10.0.0.2:{target_image}", operations) - self.assertIn(f"talos-pull:10.0.0.2:{target_image}", operations) - self.assertIn("talos-revision:10.0.0.2", operations) - self.assertTrue(self.patch_capture.exists()) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_current_talos_nodes_skip_talos_api(self) -> None: - """Skip Talos only after this revision and target image are proved.""" - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_NODES_CURRENT="true", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(self.talos_log.exists()) - self.assertTrue(self.patch_capture.exists()) - - def test_matching_revision_revalidates_changed_declared_image(self) -> None: - """Do not trust a current credential marker for a new target image.""" - previous_image = "ghcr.io/devantler-tech/ksail:v7.166.0" - target_image = ( - "ghcr.io/devantler-tech/ksail:" - f"v{KSAIL_OPERATOR_VERSION}" - ) - - result = self._run_helper( - self._valid_config(), - FAKE_TALOS_NODES_CURRENT="true", - FAKE_TALOS_VERIFIED_IMAGE=previous_image, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue( - self.talos_log.exists(), - "matching credential revision incorrectly skipped target-image proof", - ) - operations = self.talos_log.read_text(encoding="utf-8").splitlines() - self.assertEqual( - operations, - [ - "talos-auth:10.0.0.2", - "talos-reboot:10.0.0.2", - f"talos-remove:10.0.0.2:{target_image}", - f"talos-pull:10.0.0.2:{target_image}", - "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", - "talos-reboot:10.0.0.1", - f"talos-remove:10.0.0.1:{target_image}", - f"talos-pull:10.0.0.1:{target_image}", - "talos-revision:10.0.0.1", - ], - ) - self.assertNotIn(previous_image, "\n".join(operations)) - - def test_dr_without_fanout_does_not_drain_nodes(self) -> None: - """Never drain workloads before a DR cluster has its pull fanout.""" - result = self._run_helper( - self._valid_config(), - ("--allow-incomplete-fanout",), - FAKE_VARIABLES_BASE_ABSENT="true", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(self.talos_log.exists()) - self.assertTrue(self.patch_capture.exists()) - - def test_invalid_node_inventory_fails_closed(self) -> None: - """Reject empty, duplicate, and ambiguous Talos node InternalIPs.""" - invalid_inventories = [ - {"items": []}, - { - "items": [ - { - "metadata": {"name": "one"}, - "status": {"addresses": []}, - } - ] - }, - { - "items": [ - { - "metadata": {"name": "one"}, - "status": { - "addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"}, - {"type": "InternalIP", "address": "10.0.0.2"}, - ] - }, - } - ] - }, - { - "items": [ - { - "metadata": {"name": "one"}, - "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"} - ]}, - }, - { - "metadata": {"name": "two"}, - "status": {"addresses": [ - {"type": "InternalIP", "address": "10.0.0.1"} - ]}, - }, - ] - }, - ] - for inventory in invalid_inventories: - with self.subTest(inventory=inventory): - result = self._run_helper( - self._valid_config(), - FAKE_NODE_JSON=json.dumps(inventory), - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.talos_log.exists()) - self.assertFalse(self.patch_capture.exists()) - - def test_node_discovery_failure_after_safe_fanout_keeps_root_unchanged( - self, - ) -> None: - """Fail closed after safe fanout when production nodes cannot be listed.""" - result = self._run_helper( - self._valid_config(), - FAKE_NODE_DISCOVERY_FAIL="true", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.talos_log.exists()) - self.assertTrue(self.variables_patch_capture.exists()) - self.assertTrue(self.fanout_log.exists()) - self.assertFalse(self.patch_capture.exists()) - - def test_ksail_lifecycle_wrapper_uses_only_sops_pull_token(self) -> None: - """Run create, reconcile, and update with the decrypted pull token.""" - self.assertTrue(KSAIL_PULL_WRAPPER.is_file()) - for command in ( - ("cluster", "create"), - ("workload", "reconcile"), - ("cluster", "update"), - ): - with self.subTest(command=command): - result = self._run_ksail_pull_wrapper(self._valid_config(), command) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - self.ksail_token_capture.read_text(encoding="utf-8"), - "fixture-secret-token", - ) - self.assertEqual( - self.ksail_username_capture.read_text(encoding="utf-8"), - "devantler", - ) - self.assertEqual( - self.ksail_command_capture.read_text(encoding="utf-8") - .strip() - .split(), - ["--config", "ksail.prod.yaml", *command], - ) - self.assertEqual( - self.ksail_config_path_capture.read_text(encoding="utf-8"), - "ksail.prod.yaml", - ) - self.assertEqual( - self.ksail_registry_capture.read_text(encoding="utf-8"), - "devantler:${GHCR_TOKEN}" - "@ghcr.io/devantler-tech/platform/manifests", - ) - self.assertEqual( - self.ksail_registry_override_capture.read_text( - encoding="utf-8" - ), - "${GHCR_USERNAME}:${GHCR_TOKEN}" - "@ghcr.io/devantler-tech/platform/manifests", - ) - self.assertRegex( - self.ksail_revision_capture.read_text(encoding="utf-8"), - r"^[0-9a-f]{64}$", - ) - self.assertNotIn( - "fixture-secret-token", - result.stdout + result.stderr, - ) - temporary_config = Path( - self.output_path_log.read_text(encoding="utf-8") - ) - self.assertFalse(temporary_config.exists()) - - def test_ksail_publish_wrapper_preserves_actions_write_token(self) -> None: - """Inject only the SOPS revision while preserving publication auth.""" - result = self._run_ksail_pull_wrapper( - self._valid_config(), - ("workload", "push"), - GITHUB_ACTOR="fixture-publisher", - GHCR_TOKEN="fixture-actions-write-token", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - self.ksail_token_capture.read_text(encoding="utf-8"), - "fixture-actions-write-token", - ) - self.assertEqual( - self.ksail_username_capture.read_text(encoding="utf-8"), - "fixture-publisher", - ) - self.assertEqual( - self.ksail_config_path_capture.read_text(encoding="utf-8"), - "ksail.prod.yaml", - ) - self.assertEqual( - self.ksail_registry_capture.read_text(encoding="utf-8"), - "devantler:${GHCR_TOKEN}@ghcr.io/devantler-tech/platform/manifests", - ) - self.assertEqual( - self.ksail_registry_override_capture.read_text(encoding="utf-8"), - "", - ) - self.assertRegex( - self.ksail_revision_capture.read_text(encoding="utf-8"), - r"^[0-9a-f]{64}$", - ) - self.assertNotIn( - "fixture-actions-write-token", - result.stdout + result.stderr, - ) - self.assertFalse( - self.output_path_log.exists(), - "publication needs the ciphertext revision but not decryption", - ) - - def test_production_config_keeps_its_protected_registry_template(self) -> None: - """Keep dynamic pull auth in the wrapper-owned environment only.""" - config = KSAIL_PROD_CONFIG.read_text(encoding="utf-8") - - self.assertIn( - 'registry: "devantler:${GHCR_TOKEN}' - '@ghcr.io/devantler-tech/platform/manifests"', - config, - ) - self.assertNotIn("${GHCR_USERNAME}:${GHCR_TOKEN}@ghcr.io", config) - - def test_lifecycle_preserves_username_from_sops_docker_config(self) -> None: - """Avoid hard-coding the pull credential's registry username.""" - config = self._valid_config() - config["auths"]["ghcr.io"]["username"] = "pull-robot" # type: ignore[index] - - result = self._run_ksail_pull_wrapper( - config, - ("cluster", "update"), - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - self.ksail_username_capture.read_text(encoding="utf-8"), - "pull-robot", - ) - - def test_ciphertext_rotation_changes_revision_without_hashing_token(self) -> None: - """Drive template drift from SOPS ciphertext rather than plaintext auth.""" - first = self._run_ksail_pull_wrapper( - self._valid_config(), - ("cluster", "update"), - ) - self.assertEqual(first.returncode, 0, first.stderr) - first_revision = self.ksail_revision_capture.read_text(encoding="utf-8") - - self._write_encrypted_secret("ENC[AES256_GCM,data:fixture-two]") - second = self._run_ksail_pull_wrapper( - self._valid_config(), - ("cluster", "update"), - ) - self.assertEqual(second.returncode, 0, second.stderr) - second_revision = self.ksail_revision_capture.read_text(encoding="utf-8") - - normalized_plaintext = json.dumps( - self._valid_config(), sort_keys=True, separators=(",", ":") - ) - plaintext_hash = hashlib.sha256( - f"{normalized_plaintext}\n".encode() - ).hexdigest() - self.assertNotEqual(first_revision, second_revision) - self.assertNotEqual(first_revision, plaintext_hash) - self.assertNotEqual(second_revision, plaintext_hash) - - def test_wrapper_rejects_arbitrary_commands(self) -> None: - """Never pass the decrypted pull credential to an arbitrary process.""" - result = self._run_ksail_pull_wrapper( - self._valid_config(), - ("workload", "delete"), - ) - - self.assertEqual(result.returncode, 64) - self.assertFalse(self.ksail_token_capture.exists()) - - def test_plaintext_revision_source_fails_closed(self) -> None: - """Refuse to hash a source value that is not SOPS ciphertext.""" - self._write_encrypted_secret("accidentally-plaintext") - - result = self._run_ksail_pull_wrapper( - self._valid_config(), - ("cluster", "update"), - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.ksail_token_capture.exists()) - - def test_accepts_standard_auth_only_docker_config(self) -> None: - """Accept Docker configs that store only the standard encoded auth field.""" - auth = base64.b64encode(b"devantler:fixture-secret-token").decode() - config = {"auths": {"ghcr.io": {"auth": auth}}} - - result = self._run_helper(config) - - self.assertEqual(result.returncode, 0, result.stderr) - patch = json.loads(self.patch_capture.read_text(encoding="utf-8")) - encoded = patch["data"][".dockerconfigjson"] - self.assertEqual(json.loads(base64.b64decode(encoded)), config) - - def test_accepts_matching_explicit_and_encoded_auth(self) -> None: - """Accept matching explicit and encoded GHCR credentials.""" - config = self._valid_config() - registry_auth = config["auths"]["ghcr.io"] - registry_auth["auth"] = base64.b64encode( - b"devantler:fixture-secret-token" - ).decode() - - result = self._run_helper(config) - - self.assertEqual(result.returncode, 0, result.stderr) - - def test_rejects_unsafe_drain_timeout_before_cluster_access(self) -> None: - """Keep the timeout value from becoming an injected kubectl option.""" - result = self._run_helper( - self._valid_config(), - FLUX_GHCR_DRAIN_TIMEOUT="45m --disable-eviction", - ) - - self.assertEqual(result.returncode, 64) - self.assertFalse(self.kubectl_called.exists()) - self.assertFalse(self.patch_capture.exists()) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_check_only_preflights_without_patching(self) -> None: - """Keep registry preflight mode free of Kubernetes mutations.""" - result = self._run_helper(self._valid_config(), ("--check-only",)) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertFalse(self.kubectl_called.exists()) - self.assertFalse(self.patch_capture.exists()) - - def test_missing_or_malformed_registry_auth_fails_closed(self) -> None: - """Reject missing, malformed, empty, or contradictory GHCR credentials.""" - invalid_configs: list[object] = [ - {"auths": {"ghcr.io": {"username": "devantler"}}}, - {"auths": {"ghcr.io": {"username": "", "password": "token"}}}, - {"auths": {"ghcr.io": {"auth": "not-base64"}}}, - { - "auths": { - "ghcr.io": { - "username": "devantler", - "password": "fixture-secret-token", - "auth": base64.b64encode( - b"devantler:different-token" - ).decode(), - } - } - }, - {"auths": {}}, - "not-a-docker-config", - ] - - for config in invalid_configs: - with self.subTest(config=config): - result = self._run_helper(config) - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.kubectl_called.exists()) - - def test_registry_denial_prevents_cluster_patch(self) -> None: - """Leave cluster credentials untouched when GHCR denies a manifest read.""" - result = self._run_helper( - self._valid_config(), - FAKE_CURL_DENY_REPOSITORY="devantler-tech/platform/manifests", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.kubectl_called.exists()) - - def test_token_success_without_registry_read_access_prevents_cluster_patch( - self, - ) -> None: - """Require package read access even when the token exchange succeeds.""" - result = self._run_helper( - self._valid_config(), - FAKE_CURL_DENY_REPOSITORY="devantler-tech/wedding-app", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.kubectl_called.exists()) - - def test_cluster_patch_failure_is_not_hidden(self) -> None: - """Propagate a failure to patch the root Flux pull Secret.""" - result = self._run_helper(self._valid_config(), FAKE_KUBECTL_FAIL="true") - - self.assertEqual(result.returncode, 43) - self.assertTrue(self.kubectl_called.exists()) - - def test_fresh_cluster_without_variables_base_skips_existing_fanout(self) -> None: - """Bootstrap only root auth before a fresh cluster creates its fan-out.""" - result = self._run_helper( - self._valid_config(), - ("--allow-incomplete-fanout",), - FAKE_VARIABLES_BASE_ABSENT="true", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(self.patch_capture.exists()) - self.assertFalse(self.variables_patch_capture.exists()) - self.assertFalse(self.fanout_log.exists()) - - def test_missing_variables_base_fails_closed_without_bootstrap_mode(self) -> None: - """Require the explicit DR flag when the credential fan-out is absent.""" - result = self._run_helper( - self._valid_config(), - FAKE_VARIABLES_BASE_ABSENT="true", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.patch_capture.exists()) - - def test_partial_bootstrap_repairs_root_without_forcing_missing_fanout( - self, - ) -> None: - """Stage DR root auth without force-syncing an incomplete fan-out.""" - missing_resources = [ - "pushsecret/flux-system/seed-ghcr", - "externalsecret/wedding-app/ghcr-auth", - "externalsecret/ascoachingogvaner/ghcr-auth", - "externalsecret/kyverno/ghcr-auth", - ] - for resource in missing_resources: - with self.subTest(resource=resource): - result = self._run_helper( - self._valid_config(), - ("--allow-incomplete-fanout",), - FAKE_MISSING_FANOUT_RESOURCE=resource, - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(self.variables_patch_capture.exists()) - self.assertTrue(self.patch_capture.exists()) - self.assertFalse(self.fanout_log.exists()) - self.assertIn("first reconcile will complete", result.stdout) - self.assertEqual( - self.operation_log.read_text(encoding="utf-8").splitlines()[-3:], - ["root-patch", "variables-patch", "root-patch"], - ) - - def test_partial_bootstrap_repairs_root_before_staging_variables(self) -> None: - """Keep an unavailable root patch from advancing partial consumers.""" - result = self._run_helper( - self._valid_config(), - ("--allow-incomplete-fanout",), - FAKE_MISSING_FANOUT_RESOURCE="pushsecret/flux-system/seed-ghcr", - FAKE_KUBECTL_FAIL="true", - ) - - self.assertEqual(result.returncode, 43) - self.assertFalse(self.variables_patch_capture.exists()) - - def test_partial_fanout_fails_closed_without_bootstrap_mode(self) -> None: - """Reject a partial fan-out during a normal production deployment.""" - result = self._run_helper( - self._valid_config(), - FAKE_MISSING_FANOUT_RESOURCE="externalsecret/kyverno/ghcr-auth", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.variables_patch_capture.exists()) - self.assertFalse(self.patch_capture.exists()) - self.assertFalse(self.fanout_log.exists()) - - def test_missing_eso_crds_fails_without_staging_variables(self) -> None: - """Leave the fan-out source unchanged when normal mode is incomplete.""" - result = self._run_helper( - self._valid_config(), - FAKE_FANOUT_CRDS_ABSENT="true", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse(self.variables_patch_capture.exists()) - self.assertFalse(self.patch_capture.exists()) - - def test_partial_bootstrap_without_eso_crds_repairs_root(self) -> None: - """Permit DR root repair before External Secrets CRDs exist.""" - result = self._run_helper( - self._valid_config(), - ("--allow-incomplete-fanout",), - FAKE_FANOUT_CRDS_ABSENT="true", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(self.variables_patch_capture.exists()) - self.assertTrue(self.patch_capture.exists()) - self.assertFalse(self.fanout_log.exists()) - - def test_pushsecret_sync_failure_is_not_hidden(self) -> None: - """Keep root auth unchanged when the OpenBao seed does not reconcile.""" - result = self._run_helper( - self._valid_config(), - FAKE_SYNC_STALL_RESOURCE="pushsecret/flux-system/seed-ghcr", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertFalse( - self.patch_capture.exists(), - "root Flux auth must remain unchanged until fan-out verifies", - ) - self.assertTrue(self.variables_patch_capture.exists()) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - def test_same_second_sync_accepts_controller_resource_version_edge(self) -> None: - """Accept a controller update when refreshTime has one-second precision.""" - result = self._run_helper( - self._valid_config(), - FAKE_SYNC_SAME_REFRESH_TIME="true", - ) - - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue(self.patch_capture.exists()) - - def test_materialised_consumer_mismatch_is_not_hidden(self) -> None: - """Reject fan-out completion when a workload Secret has stale content.""" - result = self._run_helper( - self._valid_config(), - FAKE_CONSUMER_MISMATCH_NAMESPACE="wedding-app", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn( - "wedding-app/ghcr-auth did not materialise", - result.stdout + result.stderr, - ) - self.assertFalse( - self.patch_capture.exists(), - "root Flux auth must remain unchanged until consumers match", - ) - self.assertNotIn("fixture-secret-token", result.stdout + result.stderr) - - -class DeployActionOrderingTests(unittest.TestCase): - """Keep credential refreshes on both sides of KSail-owned mutations.""" - - def test_cluster_lifecycle_uses_sops_auth_but_publish_keeps_actions_token( - self, - ) -> None: - """Separate KSail pull lifecycle auth from the Actions publish token.""" - action = ACTION.read_text(encoding="utf-8") - workflow = DR_REBUILD.read_text(encoding="utf-8") - wrapper = "./scripts/run-ksail-prod-with-pull-auth.sh" - - action_reconcile = action.index("id: reconcile") - action_update = action.index("name: 🔄 Update cluster") - action_reassert = action.index("id: reassert_flux_ghcr_auth") - self.assertIn( - f"run: {wrapper} workload reconcile", - action[action_reconcile:action_update], - ) - self.assertNotIn("GHCR_TOKEN:", action[action_reconcile:action_update]) - self.assertIn( - f"run: {wrapper} cluster update", - action[action_update:action_reassert], - ) - self.assertNotIn("GHCR_TOKEN:", action[action_update:action_reassert]) - - action_push = action.index("name: 📦 Push manifests to GHCR") - action_sign = action.index("name: ⚙️ Install cosign") - self.assertIn( - f"run: {wrapper} workload push", - action[action_push:action_sign], - ) - self.assertIn( - "GHCR_TOKEN: ${{ inputs.ghcr-token }}", - action[action_push:action_sign], - ) - - dr_create = workflow.index("name: 🏗️ Create cluster") - dr_stage = workflow.index("id: stage_flux_ghcr_auth") - self.assertIn( - f"run: {wrapper} cluster create", - workflow[dr_create:dr_stage], - ) - self.assertNotIn("GHCR_TOKEN:", workflow[dr_create:dr_stage]) - - dr_push = workflow.index("name: 📦 Push manifests to GHCR") - dr_verify = workflow.index("id: verify_flux_ghcr_auth_after_push") - self.assertIn( - f"run: {wrapper} workload push", - workflow[dr_push:dr_verify], - ) - self.assertIn( - "GHCR_TOKEN: ${{ secrets.GHCR_TOKEN }}", - workflow[dr_push:dr_verify], - ) - - dr_reconcile = workflow.index("name: 🔁 Trigger Flux reconciliation") - dr_wait = workflow.index("name: ⏳ Wait for Flux to settle") - self.assertIn( - f"run: {wrapper} workload reconcile", - workflow[dr_reconcile:dr_wait], - ) - self.assertNotIn("GHCR_TOKEN:", workflow[dr_reconcile:dr_wait]) - - def test_talosctl_is_installed_before_any_mutating_bridge(self) -> None: - """Ensure both isolated Actions jobs can execute node synchronization.""" - action = ACTION.read_text(encoding="utf-8") - workflow = DR_REBUILD.read_text(encoding="utf-8") - - for document in (action, workflow): - with self.subTest(document=document[:40]): - setup = document.index("name: ⚙️ Setup talosctl") - stage = document.index("id: stage_flux_ghcr_auth") - self.assertLess(setup, stage) - setup_step = document[setup:stage] - self.assertIn( - f'TALOS_VERSION: "{KSAIL_PROD_TALOS_VERSION}"', setup_step - ) - self.assertIn("talosctl-linux-amd64", setup_step) - if document == action: - restore = document.index("name: 🔑 Restore talosconfig") - self.assertLess(restore, stage) - - def test_consumer_staging_precedes_publish_and_is_reasserted_after_update( - self, - ) -> None: - """Keep full credential staging before publish and after cluster update.""" - action = ACTION.read_text(encoding="utf-8") - wrapper = "./scripts/run-ksail-prod-with-pull-auth.sh" - - first_refresh = action.index("id: stage_flux_ghcr_auth") - push = action.index(f"run: {wrapper} workload push") - post_push_refresh = action.index("id: verify_flux_ghcr_auth_after_push") - reconcile = action.index("id: reconcile") - cluster_update = action.index(f"run: {wrapper} cluster update") - final_refresh = action.index("id: reassert_flux_ghcr_auth\n") - - self.assertLess(first_refresh, push) - self.assertLess(push, post_push_refresh) - self.assertLess(post_push_refresh, reconcile) - self.assertLess(reconcile, cluster_update) - self.assertLess(cluster_update, final_refresh) - self.assertIn( - "run: ./scripts/refresh-flux-ghcr-auth.sh\n", - action[first_refresh:push], - ) - self.assertNotIn( - "--check-only", - action[first_refresh:push], - ) - self.assertIn( - "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only", - action[post_push_refresh:reconcile], - ) - final_refresh_step = action[final_refresh:] - self.assertIn("always() &&", final_refresh_step) - self.assertIn( - "steps.verify_flux_ghcr_auth_after_push.outcome == 'success'", - final_refresh_step, - ) - self.assertIn( - "steps.reconcile.outcome == 'success'", - final_refresh_step, - ) - self.assertEqual(action.count("scripts/refresh-flux-ghcr-auth.sh"), 3) - - def test_disaster_rebuild_preflights_then_stages_before_publish( - self, - ) -> None: - """Keep DR credential checks around publish and OpenBao restoration.""" - workflow = DR_REBUILD.read_text(encoding="utf-8") - wrapper = "./scripts/run-ksail-prod-with-pull-auth.sh" - - preflight = workflow.index( - "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only" - ) - cluster_create = workflow.index(f"run: {wrapper} cluster create") - stage = workflow.index("id: stage_flux_ghcr_auth") - push = workflow.index(f"run: {wrapper} workload push") - verify = workflow.index("id: verify_flux_ghcr_auth_after_push") - fanout_verify = workflow.index("id: verify_flux_ghcr_fanout") - openbao_restore = workflow.index( - "name: 🔐 Restore OpenBao from the R2 snapshot mirror" - ) - post_restore_verify = workflow.index( - "id: reassert_flux_ghcr_after_restore" - ) - reconcile = workflow.index(f"run: {wrapper} workload reconcile") - - self.assertLess(preflight, cluster_create) - self.assertLess(cluster_create, stage) - self.assertLess(stage, push) - self.assertLess(push, verify) - self.assertLess(verify, reconcile) - self.assertLess(reconcile, fanout_verify) - self.assertLess(fanout_verify, openbao_restore) - self.assertLess(openbao_restore, post_restore_verify) - self.assertIn( - "run: ./scripts/refresh-flux-ghcr-auth.sh --allow-incomplete-fanout", - workflow[stage:push], - ) - self.assertIn( - "run: ./scripts/refresh-flux-ghcr-auth.sh --check-only", - workflow[verify:reconcile], - ) - self.assertIn( - "run: ./scripts/refresh-flux-ghcr-auth.sh\n", - workflow[fanout_verify:], - ) - self.assertIn( - "if: ${{ !cancelled() && inputs.restore && " - "steps.verify_flux_ghcr_fanout.outcome == 'success' }}", - workflow[post_restore_verify:], - ) - self.assertEqual(workflow.count("scripts/refresh-flux-ghcr-auth.sh"), 5) - - def test_manual_dr_waits_for_flux_before_full_bridge(self) -> None: - """Keep bootstrap mode until every first-reconcile layer is Ready.""" - runbook = DR_RUNBOOK.read_text(encoding="utf-8") - ci_workflow = CI_WORKFLOW.read_text(encoding="utf-8") - manual = runbook[ - runbook.index("# 2. Prove the Git/SOPS pull credential") : - runbook.index("# 6. ONLY if the OpenBao raft-snapshot") - ] - - bootstrap = manual.index( - "./scripts/refresh-flux-ghcr-auth.sh --allow-incomplete-fanout" - ) - reconcile = manual.index( - "./scripts/run-ksail-prod-with-pull-auth.sh workload reconcile" - ) - wait = manual.index( - "kubectl --context admin@prod -n flux-system wait" - ) - full_bridge = manual.index( - "./scripts/refresh-flux-ghcr-auth.sh # prove completed fan-out" - ) - - self.assertLess(bootstrap, reconcile) - self.assertLess(reconcile, wait) - self.assertLess(wait, full_bridge) - self.assertIn( - "workload reconciliation also requires the SOPS key", - runbook, - ) - self.assertIn("- 'docs/dr/runbook.md'", ci_workflow) - - def test_bridge_docs_and_tests_validate_without_deploying(self) -> None: - """Keep validation-only bridge changes out of production deploys.""" - workflow = CI_WORKFLOW.read_text(encoding="utf-8") - filters = workflow[ - workflow.index(" filters: |") : workflow.index("\n validate:") - ] - k8s_filter = filters[ - filters.index(" k8s:") : filters.index( - " bridge_validation:" - ) - ] - bridge_filter = filters[ - filters.index(" bridge_validation:") : filters.index( - " talos:" - ) - ] - - for validation_only_path in ( - "scripts/tests/test-refresh-flux-ghcr-auth-safety.sh", - "scripts/tests/test_refresh_flux_ghcr_auth.py", - "docs/dr/runbook.md", - ): - with self.subTest(path=validation_only_path): - quoted_path = f"- '{validation_only_path}'" - self.assertNotIn(quoted_path, k8s_filter) - self.assertIn(quoted_path, bridge_filter) - - self.assertIn( - "bridge_validation: ${{ steps.filter.outputs.bridge_validation }}", - workflow, - ) - validate = workflow[ - workflow.index(" validate:") : workflow.index(" naming:") - ] - self.assertIn( - "needs.changes.outputs.bridge_validation == 'true'", validate - ) - deploy = workflow[ - workflow.index(" deploy-prod:") : workflow.index( - " heal-prod-on-failure:" - ) - ] - heal = workflow[ - workflow.index(" heal-prod-on-failure:") : workflow.index( - " ci-required-checks:" - ) - ] - for production_job in (deploy, heal): - self.assertIn("needs.changes.outputs.k8s == 'true'", production_job) - self.assertNotIn("bridge_validation", production_job) - - -class ManualBridgePrerequisiteTests(unittest.TestCase): - """Keep manual recovery dependencies explicit and fail-fast.""" - - def test_yq_v4_is_documented_and_preflighted(self) -> None: - """Require the YAML tool used before any GHCR recovery mutation.""" - instructions = AGENT_INSTRUCTIONS.read_text(encoding="utf-8") - runbook = DR_RUNBOOK.read_text(encoding="utf-8") - library = GHCR_AUTH_LIB.read_text(encoding="utf-8") - - self.assertIn("yq v4", instructions) - self.assertIn("yq v4", runbook[: runbook.index("## Scenario 1")]) - self.assertIn("require_flux_ghcr_yaml_tool()", library) - for entrypoint in (HELPER, KSAIL_PULL_WRAPPER): - with self.subTest(entrypoint=entrypoint.name): - self.assertIn( - "require_flux_ghcr_yaml_tool", - entrypoint.read_text(encoding="utf-8"), - ) - - def test_yaml_tool_preflight_rejects_missing_or_incompatible_yq(self) -> None: - """Stop before entrypoint work when Mike Farah yq v4 is unavailable.""" - cases = ("missing", "incompatible") - for case in cases: - with self.subTest(case=case), tempfile.TemporaryDirectory() as temp_dir: - if case == "incompatible": - fake_yq = Path(temp_dir) / "yq" - fake_yq.write_text( - "#!/bin/bash\necho 'yq 3.4.3'\n", encoding="utf-8" - ) - fake_yq.chmod(0o755) - environment = os.environ.copy() - environment["PATH"] = temp_dir - - result = subprocess.run( - [ - "/bin/bash", - "-c", - 'source "$1"; require_flux_ghcr_yaml_tool', - "bash", - str(GHCR_AUTH_LIB), - ], - env=environment, - text=True, - capture_output=True, - check=False, - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("yq v4 is required", result.stderr) - - -class RequiredPackageCoverageTests(unittest.TestCase): - """Keep pinned private provider references in the live GHCR preflight.""" - - def test_provider_upjet_unifi_reference_is_preflighted(self) -> None: - """Require the live private provider package in the GHCR preflight.""" - manifest = PROVIDER_UPJET_UNIFI.read_text(encoding="utf-8") - helper = HELPER.read_text(encoding="utf-8") - - package_line = next( - line.strip() - for line in manifest.splitlines() - if line.strip().startswith("package: ghcr.io/") - ) - package_reference = package_line.removeprefix("package: ghcr.io/") - - self.assertIn(f'"{package_reference}"', helper) - - def test_exact_declared_ksail_operator_image_is_preflighted(self) -> None: - """Bind the GHCR preflight to the chart's exact KSail image version.""" - helper = HELPER.read_text(encoding="utf-8") - - self.assertIn( - '"devantler-tech/ksail:v${KSAIL_OPERATOR_VERSION}"', helper - ) - self.assertIn(".spec.chart.spec.version", helper) - self.assertTrue(KSAIL_OPERATOR_VERSION) - - -class TalosRegistryAuthConfigTests(unittest.TestCase): - """Keep lifecycle drift and post-pull verification states distinct.""" - - def test_static_desired_revision_cannot_claim_verified_pull(self) -> None: - """Require helper proof before a new Talos node is considered current.""" - static_revision = TALOS_GHCR_REVISION.read_text(encoding="utf-8") - static_config = "\n".join( - line - for line in static_revision.splitlines() - if not line.lstrip().startswith("#") - ) - helper = HELPER.read_text(encoding="utf-8") - - self.assertIn("ghcr-pull-desired-revision", static_config) - self.assertNotIn("ghcr-pull-verified-revision", static_config) - self.assertIn("ghcr-pull-verified-revision", helper) - - def test_registry_auth_uses_supported_talos_document(self) -> None: - """Use one standalone RegistryAuthConfig document with variable auth.""" - registry_auth = TALOS_GHCR_AUTH.read_text(encoding="utf-8") - - self.assertIn("kind: RegistryAuthConfig", registry_auth) - self.assertIn("username: ${GHCR_USERNAME}", registry_auth) - self.assertIn("password: ${GHCR_TOKEN}", registry_auth) - self.assertNotIn("machine:\n", registry_auth) - self.assertNotIn("\n---\n", registry_auth) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/test_validate_alert_coverage.py b/scripts/tests/test_validate_alert_coverage.py deleted file mode 100644 index a9906125e..000000000 --- a/scripts/tests/test_validate_alert_coverage.py +++ /dev/null @@ -1,217 +0,0 @@ -"""Behavioral tests for reconciliation-Alert namespace coverage.""" - -from __future__ import annotations - -import os -from pathlib import Path -import shutil -import subprocess -import tempfile -import textwrap -import unittest - - -ROOT = Path(__file__).resolve().parents[2] -VALIDATOR = ROOT / "scripts" / "validate-alert-coverage.sh" -CI_WORKFLOW = ROOT / ".github" / "workflows" / "ci.yaml" -LAYERS = ( - "k8s/clusters/prod", - "k8s/providers/hetzner/bootstrap", - "k8s/providers/hetzner/infrastructure/controllers", - "k8s/providers/hetzner/infrastructure", - "k8s/providers/hetzner/apps", -) - - -class ValidateAlertCoverageTests(unittest.TestCase): - """Exercise the validator against isolated Kustomize fixtures.""" - - def setUp(self) -> None: - """Create a minimal repository-shaped fixture for every test.""" - self.temp_dir = tempfile.TemporaryDirectory() - self.addCleanup(self.temp_dir.cleanup) - self.workspace = Path(self.temp_dir.name) - script = self.workspace / "scripts" / VALIDATOR.name - script.parent.mkdir(parents=True) - shutil.copy2(VALIDATOR, script) - self.script = script - - for index, layer in enumerate(LAYERS): - self._write_layer(layer, index) - self._write_alert(wildcard=True) - - def _write_layer(self, relative_path: str, index: int) -> None: - """Write one valid layer containing both watched Flux kinds.""" - layer = self.workspace / relative_path - layer.mkdir(parents=True, exist_ok=True) - (layer / "kustomization.yaml").write_text( - textwrap.dedent( - """\ - apiVersion: kustomize.config.k8s.io/v1beta1 - kind: Kustomization - resources: - - resources.yaml - """ - ), - encoding="utf-8", - ) - (layer / "resources.yaml").write_text( - textwrap.dedent( - f"""\ - apiVersion: helm.toolkit.fluxcd.io/v2 - kind: HelmRelease - metadata: - name: release-{index} - namespace: flux-system - spec: {{}} - --- - apiVersion: kustomize.toolkit.fluxcd.io/v1 - kind: Kustomization - metadata: - name: layer-{index} - namespace: flux-system - spec: {{}} - """ - ), - encoding="utf-8", - ) - - def _write_alert(self, *, wildcard: bool) -> None: - """Write an Alert with wildcard or resource-specific event sources.""" - alert = ( - self.workspace - / "k8s" - / "providers" - / "hetzner" - / "infrastructure" - / "flux-notifications" - / "alert.yaml" - ) - alert.parent.mkdir(parents=True, exist_ok=True) - name = "*" if wildcard else "one-resource" - alert.write_text( - textwrap.dedent( - f"""\ - apiVersion: notification.toolkit.fluxcd.io/v1beta3 - kind: Alert - metadata: - name: reconciliation - namespace: flux-system - spec: - eventSources: - - kind: HelmRelease - name: "{name}" - namespace: flux-system - - kind: Kustomization - name: "{name}" - namespace: flux-system - """ - ), - encoding="utf-8", - ) - - def _run_validator( - self, **environment_overrides: str - ) -> subprocess.CompletedProcess[str]: - """Run the copied validator with the real local kubectl and yq.""" - environment = os.environ.copy() - environment.update(environment_overrides) - return subprocess.run( - ["bash", str(self.script)], - cwd=self.workspace, - env=environment, - check=False, - capture_output=True, - text=True, - ) - - def test_valid_wildcard_alert_covers_every_rendered_namespace(self) -> None: - """Keep the known-good whole-namespace coverage path green.""" - result = self._run_validator() - - self.assertEqual(result.returncode, 0, result.stderr) - - def test_missing_layer_fails_closed_instead_of_being_skipped(self) -> None: - """A missing Kustomization must invalidate the coverage proof.""" - missing = self.workspace / LAYERS[-1] / "kustomization.yaml" - missing.unlink() - - result = self._run_validator() - - self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn(LAYERS[-1], result.stdout + result.stderr) - - def test_named_event_source_does_not_cover_its_whole_namespace(self) -> None: - """Only name '*' proves coverage for every resource in a namespace.""" - self._write_alert(wildcard=False) - - result = self._run_validator() - - self.assertNotEqual(result.returncode, 0, result.stdout + result.stderr) - self.assertIn("does not watch every namespace", result.stdout + result.stderr) - - def test_watched_resource_without_namespace_fails_closed(self) -> None: - """Reject malformed namespaced Flux resources instead of dropping them.""" - resource_file = self.workspace / LAYERS[0] / "resources.yaml" - for kind, name in ( - ("HelmRelease", "release-0"), - ("Kustomization", "layer-0"), - ): - with self.subTest(kind=kind): - self._write_layer(LAYERS[0], 0) - resources = resource_file.read_text(encoding="utf-8") - namespaced = ( - f"kind: {kind}\nmetadata:\n" - f" name: {name}\n namespace: flux-system" - ) - self.assertIn(namespaced, resources) - resource_file.write_text( - resources.replace( - namespaced, - f"kind: {kind}\nmetadata:\n name: {name}", - 1, - ), - encoding="utf-8", - ) - - result = self._run_validator() - - output = result.stdout + result.stderr - self.assertNotEqual(result.returncode, 0, output) - self.assertIn("missing metadata.namespace", output) - self.assertIn(kind, output) - self.assertIn(name, output) - - def test_yq_diagnostics_remain_visible_on_query_failure(self) -> None: - """Keep the parser's own reason when a fail-closed query errors.""" - bin_dir = self.workspace / "bin" - bin_dir.mkdir() - yq = bin_dir / "yq" - yq.write_text( - "#!/usr/bin/env bash\n" - "echo fixture-yq-query-diagnostic >&2\n" - "exit 72\n", - encoding="utf-8", - ) - yq.chmod(0o755) - - result = self._run_validator( - PATH=f"{bin_dir}:{os.environ['PATH']}", - ) - - self.assertNotEqual(result.returncode, 0) - self.assertIn("fixture-yq-query-diagnostic", result.stderr) - - def test_ci_runs_the_behavioral_regressions_when_they_change(self) -> None: - """Keep the validator's failure-mode coverage in the required CI job.""" - workflow = CI_WORKFLOW.read_text(encoding="utf-8") - - self.assertIn("'scripts/tests/test_validate_alert_coverage.py'", workflow) - self.assertIn( - "python3 -m unittest scripts.tests.test_validate_alert_coverage", - workflow, - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/scripts/tests/validate-alert-coverage/validator_test.go b/scripts/tests/validate-alert-coverage/validator_test.go new file mode 100644 index 000000000..5361b66bc --- /dev/null +++ b/scripts/tests/validate-alert-coverage/validator_test.go @@ -0,0 +1,315 @@ +package validatealertcoverage + +import ( + "bytes" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +var ( + repositoryRoot = findRepositoryRoot() + validatorPath = filepath.Join(repositoryRoot, "scripts", "validate-alert-coverage.sh") + ciWorkflowPath = filepath.Join(repositoryRoot, ".github", "workflows", "ci.yaml") +) + +var layers = []string{ + "k8s/clusters/prod", + "k8s/providers/hetzner/bootstrap", + "k8s/providers/hetzner/infrastructure/controllers", + "k8s/providers/hetzner/infrastructure", + "k8s/providers/hetzner/apps", +} + +type validatorFixture struct { + workspace string + script string +} + +type commandResult struct { + exitCode int + stdout string + stderr string +} + +func TestValidWildcardAlertCoversEveryRenderedNamespace(t *testing.T) { + fixture := newValidatorFixture(t) + + result := fixture.runValidator(t, nil) + + if result.exitCode != 0 { + t.Fatalf("validator exit code = %d, want 0; stderr = %q", result.exitCode, result.stderr) + } +} + +func TestMissingLayerFailsClosedInsteadOfBeingSkipped(t *testing.T) { + fixture := newValidatorFixture(t) + missingLayer := layers[len(layers)-1] + if err := os.Remove(filepath.Join(fixture.workspace, missingLayer, "kustomization.yaml")); err != nil { + t.Fatalf("remove missing-layer fixture: %v", err) + } + + result := fixture.runValidator(t, nil) + output := result.stdout + result.stderr + + if result.exitCode == 0 { + t.Fatalf("validator unexpectedly succeeded; output = %q", output) + } + if !strings.Contains(output, missingLayer) { + t.Fatalf("validator output = %q, want missing layer %q", output, missingLayer) + } +} + +func TestNamedEventSourceDoesNotCoverItsWholeNamespace(t *testing.T) { + fixture := newValidatorFixture(t) + fixture.writeAlert(t, false) + + result := fixture.runValidator(t, nil) + output := result.stdout + result.stderr + + if result.exitCode == 0 { + t.Fatalf("validator unexpectedly succeeded; output = %q", output) + } + if !strings.Contains(output, "does not watch every namespace") { + t.Fatalf("validator output = %q, want uncovered-namespace diagnostic", output) + } +} + +func TestWatchedResourceWithoutNamespaceFailsClosed(t *testing.T) { + tests := []struct { + kind string + name string + }{ + {kind: "HelmRelease", name: "release-0"}, + {kind: "Kustomization", name: "layer-0"}, + } + + for _, tt := range tests { + t.Run(tt.kind, func(t *testing.T) { + fixture := newValidatorFixture(t) + resourcePath := filepath.Join(fixture.workspace, layers[0], "resources.yaml") + resources, err := os.ReadFile(resourcePath) + if err != nil { + t.Fatalf("read resource fixture: %v", err) + } + + namespaced := fmt.Sprintf( + "kind: %s\nmetadata:\n name: %s\n namespace: flux-system", + tt.kind, + tt.name, + ) + if !bytes.Contains(resources, []byte(namespaced)) { + t.Fatalf("resource fixture does not contain %q", namespaced) + } + withoutNamespace := fmt.Sprintf("kind: %s\nmetadata:\n name: %s", tt.kind, tt.name) + resources = bytes.Replace(resources, []byte(namespaced), []byte(withoutNamespace), 1) + writeFile(t, resourcePath, string(resources), 0o600) + + result := fixture.runValidator(t, nil) + output := result.stdout + result.stderr + + if result.exitCode == 0 { + t.Fatalf("validator unexpectedly succeeded; output = %q", output) + } + for _, want := range []string{"missing metadata.namespace", tt.kind, tt.name} { + if !strings.Contains(output, want) { + t.Fatalf("validator output = %q, want %q", output, want) + } + } + }) + } +} + +func TestYQDiagnosticsRemainVisibleOnQueryFailure(t *testing.T) { + fixture := newValidatorFixture(t) + binDir := filepath.Join(fixture.workspace, "bin") + if err := os.Mkdir(binDir, 0o700); err != nil { + t.Fatalf("create fixture bin directory: %v", err) + } + yqPath := filepath.Join(binDir, "yq") + writeFile( + t, + yqPath, + "#!/usr/bin/env bash\necho fixture-yq-query-diagnostic >&2\nexit 72\n", + 0o700, + ) + + result := fixture.runValidator(t, map[string]string{ + "PATH": binDir + string(os.PathListSeparator) + os.Getenv("PATH"), + }) + + if result.exitCode == 0 { + t.Fatal("validator unexpectedly succeeded") + } + if !strings.Contains(result.stderr, "fixture-yq-query-diagnostic") { + t.Fatalf("validator stderr = %q, want yq diagnostic", result.stderr) + } +} + +func TestCIRunsBehavioralRegressionsWhenTheyChange(t *testing.T) { + workflow, err := os.ReadFile(ciWorkflowPath) + if err != nil { + t.Fatalf("read CI workflow: %v", err) + } + + for _, want := range []string{ + "'scripts/tests/validate-alert-coverage/**'", + "go test ./scripts/tests/validate-alert-coverage", + } { + if !bytes.Contains(workflow, []byte(want)) { + t.Fatalf("CI workflow does not contain %q", want) + } + } +} + +func findRepositoryRoot() string { + _, filename, _, ok := runtime.Caller(0) + if !ok { + panic("locate alert coverage test file") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", "..", "..")) +} + +func newValidatorFixture(t *testing.T) validatorFixture { + t.Helper() + + workspace := t.TempDir() + script := filepath.Join(workspace, "scripts", filepath.Base(validatorPath)) + if err := os.MkdirAll(filepath.Dir(script), 0o700); err != nil { + t.Fatalf("create fixture scripts directory: %v", err) + } + validator, err := os.ReadFile(validatorPath) + if err != nil { + t.Fatalf("read validator: %v", err) + } + writeFile(t, script, string(validator), 0o700) + + fixture := validatorFixture{workspace: workspace, script: script} + for index, layer := range layers { + fixture.writeLayer(t, layer, index) + } + fixture.writeAlert(t, true) + return fixture +} + +func (fixture validatorFixture) writeLayer(t *testing.T, relativePath string, index int) { + t.Helper() + + layer := filepath.Join(fixture.workspace, filepath.FromSlash(relativePath)) + if err := os.MkdirAll(layer, 0o700); err != nil { + t.Fatalf("create fixture layer: %v", err) + } + writeFile(t, filepath.Join(layer, "kustomization.yaml"), `apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - resources.yaml +`, 0o600) + writeFile(t, filepath.Join(layer, "resources.yaml"), fmt.Sprintf(`apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: release-%d + namespace: flux-system +spec: {} +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: layer-%d + namespace: flux-system +spec: {} +`, index, index), 0o600) +} + +func (fixture validatorFixture) writeAlert(t *testing.T, wildcard bool) { + t.Helper() + + name := "one-resource" + if wildcard { + name = "*" + } + alertPath := filepath.Join( + fixture.workspace, + "k8s", + "providers", + "hetzner", + "infrastructure", + "flux-notifications", + "alert.yaml", + ) + if err := os.MkdirAll(filepath.Dir(alertPath), 0o700); err != nil { + t.Fatalf("create fixture alert directory: %v", err) + } + writeFile(t, alertPath, fmt.Sprintf(`apiVersion: notification.toolkit.fluxcd.io/v1beta3 +kind: Alert +metadata: + name: reconciliation + namespace: flux-system +spec: + eventSources: + - kind: HelmRelease + name: %q + namespace: flux-system + - kind: Kustomization + name: %q + namespace: flux-system +`, name, name), 0o600) +} + +func (fixture validatorFixture) runValidator(t *testing.T, environmentOverrides map[string]string) commandResult { + t.Helper() + + command := exec.Command("bash", fixture.script) + command.Dir = fixture.workspace + command.Env = environmentWithOverrides(environmentOverrides) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + + err := command.Run() + exitCode := 0 + if err != nil { + var exitError *exec.ExitError + if !errors.As(err, &exitError) { + t.Fatalf("run validator: %v", err) + } + exitCode = exitError.ExitCode() + } + + return commandResult{ + exitCode: exitCode, + stdout: stdout.String(), + stderr: stderr.String(), + } +} + +func environmentWithOverrides(overrides map[string]string) []string { + environment := os.Environ() + for key, value := range overrides { + prefix := key + "=" + replaced := false + for index, item := range environment { + if strings.HasPrefix(item, prefix) { + environment[index] = prefix + value + replaced = true + break + } + } + if !replaced { + environment = append(environment, prefix+value) + } + } + return environment +} + +func writeFile(t *testing.T, path string, contents string, permissions os.FileMode) { + t.Helper() + if err := os.WriteFile(path, []byte(contents), permissions); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} From bd2c1f22ead19bb8b08045166202fbfdc4942730 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 06:45:59 +0200 Subject: [PATCH 14/22] fix: address GHCR rollout review findings --- scripts/refresh-flux-ghcr-auth.sh | 4 ++ .../fake_commands_test.go | 8 +++ .../fake_contracts_test.go | 65 +++++++++++++++++++ .../rollout_core_test.go | 5 ++ .../refresh-flux-ghcr-auth/wrapper_test.go | 3 - 5 files changed, 82 insertions(+), 3 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index fa85e9d69..344075f6f 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -244,6 +244,8 @@ verify_ghcr_pull_credential() { reference="${target##*:}" if ! http_status="$(curl --disable \ --config "${basic_config}" \ + --connect-timeout 10 \ + --max-time 60 \ --silent \ --show-error \ --output "${token_file}" \ @@ -271,6 +273,8 @@ verify_ghcr_pull_credential() { if ! http_status="$(curl --disable \ --config "${bearer_config}" \ + --connect-timeout 10 \ + --max-time 60 \ --silent \ --show-error \ --output /dev/null \ diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go index 4ab38ac48..5329cba88 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go @@ -84,10 +84,15 @@ func fakeCurl(args []string) int { return commandFailure(90, "curl must disable user config") } var configPath, outputPath, scope, requestURL string + var connectTimeout, maxTime string for index := 1; index < len(args); { switch args[index] { case "--config": configPath = requiredNext(args, &index) + case "--connect-timeout": + connectTimeout = requiredNext(args, &index) + case "--max-time": + maxTime = requiredNext(args, &index) case "--output": outputPath = requiredNext(args, &index) case "--data-urlencode": @@ -108,6 +113,9 @@ func fakeCurl(args []string) int { return commandFailure(90, "unexpected curl argument: %s", args[index]) } } + if connectTimeout != "10" || maxTime != "60" { + return commandFailure(90, "curl requires 10-second connect and 60-second total timeouts") + } config := mustReadCommandFile(configPath) if outputPath == "" || requestURL == "" { return commandFailure(90, "incomplete curl invocation") diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go index 9b9eabf64..8fe42b256 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_contracts_test.go @@ -101,6 +101,71 @@ func TestFakeCurlRequiresAnchoredUserConfig(t *testing.T) { } } +func TestFakeCurlRequiresBoundedTimeouts(t *testing.T) { + tests := []struct { + name string + config string + args []string + }{ + { + name: "token exchange", + config: "user = fixture:token\n", + args: []string{ + "--disable", + "--config", "config", + "--output", "response", + "--data-urlencode", "scope=repository:devantler-tech/ksail:pull", + "--write-out", "%{http_code}", + "--silent", + "--show-error", + "--get", + "https://ghcr.io/token", + }, + }, + { + name: "manifest read", + config: "header = \"Authorization: Bearer fixture-registry-token\"\n", + args: []string{ + "--disable", + "--config", "config", + "--output", "/dev/null", + "--write-out", "%{http_code}", + "--header", "Accept: application/vnd.oci.image.manifest.v1+json", + "--silent", + "--show-error", + "https://ghcr.io/v2/devantler-tech/ksail/manifests/latest", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + workspace := t.TempDir() + configPath := filepath.Join(workspace, "curl.config") + responsePath := filepath.Join(workspace, "response.json") + t.Setenv("REGISTRY_READ_LOG", filepath.Join(workspace, "registry-reads.log")) + if err := os.WriteFile(configPath, []byte(test.config), 0o600); err != nil { + t.Fatalf("write curl config: %v", err) + } + args := append([]string(nil), test.args...) + for index, argument := range args { + switch argument { + case "config": + args[index] = configPath + case "response": + args[index] = responsePath + } + } + + exitCode := fakeCurl(args) + + if exitCode == 0 { + t.Fatal("fake curl accepted an unbounded registry request") + } + }) + } +} + func setFakeKSailEnvironment(t *testing.T, workspace string) { t.Helper() for _, name := range []string{ diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go index a314f5729..a129ca803 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go @@ -189,6 +189,11 @@ func TestUnhealthyControlPlaneBlocksTheControlPlaneReboot(t *testing.T) { "platform.devantler.tech/ghcr-pull-verified-revision-v2": f.expectedRevision(), "platform.devantler.tech/ghcr-pull-verified-image-v2": ksailTargetImage, }), + nodeFixture("prod-control-plane-3", "prod-control-plane-3-uid", "10.0.0.4", true, ready, + map[string]any{ + "platform.devantler.tech/ghcr-pull-verified-revision-v2": f.expectedRevision(), + "platform.devantler.tech/ghcr-pull-verified-image-v2": ksailTargetImage, + }), }} result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_JSON": encodeJSON(inventory)}) requireFailureResult(t, result) diff --git a/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go b/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go index eeba6f01b..668002ab9 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/wrapper_test.go @@ -57,7 +57,6 @@ func TestKSailLifecycleWrapperUsesOnlySOPSPullToken(t *testing.T) { {"cluster", "update"}, } for _, command := range commands { - command := command t.Run(strings.Join(command, " "), func(t *testing.T) { f := newFixture(t) result := f.runKSailPullWrapper(validConfig(), command, nil) @@ -277,7 +276,6 @@ func TestMissingOrMalformedRegistryAuthFailsClosed(t *testing.T) { } for _, test := range tests { - test := test t.Run(test.name, func(t *testing.T) { f := newFixture(t) result := f.runHelper(test.config, nil, nil) @@ -353,7 +351,6 @@ func TestPartialBootstrapRepairsRootWithoutForcingMissingFanout(t *testing.T) { "externalsecret/kyverno/ghcr-auth", } for _, resource := range missingResources { - resource := resource t.Run(resource, func(t *testing.T) { f := newFixture(t) result := f.runHelper( From 14f2c2a7514d076b0d9e7bc7b4312ad17e83528b Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 07:41:28 +0200 Subject: [PATCH 15/22] fix: retry transient runtime probe admission --- scripts/refresh-flux-ghcr-auth.sh | 41 ++++++++++++--- .../fake_kubectl_test.go | 33 ++++++++++++ .../rollout_convergence_test.go | 51 +++++++++++++++++++ 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 344075f6f..1021037e7 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -39,6 +39,7 @@ readonly SYNC_ATTEMPTS="${FLUX_GHCR_SYNC_ATTEMPTS:-60}" readonly SYNC_INTERVAL="${FLUX_GHCR_SYNC_INTERVAL:-2}" readonly TALOS_CONVERGENCE_ATTEMPTS="${FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS:-${SYNC_ATTEMPTS}}" readonly DRAIN_TIMEOUT="${FLUX_GHCR_DRAIN_TIMEOUT:-45m}" +readonly RUNTIME_PROBE_CREATE_ATTEMPTS=3 readonly CORDON_OWNER_ANNOTATION="platform.devantler.tech/ghcr-auth-drain-owner" readonly CORDON_OWNER_JSON_PATH="/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" KSAIL_OPERATOR_VERSION="$(yq -er '.spec.chart.spec.version' \ @@ -364,7 +365,8 @@ probe_node_runtime_pull() { local node_name="$1" local probe_image="$2" local probe_name - local attempt image_id waiting_reason + local attempt create_attempt image_id waiting_reason + local probe_created=0 runtime_probe_sequence=$((runtime_probe_sequence + 1)) probe_name="ghcr-runtime-probe-$$-${RANDOM}-${runtime_probe_sequence}" @@ -416,12 +418,37 @@ probe_node_runtime_pull() { ' > "${runtime_probe_manifest_file}" active_runtime_probe="${probe_name}" - if ! kubectl \ - --context "${KUBE_CONTEXT}" \ - --namespace ksail-operator \ - create --filename "${runtime_probe_manifest_file}" \ - -o name \ - > "${runtime_probe_result_file}" 2>&1; then + for ((create_attempt = 1; create_attempt <= RUNTIME_PROBE_CREATE_ATTEMPTS; create_attempt++)); do + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + create --filename "${runtime_probe_manifest_file}" \ + -o name \ + > "${runtime_probe_result_file}" 2>&1; then + probe_created=1 + break + fi + + # A timed-out admission response is ambiguous: the API server may have + # persisted the Pod after the client stopped waiting. Reuse that exact + # named probe when it exists; otherwise retry the same immutable manifest. + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + get pod "${probe_name}" \ + -o name \ + >/dev/null 2>&1; then + probe_created=1 + break + fi + + if ((create_attempt < RUNTIME_PROBE_CREATE_ATTEMPTS)); then + echo "::warning::Runtime pull probe admission failed on ${node_name} (attempt ${create_attempt}/${RUNTIME_PROBE_CREATE_ATTEMPTS}); retrying the same target." + sleep "${SYNC_INTERVAL}" + fi + done + + if ((probe_created == 0)); then echo "::error::Could not create a kubelet/containerd GHCR pull probe on ${node_name}; refusing to drain onto an unproved runtime." emit_safe_operation_output "runtime-probe-create" \ "${runtime_probe_result_file}" diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index 48ff7f391..cfe3d28bc 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -474,6 +474,39 @@ func fakeKubectlCreateRuntimeProbe(namespace, manifestFile string) int { if probeName == "" || probeNode == "" { return commandFailure(91, "runtime probe name or node missing") } + if wordListContains( + os.Getenv("FAKE_RUNTIME_PROBE_CREATE_PERSIST_THEN_TIMEOUT_ONCE_NODES"), + probeNode, + ) { + attemptMarker := "runtime-probe-create-attempts-" + probeNode + attempt := parseInt(markerContent(attemptMarker), 0) + 1 + setMarkerContent(attemptMarker, strconv.Itoa(attempt)) + if attempt == 1 { + setMarkerContent("runtime-probe-"+probeName, probeNode+"\n"+image+"\n") + + return commandFailure( + 75, + "Error from server (InternalError): failed calling webhook: context deadline exceeded", + ) + } + } + if wordListContains( + os.Getenv("FAKE_RUNTIME_PROBE_CREATE_TIMEOUT_ONCE_NODES"), + probeNode, + ) && !markerExists("runtime-probe-create-timeout-once-"+probeNode) { + touchMarker("runtime-probe-create-timeout-once-" + probeNode) + + return commandFailure( + 75, + "Error from server (InternalError): failed calling webhook: context deadline exceeded", + ) + } + if wordListContains(os.Getenv("FAKE_RUNTIME_PROBE_CREATE_ALWAYS_FAIL_NODES"), probeNode) { + return commandFailure( + 75, + "Error from server (InternalError): failed calling webhook: context deadline exceeded", + ) + } setMarkerContent("runtime-probe-"+probeName, probeNode+"\n"+image+"\n") fmt.Printf("pod/%s\n", probeName) return 0 diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index 3137ad611..45aa6da27 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -1,6 +1,7 @@ package refreshfluxghcrauth import ( + "path/filepath" "strings" "testing" ) @@ -193,6 +194,56 @@ func TestRuntimeProbeRejectsInjectedImagePullSecret(t *testing.T) { requireNoLine(t, operations, "root-patch") } +func TestRuntimeProbeRetriesTransientAdmissionTimeout(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_RUNTIME_PROBE_CREATE_TIMEOUT_ONCE_NODES": "prod-control-plane-2", + }) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-drain:prod-worker-1") + requireLine(t, operations, "root-patch") +} + +func TestRuntimeProbeReusesPersistedPodAfterAmbiguousAdmissionTimeout(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_RUNTIME_PROBE_CREATE_PERSIST_THEN_TIMEOUT_ONCE_NODES": "prod-control-plane-2", + }) + requireSuccessResult(t, result) + attempts := strings.TrimSpace(mustRead(filepath.Join( + f.syncStateDir, + "runtime-probe-create-attempts-prod-control-plane-2", + ))) + // This node receives one probe per private image. A third create would mean + // the first, already-persisted Pod was retried instead of reused. + if attempts != "2" { + t.Fatalf("persisted runtime probe create attempts = %s, want 2", attempts) + } + staleProbes, err := filepath.Glob(filepath.Join( + f.syncStateDir, + "runtime-probe-ghcr-runtime-probe-*", + )) + if err != nil { + t.Fatalf("find stale runtime probes: %v", err) + } + if len(staleProbes) != 0 { + t.Fatalf("persisted runtime probes not cleaned up: %v", staleProbes) + } +} + +func TestRuntimeProbePersistentAdmissionTimeoutFailsClosed(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_RUNTIME_PROBE_CREATE_ALWAYS_FAIL_NODES": "prod-control-plane-2", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "Could not create a kubelet/containerd GHCR pull probe") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + func TestEachPrivateRuntimePackageACLMustPass(t *testing.T) { for _, image := range []string{ "ghcr.io/devantler-tech/wedding-app:latest", From 821f8fb93f5167fce9116d92104a0b42783eb9fd Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 09:54:38 +0200 Subject: [PATCH 16/22] fix(deploy): bootstrap stale runtimes safely --- scripts/refresh-flux-ghcr-auth.sh | 387 ++++++++++++++++-- .../fake_kubectl_test.go | 85 +++- .../rollout_convergence_test.go | 114 +++++- 3 files changed, 531 insertions(+), 55 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 1021037e7..d9611c378 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -83,6 +83,12 @@ work_dir="$(mktemp -d)" chmod 700 "${work_dir}" umask 077 active_runtime_probe="" +bootstrap_cordon_dir="${work_dir}/bootstrap-cordons" +bootstrap_retain_dir="${work_dir}/bootstrap-retain" +bootstrap_ordered_targets="${work_dir}/bootstrap-ordered-targets.tsv" +bootstrap_overlap_result="${work_dir}/bootstrap-overlap-result.txt" +bootstrap_seed_uid="" +mkdir -p "${bootstrap_cordon_dir}" "${bootstrap_retain_dir}" cleanup_refresh_work() { if [[ -n "${active_runtime_probe}" ]]; then @@ -94,6 +100,7 @@ cleanup_refresh_work() { --wait=false \ >/dev/null 2>&1 || true fi + cleanup_bootstrap_quarantine || true rm -rf "${work_dir}" } trap cleanup_refresh_work EXIT @@ -133,6 +140,7 @@ runtime_probe_manifest_file="${work_dir}/runtime-probe-pod.json" runtime_probe_state_file="${work_dir}/runtime-probe-state.json" runtime_probe_result_file="${work_dir}/runtime-probe-result.txt" runtime_probe_sequence=0 +runtime_probe_bootstrap_needed=0 # Force an ESO resource to reconcile and observe a post-annotation Ready edge. force_sync_resource() { @@ -486,11 +494,17 @@ probe_node_runtime_pull() { '.status.containerStatuses[0].state.waiting.reason // ""' \ "${runtime_probe_state_file}")" case "${waiting_reason}" in - ErrImagePull|ImagePullBackOff|InvalidImageName) + ErrImagePull|ImagePullBackOff) + runtime_probe_bootstrap_needed=1 delete_runtime_pull_probe "${probe_name}" || true echo "::error::The running containerd on ${node_name} could not pull ${probe_image} (${waiting_reason}); refusing to drain workloads onto peers with unproved runtime auth." return 1 ;; + InvalidImageName) + delete_runtime_pull_probe "${probe_name}" || true + echo "::error::The runtime probe image ${probe_image} was invalid on ${node_name}; refusing to treat that as stale credential evidence." + return 1 + ;; esac sleep "${SYNC_INTERVAL}" done @@ -532,6 +546,7 @@ verify_peer_runtime_pull_overlap() { return 1 fi if [[ ! -s "${runtime_probe_targets_file}" ]]; then + runtime_probe_bootstrap_needed=1 echo "::error::No Ready schedulable peer can receive workloads while ${draining_node} reboots; refusing the drain." return 1 fi @@ -558,14 +573,16 @@ verify_peer_runtime_pull_overlap() { # already-cordoned node must replace the annotation to express new ownership. claim_node_cordon_ownership() { local node_name="$1" owner_token="$2" state_file="$3" result_file="$4" - local resource_version + local resource_version node_uid resource_version="$(jq -er '.metadata.resourceVersion' "${state_file}")" + node_uid="$(jq -er '.metadata.uid' "${state_file}")" if jq -e '.metadata.annotations | type == "object"' \ "${state_file}" >/dev/null; then jq -n \ --arg owner_path "${CORDON_OWNER_JSON_PATH}" \ --arg owner "${owner_token}" \ + --arg uid "${node_uid}" \ --arg resource_version "${resource_version}" ' [ { @@ -573,6 +590,7 @@ claim_node_cordon_ownership() { path: "/metadata/resourceVersion", value: $resource_version }, + {op: "test", path: "/metadata/uid", value: $uid}, {op: "add", path: $owner_path, value: $owner}, {op: "add", path: "/spec/unschedulable", value: true} ] @@ -581,6 +599,7 @@ claim_node_cordon_ownership() { jq -n \ --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ --arg owner "${owner_token}" \ + --arg uid "${node_uid}" \ --arg resource_version "${resource_version}" ' [ { @@ -588,6 +607,7 @@ claim_node_cordon_ownership() { path: "/metadata/resourceVersion", value: $resource_version }, + {op: "test", path: "/metadata/uid", value: $uid}, { op: "add", path: "/metadata/annotations", @@ -648,9 +668,11 @@ restore_node_schedulability_if_needed() { jq -n \ --arg path "${CORDON_OWNER_JSON_PATH}" \ --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" \ --arg resource_version "${current_resource_version}" ' [ {op: "test", path: $path, value: $owner}, + {op: "test", path: "/metadata/uid", value: $uid}, { op: "test", path: "/metadata/resourceVersion", @@ -674,6 +696,233 @@ restore_node_schedulability_if_needed() { echo "Restored schedulability on ${node_name}." } +# Rollback only bootstrap cordons still owned by this invocation. A node that +# reached the reboot edge stays cordoned on uncertainty, matching the normal +# fail-closed path. Missing ownership means the node was already restored or a +# newer actor took over; neither case is ours to reverse. +cleanup_bootstrap_quarantine() { + local state_file node_name was_cordoned owner_token initial_uid + local initial_taints current_owner + + [[ -d "${bootstrap_cordon_dir:-}" ]] || return 0 + for state_file in "${bootstrap_cordon_dir}"/*.json; do + [[ -e "${state_file}" ]] || continue + node_name="$(jq -er '.nodeName' "${state_file}")" || continue + was_cordoned="$(jq -er '.wasCordoned' "${state_file}")" || continue + if [[ "${was_cordoned}" == "1" ]]; then + rm -f "${state_file}" + continue + fi + if [[ -e "${bootstrap_retain_dir}/${node_name}" ]]; then + continue + fi + owner_token="$(jq -er '.ownerToken' "${state_file}")" || continue + initial_uid="$(jq -er '.initialUID' "${state_file}")" || continue + initial_taints="$(jq -c '.initialTaints' "${state_file}")" || continue + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2>/dev/null; then + continue + fi + current_owner="$(jq -r \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + '.metadata.annotations[$owner_annotation] // ""' \ + "${cordon_state_file}")" + if [[ "${current_owner}" != "${owner_token}" ]]; then + [[ -z "${current_owner}" ]] && rm -f "${state_file}" + continue + fi + if restore_node_schedulability_if_needed \ + "${node_name}" 0 "${owner_token}" \ + "${initial_uid}" "${initial_taints}" \ + "${drain_result_file}"; then + rm -f "${state_file}" + fi + done +} + +node_has_no_evictable_workloads() { + local node_name="$1" + local pods_file="${work_dir}/bootstrap-pods-${node_name}.json" + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get pods \ + --all-namespaces \ + --field-selector "spec.nodeName=${node_name}" \ + -o json > "${pods_file}"; then + echo "::error::Could not inspect workloads on bootstrap candidate ${node_name}; refusing to infer that it is empty." + return 2 + fi + jq -e ' + all(.items[]?; + (.status.phase == "Succeeded" or .status.phase == "Failed") + or ((.metadata.annotations["kubernetes.io/config.mirror"] // "") != "") + or any(.metadata.ownerReferences[]?; .kind == "DaemonSet")) + ' "${pods_file}" >/dev/null +} + +node_is_ready_workload_destination() { + local state_file="$1" + local expected_uid="$2" + + jq -e \ + --arg uid "${expected_uid}" ' + .metadata.uid == $uid + and .metadata.deletionTimestamp == null + and ((.spec.unschedulable // false) == false) + and any(.status.conditions[]?; + .type == "Ready" and .status == "True") + and all(.spec.taints[]?; + .effect != "NoSchedule" and .effect != "NoExecute") + ' "${state_file}" >/dev/null +} + +# When the previous host credential is already revoked, no stale runtime can +# receive an eviction. Use the platform's empty warm worker as a seed: atomically +# cordon every stale target first, reboot the empty workload-schedulable seed, +# then release nodes one by one only after their runtime pull proof succeeds. +# If the warm-spare contract is not currently satisfied, make no destructive +# progress and leave the pre-existing scheduling state intact. +prepare_runtime_bootstrap_roll() { + local desired_revision="$1" + local pending_targets_file="$2" + local node_role node_name node_ip node_mode node_uid + local seed_line="" state_file was_cordoned owner_token existing_owner + local initial_taints bootstrap_owner + local workload_rc + + bootstrap_seed_uid="" + : > "${bootstrap_ordered_targets}" + + while IFS=$'\t' read -r \ + node_role node_name node_ip node_mode node_uid; do + [[ "${node_mode}" == "reboot" ]] || continue + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json > "${cordon_state_file}"; then + echo "::error::Could not inspect bootstrap candidate ${node_name}; refusing the all-stale rollout." + return 1 + fi + if ! selected_node_identity_is_current \ + "${cordon_state_file}" "${node_name}" "${node_uid}" \ + "${node_ip}" "${node_role}"; then + echo "::error::Bootstrap candidate ${node_name} changed identity; refusing the all-stale rollout." + return 1 + fi + if ! node_is_ready_workload_destination \ + "${cordon_state_file}" "${node_uid}"; then + continue + fi + if node_has_no_evictable_workloads "${node_name}"; then + bootstrap_seed_uid="${node_uid}" + seed_line="${node_role}"$'\t'"${node_name}"$'\t'"${node_ip}"$'\t'"${node_mode}"$'\t'"${node_uid}" + break + else + workload_rc=$? + ((workload_rc == 1)) || return "${workload_rc}" + fi + done < "${pending_targets_file}" + + if [[ -z "${bootstrap_seed_uid}" ]]; then + echo "::error::All eligible runtimes use the stale GHCR credential and no empty workload-schedulable node is available to seed the refresh; refusing to drain any workload." + return 1 + fi + + bootstrap_owner="bootstrap-${desired_revision:0:12}-$$-${RANDOM}" + while IFS=$'\t' read -r \ + node_role node_name node_ip node_mode node_uid; do + [[ "${node_mode}" == "reboot" ]] || continue + state_file="${bootstrap_cordon_dir}/${node_name}.json" + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json > "${cordon_state_file}"; then + echo "::error::Could not capture scheduling state for stale node ${node_name}; refusing the bootstrap roll." + return 1 + fi + if ! selected_node_identity_is_current \ + "${cordon_state_file}" "${node_name}" "${node_uid}" \ + "${node_ip}" "${node_role}"; then + echo "::error::Stale node ${node_name} changed identity before bootstrap quarantine." + return 1 + fi + if ! jq -e \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" ' + (.metadata.resourceVersion | type == "string" and length > 0) + and ((.spec.unschedulable // false) | type == "boolean") + and ((.metadata.annotations[$owner_annotation] // "") + | type == "string") + and ((.spec.taints // []) | type == "array") + ' "${cordon_state_file}" >/dev/null; then + echo "::error::Scheduling state for stale node ${node_name} was malformed; refusing bootstrap quarantine." + return 1 + fi + if ! existing_owner="$(jq -er \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + '.metadata.annotations[$owner_annotation] // ""' \ + "${cordon_state_file}")"; then + echo "::error::Could not read GHCR bridge ownership for stale node ${node_name}." + return 1 + fi + if [[ -n "${existing_owner}" ]]; then + echo "::error::Stale node ${node_name} already has a GHCR bridge owner; refusing concurrent bootstrap quarantine." + return 1 + fi + if ! initial_taints="$(jq -ecS ' + (.spec.taints // []) + | map(select(( + .key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")]) + ' "${cordon_state_file}")"; then + echo "::error::Could not normalize scheduling taints for stale node ${node_name}." + return 1 + fi + if jq -e '.spec.unschedulable == true' \ + "${cordon_state_file}" >/dev/null; then + was_cordoned=1 + owner_token="" + else + was_cordoned=0 + owner_token="${bootstrap_owner}" + fi + if ! jq -n \ + --arg node_name "${node_name}" \ + --arg owner_token "${owner_token}" \ + --arg initial_uid "${node_uid}" \ + --argjson was_cordoned "${was_cordoned}" \ + --argjson initial_taints "${initial_taints}" ' + { + nodeName: $node_name, + ownerToken: $owner_token, + initialUID: $initial_uid, + wasCordoned: $was_cordoned, + initialTaints: $initial_taints + } + ' > "${state_file}"; then + echo "::error::Could not persist bootstrap ownership state for stale node ${node_name}." + return 1 + fi + if [[ "${was_cordoned}" == "0" ]] \ + && ! claim_node_cordon_ownership \ + "${node_name}" "${owner_token}" \ + "${cordon_state_file}" "${drain_result_file}"; then + return 1 + fi + done < "${pending_targets_file}" + + printf '%s\n' "${seed_line}" > "${bootstrap_ordered_targets}" + awk -F '\t' -v seed_uid="${bootstrap_seed_uid}" \ + '$5 != seed_uid' "${pending_targets_file}" \ + >> "${bootstrap_ordered_targets}" +} + # Close every post-cordon scheduling race. A drain, reboot, readiness wait, or # image proof can outlive an operator/autoscaler change, so re-read before each # destructive Talos edge and fail closed when the captured guard no longer holds. @@ -758,6 +1007,7 @@ process_talos_node_target() { local node_uid="$7" local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" local initial_node_uid="" initial_node_taints="[]" + local bootstrap_state_file="${bootstrap_cordon_dir}/${node_name}.json" if [[ "${node_mode}" != "reboot" && "${node_mode}" != "image-only" ]]; then echo "::error::Unknown Talos GHCR synchronization mode '${node_mode}' for ${node_name}." @@ -849,32 +1099,60 @@ process_talos_node_target() { echo "::error::Refusing to synchronize ${node_name}: its identity changed or scheduling state was malformed." return 1 fi - initial_node_uid="$(jq -r '.metadata.uid' "${cordon_state_file}")" - initial_node_taints="$(jq -cS ' - (.spec.taints // []) - | map(select(( - .key == "node.kubernetes.io/unschedulable" - and .effect == "NoSchedule" - and (.value // "") == "" - ) | not)) - | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")]) - ' "${cordon_state_file}")" - existing_cordon_owner="$(jq -r \ - --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ - '.metadata.annotations[$owner_annotation] // ""' \ - "${cordon_state_file}")" - if [[ -n "${existing_cordon_owner}" ]]; then - echo "::error::Refusing to synchronize ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." - return 1 - fi - if jq -e '.spec.unschedulable == true' \ - "${cordon_state_file}" >/dev/null; then - was_cordoned=1 + if [[ -f "${bootstrap_state_file}" ]]; then + if ! jq -e \ + --arg node_name "${node_name}" \ + --arg node_uid "${node_uid}" ' + .nodeName == $node_name + and .initialUID == $node_uid + and (.ownerToken | type == "string") + and (.wasCordoned == 0 or .wasCordoned == 1) + and (.initialTaints | type == "array") + ' "${bootstrap_state_file}" >/dev/null; then + echo "::error::Bootstrap ownership state for ${node_name} was malformed; refusing the mutation." + return 1 + fi + initial_node_uid="$(jq -er '.initialUID' "${bootstrap_state_file}")" + initial_node_taints="$(jq -c '.initialTaints' "${bootstrap_state_file}")" + was_cordoned="$(jq -er '.wasCordoned' "${bootstrap_state_file}")" + cordon_owner_token="$(jq -er '.ownerToken' "${bootstrap_state_file}")" + if ! node_scheduling_state_is_safe_to_reboot \ + "${cordon_state_file}" \ + "${was_cordoned}" \ + "${cordon_owner_token}" \ + "${initial_node_uid}" \ + "${initial_node_taints}"; then + echo "::error::Bootstrap quarantine ownership or scheduling state changed for ${node_name}; refusing the mutation." + return 1 + fi else - cordon_owner_token="${desired_revision:0:16}-$$-${RANDOM}" - claim_node_cordon_ownership \ - "${node_name}" "${cordon_owner_token}" \ - "${cordon_state_file}" "${drain_result_file}" || return 1 + initial_node_uid="$(jq -r '.metadata.uid' "${cordon_state_file}")" + initial_node_taints="$(jq -cS ' + (.spec.taints // []) + | map(select(( + .key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")]) + ' "${cordon_state_file}")" + existing_cordon_owner="$(jq -r \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + '.metadata.annotations[$owner_annotation] // ""' \ + "${cordon_state_file}")" + if [[ -n "${existing_cordon_owner}" ]]; then + echo "::error::Refusing to synchronize ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." + return 1 + fi + if jq -e '.spec.unschedulable == true' \ + "${cordon_state_file}" >/dev/null; then + was_cordoned=1 + else + cordon_owner_token="${desired_revision:0:16}-$$-${RANDOM}" + claim_node_cordon_ownership \ + "${node_name}" "${cordon_owner_token}" \ + "${cordon_state_file}" "${drain_result_file}" || return 1 + fi fi if [[ "${node_mode}" == "reboot" ]]; then @@ -926,6 +1204,9 @@ process_talos_node_target() { # The node is now cordoned and fully drained under PDB control, so a plain # Talos reboot cannot terminate a workload behind Kubernetes' back. Keep # --wait explicit so Kubernetes readiness is checked only after a new boot. + if [[ -f "${bootstrap_state_file}" ]]; then + : > "${bootstrap_retain_dir}/${node_name}" + fi if ! talosctl \ --nodes "${node_ip}" \ reboot \ @@ -986,6 +1267,7 @@ process_talos_node_target() { # Restore original scheduling intent only after the uncached pull succeeds. # On failure, a cordon claimed by this bridge remains as a fail-closed signal # that the node must not receive new pods until registry access is repaired. + rm -f "${bootstrap_retain_dir}/${node_name}" restore_node_schedulability_if_needed \ "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ "${initial_node_uid}" "${initial_node_taints}" \ @@ -1049,6 +1331,7 @@ sync_talos_registry_auth() { local consecutive_clean_inventories=0 local processed_any_node=0 local node_role node_name node_ip node_mode node_uid + local batch_targets_file first_reboot_name bootstrap_mode : > "${talos_result_file}" : > "${drain_result_file}" @@ -1118,12 +1401,37 @@ sync_talos_registry_auth() { fi else consecutive_clean_inventories=0 - # A valid previous credential is the safety bridge for the first drain: - # not-yet-rebooted peers must still be able to pull evicted workloads. - # Re-prove overlap for every newly discovered reboot batch. + batch_targets_file="${talos_pending_targets}" + bootstrap_mode=0 + # Prefer direct peer-runtime overlap: it avoids batch-wide quarantine + # while every possible destination can still pull. A revoked root Secret + # does not outweigh that stronger live proof. Only a stale/no-peer result + # enters the owned warm-spare bootstrap; admission and probe-integrity + # errors remain immediate fail-closed outcomes. if awk -F '\t' '$4 == "reboot" { found = 1 } END { exit !found }' \ "${talos_pending_targets}"; then - verify_current_root_credential_overlap || return 1 + first_reboot_name="$(awk -F '\t' '$4 == "reboot" { print $2; exit }' \ + "${talos_pending_targets}")" + : > "${bootstrap_overlap_result}" + runtime_probe_bootstrap_needed=0 + verify_current_root_credential_overlap \ + >> "${bootstrap_overlap_result}" 2>&1 || true + if ! verify_peer_runtime_pull_overlap "${first_reboot_name}" \ + >> "${bootstrap_overlap_result}" 2>&1; then + if ((runtime_probe_bootstrap_needed == 0)); then + emit_safe_operation_output \ + "runtime-overlap" "${bootstrap_overlap_result}" + return 1 + fi + if ! prepare_runtime_bootstrap_roll \ + "${desired_revision}" "${talos_pending_targets}"; then + emit_safe_operation_output \ + "runtime-overlap" "${bootstrap_overlap_result}" + return 1 + fi + bootstrap_mode=1 + batch_targets_file="${bootstrap_ordered_targets}" + fi fi # Targets are sorted workers-first and processed strictly sequentially, @@ -1131,8 +1439,16 @@ sync_talos_registry_auth() { while IFS=$'\t' read -r \ node_role node_name node_ip node_mode node_uid; do if [[ "${node_mode}" == "reboot" ]]; then - verify_peer_runtime_pull_overlap \ - "${node_name}" || return 1 + if ((bootstrap_mode == 1)) \ + && [[ "${node_uid}" == "${bootstrap_seed_uid}" ]]; then + if ! node_has_no_evictable_workloads "${node_name}"; then + echo "::error::Bootstrap seed ${node_name} gained an evictable workload before its reboot; refusing the roll." + return 1 + fi + else + verify_peer_runtime_pull_overlap \ + "${node_name}" || return 1 + fi fi process_talos_node_target \ "${desired_revision}" \ @@ -1142,9 +1458,12 @@ sync_talos_registry_auth() { "${node_ip}" \ "${node_mode}" \ "${node_uid}" || return 1 + rm -f \ + "${bootstrap_cordon_dir}/${node_name}.json" \ + "${bootstrap_retain_dir}/${node_name}" processed_any_node=1 printf '%s\n' "${node_uid}" >> "${talos_processed_targets}" - done < "${talos_pending_targets}" + done < "${batch_targets_file}" fi if ((convergence_attempt < TALOS_CONVERGENCE_ATTEMPTS)); then diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index cfe3d28bc..53876704d 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -33,6 +33,8 @@ func fakeKubectlImplementation(args []string) int { switch { case containsSequence(args, "get", "nodes"): return fakeKubectlGetNodes() + case containsSequence(args, "get", "pods"): + return fakeKubectlGetPods(args) case containsSequence(args, "get", "node"): return fakeKubectlGetNode(args) case containsArg(args, "drain"): @@ -99,6 +101,34 @@ func fakeKubectlGetNodes() int { fakeInventoryNode("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", "198.51.100.3", true, revision, revision, image, false), fakeInventoryNode("prod-control-plane-3", "prod-control-plane-3-uid", "10.0.0.4", "198.51.100.4", true, revision, revision, image, false), } + if os.Getenv("FAKE_ALL_TALOS_NODES_STALE") == "true" { + for _, node := range nodes { + nodeMap := node.(map[string]any) + metadata := nodeMap["metadata"].(map[string]any) + annotations := metadata["annotations"].(map[string]any) + delete(annotations, "platform.devantler.tech/ghcr-pull-verified-revision-v2") + delete(annotations, "platform.devantler.tech/ghcr-pull-verified-image-v2") + } + } + if bootstrapWorker := os.Getenv("FAKE_BOOTSTRAP_WORKER_NAME"); bootstrapWorker != "" { + verifiedRevision := "" + verifiedWorkerImage := "" + if markerExists("talos-revision-10.0.0.5") { + verifiedRevision = revision + verifiedWorkerImage = image + } + nodes = append(nodes, fakeInventoryNode( + bootstrapWorker, + bootstrapWorker+"-uid", + "10.0.0.5", + "198.51.100.5", + false, + revision, + verifiedRevision, + verifiedWorkerImage, + false, + )) + } if os.Getenv("FAKE_TALOS_NODES_CURRENT") == "true" { setInventoryProof(nodes[0], revision, verifiedImage) setInventoryProof(nodes[1], revision, verifiedImage) @@ -109,6 +139,18 @@ func fakeKubectlGetNodes() int { if markerExists("talos-revision-10.0.0.1") { setInventoryProof(nodes[1], revision, image) } + for _, node := range nodes { + nodeMap := node.(map[string]any) + status := nodeMap["status"].(map[string]any) + addresses := status["addresses"].([]any) + internalIP := addresses[0].(map[string]any)["address"].(string) + if markerExists("talos-revision-" + internalIP) { + setInventoryProof(node, revision, image) + } + if markerExists("talos-reboot-" + internalIP) { + status["conditions"] = []any{map[string]any{"type": "Ready", "status": "True"}} + } + } newNodeName := "" if configured := os.Getenv("FAKE_NODE_APPEARS_AFTER_ROLL"); configured != "" && @@ -175,6 +217,7 @@ func fakeInventoryNode( if !omitReady { status["conditions"] = []any{map[string]any{"type": "Ready", "status": "True"}} } + cordoned := wordListContains(os.Getenv("FAKE_CORDONED_NODES"), name) || markerExists("cordoned-"+name) return map[string]any{ "metadata": map[string]any{ "name": name, @@ -182,10 +225,41 @@ func fakeInventoryNode( "labels": labels, "annotations": annotations, }, + "spec": map[string]any{ + "unschedulable": cordoned, + }, "status": status, } } +func fakeKubectlGetPods(args []string) int { + nodeName := flagValue(args, "--field-selector") + nodeName = strings.TrimPrefix(nodeName, "spec.nodeName=") + if nodeName == "" || (!containsSequence(args, "-o", "json") && !containsArg(args, "-o=json")) { + return commandFailure(91, "pod inventory must select one node as JSON") + } + items := []any{ + map[string]any{ + "metadata": map[string]any{ + "name": "cilium-" + nodeName, + "ownerReferences": []any{map[string]any{"kind": "DaemonSet"}}, + }, + "status": map[string]any{"phase": "Running"}, + }, + } + if !wordListContains(os.Getenv("FAKE_EMPTY_WORKLOAD_NODES"), nodeName) { + items = append(items, map[string]any{ + "metadata": map[string]any{ + "name": "workload-" + nodeName, + "ownerReferences": []any{map[string]any{"kind": "ReplicaSet"}}, + }, + "status": map[string]any{"phase": "Running"}, + }) + } + fmt.Println(encodeJSON(map[string]any{"items": items})) + return 0 +} + func setInventoryProof(node any, revision, image string) { nodeMap := node.(map[string]any) metadata := nodeMap["metadata"].(map[string]any) @@ -264,7 +338,8 @@ func fakeKubectlGetNode(args []string) int { "taints": taints, }, "status": map[string]any{ - "addresses": []any{map[string]any{"type": "InternalIP", "address": nodeIP}}, + "addresses": []any{map[string]any{"type": "InternalIP", "address": nodeIP}}, + "conditions": []any{map[string]any{"type": "Ready", "status": "True"}}, }, } fmt.Println(encodeJSON(node)) @@ -352,6 +427,9 @@ func fakeKubectlPatchNode(args []string, patchFile string) int { patch[0].Path != "/metadata/resourceVersion" || fmt.Sprint(patch[0].Value) != currentResourceVersion { return commandFailure(56, "invalid atomic cordon claim") } + if !hasPatchOperation(patch, "test", "/metadata/uid", nodeName+"-uid") { + return commandFailure(56, "atomic cordon claim omitted node UID") + } setMarkerContent("cordon-owner-"+nodeName, owner) touchMarker("cordoned-" + nodeName) setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) @@ -368,6 +446,7 @@ func fakeKubectlPatchNode(args []string, patchFile string) int { } if len(patch) == 0 || patch[0].Operation != "test" || patch[0].Path != "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" || + !hasPatchOperation(patch, "test", "/metadata/uid", nodeName+"-uid") || !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) || !hasPatchOperation(patch, "add", "/spec/unschedulable", false) || !hasPatchPath(patch, "remove", "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner") { @@ -525,7 +604,9 @@ func fakeKubectlGetRuntimeProbe(args []string) int { pullSecrets = append(pullSecrets, map[string]any{"name": "injected-pull-secret"}) } status := map[string]any{} - if wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_NODES"), probeNode) || + probeIP, _ := fakeNodeAddress(probeNode) + if (wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_NODES"), probeNode) && + !markerExists("talos-reboot-"+probeIP)) || wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_IMAGES"), probeImage) { status["containerStatuses"] = []any{map[string]any{ "state": map[string]any{"waiting": map[string]any{"reason": "ImagePullBackOff"}}, diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index 45aa6da27..84693cb81 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -151,39 +151,115 @@ func lineIndices(lines []string, target string) []int { return result } -func TestRevokedPreviousCredentialBlocksFirstDrain(t *testing.T) { +func TestRevokedPreviousCredentialBootstrapsThroughEmptyWorker(t *testing.T) { f := newFixture(t) - result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true"}) - requireFailureResult(t, result) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + }) + requireSuccessResult(t, result) output := result.stdout + result.stderr - requireContains(t, output, "current root GHCR credential") - operations := readLines(f.operationLog) - requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") - requireNotContains(t, strings.Join(operations, "\n"), "talos-reboot:") - requireNoLine(t, operations, "root-patch") requireNotContains(t, output, "previous-runtime-token") + operations := readLines(f.operationLog) + seedDrain := lineIndex(t, operations, "node-drain:prod-worker-2") + seedReboot := lineIndex(t, operations, "talos-reboot:10.0.0.5") + seedPull := lineIndex(t, operations, "talos-pull:10.0.0.5:"+ksailTargetImage) + seedRelease := lineIndex(t, operations, "node-uncordon:prod-worker-2") + firstWorkloadDrain := lineIndex(t, operations, "node-drain:prod-worker-1") + if seedDrain >= seedReboot || seedReboot >= seedPull || seedPull >= seedRelease || seedRelease >= firstWorkloadDrain { + t.Fatalf("unsafe bootstrap ordering: seed drain=%d reboot=%d pull=%d release=%d workload drain=%d", seedDrain, seedReboot, seedPull, seedRelease, firstWorkloadDrain) + } + for _, nodeName := range []string{ + "prod-worker-1", + "prod-worker-2", + "prod-control-plane-1", + "prod-control-plane-2", + "prod-control-plane-3", + } { + claim := lineIndex(t, operations, "node-claim-cordon:"+nodeName) + if claim >= seedDrain { + t.Fatalf("stale node %s was not quarantined before seed drain: claim=%d drain=%d", nodeName, claim, seedDrain) + } + } + requireLine(t, operations, "root-patch") } -func TestValidRootTokenDoesNotSubstituteForPeerRuntimeProof(t *testing.T) { +func TestAllStaleRuntimesWithoutEmptyWorkerFailClosed(t *testing.T) { f := newFixture(t) - ready := []any{map[string]any{"type": "Ready", "status": "True"}} - inventory := map[string]any{"items": []any{ - nodeFixture("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", false, ready, nil), - nodeFixture("prod-control-plane-1", "prod-control-plane-1-uid", "10.0.0.1", true, ready, nil), - nodeFixture("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", true, ready, nil), - nodeFixture("prod-control-plane-3", "prod-control-plane-3-uid", "10.0.0.5", true, ready, nil), - }} result := f.runHelper(validConfig(), nil, map[string]string{ - "FAKE_NODE_JSON": encodeJSON(inventory), - "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", }) requireFailureResult(t, result) - requireContains(t, result.stdout+result.stderr, "running containerd") + requireContains(t, result.stdout+result.stderr, "no empty workload-schedulable node") operations := readLines(f.operationLog) requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") requireNoLine(t, operations, "root-patch") } +func TestBootstrapFailureBeforeRebootRestoresEveryOwnedCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_TALOS_FAIL_NODE": "10.0.0.5", + "FAKE_TALOS_FAIL_OPERATION": "auth", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireNoLine(t, operations, "talos-reboot:10.0.0.5") + for _, nodeName := range []string{ + "prod-worker-1", + "prod-worker-2", + "prod-control-plane-1", + "prod-control-plane-2", + "prod-control-plane-3", + } { + requireLine(t, operations, "node-uncordon:"+nodeName) + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName)) { + t.Fatalf("pre-reboot bootstrap failure left %s owned-cordoned", nodeName) + } + } +} + +func TestBootstrapPullFailureRetainsOnlyUnprovedSeedCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_TALOS_FAIL_NODE": "10.0.0.5", + "FAKE_TALOS_FAIL_OPERATION": "pull", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.5") + requireNoLine(t, operations, "node-uncordon:prod-worker-2") + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-2")) { + t.Fatal("unproved bootstrap seed did not retain its owned cordon") + } + for _, nodeName := range []string{ + "prod-worker-1", + "prod-control-plane-1", + "prod-control-plane-2", + "prod-control-plane-3", + } { + requireLine(t, operations, "node-uncordon:"+nodeName) + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName)) { + t.Fatalf("unprocessed stale peer %s kept a bootstrap-only cordon", nodeName) + } + } +} + func TestRuntimeProbeRejectsInjectedImagePullSecret(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES": "prod-control-plane-2"}) From f0b1e74e6da6397500511b8f0c6bd8a31a92874d Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 09:39:25 +0200 Subject: [PATCH 17/22] fix: exclude tainted peers from runtime capacity --- scripts/refresh-flux-ghcr-auth.sh | 2 ++ .../rollout_convergence_test.go | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index d9611c378..0e09d8ecb 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -537,6 +537,8 @@ verify_peer_runtime_pull_overlap() { | select(.metadata.name != $draining) | select(.metadata.deletionTimestamp == null) | select((.spec.unschedulable // false) == false) + | select(any(.spec.taints[]?; + .effect == "NoSchedule" or .effect == "NoExecute") | not) | select(any(.status.conditions[]?; .type == "Ready" and .status == "True")) | [.metadata.name, .metadata.uid] diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index 84693cb81..a24584297 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -260,6 +260,39 @@ func TestBootstrapPullFailureRetainsOnlyUnprovedSeedCordon(t *testing.T) { } } +func TestTaintedPeersDoNotCountAsRuntimePullCapacity(t *testing.T) { + f := newFixture(t) + ready := []any{map[string]any{"type": "Ready", "status": "True"}} + worker := nodeFixture("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", false, ready, nil) + controlPlaneOne := nodeFixture("prod-control-plane-1", "prod-control-plane-1-uid", "10.0.0.1", true, ready, nil) + controlPlaneOne["spec"] = map[string]any{ + "unschedulable": false, + "taints": []any{map[string]any{ + "key": "node-role.kubernetes.io/control-plane", + "effect": "NoSchedule", + }}, + } + controlPlaneTwo := nodeFixture("prod-control-plane-2", "prod-control-plane-2-uid", "10.0.0.3", true, ready, nil) + controlPlaneTwo["spec"] = map[string]any{ + "unschedulable": false, + "taints": []any{map[string]any{ + "key": "node-role.kubernetes.io/control-plane", + "effect": "NoExecute", + }}, + } + inventory := map[string]any{"items": []any{worker, controlPlaneOne, controlPlaneTwo}} + + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_NODE_JSON": encodeJSON(inventory), + }) + + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "No Ready schedulable peer") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + func TestRuntimeProbeRejectsInjectedImagePullSecret(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_RUNTIME_PROBE_INJECT_PULL_SECRET_NODES": "prod-control-plane-2"}) From 3f8f917b5ddf82a6e770f207c1f1d449b0bc0260 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 09:41:38 +0200 Subject: [PATCH 18/22] fix(auth): wait for transient node lifecycle taints --- scripts/refresh-flux-ghcr-auth-safety.sh | 51 +++++++++++++++++++ scripts/refresh-flux-ghcr-auth.sh | 47 +++++++++++++++++ .../fake_kubectl_test.go | 19 +++++++ .../rollout_safety_test.go | 46 +++++++++++++++++ 4 files changed, 163 insertions(+) diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh index 25fac08f5..606e6d423 100644 --- a/scripts/refresh-flux-ghcr-auth-safety.sh +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -93,6 +93,57 @@ node_scheduling_state_is_safe_to_reboot() { ' "${state_file}" >/dev/null } +# Kubernetes may briefly retain its Ready-condition lifecycle taints after the +# Ready condition itself turns True. While waiting for those controller-owned +# taints to disappear, preserve every other part of the captured scheduling +# intent exactly; a replacement, ownership change, uncordon, or unrelated taint +# must still fail closed immediately. +node_scheduling_state_is_safe_while_lifecycle_taints_clear() { + local state_file="$1" + local was_cordoned="$2" + local owner_token="$3" + local initial_node_uid="$4" + local initial_node_taints="$5" + + jq -e \ + --arg owner_annotation \ + "platform.devantler.tech/ghcr-auth-drain-owner" \ + --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" \ + --argjson was_cordoned "${was_cordoned}" \ + --argjson initial_taints "${initial_node_taints}" ' + def scheduling_taints: + map(select(( + (.key == "node.kubernetes.io/unschedulable" + and .effect == "NoSchedule" + and (.value // "") == "") + or .key == "node.kubernetes.io/not-ready" + or .key == "node.kubernetes.io/unreachable" + ) | not)) + | sort_by([.key, .effect, (.value // ""), (.timeAdded // "")]); + .metadata.uid == $uid + and .metadata.deletionTimestamp == null + and .spec.unschedulable == true + and (if $was_cordoned == 0 then + .metadata.annotations[$owner_annotation] == $owner + else + (.metadata.annotations[$owner_annotation] // "") == "" + end) + and (((.spec.taints // []) | scheduling_taints) + == ($initial_taints | scheduling_taints)) + ' "${state_file}" >/dev/null +} + +node_has_lifecycle_taints() { + local state_file="$1" + + jq -e ' + any(.spec.taints[]?; + .key == "node.kubernetes.io/not-ready" + or .key == "node.kubernetes.io/unreachable") + ' "${state_file}" >/dev/null +} + # Bind a selected node name to the same UID, InternalIP, and role immediately # before any Talos API mutation. Names and addresses can be reused when an # autoscaler replaces a node between inventory reads; the immutable UID keeps diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index 0e09d8ecb..fbac0d0a9 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -960,6 +960,49 @@ revalidate_node_scheduling_guard() { fi } +wait_for_node_lifecycle_taints_to_clear() { + local node_name="$1" was_cordoned="$2" owner_token="$3" + local initial_node_uid="$4" initial_node_taints="$5" result_file="$6" + local selected_node_ip="$7" selected_node_role="$8" + local attempt + + for ((attempt = 1; attempt <= SYNC_ATTEMPTS; attempt++)); do + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2> "${result_file}"; then + echo "::error::Could not re-read Talos node ${node_name} while waiting for its post-reboot lifecycle taints to clear; refusing image verification." + emit_safe_operation_output "lifecycle-taint-read" "${result_file}" + return 1 + fi + if ! selected_node_identity_is_current \ + "${cordon_state_file}" \ + "${node_name}" \ + "${initial_node_uid}" \ + "${selected_node_ip}" \ + "${selected_node_role}" \ + || ! node_scheduling_state_is_safe_while_lifecycle_taints_clear \ + "${cordon_state_file}" \ + "${was_cordoned}" \ + "${owner_token}" \ + "${initial_node_uid}" \ + "${initial_node_taints}"; then + echo "::error::Talos node ${node_name} identity changed, cordon ownership changed, or non-lifecycle scheduling safety state changed while waiting for its post-reboot lifecycle taints to clear; refusing image verification." + return 1 + fi + if ! node_has_lifecycle_taints "${cordon_state_file}"; then + return 0 + fi + if ((attempt < SYNC_ATTEMPTS)); then + sleep "${SYNC_INTERVAL}" + fi + done + + echo "::error::Timed out waiting for Talos node ${node_name} post-reboot lifecycle taints to clear; it remains cordoned and image verification was not attempted." + return 1 +} + # Talos returns gRPC NotFound with the exact image reference when that image is # already absent from the selected runtime namespace. Match both so transport, # authorization, and unrelated removal failures remain fatal. @@ -1229,6 +1272,10 @@ process_talos_node_target() { emit_safe_operation_output "ready" "${reboot_result_file}" return 1 fi + wait_for_node_lifecycle_taints_to_clear \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${reboot_result_file}" "${node_ip}" "${node_role}" || return 1 fi # A reboot/readiness wait or even a short image-only cordon can outlive a diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index 53876704d..8c091e3f0 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -323,6 +323,25 @@ func fakeKubectlGetNode(args []string) int { "effect": "NoSchedule", }) } + if markerExists("ready-"+nodeName) && + (nodeName == os.Getenv("FAKE_TRANSIENT_LIFECYCLE_TAINT_AFTER_READY_NODE") || + nodeName == os.Getenv("FAKE_PERSISTENT_LIFECYCLE_TAINT_AFTER_READY_NODE")) { + readMarker := "post-ready-node-read-count-" + nodeName + readCount := parseInt(markerContent(readMarker), 0) + 1 + setMarkerContent(readMarker, strconv.Itoa(readCount)) + if readCount == 1 || nodeName == os.Getenv("FAKE_PERSISTENT_LIFECYCLE_TAINT_AFTER_READY_NODE") { + taints = append(taints, + map[string]any{ + "key": "node.kubernetes.io/not-ready", + "effect": "NoSchedule", + }, + map[string]any{ + "key": "node.kubernetes.io/unreachable", + "effect": "NoExecute", + }, + ) + } + } resourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") node := map[string]any{ "metadata": map[string]any{ diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go index 4376f7f95..05d7f4efa 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go @@ -94,6 +94,52 @@ func TestExternalUncordonAfterReadyBlocksImageMutation(t *testing.T) { requireNoLine(t, operations, "root-patch") } +func TestTransientLifecycleTaintsClearBeforeImageMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TRANSIENT_LIFECYCLE_TAINT_AFTER_READY_NODE": "prod-worker-1", + }) + requireSuccessResult(t, result) + if reads := parseInt(mustRead(filepath.Join(f.syncStateDir, "post-ready-node-read-count-prod-worker-1")), 0); reads < 2 { + t.Errorf("post-Ready node reads = %d, want at least 2", reads) + } + operations := readLines(f.operationLog) + ready := lineIndex(t, operations, "node-ready:prod-worker-1") + remove := lineIndex(t, operations, "talos-remove:10.0.0.2:"+ksailTargetImage) + if ready >= remove { + t.Errorf("Ready index %d is not before image removal index %d", ready, remove) + } + requireLine(t, operations, "node-uncordon:prod-worker-1") + requireLine(t, operations, "talos-revision:10.0.0.2") + requireLine(t, operations, "root-patch") +} + +func TestPersistentLifecycleTaintsKeepNodeCordoned(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_PERSISTENT_LIFECYCLE_TAINT_AFTER_READY_NODE": "prod-worker-1", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "lifecycle taints to clear") + if reads := mustRead(filepath.Join(f.syncStateDir, "post-ready-node-read-count-prod-worker-1")); reads != "2" { + t.Errorf("post-Ready node reads = %q, want bounded 2", reads) + } + operations := readLines(f.operationLog) + requireLine(t, operations, "node-ready:prod-worker-1") + for _, unexpected := range []string{ + "talos-remove:10.0.0.2:" + ksailTargetImage, + "node-uncordon:prod-worker-1", + "talos-revision:10.0.0.2", + "root-patch", + } { + requireNoLine(t, operations, unexpected) + } + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Error("persistent lifecycle taints did not preserve the owned cordon") + } +} + func TestReplacementAfterUncordonBlocksRevisionMarker(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE": "prod-worker-1"}) From 1c6b797cd88e2c8f653e2db8bee4c1207a51f905 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 10:30:57 +0200 Subject: [PATCH 19/22] fix(deploy): close bootstrap scheduling races --- scripts/refresh-flux-ghcr-auth.sh | 121 +++++++++++++++++- .../fake_kubectl_test.go | 60 ++++++++- .../rollout_convergence_test.go | 95 +++++++++++++- .../rollout_safety_test.go | 26 ++++ 4 files changed, 292 insertions(+), 10 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index fbac0d0a9..a93def4a3 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -568,6 +568,43 @@ verify_peer_runtime_pull_overlap() { done < "${runtime_probe_targets_file}" } +verify_bootstrap_quarantine_covers_unproved_destinations() { + local pending_targets_file="$1" + local peer_name peer_uid + + if ! jq -r ' + .items[] + | select(.metadata.deletionTimestamp == null) + | select((.spec.unschedulable // false) == false) + | select(any(.spec.taints[]?; + .effect == "NoSchedule" or .effect == "NoExecute") | not) + | select(any(.status.conditions[]?; + .type == "Ready" and .status == "True")) + | [.metadata.name, .metadata.uid] + | @tsv + ' "${runtime_probe_nodes_file}" > "${runtime_probe_targets_file}"; then + echo "::error::Could not enumerate workload destinations for bootstrap quarantine; refusing the roll." + return 1 + fi + + while IFS=$'\t' read -r peer_name peer_uid; do + [[ -n "${peer_name}" && -n "${peer_uid}" ]] || { + echo "::error::Workload-destination identity was empty during bootstrap quarantine; refusing the roll." + return 1 + } + if grep -Fqx -- "${peer_uid}" "${runtime_proved_targets_file}"; then + continue + fi + if ! awk -F '\t' -v uid="${peer_uid}" ' + $4 == "reboot" && $5 == uid { found = 1 } + END { exit !found } + ' "${pending_targets_file}"; then + echo "::error::Runtime-unproved workload destination ${peer_name} is not a pending credential-reboot target; refusing bootstrap quarantine." + return 1 + fi + done < "${runtime_probe_targets_file}" +} + # Atomically claim the right to reverse the cordon and make the node # unschedulable. Combining both mutations closes the gap where another actor # could cordon after our ownership annotation but before kubectl drain. A bare @@ -759,7 +796,8 @@ node_has_no_evictable_workloads() { return 2 fi jq -e ' - all(.items[]?; + (.items | type == "array") + and all(.items[]; (.status.phase == "Succeeded" or .status.phase == "Failed") or ((.metadata.annotations["kubernetes.io/config.mirror"] // "") != "") or any(.metadata.ownerReferences[]?; .kind == "DaemonSet")) @@ -782,6 +820,53 @@ node_is_ready_workload_destination() { ' "${state_file}" >/dev/null } +wait_for_bootstrap_seed_release() { + local node_name="$1" node_uid="$2" node_ip="$3" node_role="$4" + local attempt + + for ((attempt = 1; attempt <= SYNC_ATTEMPTS; attempt++)); do + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json > "${cordon_state_file}"; then + echo "::error::Could not re-read proven bootstrap seed ${node_name} while waiting for scheduling release." + return 1 + fi + if ! selected_node_identity_is_current \ + "${cordon_state_file}" "${node_name}" "${node_uid}" \ + "${node_ip}" "${node_role}"; then + echo "::error::Proven bootstrap seed ${node_name} changed identity before it could become an eviction destination." + return 1 + fi + # The release patch is already committed at this point. Wait only for + # Kubernetes' controller-owned Ready/taint projection; an owner, renewed + # spec cordon, or unrelated hard taint is newer scheduling intent. + if ! jq -e \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" ' + ((.metadata.annotations[$owner_annotation] // "") == "") + and ((.spec.unschedulable // false) == false) + and all(.spec.taints[]?; + (.effect != "NoSchedule" and .effect != "NoExecute") + or .key == "node.kubernetes.io/unschedulable" + or .key == "node.kubernetes.io/not-ready" + or .key == "node.kubernetes.io/unreachable") + ' "${cordon_state_file}" >/dev/null; then + echo "::error::Scheduling intent changed on proven bootstrap seed ${node_name} after its owned cordon was released." + return 1 + fi + if node_is_ready_workload_destination \ + "${cordon_state_file}" "${node_uid}"; then + return 0 + fi + if ((attempt < SYNC_ATTEMPTS)); then + sleep "${SYNC_INTERVAL}" + fi + done + + echo "::error::Timed out waiting for proven bootstrap seed ${node_name} to become a workload-schedulable eviction destination; root Flux auth remains unchanged." + return 1 +} + # When the previous host credential is already revoked, no stale runtime can # receive an eviction. Use the platform's empty warm worker as a seed: atomically # cordon every stale target first, reboot the empty workload-schedulable seed, @@ -991,7 +1076,11 @@ wait_for_node_lifecycle_taints_to_clear() { echo "::error::Talos node ${node_name} identity changed, cordon ownership changed, or non-lifecycle scheduling safety state changed while waiting for its post-reboot lifecycle taints to clear; refusing image verification." return 1 fi - if ! node_has_lifecycle_taints "${cordon_state_file}"; then + if ! node_has_lifecycle_taints "${cordon_state_file}" \ + && jq -e ' + any(.status.conditions[]?; + .type == "Ready" and .status == "True") + ' "${cordon_state_file}" >/dev/null; then return 0 fi if ((attempt < SYNC_ATTEMPTS)); then @@ -999,7 +1088,7 @@ wait_for_node_lifecycle_taints_to_clear() { fi done - echo "::error::Timed out waiting for Talos node ${node_name} post-reboot lifecycle taints to clear; it remains cordoned and image verification was not attempted." + echo "::error::Timed out waiting for Talos node ${node_name} to remain Ready and for post-reboot lifecycle taints to clear; it remains cordoned and image verification was not attempted." return 1 } @@ -1053,6 +1142,7 @@ process_talos_node_target() { local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" local initial_node_uid="" initial_node_taints="[]" local bootstrap_state_file="${bootstrap_cordon_dir}/${node_name}.json" + local probe_image if [[ "${node_mode}" != "reboot" && "${node_mode}" != "image-only" ]]; then echo "::error::Unknown Talos GHCR synchronization mode '${node_mode}' for ${node_name}." @@ -1313,6 +1403,19 @@ process_talos_node_target() { return 1 fi + # Talos' image API authenticates from machine config, not through the + # kubelet's running CRI client. Before this freshly rebooted node can + # receive workloads, prove both private images through kubelet/containerd + # while the bridge-owned cordon is still in place. + if [[ "${node_mode}" == "reboot" ]]; then + for probe_image in "${RUNTIME_CREDENTIAL_PROBE_IMAGES[@]}"; do + probe_node_runtime_pull "${node_name}" "${probe_image}" || return 1 + done + if ! grep -Fqx -- "${node_uid}" "${runtime_proved_targets_file}"; then + printf '%s\n' "${node_uid}" >> "${runtime_proved_targets_file}" + fi + fi + # Restore original scheduling intent only after the uncached pull succeeds. # On failure, a cordon claimed by this bridge remains as a fail-closed signal # that the node must not receive new pods until registry access is repaired. @@ -1472,6 +1575,12 @@ sync_talos_registry_auth() { "runtime-overlap" "${bootstrap_overlap_result}" return 1 fi + if ! verify_bootstrap_quarantine_covers_unproved_destinations \ + "${talos_pending_targets}"; then + emit_safe_operation_output \ + "runtime-overlap" "${bootstrap_overlap_result}" + return 1 + fi if ! prepare_runtime_bootstrap_roll \ "${desired_revision}" "${talos_pending_targets}"; then emit_safe_operation_output \ @@ -1507,6 +1616,12 @@ sync_talos_registry_auth() { "${node_ip}" \ "${node_mode}" \ "${node_uid}" || return 1 + if ((bootstrap_mode == 1)) \ + && [[ "${node_uid}" == "${bootstrap_seed_uid}" ]]; then + wait_for_bootstrap_seed_release \ + "${node_name}" "${node_uid}" \ + "${node_ip}" "${node_role}" || return 1 + fi rm -f \ "${bootstrap_cordon_dir}/${node_name}.json" \ "${bootstrap_retain_dir}/${node_name}" diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index 8c091e3f0..a1b6bce34 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -218,6 +218,21 @@ func fakeInventoryNode( status["conditions"] = []any{map[string]any{"type": "Ready", "status": "True"}} } cordoned := wordListContains(os.Getenv("FAKE_CORDONED_NODES"), name) || markerExists("cordoned-"+name) + taints := []any{} + if cordoned { + taints = append(taints, map[string]any{ + "key": "node.kubernetes.io/unschedulable", + "effect": "NoSchedule", + }) + } + if markerExists("uncordoned-"+name) && + name == os.Getenv("FAKE_TRANSIENT_UNSCHEDULABLE_TAINT_AFTER_RELEASE_NODE") && + !markerExists("release-taint-cleared-"+name) { + taints = append(taints, map[string]any{ + "key": "node.kubernetes.io/unschedulable", + "effect": "NoSchedule", + }) + } return map[string]any{ "metadata": map[string]any{ "name": name, @@ -227,6 +242,7 @@ func fakeInventoryNode( }, "spec": map[string]any{ "unschedulable": cordoned, + "taints": taints, }, "status": status, } @@ -238,6 +254,10 @@ func fakeKubectlGetPods(args []string) int { if nodeName == "" || (!containsSequence(args, "-o", "json") && !containsArg(args, "-o=json")) { return commandFailure(91, "pod inventory must select one node as JSON") } + if nodeName == os.Getenv("FAKE_MALFORMED_POD_INVENTORY_NODE") { + fmt.Println(`{}`) + return 0 + } items := []any{ map[string]any{ "metadata": map[string]any{ @@ -323,6 +343,20 @@ func fakeKubectlGetNode(args []string) int { "effect": "NoSchedule", }) } + if markerExists("uncordoned-"+nodeName) && + nodeName == os.Getenv("FAKE_TRANSIENT_UNSCHEDULABLE_TAINT_AFTER_RELEASE_NODE") { + readMarker := "post-release-node-read-count-" + nodeName + readCount := parseInt(markerContent(readMarker), 0) + 1 + setMarkerContent(readMarker, strconv.Itoa(readCount)) + if readCount <= 2 { + taints = append(taints, map[string]any{ + "key": "node.kubernetes.io/unschedulable", + "effect": "NoSchedule", + }) + } else { + touchMarker("release-taint-cleared-" + nodeName) + } + } if markerExists("ready-"+nodeName) && (nodeName == os.Getenv("FAKE_TRANSIENT_LIFECYCLE_TAINT_AFTER_READY_NODE") || nodeName == os.Getenv("FAKE_PERSISTENT_LIFECYCLE_TAINT_AFTER_READY_NODE")) { @@ -342,7 +376,20 @@ func fakeKubectlGetNode(args []string) int { ) } } + readyStatus := "True" + if markerExists("ready-"+nodeName) && + nodeName == os.Getenv("FAKE_NOT_READY_WITHOUT_LIFECYCLE_TAINT_NODE") { + readMarker := "post-ready-node-read-count-" + nodeName + readCount := parseInt(markerContent(readMarker), 0) + 1 + setMarkerContent(readMarker, strconv.Itoa(readCount)) + readyStatus = "False" + } resourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") + nodeSpec := map[string]any{"taints": taints} + if !markerExists("uncordoned-"+nodeName) || + nodeName != os.Getenv("FAKE_OMIT_UNSCHEDULABLE_AFTER_RELEASE_NODE") { + nodeSpec["unschedulable"] = cordoned + } node := map[string]any{ "metadata": map[string]any{ "name": nodeName, @@ -352,13 +399,10 @@ func fakeKubectlGetNode(args []string) int { "deletionTimestamp": nil, "annotations": annotations, }, - "spec": map[string]any{ - "unschedulable": cordoned, - "taints": taints, - }, + "spec": nodeSpec, "status": map[string]any{ "addresses": []any{map[string]any{"type": "InternalIP", "address": nodeIP}}, - "conditions": []any{map[string]any{"type": "Ready", "status": "True"}}, + "conditions": []any{map[string]any{"type": "Ready", "status": readyStatus}}, }, } fmt.Println(encodeJSON(node)) @@ -631,6 +675,12 @@ func fakeKubectlGetRuntimeProbe(args []string) int { "state": map[string]any{"waiting": map[string]any{"reason": "ImagePullBackOff"}}, }} } else { + if os.Getenv("FAKE_LOG_RUNTIME_PROBE_SUCCESS") == "true" { + appendEnvFile( + "OPERATION_LOG", + "runtime-probe-success:"+probeNode+":"+probeImage+"\n", + ) + } status["containerStatuses"] = []any{map[string]any{ "imageID": "ghcr.io/private@sha256:runtime-probe", "state": map[string]any{ diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index a24584297..5b28c8c0d 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -159,6 +159,7 @@ func TestRevokedPreviousCredentialBootstrapsThroughEmptyWorker(t *testing.T) { "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_LOG_RUNTIME_PROBE_SUCCESS": "true", }) requireSuccessResult(t, result) output := result.stdout + result.stderr @@ -167,10 +168,14 @@ func TestRevokedPreviousCredentialBootstrapsThroughEmptyWorker(t *testing.T) { seedDrain := lineIndex(t, operations, "node-drain:prod-worker-2") seedReboot := lineIndex(t, operations, "talos-reboot:10.0.0.5") seedPull := lineIndex(t, operations, "talos-pull:10.0.0.5:"+ksailTargetImage) + seedWeddingProbe := lineIndex(t, operations, "runtime-probe-success:prod-worker-2:ghcr.io/devantler-tech/wedding-app:latest") + seedCoachingProbe := lineIndex(t, operations, "runtime-probe-success:prod-worker-2:ghcr.io/devantler-tech/ascoachingogvaner:latest") seedRelease := lineIndex(t, operations, "node-uncordon:prod-worker-2") firstWorkloadDrain := lineIndex(t, operations, "node-drain:prod-worker-1") - if seedDrain >= seedReboot || seedReboot >= seedPull || seedPull >= seedRelease || seedRelease >= firstWorkloadDrain { - t.Fatalf("unsafe bootstrap ordering: seed drain=%d reboot=%d pull=%d release=%d workload drain=%d", seedDrain, seedReboot, seedPull, seedRelease, firstWorkloadDrain) + if seedDrain >= seedReboot || seedReboot >= seedPull || + seedPull >= seedWeddingProbe || seedWeddingProbe >= seedCoachingProbe || + seedCoachingProbe >= seedRelease || seedRelease >= firstWorkloadDrain { + t.Fatalf("unsafe bootstrap ordering: seed drain=%d reboot=%d Talos pull=%d runtime probes=(%d,%d) release=%d workload drain=%d", seedDrain, seedReboot, seedPull, seedWeddingProbe, seedCoachingProbe, seedRelease, firstWorkloadDrain) } for _, nodeName := range []string{ "prod-worker-1", @@ -201,6 +206,92 @@ func TestAllStaleRuntimesWithoutEmptyWorkerFailClosed(t *testing.T) { requireNoLine(t, operations, "root-patch") } +func TestBootstrapRejectsUnprovedCurrentMarkedDestination(t *testing.T) { + f := newFixture(t) + ready := []any{map[string]any{"type": "Ready", "status": "True"}} + inventory := map[string]any{"items": []any{ + nodeFixture("prod-worker-1", "prod-worker-1-uid", "10.0.0.2", false, ready, nil), + nodeFixture( + "prod-control-plane-1", + "prod-control-plane-1-uid", + "10.0.0.1", + true, + ready, + map[string]any{ + "platform.devantler.tech/ghcr-pull-verified-revision-v2": f.expectedRevision(), + "platform.devantler.tech/ghcr-pull-verified-image-v2": ksailTargetImage, + }, + ), + }} + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_NODE_JSON": encodeJSON(inventory), + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-control-plane-1", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "not a pending credential-reboot target") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-claim-cordon:") + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + +func TestMalformedPodInventoryCannotAuthorizeBootstrapSeed(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_MALFORMED_POD_INVENTORY_NODE": "prod-worker-2", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "no empty workload-schedulable node") + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-claim-cordon:") + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + +func TestBootstrapWaitsForSeedReleaseTaintToClear(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_TRANSIENT_UNSCHEDULABLE_TAINT_AFTER_RELEASE_NODE": "prod-worker-2", + }) + requireSuccessResult(t, result) + if reads := mustRead(filepath.Join(f.syncStateDir, "post-release-node-read-count-prod-worker-2")); reads != "3" { + t.Fatalf("post-release node reads = %q, want identity revalidation plus two bounded release checks", reads) + } + if !pathExists(filepath.Join(f.syncStateDir, "release-taint-cleared-prod-worker-2")) { + t.Fatal("bootstrap continued before the lagging release taint cleared") + } + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-reboot:10.0.0.5") + requireLine(t, operations, "node-drain:prod-worker-1") + requireLine(t, operations, "root-patch") +} + +func TestBootstrapAcceptsOmittedUnschedulableAfterSeedRelease(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_REVOKE_CURRENT_ROOT_TOKEN": "true", + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_OMIT_UNSCHEDULABLE_AFTER_RELEASE_NODE": "prod-worker-2", + }) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-uncordon:prod-worker-2") + requireLine(t, operations, "node-drain:prod-worker-1") + requireLine(t, operations, "root-patch") +} + func TestBootstrapFailureBeforeRebootRestoresEveryOwnedCordon(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{ diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go index 05d7f4efa..648b379a4 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go @@ -140,6 +140,32 @@ func TestPersistentLifecycleTaintsKeepNodeCordoned(t *testing.T) { } } +func TestReadyFalseWithoutLifecycleTaintKeepsNodeCordoned(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_NOT_READY_WITHOUT_LIFECYCLE_TAINT_NODE": "prod-worker-1", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "remain Ready and for post-reboot lifecycle taints to clear") + if reads := mustRead(filepath.Join(f.syncStateDir, "post-ready-node-read-count-prod-worker-1")); reads != "2" { + t.Errorf("post-Ready node reads = %q, want bounded 2", reads) + } + operations := readLines(f.operationLog) + requireLine(t, operations, "node-ready:prod-worker-1") + for _, unexpected := range []string{ + "talos-remove:10.0.0.2:" + ksailTargetImage, + "node-uncordon:prod-worker-1", + "talos-revision:10.0.0.2", + "root-patch", + } { + requireNoLine(t, operations, unexpected) + } + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Error("Ready=False state did not preserve the owned cordon") + } +} + func TestReplacementAfterUncordonBlocksRevisionMarker(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE": "prod-worker-1"}) From 882313e7edd2dbedfca6565d00574198a8d35d68 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 12:40:50 +0200 Subject: [PATCH 20/22] fix(scripts): claim cordon before Talos patch, gate bootstrap on auth evidence, preserve cordon recovery records (codex/CR review) - Claim cordon ownership BEFORE the reboot-mode machineconfig patch and release it when the patch or the pre-drain quorum check fails, so a concurrent roll cannot overwrite a freshly written node credential. - Only registry auth-denial evidence in the probe's waiting message may opt a roll into the warm-spare bootstrap; a transient outage stays a plain fail-closed refusal (both directions tested). - Externalize un-restored bridge-owned cordon recovery records to a ksail-operator ConfigMap before the work dir is deleted. - Checked type assertions in the node-inventory fake; assert the injected admission timeout is actually exercised. Co-Authored-By: Claude Fable 5 --- scripts/refresh-flux-ghcr-auth.sh | 168 +++++++++++------- .../fake_kubectl_test.go | 60 ++++++- .../rollout_convergence_test.go | 28 +++ .../rollout_core_test.go | 4 +- .../rollout_safety_test.go | 2 +- 5 files changed, 195 insertions(+), 67 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index a93def4a3..1fa9c1b71 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -91,6 +91,7 @@ bootstrap_seed_uid="" mkdir -p "${bootstrap_cordon_dir}" "${bootstrap_retain_dir}" cleanup_refresh_work() { + local recovery_state_file recovery_node if [[ -n "${active_runtime_probe}" ]]; then kubectl \ --context "${KUBE_CONTEXT}" \ @@ -101,6 +102,30 @@ cleanup_refresh_work() { >/dev/null 2>&1 || true fi cleanup_bootstrap_quarantine || true + # Any state file still present here belongs to a bridge-owned cordon that + # was not restored — a deliberate reboot-edge retention or a failed + # rollback. Its owner token, original UID, and original taints are the only + # recovery records, so externalize them to the cluster before the work dir + # is deleted; otherwise a transient API failure strands the node cordoned + # while every future run refuses its surviving foreign owner annotation. + for recovery_state_file in "${bootstrap_cordon_dir}"/*.json; do + [[ -e "${recovery_state_file}" ]] || continue + recovery_node="$(basename "${recovery_state_file}" .json)" + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace ksail-operator \ + create configmap "ghcr-cordon-recovery-${recovery_node}" \ + --from-file=state.json="${recovery_state_file}" \ + --dry-run=client -o json 2>/dev/null \ + | kubectl \ + --context "${KUBE_CONTEXT}" \ + apply -f - \ + >/dev/null 2>&1; then + echo "::warning::Node ${recovery_node} still carries a bridge-owned cordon; its recovery record was preserved in configmap ksail-operator/ghcr-cordon-recovery-${recovery_node}. Restore schedulability from it, then delete the configmap." + else + echo "::error::Node ${recovery_node} still carries a bridge-owned cordon and its recovery record could not be preserved in the cluster; restore its schedulability manually before the next roll." + fi + done rm -rf "${work_dir}" } trap cleanup_refresh_work EXIT @@ -373,7 +398,7 @@ probe_node_runtime_pull() { local node_name="$1" local probe_image="$2" local probe_name - local attempt create_attempt image_id waiting_reason + local attempt create_attempt image_id waiting_reason waiting_message local probe_created=0 runtime_probe_sequence=$((runtime_probe_sequence + 1)) @@ -495,7 +520,18 @@ probe_node_runtime_pull() { "${runtime_probe_state_file}")" case "${waiting_reason}" in ErrImagePull|ImagePullBackOff) - runtime_probe_bootstrap_needed=1 + # A pull failure alone does not identify its cause: a transient + # registry, DNS, or rate-limit outage reports the same waiting reason + # as a stale credential. Only registry auth-denial evidence in the + # kubelet's waiting message may opt this roll into the warm-spare + # bootstrap; anything else stays a plain fail-closed refusal so a + # passing outage never triggers machineconfig patches and reboots. + waiting_message="$(jq -r \ + '.status.containerStatuses[0].state.waiting.message // ""' \ + "${runtime_probe_state_file}")" + if [[ "${waiting_message}" =~ (401|403|[Uu]nauthorized|[Ff]orbidden|pull\ access\ denied|[Aa]uthentication\ required) ]]; then + runtime_probe_bootstrap_needed=1 + fi delete_runtime_pull_probe "${probe_name}" || true echo "::error::The running containerd on ${node_name} could not pull ${probe_image} (${waiting_reason}); refusing to drain workloads onto peers with unproved runtime auth." return 1 @@ -1151,64 +1187,13 @@ process_talos_node_target() { revalidate_selected_node_identity_before_mutation \ "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 - if [[ "${node_mode}" == "reboot" ]]; then - if ! talosctl \ - --nodes "${node_ip}" \ - patch machineconfig \ - --mode=no-reboot \ - --patch-file="${talos_auth_patch_file}" \ - >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} did not accept the Git/SOPS GHCR registry auth." - return 1 - fi - - # Writing the credential is NOT enough to make a RUNNING node use it, and - # this is the step whose absence caused the 2026-07-14 outage. - # - # containerd reads registry auth from its STATIC config - # (plugins.'io.containerd.cri.v1.images'.registry.configs.'ghcr.io'.auth), - # which it loads ONCE at process start. Talos re-renders that file - # (/etc/cri/conf.d/01-registries.part) immediately on a config change, but - # it does not restart containerd — and it refuses to let us either: - # - # $ talosctl service cri restart - # error: service "cri" doesn't support restart operation via API - # - # So after a --mode=no-reboot patch the new credential sits on disk, - # correct and INERT, while the running containerd keeps presenting the old - # one. A REBOOT is the only supported way to make it adopt the new auth. - # - # Do not be tempted to drop this and trust the `image pull` check below: - # that check goes through the TALOS image API, which builds its auth from - # the machine config we just wrote, NOT from containerd's CRI plugin. It - # therefore passes on a node whose kubelet pulls are still failing 403 — - # which is exactly what happened: every node had the legacy unversioned - # ghcr-pull-verified-revision marker while every ksail-operator pod sat in - # ImagePullBackOff, and prod stayed four releases behind for over a day. - # The pull check proves the CREDENTIAL is good; only the reboot proves - # CONTAINERD is using it. - # - # Credential-revision drift always takes this reboot path; a desired-machine - # marker is not evidence that the running containerd loaded the credential. - # A node whose v2 credential proof is already current but whose declared - # image changed takes the image-only path below and is never rebooted. - # - # etcd tolerates exactly one control plane down in a 3-member cluster. This - # loop is serial and control planes sort last, but a peer can be - # Kubernetes-Ready while its etcd member is unhealthy. Re-read the peer - # inventory, then prove every other peer is Ready, answers `etcd status`, - # and has no etcd alarm immediately before each control-plane reboot. - if [[ "${node_role}" == "1" ]] \ - && ! other_control_planes_safe_to_reboot \ - "${node_name}" "${KUBE_CONTEXT}" "${work_dir}"; then - echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready with healthy, alarm-free etcd, so rebooting this one risks quorum." - return 1 - fi - fi - - # Remember scheduling intent before any cordon. Both reboot and image-only - # verification exclude new placements while the exact target is removed; - # only the reboot path drains existing workloads. + # Remember scheduling intent and claim cordon ownership BEFORE any Talos + # credential write. Both reboot and image-only verification exclude new + # placements while the exact target is removed; only the reboot path + # drains existing workloads. Claiming first makes the node credential + # single-writer: a concurrent roll now fails its own claim instead of + # overwriting this roll's freshly patched credential between the patch + # and a later ownership check. if ! kubectl \ --context "${KUBE_CONTEXT}" \ get node "${node_name}" \ @@ -1290,6 +1275,69 @@ process_talos_node_target() { fi fi + if [[ "${node_mode}" == "reboot" ]]; then + if ! talosctl \ + --nodes "${node_ip}" \ + patch machineconfig \ + --mode=no-reboot \ + --patch-file="${talos_auth_patch_file}" \ + >"${talos_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not accept the Git/SOPS GHCR registry auth." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 + return 1 + fi + + # Writing the credential is NOT enough to make a RUNNING node use it, and + # this is the step whose absence caused the 2026-07-14 outage. + # + # containerd reads registry auth from its STATIC config + # (plugins.'io.containerd.cri.v1.images'.registry.configs.'ghcr.io'.auth), + # which it loads ONCE at process start. Talos re-renders that file + # (/etc/cri/conf.d/01-registries.part) immediately on a config change, but + # it does not restart containerd — and it refuses to let us either: + # + # $ talosctl service cri restart + # error: service "cri" doesn't support restart operation via API + # + # So after a --mode=no-reboot patch the new credential sits on disk, + # correct and INERT, while the running containerd keeps presenting the old + # one. A REBOOT is the only supported way to make it adopt the new auth. + # + # Do not be tempted to drop this and trust the `image pull` check below: + # that check goes through the TALOS image API, which builds its auth from + # the machine config we just wrote, NOT from containerd's CRI plugin. It + # therefore passes on a node whose kubelet pulls are still failing 403 — + # which is exactly what happened: every node had the legacy unversioned + # ghcr-pull-verified-revision marker while every ksail-operator pod sat in + # ImagePullBackOff, and prod stayed four releases behind for over a day. + # The pull check proves the CREDENTIAL is good; only the reboot proves + # CONTAINERD is using it. + # + # Credential-revision drift always takes this reboot path; a desired-machine + # marker is not evidence that the running containerd loaded the credential. + # A node whose v2 credential proof is already current but whose declared + # image changed takes the image-only path below and is never rebooted. + # + # etcd tolerates exactly one control plane down in a 3-member cluster. This + # loop is serial and control planes sort last, but a peer can be + # Kubernetes-Ready while its etcd member is unhealthy. Re-read the peer + # inventory, then prove every other peer is Ready, answers `etcd status`, + # and has no etcd alarm immediately before each control-plane reboot. + if [[ "${node_role}" == "1" ]] \ + && ! other_control_planes_safe_to_reboot \ + "${node_name}" "${KUBE_CONTEXT}" "${work_dir}"; then + echo "::error::Refusing to reboot control plane ${node_name} for the GHCR auth refresh: another control plane is not Ready with healthy, alarm-free etcd, so rebooting this one risks quorum." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" || return 1 + return 1 + fi + fi + if [[ "${node_mode}" == "reboot" ]]; then # Drain through the Kubernetes context already proven by this deployment. # Talos v1.13's integrated --drain path fetches a separate admin kubeconfig; diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index a1b6bce34..7b6a262b4 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -47,6 +47,10 @@ func fakeKubectlImplementation(args []string) int { return fakeKubectlCordon(args) case containsArg(args, "wait"): return fakeKubectlWaitForNode(args) + case containsSequence(args, "create", "configmap"): + return fakeKubectlCreateRecoveryConfigMap(args, namespace) + case containsArg(args, "apply"): + return fakeKubectlApplyRecoveryConfigMap() case containsArg(args, "create") && manifestFile != "": return fakeKubectlCreateRuntimeProbe(namespace, manifestFile) case containsSequence(args, "get", "pod"): @@ -83,6 +87,35 @@ func fakeKubectlImplementation(args []string) int { return commandFailure(91, "unexpected kubectl invocation: %s", strings.Join(args, " ")) } +func fakeKubectlCreateRecoveryConfigMap(args []string, namespace string) int { + name := argumentAfter(args, "configmap") + if name == "" || namespace != "ksail-operator" { + return commandFailure(91, "unexpected configmap creation: %s", strings.Join(args, " ")) + } + if !containsArg(args, "--dry-run=client") { + return commandFailure(91, "recovery configmap creation must be a client-side dry run") + } + fmt.Println(encodeJSON(map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": name, "namespace": namespace}, + })) + return 0 +} + +func fakeKubectlApplyRecoveryConfigMap() int { + var manifest struct { + Metadata struct { + Name string `json:"name"` + } `json:"metadata"` + } + if err := json.NewDecoder(os.Stdin).Decode(&manifest); err != nil || manifest.Metadata.Name == "" { + return commandFailure(91, "apply received no parseable manifest") + } + appendEnvFile("OPERATION_LOG", "configmap-recovery:"+manifest.Metadata.Name+"\n") + return 0 +} + func fakeKubectlGetNodes() int { if os.Getenv("FAKE_NODE_DISCOVERY_FAIL") == "true" { return commandFailure(46, "node discovery failed") @@ -103,9 +136,18 @@ func fakeKubectlGetNodes() int { } if os.Getenv("FAKE_ALL_TALOS_NODES_STALE") == "true" { for _, node := range nodes { - nodeMap := node.(map[string]any) - metadata := nodeMap["metadata"].(map[string]any) - annotations := metadata["annotations"].(map[string]any) + nodeMap, ok := node.(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node object") + } + metadata, ok := nodeMap["metadata"].(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node metadata") + } + annotations, ok := metadata["annotations"].(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node annotations") + } delete(annotations, "platform.devantler.tech/ghcr-pull-verified-revision-v2") delete(annotations, "platform.devantler.tech/ghcr-pull-verified-image-v2") } @@ -671,8 +713,18 @@ func fakeKubectlGetRuntimeProbe(args []string) int { if (wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_NODES"), probeNode) && !markerExists("talos-reboot-"+probeIP)) || wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_IMAGES"), probeImage) { + // The default failure carries registry auth-denial evidence, matching + // the stale-credential scenario; a transient outage is simulated by + // overriding the message with text that names no auth failure. + waitingMessage := "failed to authorize: failed to fetch anonymous token: unexpected status: 403 Forbidden" + if custom := os.Getenv("FAKE_RUNTIME_PULL_FAIL_MESSAGE"); custom != "" { + waitingMessage = custom + } status["containerStatuses"] = []any{map[string]any{ - "state": map[string]any{"waiting": map[string]any{"reason": "ImagePullBackOff"}}, + "state": map[string]any{"waiting": map[string]any{ + "reason": "ImagePullBackOff", + "message": waitingMessage, + }}, }} } else { if os.Getenv("FAKE_LOG_RUNTIME_PROBE_SUCCESS") == "true" { diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index 5b28c8c0d..895709520 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -192,6 +192,25 @@ func TestRevokedPreviousCredentialBootstrapsThroughEmptyWorker(t *testing.T) { requireLine(t, operations, "root-patch") } +func TestTransientPullOutageDoesNotEnterBootstrap(t *testing.T) { + f := newFixture(t) + // Identical to the revoked-credential bootstrap scenario — an empty + // worker is available — except the probe failure carries no auth-denial + // evidence. Only a proved stale credential may opt into the warm-spare + // bootstrap; a transient registry/DNS outage must stay a plain refusal. + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_RUNTIME_PULL_FAIL_MESSAGE": "dial tcp: lookup ghcr.io: no such host", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") +} + func TestAllStaleRuntimesWithoutEmptyWorkerFailClosed(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{ @@ -338,6 +357,9 @@ func TestBootstrapPullFailureRetainsOnlyUnprovedSeedCordon(t *testing.T) { if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-2")) { t.Fatal("unproved bootstrap seed did not retain its owned cordon") } + // The retained cordon's recovery record (owner token, original UID and + // taints) must survive the run's work-dir deletion. + requireLine(t, operations, "configmap-recovery:ghcr-cordon-recovery-prod-worker-2") for _, nodeName := range []string{ "prod-worker-1", "prod-control-plane-1", @@ -400,6 +422,12 @@ func TestRuntimeProbeRetriesTransientAdmissionTimeout(t *testing.T) { "FAKE_RUNTIME_PROBE_CREATE_TIMEOUT_ONCE_NODES": "prod-control-plane-2", }) requireSuccessResult(t, result) + if !pathExists(filepath.Join( + f.syncStateDir, + "runtime-probe-create-timeout-once-prod-control-plane-2", + )) { + t.Fatal("transient runtime-probe timeout was not exercised") + } operations := readLines(f.operationLog) requireLine(t, operations, "node-drain:prod-worker-1") requireLine(t, operations, "root-patch") diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go index a129ca803..eb92340c6 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go @@ -145,8 +145,8 @@ func TestStagesKubernetesConsumersBeforeTalosDrains(t *testing.T) { "fanout:externalsecret/wedding-app/ghcr-auth", "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", "node-claim-cordon:prod-worker-1", + "talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", @@ -154,8 +154,8 @@ func TestStagesKubernetesConsumersBeforeTalosDrains(t *testing.T) { "talos-pull:10.0.0.2:" + target, "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", "node-claim-cordon:prod-control-plane-1", + "talos-auth:10.0.0.1", "node-drain:prod-control-plane-1", "talos-reboot:10.0.0.1", "node-ready:prod-control-plane-1", diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go index 648b379a4..3bed27a64 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go @@ -224,8 +224,8 @@ func TestUnreadyNodeAfterRebootStopsTheRoll(t *testing.T) { "fanout:externalsecret/wedding-app/ghcr-auth", "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", "node-claim-cordon:prod-worker-1", + "talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", From f8c50cff16b81faedf1ae2eea8af7b1aafe19e21 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 12:44:28 +0200 Subject: [PATCH 21/22] fix(deploy): fence GHCR credential rollouts --- scripts/refresh-flux-ghcr-auth-safety.sh | 65 +- scripts/refresh-flux-ghcr-auth.sh | 1012 +++++++++++++++-- .../fake_commands_test.go | 52 + .../fake_kubectl_test.go | 436 ++++++- .../refresh-flux-ghcr-auth/fixture_test.go | 25 +- .../rollout_convergence_test.go | 264 +++++ .../rollout_core_test.go | 21 +- .../rollout_safety_test.go | 326 +++++- 8 files changed, 2038 insertions(+), 163 deletions(-) diff --git a/scripts/refresh-flux-ghcr-auth-safety.sh b/scripts/refresh-flux-ghcr-auth-safety.sh index 606e6d423..8fe9218d5 100644 --- a/scripts/refresh-flux-ghcr-auth-safety.sh +++ b/scripts/refresh-flux-ghcr-auth-safety.sh @@ -24,24 +24,32 @@ select_talos_node_targets() { --arg revision "${desired_revision}" \ --arg image "${operator_image}" \ --arg revision_annotation "${GHCR_PULL_VERIFIED_REVISION_ANNOTATION}" \ - --arg image_annotation "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION}" ' - .items[] - | (.metadata.annotations[$revision_annotation] // "") as $verified_revision - | (.metadata.annotations[$image_annotation] // "") as $verified_image - | select($verified_revision != $revision or $verified_image != $image) - | (.metadata.labels // {}) as $labels - | [ - (if (($labels | has("node-role.kubernetes.io/control-plane")) - or ($labels | has("node-role.kubernetes.io/master"))) - then "1" else "0" end), - .metadata.name, - ([.status.addresses[] - | select(.type == "InternalIP") | .address][0]), - (if $verified_revision != $revision - then "reboot" else "image-only" end), - (.metadata.uid // "") - ] - | @tsv + --arg image_annotation "${GHCR_PULL_VERIFIED_IMAGE_ANNOTATION}" \ + --arg owner_annotation "platform.devantler.tech/ghcr-auth-drain-owner" \ + --arg recovery_annotation "platform.devantler.tech/ghcr-auth-drain-recovery" ' + if any(.items[]; + ((.metadata.annotations[$owner_annotation] // "") != "") + or ((.metadata.annotations[$recovery_annotation] // "") != "")) + then error("residual GHCR bridge ownership") + else + .items[] + | (.metadata.annotations[$revision_annotation] // "") as $verified_revision + | (.metadata.annotations[$image_annotation] // "") as $verified_image + | select($verified_revision != $revision or $verified_image != $image) + | (.metadata.labels // {}) as $labels + | [ + (if (($labels | has("node-role.kubernetes.io/control-plane")) + or ($labels | has("node-role.kubernetes.io/master"))) + then "1" else "0" end), + .metadata.name, + ([.status.addresses[] + | select(.type == "InternalIP") | .address][0]), + (if $verified_revision != $revision + then "reboot" else "image-only" end), + (.metadata.uid // "") + ] + | @tsv + end ' "${nodes_file}" > "${unsorted_targets}"; then rm -f "${unsorted_targets}" return 1 @@ -56,10 +64,11 @@ select_talos_node_targets() { } # Validate the scheduling state captured immediately before a Talos reboot. -# The bridge must still own a cordon it created; a pre-existing cordon must stay -# ownerless, and neither path may proceed after UID, taint, deletion, or -# schedulability drift. The unschedulable taint mirrors spec.unschedulable and is -# excluded from the comparison; all other scheduling intent is preserved. +# The bridge must still own its serialization annotation while the node stays +# cordoned; neither a bridge-created nor a pre-existing cordon may proceed after +# UID, taint, deletion, ownership, or schedulability drift. The unschedulable +# taint mirrors spec.unschedulable and is excluded from the comparison; all +# other scheduling intent is preserved. node_scheduling_state_is_safe_to_reboot() { local state_file="$1" local was_cordoned="$2" @@ -77,11 +86,7 @@ node_scheduling_state_is_safe_to_reboot() { .metadata.uid == $uid and .metadata.deletionTimestamp == null and .spec.unschedulable == true - and (if $was_cordoned == 0 then - .metadata.annotations[$owner_annotation] == $owner - else - (.metadata.annotations[$owner_annotation] // "") == "" - end) + and .metadata.annotations[$owner_annotation] == $owner and (((.spec.taints // []) | map(select(( .key == "node.kubernetes.io/unschedulable" @@ -124,11 +129,7 @@ node_scheduling_state_is_safe_while_lifecycle_taints_clear() { .metadata.uid == $uid and .metadata.deletionTimestamp == null and .spec.unschedulable == true - and (if $was_cordoned == 0 then - .metadata.annotations[$owner_annotation] == $owner - else - (.metadata.annotations[$owner_annotation] // "") == "" - end) + and .metadata.annotations[$owner_annotation] == $owner and (((.spec.taints // []) | scheduling_taints) == ($initial_taints | scheduling_taints)) ' "${state_file}" >/dev/null diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index a93def4a3..c36b710ba 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -40,8 +40,13 @@ readonly SYNC_INTERVAL="${FLUX_GHCR_SYNC_INTERVAL:-2}" readonly TALOS_CONVERGENCE_ATTEMPTS="${FLUX_GHCR_TALOS_CONVERGENCE_ATTEMPTS:-${SYNC_ATTEMPTS}}" readonly DRAIN_TIMEOUT="${FLUX_GHCR_DRAIN_TIMEOUT:-45m}" readonly RUNTIME_PROBE_CREATE_ATTEMPTS=3 +readonly SYNC_LEASE_NAME="ghcr-auth-refresh" +readonly SYNC_LEASE_DURATION_SECONDS=120 +readonly SYNC_LEASE_HEARTBEAT_SECONDS=30 readonly CORDON_OWNER_ANNOTATION="platform.devantler.tech/ghcr-auth-drain-owner" readonly CORDON_OWNER_JSON_PATH="/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" +readonly CORDON_RECOVERY_ANNOTATION="platform.devantler.tech/ghcr-auth-drain-recovery" +readonly CORDON_RECOVERY_JSON_PATH="/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery" KSAIL_OPERATOR_VERSION="$(yq -er '.spec.chart.spec.version' \ k8s/bases/infrastructure/controllers/ksail-operator/helm-release.yaml)" readonly KSAIL_OPERATOR_VERSION @@ -91,6 +96,11 @@ bootstrap_seed_uid="" mkdir -p "${bootstrap_cordon_dir}" "${bootstrap_retain_dir}" cleanup_refresh_work() { + local original_status=$? + local cleanup_status=0 + + trap - EXIT + if [[ -n "${active_runtime_probe}" ]]; then kubectl \ --context "${KUBE_CONTEXT}" \ @@ -100,8 +110,21 @@ cleanup_refresh_work() { --wait=false \ >/dev/null 2>&1 || true fi - cleanup_bootstrap_quarantine || true + if ! cleanup_bootstrap_quarantine; then + cleanup_status=1 + echo "::error::Bootstrap quarantine cleanup was incomplete; durable recovery annotations remain on the affected nodes." + fi + if declare -F release_sync_lease >/dev/null \ + && [[ "${sync_lease_acquired:-false}" == "true" ]] \ + && ! release_sync_lease; then + cleanup_status=1 + echo "::error::Could not safely release the GHCR synchronization lease." + fi rm -rf "${work_dir}" + if ((original_status == 0 && cleanup_status != 0)); then + exit 1 + fi + exit "${original_status}" } trap cleanup_refresh_work EXIT @@ -128,6 +151,7 @@ reboot_result_file="${work_dir}/reboot-result.txt" cordon_state_file="${work_dir}/cordon-state.json" cordon_claim_patch_file="${work_dir}/cordon-claim-patch.json" cordon_release_patch_file="${work_dir}/cordon-release-patch.json" +cordon_recovery_patch_file="${work_dir}/cordon-recovery-patch.json" talos_nodes_file="${work_dir}/talos-nodes.json" talos_node_targets="${work_dir}/talos-node-targets.tsv" talos_pending_targets="${work_dir}/talos-pending-targets.tsv" @@ -139,6 +163,23 @@ runtime_proved_targets_file="${work_dir}/runtime-proved-targets.txt" runtime_probe_manifest_file="${work_dir}/runtime-probe-pod.json" runtime_probe_state_file="${work_dir}/runtime-probe-state.json" runtime_probe_result_file="${work_dir}/runtime-probe-result.txt" +recovery_nodes_file="${work_dir}/recovery-nodes.json" +recovery_node_file="${work_dir}/recovery-node.json" +recovery_targets_file="${work_dir}/recovery-targets.jsonl" +recovery_record_file="${work_dir}/recovery-record.json" +recovery_blocked_owners_file="${work_dir}/recovery-blocked-owners.txt" +sync_lease_file="${work_dir}/sync-lease.json" +sync_lease_manifest_file="${work_dir}/sync-lease-manifest.json" +sync_lease_patch_file="${work_dir}/sync-lease-patch.json" +sync_lease_result_file="${work_dir}/sync-lease-result.txt" +sync_lease_lost_file="${work_dir}/sync-lease-lost" +root_secret_state_file="${work_dir}/root-secret-state.json" +root_secret_cas_patch_file="${work_dir}/root-secret-cas-patch.json" +variables_secret_state_file="${work_dir}/variables-secret-state.json" +variables_secret_cas_patch_file="${work_dir}/variables-secret-cas-patch.json" +sync_lease_holder="" +sync_lease_acquired=false +sync_lease_heartbeat_pid="" runtime_probe_sequence=0 runtime_probe_bootstrap_needed=0 @@ -373,9 +414,10 @@ probe_node_runtime_pull() { local node_name="$1" local probe_image="$2" local probe_name - local attempt create_attempt image_id waiting_reason + local attempt create_attempt image_id waiting_reason auth_rejected local probe_created=0 + assert_sync_lease_held || return 1 runtime_probe_sequence=$((runtime_probe_sequence + 1)) probe_name="ghcr-runtime-probe-$$-${RANDOM}-${runtime_probe_sequence}" jq -n \ @@ -427,6 +469,7 @@ probe_node_runtime_pull() { active_runtime_probe="${probe_name}" for ((create_attempt = 1; create_attempt <= RUNTIME_PROBE_CREATE_ATTEMPTS; create_attempt++)); do + assert_sync_lease_held || return 1 if kubectl \ --context "${KUBE_CONTEXT}" \ --namespace ksail-operator \ @@ -483,19 +526,36 @@ probe_node_runtime_pull() { echo "::error::Runtime probe on ${node_name} received an imagePullSecret, so it did not prove the running containerd credential; refusing the drain." return 1 fi - image_id="$(jq -r \ - '.status.containerStatuses[0].imageID // ""' \ + image_id="$(jq -r ' + first(.status.containerStatuses[]? + | select(.name == "pull-probe") + | .imageID) // "" + ' \ "${runtime_probe_state_file}")" if [[ -n "${image_id}" ]]; then delete_runtime_pull_probe "${probe_name}" || return 1 return 0 fi - waiting_reason="$(jq -r \ - '.status.containerStatuses[0].state.waiting.reason // ""' \ + waiting_reason="$(jq -r ' + first(.status.containerStatuses[]? + | select(.name == "pull-probe") + | .state.waiting.reason) // "" + ' \ "${runtime_probe_state_file}")" case "${waiting_reason}" in ErrImagePull|ImagePullBackOff) - runtime_probe_bootstrap_needed=1 + auth_rejected="$(jq -r ' + first(.status.containerStatuses[]? + | select(.name == "pull-probe") + | .state.waiting.message) // "" + | test( + "(^|.*: )(unexpected status from GET request to https://ghcr\\.io/token(?:\\?[^[:space:]]*)?: (401 Unauthorized|403 Forbidden)|unauthorized: authentication required|insufficient_scope: authorization failed)$"; + "i" + ) + ' "${runtime_probe_state_file}")" + if [[ "${auth_rejected}" == "true" ]]; then + runtime_probe_bootstrap_needed=1 + fi delete_runtime_pull_probe "${probe_name}" || true echo "::error::The running containerd on ${node_name} could not pull ${probe_image} (${waiting_reason}); refusing to drain workloads onto peers with unproved runtime auth." return 1 @@ -612,6 +672,7 @@ verify_bootstrap_quarantine_covers_unproved_destinations() { # already-cordoned node must replace the annotation to express new ownership. claim_node_cordon_ownership() { local node_name="$1" owner_token="$2" state_file="$3" result_file="$4" + local recovery_record="${5:-}" local resource_version node_uid resource_version="$(jq -er '.metadata.resourceVersion' "${state_file}")" node_uid="$(jq -er '.metadata.uid' "${state_file}")" @@ -621,6 +682,8 @@ claim_node_cordon_ownership() { jq -n \ --arg owner_path "${CORDON_OWNER_JSON_PATH}" \ --arg owner "${owner_token}" \ + --arg recovery_path "${CORDON_RECOVERY_JSON_PATH}" \ + --arg recovery "${recovery_record}" \ --arg uid "${node_uid}" \ --arg resource_version "${resource_version}" ' [ @@ -630,7 +693,12 @@ claim_node_cordon_ownership() { value: $resource_version }, {op: "test", path: "/metadata/uid", value: $uid}, - {op: "add", path: $owner_path, value: $owner}, + {op: "add", path: $owner_path, value: $owner} + ] + + (if $recovery == "" then [] else + [{op: "add", path: $recovery_path, value: $recovery}] + end) + + [ {op: "add", path: "/spec/unschedulable", value: true} ] ' > "${cordon_claim_patch_file}" @@ -638,6 +706,8 @@ claim_node_cordon_ownership() { jq -n \ --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ --arg owner "${owner_token}" \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + --arg recovery "${recovery_record}" \ --arg uid "${node_uid}" \ --arg resource_version "${resource_version}" ' [ @@ -650,7 +720,10 @@ claim_node_cordon_ownership() { { op: "add", path: "/metadata/annotations", - value: {($owner_annotation): $owner} + value: ({($owner_annotation): $owner} + + (if $recovery == "" then {} else + {($recovery_annotation): $recovery} + end)) }, {op: "add", path: "/spec/unschedulable", value: true} ] @@ -675,11 +748,11 @@ claim_node_cordon_ownership() { restore_node_schedulability_if_needed() { local node_name="$1" was_cordoned="$2" owner_token="$3" local initial_node_uid="$4" initial_node_taints="$5" result_file="$6" - local current_resource_version - [[ "${was_cordoned}" == "0" ]] || return 0 + local expected_recovery="${7:-}" + local current_resource_version current_recovery if [[ -z "${owner_token}" ]]; then - echo "::error::Refusing to uncordon Talos node ${node_name} without a bridge ownership token." + echo "::error::Refusing to release Talos node ${node_name} without a bridge ownership token." return 1 fi @@ -703,12 +776,41 @@ restore_node_schedulability_if_needed() { fi current_resource_version="$(jq -er \ '.metadata.resourceVersion' "${cordon_state_file}")" + current_recovery="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${cordon_state_file}")" + if [[ "${current_recovery}" != "${expected_recovery}" ]]; then + echo "::error::Recovery journal changed for Talos node ${node_name}; refusing to release its cordon ownership." + return 1 + fi + if [[ -n "${current_recovery}" ]] \ + && ! jq -ne \ + --arg recovery "${current_recovery}" \ + --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" ' + ($recovery | fromjson?) as $record + | $record != null + and $record.v == 1 + and $record.owner == $owner + and $record.uid == $uid + and ($record.phase == "rollback-safe" + or $record.phase == "active" + or $record.phase == "retain" + or $record.phase == "release-ready") + ' >/dev/null; then + echo "::error::Recovery journal changed or was malformed for Talos node ${node_name}; refusing to release its cordon ownership." + return 1 + fi jq -n \ --arg path "${CORDON_OWNER_JSON_PATH}" \ --arg owner "${owner_token}" \ + --arg recovery_path "${CORDON_RECOVERY_JSON_PATH}" \ + --arg recovery "${current_recovery}" \ --arg uid "${initial_node_uid}" \ - --arg resource_version "${current_resource_version}" ' + --arg resource_version "${current_resource_version}" \ + --argjson was_cordoned "${was_cordoned}" ' [ {op: "test", path: $path, value: $owner}, {op: "test", path: "/metadata/uid", value: $uid}, @@ -716,10 +818,18 @@ restore_node_schedulability_if_needed() { op: "test", path: "/metadata/resourceVersion", value: $resource_version - }, - {op: "add", path: "/spec/unschedulable", value: false}, - {op: "remove", path: $path} + } ] + + (if $was_cordoned == 0 then + [{op: "add", path: "/spec/unschedulable", value: false}] + else [] end) + + (if $recovery == "" then [] else + [ + {op: "test", path: $recovery_path, value: $recovery}, + {op: "remove", path: $recovery_path} + ] + end) + + [{op: "remove", path: $path}] ' > "${cordon_release_patch_file}" if ! kubectl \ @@ -732,7 +842,105 @@ restore_node_schedulability_if_needed() { emit_safe_operation_output "uncordon" "${result_file}" return 1 fi - echo "Restored schedulability on ${node_name}." + if [[ "${was_cordoned}" == "0" ]]; then + echo "Restored schedulability on ${node_name}." + else + echo "Released bridge ownership while preserving the pre-existing cordon on ${node_name}." + fi +} + +update_bootstrap_recovery_phase() { + local node_name="$1" owner_token="$2" initial_node_uid="$3" + local desired_revision="$4" expected_phase="$5" next_phase="$6" + local result_file="$7" + local current_recovery updated_recovery current_resource_version + local was_cordoned initial_taints + + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get node "${node_name}" \ + --output json \ + > "${cordon_state_file}" 2> "${result_file}"; then + echo "::error::Could not re-read bootstrap recovery journal for ${node_name}; refusing to cross the reboot/release edge." + emit_safe_operation_output "recovery-read" "${result_file}" + return 1 + fi + current_recovery="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${cordon_state_file}")" + if ! jq -ne \ + --arg recovery "${current_recovery}" \ + --arg owner "${owner_token}" \ + --arg uid "${initial_node_uid}" \ + --arg revision "${desired_revision}" \ + --arg phase "${expected_phase}" ' + ($recovery | fromjson?) as $record + | $record != null + and ($record | keys | sort) == ([ + "desiredRevision", "initialTaints", "owner", "phase", + "uid", "v", "wasCordoned" + ] | sort) + and $record.v == 1 + and $record.owner == $owner + and $record.uid == $uid + and $record.desiredRevision == $revision + and ($record.wasCordoned == 0 or $record.wasCordoned == 1) + and ($record.initialTaints | type == "array") + and $record.phase == $phase + '; then + echo "::error::Bootstrap recovery journal for ${node_name} was missing, malformed, or changed; refusing to cross the reboot/release edge." + return 1 + fi + was_cordoned="$(jq -nr \ + --arg recovery "${current_recovery}" \ + '$recovery | fromjson | .wasCordoned')" + initial_taints="$(jq -nc \ + --arg recovery "${current_recovery}" \ + '$recovery | fromjson | .initialTaints')" + if ! node_scheduling_state_is_safe_to_reboot \ + "${cordon_state_file}" "${was_cordoned}" "${owner_token}" \ + "${initial_node_uid}" "${initial_taints}"; then + echo "::error::Bootstrap scheduling state changed on ${node_name}; refusing to cross the reboot/release edge." + return 1 + fi + current_resource_version="$(jq -er \ + '.metadata.resourceVersion' "${cordon_state_file}")" + updated_recovery="$(jq -cn \ + --arg recovery "${current_recovery}" \ + --arg phase "${next_phase}" ' + ($recovery | fromjson) + {phase: $phase} + ')" + jq -n \ + --arg owner_path "${CORDON_OWNER_JSON_PATH}" \ + --arg recovery_path "${CORDON_RECOVERY_JSON_PATH}" \ + --arg owner "${owner_token}" \ + --arg recovery "${current_recovery}" \ + --arg updated_recovery "${updated_recovery}" \ + --arg uid "${initial_node_uid}" \ + --arg resource_version "${current_resource_version}" ' + [ + {op: "test", path: $owner_path, value: $owner}, + {op: "test", path: $recovery_path, value: $recovery}, + {op: "test", path: "/metadata/uid", value: $uid}, + { + op: "test", + path: "/metadata/resourceVersion", + value: $resource_version + }, + {op: "replace", path: $recovery_path, value: $updated_recovery} + ] + ' > "${cordon_recovery_patch_file}" + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + patch node "${node_name}" \ + --type=json \ + --patch-file="${cordon_recovery_patch_file}" \ + >"${result_file}" 2>&1; then + echo "::error::Bootstrap recovery phase changed or could not be updated for ${node_name}; refusing to cross the reboot/release edge." + emit_safe_operation_output "recovery-phase" "${result_file}" + return 1 + fi } # Rollback only bootstrap cordons still owned by this invocation. A node that @@ -741,45 +949,261 @@ restore_node_schedulability_if_needed() { # newer actor took over; neither case is ours to reverse. cleanup_bootstrap_quarantine() { local state_file node_name was_cordoned owner_token initial_uid - local initial_taints current_owner + local initial_taints current_owner current_recovery expected_recovery + local expected_phase desired_revision + local cleanup_failed=0 [[ -d "${bootstrap_cordon_dir:-}" ]] || return 0 for state_file in "${bootstrap_cordon_dir}"/*.json; do [[ -e "${state_file}" ]] || continue - node_name="$(jq -er '.nodeName' "${state_file}")" || continue - was_cordoned="$(jq -er '.wasCordoned' "${state_file}")" || continue - if [[ "${was_cordoned}" == "1" ]]; then - rm -f "${state_file}" + if ! node_name="$(jq -er '.nodeName' "${state_file}")" \ + || ! was_cordoned="$(jq -er '.wasCordoned' "${state_file}")"; then + echo "::error::Could not read bootstrap recovery state from ${state_file}; the durable node journal was left intact." + cleanup_failed=1 continue fi if [[ -e "${bootstrap_retain_dir}/${node_name}" ]]; then continue fi - owner_token="$(jq -er '.ownerToken' "${state_file}")" || continue - initial_uid="$(jq -er '.initialUID' "${state_file}")" || continue - initial_taints="$(jq -c '.initialTaints' "${state_file}")" || continue + if ! owner_token="$(jq -er '.ownerToken' "${state_file}")" \ + || ! initial_uid="$(jq -er '.initialUID' "${state_file}")" \ + || ! initial_taints="$(jq -c '.initialTaints' "${state_file}")" \ + || ! expected_recovery="$(jq -er '.recoveryRecord' "${state_file}")"; then + echo "::error::Bootstrap recovery state for ${node_name} was malformed; the durable node journal was left intact." + cleanup_failed=1 + continue + fi if ! kubectl \ --context "${KUBE_CONTEXT}" \ get node "${node_name}" \ --output json \ > "${cordon_state_file}" 2>/dev/null; then + echo "::error::Could not re-read bootstrap-owned node ${node_name} during rollback; its durable recovery journal was left intact." + cleanup_failed=1 continue fi current_owner="$(jq -r \ --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ '.metadata.annotations[$owner_annotation] // ""' \ "${cordon_state_file}")" + current_recovery="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${cordon_state_file}")" if [[ "${current_owner}" != "${owner_token}" ]]; then - [[ -z "${current_owner}" ]] && rm -f "${state_file}" + if [[ -z "${current_owner}" && -z "${current_recovery}" ]]; then + rm -f "${state_file}" + elif [[ -z "${current_owner}" ]]; then + echo "::error::Bootstrap recovery journal on ${node_name} remained after its owner disappeared; refusing to discard the local recovery state." + cleanup_failed=1 + else + echo "::error::Bootstrap owner changed on ${node_name} during rollback; refusing to release the cordon." + cleanup_failed=1 + fi continue fi + expected_phase="$(jq -nr \ + --arg recovery "${expected_recovery}" \ + '$recovery | fromjson? | .phase // ""')" + if [[ "${current_recovery}" == "${expected_recovery}" \ + && "${expected_phase}" == "active" ]]; then + desired_revision="$(jq -nr \ + --arg recovery "${expected_recovery}" \ + '$recovery | fromjson? | .desiredRevision // ""')" + if [[ ! "${desired_revision}" =~ ^[0-9a-f]{64}$ ]] \ + || ! update_bootstrap_recovery_phase \ + "${node_name}" "${owner_token}" "${initial_uid}" \ + "${desired_revision}" "active" "rollback-safe" \ + "${drain_result_file}"; then + echo "::error::Could not mark bootstrap recovery on ${node_name} rollback-safe during cleanup; leaving it cordoned." + cleanup_failed=1 + continue + fi + expected_recovery="$(jq -cn \ + --arg recovery "${expected_recovery}" ' + ($recovery | fromjson) + {phase: "rollback-safe"} + ')" + fi if restore_node_schedulability_if_needed \ - "${node_name}" 0 "${owner_token}" \ + "${node_name}" "${was_cordoned}" "${owner_token}" \ "${initial_uid}" "${initial_taints}" \ - "${drain_result_file}"; then + "${drain_result_file}" "${expected_recovery}"; then rm -f "${state_file}" + else + cleanup_failed=1 fi done + return "${cleanup_failed}" +} + +reconcile_bootstrap_recovery_journals() { + local desired_revision="$1" + local node_json node_name owner_token initial_uid initial_taints + local was_cordoned phase recorded_revision recovery_record + local reconcile_failed=0 + + assert_sync_lease_held || return 1 + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + get nodes \ + -o json > "${recovery_nodes_file}"; then + echo "::error::Could not inspect durable GHCR bootstrap recovery journals; refusing a new rollout." + return 1 + fi + if ! validate_talos_node_inventory "${recovery_nodes_file}"; then + echo "::error::Node inventory was malformed while reconciling durable GHCR bootstrap recovery journals." + return 1 + fi + if ! jq -c \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" ' + .items[] + | select((.metadata.annotations[$recovery_annotation] // "") != "") + ' "${recovery_nodes_file}" > "${recovery_targets_file}"; then + echo "::error::Could not select durable GHCR bootstrap recovery journals." + return 1 + fi + if ! jq -e \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" ' + [ + .items[] + | select((.metadata.annotations[$recovery_annotation] // "") != "") + | . as $node + | ($node.metadata.annotations[$recovery_annotation] | fromjson?) as $record + | {node: $node, record: $record} + ] as $journals + | all($journals[]; + .record != null + and (.record | keys | sort) == ([ + "desiredRevision", "initialTaints", "owner", "phase", + "uid", "v", "wasCordoned" + ] | sort) + and .record.v == 1 + and (.record.owner | type == "string" and length > 0) + and (.record.uid | type == "string" and length > 0) + and (.record.desiredRevision + | type == "string" and test("^[0-9a-f]{64}$")) + and (.record.wasCordoned == 0 or .record.wasCordoned == 1) + and (.record.initialTaints | type == "array") + and (.record.phase == "rollback-safe" + or .record.phase == "active" + or .record.phase == "retain" + or .record.phase == "release-ready") + and .node.metadata.uid == .record.uid + and .node.metadata.deletionTimestamp == null + and .node.metadata.annotations[$owner_annotation] == .record.owner) + ' "${recovery_nodes_file}" >/dev/null; then + echo "::error::At least one durable GHCR bootstrap recovery journal is malformed or does not match its owner/UID; refusing every recovery mutation." + return 1 + fi + if ! jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" ' + [ + .items[] + | select((.metadata.annotations[$recovery_annotation] // "") != "") + | (.metadata.annotations[$recovery_annotation] | fromjson) + ] + | sort_by(.owner) + | group_by(.owner)[] + | select( + (map(.phase) | unique | length) > 1 + or .[0].phase == "active" + or .[0].phase == "retain" + ) + | .[0].owner + ' "${recovery_nodes_file}" > "${recovery_blocked_owners_file}"; then + echo "::error::Could not group durable GHCR bootstrap recovery journals by owner." + return 1 + fi + + while IFS= read -r node_json; do + [[ -n "${node_json}" ]] || continue + printf '%s\n' "${node_json}" > "${recovery_node_file}" + node_name="$(jq -r '.metadata.name // ""' "${recovery_node_file}")" + recovery_record="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${recovery_node_file}")" + if ! jq -e \ + --arg recovery "${recovery_record}" \ + --arg owner_annotation "${CORDON_OWNER_ANNOTATION}" \ + --arg node_name "${node_name}" ' + ($recovery | fromjson?) as $record + | $record != null + and ($record | keys | sort) == ([ + "desiredRevision", "initialTaints", "owner", "phase", + "uid", "v", "wasCordoned" + ] | sort) + and $record.v == 1 + and ($record.owner | type == "string" and length > 0) + and ($record.uid | type == "string" and length > 0) + and ($record.desiredRevision + | type == "string" and test("^[0-9a-f]{64}$")) + and ($record.wasCordoned == 0 or $record.wasCordoned == 1) + and ($record.initialTaints | type == "array") + and ($record.phase == "rollback-safe" + or $record.phase == "active" + or $record.phase == "retain" + or $record.phase == "release-ready") + and .metadata.name == $node_name + and .metadata.uid == $record.uid + and .metadata.deletionTimestamp == null + and .metadata.annotations[$owner_annotation] == $record.owner + ' "${recovery_node_file}" >/dev/null; then + echo "::error::Durable GHCR bootstrap recovery journal on ${node_name:-unknown node} is malformed or does not match its owner/UID; refusing to execute it." + reconcile_failed=1 + continue + fi + printf '%s\n' "${recovery_record}" > "${recovery_record_file}" + owner_token="$(jq -er '.owner' "${recovery_record_file}")" + initial_uid="$(jq -er '.uid' "${recovery_record_file}")" + initial_taints="$(jq -c '.initialTaints' "${recovery_record_file}")" + was_cordoned="$(jq -er '.wasCordoned' "${recovery_record_file}")" + phase="$(jq -er '.phase' "${recovery_record_file}")" + recorded_revision="$(jq -er '.desiredRevision' "${recovery_record_file}")" + + if grep -Fqx -- "${owner_token}" "${recovery_blocked_owners_file}"; then + echo "::error::Bootstrap recovery owner ${owner_token} still has an active, retained, or mixed-phase quarantine; refusing to release any node in that batch." + reconcile_failed=1 + continue + fi + + case "${phase}" in + rollback-safe) + ;; + release-ready) + if [[ "${recorded_revision}" != "${desired_revision}" ]]; then + echo "::error::Release-ready bootstrap journal on ${node_name} belongs to a different credential revision; leaving it cordoned." + reconcile_failed=1 + continue + fi + ;; + active) + echo "::error::Bootstrap node ${node_name} has an active or interrupted pre-reboot mutation; leaving it cordoned for explicit recovery." + reconcile_failed=1 + continue + ;; + retain) + echo "::error::Bootstrap node ${node_name} crossed the reboot edge without a release-ready proof; leaving it cordoned for explicit recovery." + reconcile_failed=1 + continue + ;; + esac + + if ! assert_sync_lease_held; then + reconcile_failed=1 + continue + fi + if ! restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${owner_token}" \ + "${initial_uid}" "${initial_taints}" "${drain_result_file}" \ + "${recovery_record}"; then + echo "::error::Could not reconcile durable GHCR bootstrap recovery journal on ${node_name}; leaving it cordoned." + reconcile_failed=1 + fi + done < "${recovery_targets_file}" + + return "${reconcile_failed}" } node_has_no_evictable_workloads() { @@ -878,11 +1302,12 @@ prepare_runtime_bootstrap_roll() { local pending_targets_file="$2" local node_role node_name node_ip node_mode node_uid local seed_line="" state_file was_cordoned owner_token existing_owner - local initial_taints bootstrap_owner + local initial_taints bootstrap_owner existing_recovery recovery_record local workload_rc bootstrap_seed_uid="" : > "${bootstrap_ordered_targets}" + assert_sync_lease_held || return 1 while IFS=$'\t' read -r \ node_role node_name node_ip node_mode node_uid; do @@ -959,6 +1384,14 @@ prepare_runtime_bootstrap_roll() { echo "::error::Stale node ${node_name} already has a GHCR bridge owner; refusing concurrent bootstrap quarantine." return 1 fi + existing_recovery="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${cordon_state_file}")" + if [[ -n "${existing_recovery}" ]]; then + echo "::error::Stale node ${node_name} has a GHCR bridge recovery journal without an owner; refusing bootstrap quarantine." + return 1 + fi if ! initial_taints="$(jq -ecS ' (.spec.taints // []) | map(select(( @@ -974,20 +1407,37 @@ prepare_runtime_bootstrap_roll() { if jq -e '.spec.unschedulable == true' \ "${cordon_state_file}" >/dev/null; then was_cordoned=1 - owner_token="" else was_cordoned=0 - owner_token="${bootstrap_owner}" fi + owner_token="${bootstrap_owner}" + recovery_record="$(jq -cn \ + --arg owner "${owner_token}" \ + --arg uid "${node_uid}" \ + --arg desired_revision "${desired_revision}" \ + --argjson was_cordoned "${was_cordoned}" \ + --argjson initial_taints "${initial_taints}" ' + { + v: 1, + owner: $owner, + uid: $uid, + desiredRevision: $desired_revision, + wasCordoned: $was_cordoned, + initialTaints: $initial_taints, + phase: "active" + } + ')" if ! jq -n \ --arg node_name "${node_name}" \ --arg owner_token "${owner_token}" \ + --arg recovery_record "${recovery_record}" \ --arg initial_uid "${node_uid}" \ --argjson was_cordoned "${was_cordoned}" \ --argjson initial_taints "${initial_taints}" ' { nodeName: $node_name, ownerToken: $owner_token, + recoveryRecord: $recovery_record, initialUID: $initial_uid, wasCordoned: $was_cordoned, initialTaints: $initial_taints @@ -996,12 +1446,14 @@ prepare_runtime_bootstrap_roll() { echo "::error::Could not persist bootstrap ownership state for stale node ${node_name}." return 1 fi - if [[ "${was_cordoned}" == "0" ]] \ - && ! claim_node_cordon_ownership \ + assert_sync_lease_held || return 1 + if ! claim_node_cordon_ownership \ "${node_name}" "${owner_token}" \ - "${cordon_state_file}" "${drain_result_file}"; then + "${cordon_state_file}" "${drain_result_file}" \ + "${recovery_record}"; then return 1 fi + assert_sync_lease_held || return 1 done < "${pending_targets_file}" printf '%s\n' "${seed_line}" > "${bootstrap_ordered_targets}" @@ -1019,6 +1471,7 @@ revalidate_node_scheduling_guard() { local selected_node_ip="$7" selected_node_role="$8" local operation="$9" + assert_sync_lease_held || return 1 if ! kubectl \ --context "${KUBE_CONTEXT}" \ get node "${node_name}" \ @@ -1139,10 +1592,13 @@ process_talos_node_target() { local node_ip="$5" local node_mode="$6" local node_uid="$7" - local was_cordoned=0 existing_cordon_owner="" cordon_owner_token="" + local was_cordoned=0 existing_cordon_owner="" existing_cordon_recovery="" + local cordon_owner_token="" local initial_node_uid="" initial_node_taints="[]" local bootstrap_state_file="${bootstrap_cordon_dir}/${node_name}.json" - local probe_image + local probe_image recovery_record + + assert_sync_lease_held || return 1 if [[ "${node_mode}" != "reboot" && "${node_mode}" != "image-only" ]]; then echo "::error::Unknown Talos GHCR synchronization mode '${node_mode}' for ${node_name}." @@ -1152,16 +1608,6 @@ process_talos_node_target() { "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 if [[ "${node_mode}" == "reboot" ]]; then - if ! talosctl \ - --nodes "${node_ip}" \ - patch machineconfig \ - --mode=no-reboot \ - --patch-file="${talos_auth_patch_file}" \ - >"${talos_result_file}" 2>&1; then - echo "::error::Talos node ${node_name} did not accept the Git/SOPS GHCR registry auth." - return 1 - fi - # Writing the credential is NOT enough to make a RUNNING node use it, and # this is the step whose absence caused the 2026-07-14 outage. # @@ -1241,6 +1687,7 @@ process_talos_node_target() { .nodeName == $node_name and .initialUID == $node_uid and (.ownerToken | type == "string") + and (.recoveryRecord | type == "string" and length > 0) and (.wasCordoned == 0 or .wasCordoned == 1) and (.initialTaints | type == "array") ' "${bootstrap_state_file}" >/dev/null; then @@ -1251,6 +1698,15 @@ process_talos_node_target() { initial_node_taints="$(jq -c '.initialTaints' "${bootstrap_state_file}")" was_cordoned="$(jq -er '.wasCordoned' "${bootstrap_state_file}")" cordon_owner_token="$(jq -er '.ownerToken' "${bootstrap_state_file}")" + recovery_record="$(jq -er '.recoveryRecord' "${bootstrap_state_file}")" + if ! jq -e \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + --arg recovery "${recovery_record}" \ + '.metadata.annotations[$recovery_annotation] == $recovery' \ + "${cordon_state_file}" >/dev/null; then + echo "::error::Bootstrap recovery journal changed for ${node_name}; refusing the mutation." + return 1 + fi if ! node_scheduling_state_is_safe_to_reboot \ "${cordon_state_file}" \ "${was_cordoned}" \ @@ -1279,18 +1735,65 @@ process_talos_node_target() { echo "::error::Refusing to synchronize ${node_name}: it already has a GHCR bridge cordon owner, so a previous or concurrent roll must be resolved first." return 1 fi + existing_cordon_recovery="$(jq -r \ + --arg recovery_annotation "${CORDON_RECOVERY_ANNOTATION}" \ + '.metadata.annotations[$recovery_annotation] // ""' \ + "${cordon_state_file}")" + if [[ -n "${existing_cordon_recovery}" ]]; then + echo "::error::Refusing to synchronize ${node_name}: it has a GHCR bridge recovery journal without an owner." + return 1 + fi if jq -e '.spec.unschedulable == true' \ "${cordon_state_file}" >/dev/null; then was_cordoned=1 else - cordon_owner_token="${desired_revision:0:16}-$$-${RANDOM}" - claim_node_cordon_ownership \ - "${node_name}" "${cordon_owner_token}" \ - "${cordon_state_file}" "${drain_result_file}" || return 1 + was_cordoned=0 fi + cordon_owner_token="${desired_revision:0:16}-$$-${RANDOM}" + assert_sync_lease_held || return 1 + claim_node_cordon_ownership \ + "${node_name}" "${cordon_owner_token}" \ + "${cordon_state_file}" "${drain_result_file}" || return 1 fi + # A node resourceVersion fences only the claim itself. Renew after the claim + # and re-read the owned scheduling guard so a process that lost its cluster + # transaction cannot carry stale credentials into Talos. + if ! revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${talos_result_file}" "${node_ip}" "${node_role}" \ + "credential patch"; then + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" "${recovery_record}" || true + return 1 + fi + if [[ "${node_mode}" == "reboot" ]]; then + if ! talosctl \ + --nodes "${node_ip}" \ + patch machineconfig \ + --mode=no-reboot \ + --patch-file="${talos_auth_patch_file}" \ + >"${talos_result_file}" 2>&1; then + echo "::error::Talos node ${node_name} did not accept the Git/SOPS GHCR registry auth." + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" "${recovery_record}" || return 1 + return 1 + fi + + # The Talos API call above is a concurrency window. Rebind both the selected + # machine identity and the owned scheduling state before asking Kubernetes + # to evict anything from that node. + revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" "${node_ip}" "${node_role}" "drain" || return 1 + # Drain through the Kubernetes context already proven by this deployment. # Talos v1.13's integrated --drain path fetches a separate admin kubeconfig; # this cluster's generated config targets an unreachable API endpoint. @@ -1308,7 +1811,7 @@ process_talos_node_target() { restore_node_schedulability_if_needed \ "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ "${initial_node_uid}" "${initial_node_taints}" \ - "${drain_result_file}" || return 1 + "${drain_result_file}" "${recovery_record}" || return 1 return 1 fi @@ -1322,7 +1825,7 @@ process_talos_node_target() { restore_node_schedulability_if_needed \ "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ "${initial_node_uid}" "${initial_node_taints}" \ - "${drain_result_file}" || return 1 + "${drain_result_file}" "${recovery_record}" || return 1 return 1 fi @@ -1340,8 +1843,13 @@ process_talos_node_target() { # Talos reboot cannot terminate a workload behind Kubernetes' back. Keep # --wait explicit so Kubernetes readiness is checked only after a new boot. if [[ -f "${bootstrap_state_file}" ]]; then + update_bootstrap_recovery_phase \ + "${node_name}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${desired_revision}" \ + "active" "retain" "${drain_result_file}" || return 1 : > "${bootstrap_retain_dir}/${node_name}" fi + assert_sync_lease_held || return 1 if ! talosctl \ --nodes "${node_ip}" \ reboot \ @@ -1392,6 +1900,12 @@ process_talos_node_target() { fi fi + revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${talos_result_file}" "${node_ip}" "${node_role}" \ + "image pull" || return 1 + # Credential validity against GHCR (see the caveat above: this is not, on # its own, proof that containerd is using it — the reboot is). if ! talosctl \ @@ -1403,6 +1917,12 @@ process_talos_node_target() { return 1 fi + revalidate_node_scheduling_guard \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${talos_result_file}" "${node_ip}" "${node_role}" \ + "runtime pull proof" || return 1 + # Talos' image API authenticates from machine config, not through the # kubelet's running CRI client. Before this freshly rebooted node can # receive workloads, prove both private images through kubelet/containerd @@ -1416,24 +1936,15 @@ process_talos_node_target() { fi fi - # Restore original scheduling intent only after the uncached pull succeeds. - # On failure, a cordon claimed by this bridge remains as a fail-closed signal - # that the node must not receive new pods until registry access is repaired. - rm -f "${bootstrap_retain_dir}/${node_name}" - restore_node_schedulability_if_needed \ + revalidate_node_scheduling_guard \ "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ "${initial_node_uid}" "${initial_node_taints}" \ - "${drain_result_file}" || return 1 - - # The scheduling release is a Kubernetes mutation and therefore another - # autoscaler replacement boundary. Rebind the selected UID and InternalIP - # at the last possible point before the final Talos proof-marker write. - revalidate_selected_node_identity_before_mutation \ - "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 + "${talos_result_file}" "${node_ip}" "${node_role}" \ + "revision marker" || return 1 - # Recorded LAST, and only now: the marker means "this node's containerd has - # provably loaded this credential revision", so it must not be written - # before the reboot that makes that true. + # Record the proof only after the real runtime checks, while the selected + # machine remains protected by the owned cordon. Releasing ownership first + # would let a concurrent credential revision race this marker write. if ! talosctl \ --nodes "${node_ip}" \ patch machineconfig \ @@ -1443,6 +1954,33 @@ process_talos_node_target() { echo "::error::Talos node ${node_name} proved GHCR access but could not record the synchronized credential revision." return 1 fi + + if [[ -f "${bootstrap_state_file}" ]]; then + update_bootstrap_recovery_phase \ + "${node_name}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${desired_revision}" \ + "retain" "release-ready" "${drain_result_file}" || return 1 + recovery_record="$(jq -cn \ + --arg recovery "${recovery_record}" ' + ($recovery | fromjson) + {phase: "release-ready"} + ')" + fi + + # Restore original scheduling intent only after the proof marker is durable. + # Residual ownership makes the next selector fail closed rather than letting + # a release failure masquerade as a clean node. + rm -f "${bootstrap_retain_dir}/${node_name}" + assert_sync_lease_held || return 1 + restore_node_schedulability_if_needed \ + "${node_name}" "${was_cordoned}" "${cordon_owner_token}" \ + "${initial_node_uid}" "${initial_node_taints}" \ + "${drain_result_file}" "${recovery_record}" || return 1 + + # The release is the final replacement boundary before this UID is marked + # processed in the convergence loop. Rebind it once more so a replacement + # cannot inherit the old machine's proof within this pass. + revalidate_selected_node_identity_before_mutation \ + "${node_name}" "${node_uid}" "${node_ip}" "${node_role}" || return 1 } validate_talos_node_inventory() { @@ -1499,6 +2037,8 @@ sync_talos_registry_auth() { "${sync_result_file}" \ "${runtime_proved_targets_file}" + reconcile_bootstrap_recovery_journals "${desired_revision}" || return 1 + while ((convergence_attempt < TALOS_CONVERGENCE_ATTEMPTS)); do convergence_attempt=$((convergence_attempt + 1)) if ! kubectl \ @@ -1707,25 +2247,341 @@ base64 < "${docker_config}" \ | jq -Rs '{data: {".dockerconfigjson": .}}' \ > "${patch_file}" -# Patch only the root Flux Secret payload, preserving KSail ownership metadata. -patch_root_secret() { - kubectl \ +sync_lease_is_available() { + # Talos machine-config writes do not expose a downstream fencing token. An + # expired shell process could resume after an automatic timeout takeover and + # write stale credentials even if every Kubernetes write uses CAS. Therefore + # expiry is diagnostic only: a non-empty holder always requires explicit + # recovery after the old process has been proven dead. + jq -e '(.spec.holderIdentity // "") == ""' \ + "${sync_lease_file}" >/dev/null +} + +acquire_sync_lease() { + local desired_revision="$1" + local attempt now resource_version current_holder transitions + + sync_lease_holder="${desired_revision:0:16}-$$-${RANDOM}" + export FLUX_GHCR_SYNC_LEASE_HOLDER="${sync_lease_holder}" + for attempt in 1 2 3; do + : > "${sync_lease_file}" + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + get lease "${SYNC_LEASE_NAME}" \ + --ignore-not-found \ + -o json > "${sync_lease_file}"; then + echo "::error::Could not inspect the GHCR synchronization lease." + return 1 + fi + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + if [[ ! -s "${sync_lease_file}" ]]; then + jq -n \ + --arg name "${SYNC_LEASE_NAME}" \ + --arg holder "${sync_lease_holder}" \ + --arg now "${now}" \ + --argjson duration "${SYNC_LEASE_DURATION_SECONDS}" ' + { + apiVersion: "coordination.k8s.io/v1", + kind: "Lease", + metadata: {name: $name, namespace: "flux-system"}, + spec: { + holderIdentity: $holder, + leaseDurationSeconds: $duration, + acquireTime: $now, + renewTime: $now, + leaseTransitions: 0 + } + } + ' > "${sync_lease_manifest_file}" + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + create --filename "${sync_lease_manifest_file}" \ + > "${sync_lease_result_file}" 2>&1; then + sync_lease_acquired=true + sync_lease_heartbeat_loop & + sync_lease_heartbeat_pid=$! + return 0 + fi + continue + fi + if ! jq -e ' + (.metadata.resourceVersion | type == "string" and length > 0) + and ((.spec.holderIdentity // "") | type == "string") + and (.spec.leaseDurationSeconds | type == "number" and . > 0) + and ((.spec.renewTime // .spec.acquireTime // "") + | type == "string" and length > 0) + and ((.spec.leaseTransitions // 0) | type == "number") + ' "${sync_lease_file}" >/dev/null; then + echo "::error::The GHCR synchronization lease is malformed; refusing cluster mutation." + return 1 + fi + if ! sync_lease_is_available; then + echo "::error::Another GHCR synchronization transaction holds the synchronization lease; automatic expiry takeover is disabled because Talos writes cannot be fenced. Prove the prior process is dead before explicitly recovering the Lease." + return 1 + fi + resource_version="$(jq -er '.metadata.resourceVersion' "${sync_lease_file}")" + current_holder="$(jq -r '.spec.holderIdentity // ""' "${sync_lease_file}")" + transitions="$(jq -er '(.spec.leaseTransitions // 0) + 1' "${sync_lease_file}")" + jq -n \ + --arg resource_version "${resource_version}" \ + --arg current_holder "${current_holder}" \ + --arg holder "${sync_lease_holder}" \ + --arg now "${now}" \ + --argjson duration "${SYNC_LEASE_DURATION_SECONDS}" \ + --argjson transitions "${transitions}" ' + [ + {op: "test", path: "/metadata/resourceVersion", value: $resource_version}, + {op: "test", path: "/spec/holderIdentity", value: $current_holder}, + {op: "replace", path: "/spec/holderIdentity", value: $holder}, + {op: "replace", path: "/spec/leaseDurationSeconds", value: $duration}, + {op: "replace", path: "/spec/acquireTime", value: $now}, + {op: "replace", path: "/spec/renewTime", value: $now}, + {op: "replace", path: "/spec/leaseTransitions", value: $transitions} + ] + ' > "${sync_lease_patch_file}" + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + patch lease "${SYNC_LEASE_NAME}" \ + --type=json \ + --patch-file="${sync_lease_patch_file}" \ + > "${sync_lease_result_file}" 2>&1; then + sync_lease_acquired=true + sync_lease_heartbeat_loop & + sync_lease_heartbeat_pid=$! + return 0 + fi + done + + echo "::error::Could not atomically acquire the GHCR synchronization lease after concurrent updates." + return 1 +} + +renew_sync_lease() { + local invocation_id="$$-${RANDOM}" + local lease_file="${work_dir}/sync-lease-renew-${invocation_id}.json" + local patch_file_local="${work_dir}/sync-lease-renew-patch-${invocation_id}.json" + local result_file="${work_dir}/sync-lease-renew-result-${invocation_id}.txt" + local resource_version now + + [[ "${sync_lease_acquired}" == "true" && -n "${sync_lease_holder}" ]] || return 1 + if ! kubectl \ --context "${KUBE_CONTEXT}" \ --namespace flux-system \ - patch secret ksail-registry-credentials \ - --type=merge \ - --patch-file="${patch_file}" + get lease "${SYNC_LEASE_NAME}" \ + -o json > "${lease_file}"; then + return 1 + fi + resource_version="$(jq -er \ + --arg holder "${sync_lease_holder}" ' + select(.spec.holderIdentity == $holder) + | .metadata.resourceVersion + ' "${lease_file}")" || return 1 + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + jq -n \ + --arg resource_version "${resource_version}" \ + --arg holder "${sync_lease_holder}" \ + --arg now "${now}" \ + --argjson duration "${SYNC_LEASE_DURATION_SECONDS}" ' + [ + {op: "test", path: "/metadata/resourceVersion", value: $resource_version}, + {op: "test", path: "/spec/holderIdentity", value: $holder}, + {op: "replace", path: "/spec/renewTime", value: $now}, + {op: "replace", path: "/spec/leaseDurationSeconds", value: $duration} + ] + ' > "${patch_file_local}" + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + patch lease "${SYNC_LEASE_NAME}" \ + --type=json \ + --patch-file="${patch_file_local}" \ + > "${result_file}" 2>&1; then + return 0 + fi + + # Foreground guards and the heartbeat can legitimately race each other. A + # resourceVersion conflict is harmless when the winning renewal still belongs + # to this transaction and remains live; re-read before declaring lease loss. + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + get lease "${SYNC_LEASE_NAME}" \ + -o json > "${lease_file}"; then + return 1 + fi + jq -e \ + --arg holder "${sync_lease_holder}" \ + --argjson now_epoch "$(date -u +%s)" ' + .spec.holderIdentity == $holder + and (((.spec.renewTime // .spec.acquireTime) + | sub("\\.[0-9]+Z$"; "Z") + | fromdateiso8601) + .spec.leaseDurationSeconds > $now_epoch) + ' "${lease_file}" >/dev/null } -patch_variables_base() { - kubectl \ +sync_lease_heartbeat_loop() { + local elapsed + while true; do + for ((elapsed = 0; elapsed < SYNC_LEASE_HEARTBEAT_SECONDS; elapsed++)); do + sleep 1 + done + if ! renew_sync_lease; then + : > "${sync_lease_lost_file}" + return 1 + fi + done +} + +assert_sync_lease_held() { + if [[ -e "${sync_lease_lost_file}" ]] || ! renew_sync_lease; then + echo "::error::The GHCR synchronization lease was lost; refusing further cluster mutation." + return 1 + fi +} + +release_sync_lease() { + local lease_file="${work_dir}/sync-lease-release.json" + local patch_file_local="${work_dir}/sync-lease-release-patch.json" + local result_file="${work_dir}/sync-lease-release-result.txt" + local resource_version now + + if [[ -n "${sync_lease_heartbeat_pid}" ]]; then + kill "${sync_lease_heartbeat_pid}" 2>/dev/null || true + wait "${sync_lease_heartbeat_pid}" 2>/dev/null || true + sync_lease_heartbeat_pid="" + fi + [[ "${sync_lease_acquired}" == "true" && -n "${sync_lease_holder}" ]] || return 0 + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace flux-system \ + get lease "${SYNC_LEASE_NAME}" \ + -o json > "${lease_file}"; then + return 1 + fi + resource_version="$(jq -er \ + --arg holder "${sync_lease_holder}" ' + select(.spec.holderIdentity == $holder) + | .metadata.resourceVersion + ' "${lease_file}")" || return 1 + now="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + jq -n \ + --arg resource_version "${resource_version}" \ + --arg holder "${sync_lease_holder}" \ + --arg now "${now}" ' + [ + {op: "test", path: "/metadata/resourceVersion", value: $resource_version}, + {op: "test", path: "/spec/holderIdentity", value: $holder}, + {op: "replace", path: "/spec/holderIdentity", value: ""}, + {op: "replace", path: "/spec/leaseDurationSeconds", value: 1}, + {op: "replace", path: "/spec/renewTime", value: $now} + ] + ' > "${patch_file_local}" + if ! kubectl \ --context "${KUBE_CONTEXT}" \ --namespace flux-system \ - patch secret variables-base \ - --type=merge \ - --patch-file="${variables_patch_file}" + patch lease "${SYNC_LEASE_NAME}" \ + --type=json \ + --patch-file="${patch_file_local}" \ + > "${result_file}" 2>&1; then + return 1 + fi + sync_lease_holder="" + sync_lease_acquired=false } +# Every Secret write is fenced by the resourceVersion observed after a +# foreground lease renewal. A delayed request from an expired lease holder can +# therefore never overwrite a newer transaction that has already updated the +# same Secret. Keep the credential payload in files so it never enters argv. +patch_secret_data_with_cas() { + local namespace="$1" + local name="$2" + local data_key="$3" + local payload_file="$4" + local state_file="$5" + local cas_patch_file="$6" + local resource_version attempt patch_status=1 + + for attempt in 1 2 3; do + assert_sync_lease_held || return 1 + if ! kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace "${namespace}" \ + get secret "${name}" \ + -o json > "${state_file}"; then + echo "::error::Could not inspect Secret ${namespace}/${name} for an atomic credential update." + return 1 + fi + resource_version="$(jq -er ' + .metadata.resourceVersion + | select(type == "string" and length > 0) + ' "${state_file}")" || { + echo "::error::Secret ${namespace}/${name} has no valid resourceVersion; refusing a non-atomic credential update." + return 1 + } + jq -n \ + --arg resource_version "${resource_version}" \ + --arg data_path "/data/${data_key}" \ + --arg data_key "${data_key}" \ + --slurpfile payload "${payload_file}" ' + ($payload[0].data[$data_key] // null) as $value + | if ($value | type) != "string" or ($value | length) == 0 then + error("credential payload is missing its data key") + else + [ + {op: "test", path: "/metadata/resourceVersion", value: $resource_version}, + {op: "add", path: $data_path, value: $value} + ] + end + ' > "${cas_patch_file}" + + # Renew again after the read/build window. If a stale request lands after + # this point, the captured Secret resourceVersion rejects it; if it lands + # first, the current holder retries and deterministically wins. + assert_sync_lease_held || return 1 + if kubectl \ + --context "${KUBE_CONTEXT}" \ + --namespace "${namespace}" \ + patch secret "${name}" \ + --type=json \ + --patch-file="${cas_patch_file}"; then + return 0 + else + patch_status=$? + fi + assert_sync_lease_held || return 1 + done + + echo "::error::Could not atomically update Secret ${namespace}/${name} after concurrent writes." + return "${patch_status}" +} + +# Patch only the root Flux Secret payload, preserving KSail ownership metadata. +patch_root_secret() { + patch_secret_data_with_cas \ + flux-system \ + ksail-registry-credentials \ + .dockerconfigjson \ + "${patch_file}" \ + "${root_secret_state_file}" \ + "${root_secret_cas_patch_file}" +} + +patch_variables_base() { + patch_secret_data_with_cas \ + flux-system \ + variables-base \ + ghcr_dockerconfigjson \ + "${variables_patch_file}" \ + "${variables_secret_state_file}" \ + "${variables_secret_cas_patch_file}" +} + +acquire_sync_lease "${pull_revision}" + # A fresh DR cluster does not have variables-base or the ESO fan-out resources # until its first Flux reconcile. In that case the current artifact creates the # chain from the same SOPS value, so only the root bootstrap patch is needed. diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go index 5329cba88..e3e917b5b 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_commands_test.go @@ -222,11 +222,24 @@ func fakeTalosctl(args []string) int { patch["username"] != os.Getenv("EXPECTED_PULL_USERNAME") || patch["password"] != os.Getenv("EXPECTED_PULL_TOKEN") { return commandFailure(93, "invalid RegistryAuthConfig") } + nodeName := fakeNodeName(node) + if nodeName == "" || !markerExists("cordoned-"+nodeName) || + markerContent("cordon-owner-"+nodeName) == "" { + return commandFailure(93, "Talos auth mutation lacked an owned Kubernetes cordon") + } + if markerContent("cordon-recovery-"+nodeName) != "" && + fakeRecoveryPhase(nodeName) != "active" { + return commandFailure(93, "bootstrap Talos auth mutation lacked an active recovery journal") + } appendTalosOperation("talos-auth:" + node) if talosFailure(node, "auth") { return commandFailure(45, "talos auth failed with %s", os.Getenv("EXPECTED_PULL_TOKEN")) } touchMarker("talos-auth-" + node) + if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_AUTH_NODE") { + removeMarker("cordoned-" + nodeName) + appendEnvFile("OPERATION_LOG", "operator-uncordon-after-auth:"+nodeName+"\n") + } return 0 } if markerExists("talos-auth-" + node) { @@ -239,6 +252,15 @@ func fakeTalosctl(args []string) int { if !markerExists("talos-remove-"+node) || !markerExists("talos-pull-"+node) { return commandFailure(93, "revision preceded registry pull proof") } + nodeName := fakeNodeName(node) + if nodeName == "" || !markerExists("cordoned-"+nodeName) || + markerContent("cordon-owner-"+nodeName) == "" { + return commandFailure(93, "Talos revision mutation lacked an owned Kubernetes cordon") + } + if markerContent("cordon-recovery-"+nodeName) != "" && + fakeRecoveryPhase(nodeName) != "retain" { + return commandFailure(93, "bootstrap Talos revision mutation lacked a retained recovery journal") + } machine, _ := patch["machine"].(map[string]any) annotations, _ := machine["nodeAnnotations"].(map[string]any) if annotations["platform.devantler.tech/ghcr-pull-verified-revision-v2"] != os.Getenv("EXPECTED_GHCR_REVISION") || @@ -279,6 +301,11 @@ func fakeTalosctl(args []string) int { if !markerExists("talos-auth-"+node) && os.Getenv("FAKE_TALOS_NODES_CURRENT") != "true" { return commandFailure(93, "image-only cache mutation lacks proof") } + nodeName := fakeNodeName(node) + if nodeName == "" || !markerExists("cordoned-"+nodeName) || + markerContent("cordon-owner-"+nodeName) == "" { + return commandFailure(93, "Talos image removal lacked an owned Kubernetes cordon") + } image := argumentAfter(args, "remove") operation := "talos-remove:" + node + ":" + image appendTalosOperation(operation) @@ -290,6 +317,10 @@ func fakeTalosctl(args []string) int { return commandFailure(49, "talos remove failed") } touchMarker("talos-remove-" + node) + if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_REMOVE_NODE") { + removeMarker("cordoned-" + nodeName) + appendEnvFile("OPERATION_LOG", "operator-uncordon-after-remove:"+nodeName+"\n") + } return 0 } if containsSequence(args, "image", "pull") { @@ -305,6 +336,11 @@ func fakeTalosctl(args []string) int { if !markerExists("talos-remove-" + node) { return commandFailure(93, "cached image not removed") } + nodeName := fakeNodeName(node) + if nodeName == "" || !markerExists("cordoned-"+nodeName) || + markerContent("cordon-owner-"+nodeName) == "" { + return commandFailure(93, "Talos image pull lacked an owned Kubernetes cordon") + } image := argumentAfter(args, "pull") operation := "talos-pull:" + node + ":" + image appendTalosOperation(operation) @@ -312,11 +348,27 @@ func fakeTalosctl(args []string) int { return commandFailure(47, "talos pull failed with %s", os.Getenv("EXPECTED_PULL_TOKEN")) } touchMarker("talos-pull-" + node) + if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_PULL_NODE") { + removeMarker("cordoned-" + nodeName) + appendEnvFile("OPERATION_LOG", "operator-uncordon-after-pull:"+nodeName+"\n") + } return 0 } return commandFailure(93, "unexpected talosctl invocation") } +func fakeRecoveryPhase(nodeName string) string { + var recovery map[string]any + if err := json.Unmarshal( + []byte(markerContent("cordon-recovery-"+nodeName)), + &recovery, + ); err != nil { + return "" + } + phase, _ := recovery["phase"].(string) + return phase +} + func fakeKubectl(args []string) int { return fakeKubectlImplementation(args) } diff --git a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go index a1b6bce34..e4e523901 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fake_kubectl_test.go @@ -31,6 +31,12 @@ func fakeKubectlImplementation(args []string) int { } switch { + case containsSequence(args, "get", "lease"): + return fakeKubectlGetSyncLease(args, namespace) + case containsSequence(args, "patch", "lease"): + return fakeKubectlPatchSyncLease(args, namespace, patchFile) + case containsArg(args, "create") && manifestFile != "" && fakeManifestKind(manifestFile) == "Lease": + return fakeKubectlCreateSyncLease(namespace, manifestFile) case containsSequence(args, "get", "nodes"): return fakeKubectlGetNodes() case containsSequence(args, "get", "pods"): @@ -83,6 +89,119 @@ func fakeKubectlImplementation(args []string) int { return commandFailure(91, "unexpected kubectl invocation: %s", strings.Join(args, " ")) } +func fakeManifestKind(path string) string { + var manifest map[string]any + if err := json.Unmarshal([]byte(mustReadCommandFile(path)), &manifest); err != nil { + return "" + } + kind, _ := manifest["kind"].(string) + return kind +} + +func fakeKubectlGetSyncLease(args []string, namespace string) int { + if namespace != "flux-system" || argumentAfter(args, "lease") != "ghcr-auth-refresh" || + (!containsArg(args, "-o") && !containsArg(args, "--output")) { + return commandFailure(91, "invalid synchronization lease lookup") + } + holder := markerContent("sync-lease-holder") + if !markerExists("sync-lease-holder") { + if os.Getenv("FAKE_HELD_SYNC_LEASE") != "true" { + if !containsArg(args, "--ignore-not-found") { + return commandFailure(44, "lease not found") + } + return 0 + } + holder = "other-live-transaction" + } + defaultLeaseTime := "2999-01-01T00:00:00Z" + if os.Getenv("FAKE_EXPIRED_SYNC_LEASE") == "true" { + defaultLeaseTime = "2000-01-01T00:00:00Z" + } + fmt.Println(encodeJSON(map[string]any{ + "metadata": map[string]any{ + "name": "ghcr-auth-refresh", + "namespace": "flux-system", + "resourceVersion": defaultString(markerContent("sync-lease-resource-version"), "10"), + }, + "spec": map[string]any{ + "holderIdentity": holder, + "leaseDurationSeconds": parseInt(defaultString(markerContent("sync-lease-duration"), "120"), 120), + "acquireTime": defaultString(markerContent("sync-lease-acquire-time"), defaultLeaseTime), + "renewTime": defaultString(markerContent("sync-lease-renew-time"), defaultLeaseTime), + "leaseTransitions": parseInt(defaultString(markerContent("sync-lease-transitions"), "0"), 0), + }, + })) + return 0 +} + +func fakeKubectlCreateSyncLease(namespace, manifestFile string) int { + if namespace != "flux-system" || markerExists("sync-lease-holder") { + return commandFailure(45, "synchronization lease already exists") + } + var manifest map[string]any + if err := json.Unmarshal([]byte(mustReadCommandFile(manifestFile)), &manifest); err != nil { + return commandFailure(91, "parse synchronization lease manifest: %v", err) + } + metadata, _ := manifest["metadata"].(map[string]any) + spec, _ := manifest["spec"].(map[string]any) + holder, _ := spec["holderIdentity"].(string) + if manifest["apiVersion"] != "coordination.k8s.io/v1" || manifest["kind"] != "Lease" || + metadata["name"] != "ghcr-auth-refresh" || metadata["namespace"] != "flux-system" || + holder == "" || holder != os.Getenv("FLUX_GHCR_SYNC_LEASE_HOLDER") { + return commandFailure(91, "invalid synchronization lease manifest") + } + setMarkerContent("sync-lease-holder", holder) + setMarkerContent("sync-lease-resource-version", "10") + setMarkerContent("sync-lease-duration", fmt.Sprint(spec["leaseDurationSeconds"])) + setMarkerContent("sync-lease-acquire-time", fmt.Sprint(spec["acquireTime"])) + setMarkerContent("sync-lease-renew-time", fmt.Sprint(spec["renewTime"])) + setMarkerContent("sync-lease-transitions", fmt.Sprint(spec["leaseTransitions"])) + fmt.Println("lease.coordination.k8s.io/ghcr-auth-refresh created") + return 0 +} + +func fakeKubectlPatchSyncLease(args []string, namespace, patchFile string) int { + if namespace != "flux-system" || argumentAfter(args, "lease") != "ghcr-auth-refresh" || + !containsArg(args, "--type=json") || patchFile == "" || !markerExists("sync-lease-holder") { + return commandFailure(91, "invalid synchronization lease patch") + } + var patch []jsonPatchOperation + if err := json.Unmarshal([]byte(mustReadCommandFile(patchFile)), &patch); err != nil { + return commandFailure(91, "parse synchronization lease patch: %v", err) + } + currentResourceVersion := defaultString(markerContent("sync-lease-resource-version"), "10") + currentHolder := markerContent("sync-lease-holder") + if !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) || + !hasPatchOperation(patch, "test", "/spec/holderIdentity", currentHolder) { + return commandFailure(56, "synchronization lease CAS failed") + } + if os.Getenv("FAKE_SYNC_LEASE_RENEW_CONFLICT_ONCE") == "true" && + !markerExists("sync-lease-renew-conflict") && + !hasPatchPath(patch, "replace", "/spec/holderIdentity") && + hasPatchPath(patch, "replace", "/spec/renewTime") { + setMarkerContent("sync-lease-renew-time", patchValueString(patch, "replace", "/spec/renewTime")) + setMarkerContent("sync-lease-resource-version", incrementDecimal(currentResourceVersion)) + touchMarker("sync-lease-renew-conflict") + return commandFailure(56, "simulated same-holder lease renewal race") + } + if holder := patchValueString(patch, "replace", "/spec/holderIdentity"); hasPatchPath(patch, "replace", "/spec/holderIdentity") { + setMarkerContent("sync-lease-holder", holder) + } + for path, marker := range map[string]string{ + "/spec/leaseDurationSeconds": "sync-lease-duration", + "/spec/acquireTime": "sync-lease-acquire-time", + "/spec/renewTime": "sync-lease-renew-time", + "/spec/leaseTransitions": "sync-lease-transitions", + } { + if hasPatchPath(patch, "replace", path) { + setMarkerContent(marker, patchValueString(patch, "replace", path)) + } + } + setMarkerContent("sync-lease-resource-version", incrementDecimal(currentResourceVersion)) + fmt.Println("lease.coordination.k8s.io/ghcr-auth-refresh patched") + return 0 +} + func fakeKubectlGetNodes() int { if os.Getenv("FAKE_NODE_DISCOVERY_FAIL") == "true" { return commandFailure(46, "node discovery failed") @@ -103,9 +222,18 @@ func fakeKubectlGetNodes() int { } if os.Getenv("FAKE_ALL_TALOS_NODES_STALE") == "true" { for _, node := range nodes { - nodeMap := node.(map[string]any) - metadata := nodeMap["metadata"].(map[string]any) - annotations := metadata["annotations"].(map[string]any) + nodeMap, ok := node.(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node object") + } + metadata, ok := nodeMap["metadata"].(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node metadata") + } + annotations, ok := metadata["annotations"].(map[string]any) + if !ok { + return commandFailure(91, "invalid fake node annotations") + } delete(annotations, "platform.devantler.tech/ghcr-pull-verified-revision-v2") delete(annotations, "platform.devantler.tech/ghcr-pull-verified-image-v2") } @@ -208,6 +336,12 @@ func fakeInventoryNode( if verifiedImage != "" { annotations["platform.devantler.tech/ghcr-pull-verified-image-v2"] = verifiedImage } + if owner := markerContent("cordon-owner-" + name); owner != "" { + annotations["platform.devantler.tech/ghcr-auth-drain-owner"] = owner + } + if recovery := markerContent("cordon-recovery-" + name); recovery != "" { + annotations["platform.devantler.tech/ghcr-auth-drain-recovery"] = recovery + } status := map[string]any{ "addresses": []any{ map[string]any{"type": "InternalIP", "address": internalIP}, @@ -299,6 +433,21 @@ func fakeKubectlGetNode(args []string) int { } return 0 } + if nodeName == os.Getenv("FAKE_RECOVERY_ADVANCES_BEFORE_RELEASE_NODE") && + !markerExists("recovery-advanced-before-release-"+nodeName) { + var recoveryRecord map[string]any + if err := json.Unmarshal( + []byte(markerContent("cordon-recovery-"+nodeName)), + &recoveryRecord, + ); err == nil && recoveryRecord["phase"] == "rollback-safe" { + recoveryRecord["phase"] = "active" + setMarkerContent("cordon-recovery-"+nodeName, encodeJSON(recoveryRecord)) + currentResourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") + setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) + touchMarker("recovery-advanced-before-release-" + nodeName) + appendEnvFile("OPERATION_LOG", "concurrent-recovery-phase:"+nodeName+":active\n") + } + } nodeUID := nodeName + "-uid" nodeIP, controlPlane := fakeNodeAddress(nodeName) @@ -326,6 +475,9 @@ func fakeKubectlGetNode(args []string) int { if owner := markerContent("cordon-owner-" + nodeName); owner != "" { annotations["platform.devantler.tech/ghcr-auth-drain-owner"] = owner } + if recovery := markerContent("cordon-recovery-" + nodeName); recovery != "" { + annotations["platform.devantler.tech/ghcr-auth-drain-recovery"] = recovery + } cordoned := wordListContains(os.Getenv("FAKE_CORDONED_NODES"), nodeName) || markerExists("cordoned-"+nodeName) if nodeName == os.Getenv("FAKE_EXTERNAL_UNCORDON_AFTER_READY_NODE") && markerExists("ready-"+nodeName) { cordoned = false @@ -424,6 +576,22 @@ func fakeNodeAddress(nodeName string) (string, bool) { } } +func fakeNodeName(nodeAddress string) string { + for _, nodeName := range []string{ + "prod-worker-1", + "prod-worker-2", + "prod-control-plane-1", + "prod-control-plane-2", + "prod-control-plane-3", + } { + address, _ := fakeNodeAddress(nodeName) + if address == nodeAddress { + return nodeName + } + } + return "" +} + func fakeKubectlDrain(args []string) int { nodeName := argumentAfter(args, "drain") if nodeName == "" || !containsArg(args, "--ignore-daemonsets") || @@ -478,6 +646,48 @@ func fakeKubectlPatchNode(args []string, patchFile string) int { } currentResourceVersion := defaultString(markerContent("resource-version-"+nodeName), "10") isClaim := hasPatchOperation(patch, "add", "/spec/unschedulable", true) + isRecoveryPhase := hasPatchPath( + patch, + "replace", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + ) + if isRecoveryPhase { + expectedOwner := patchValueString( + patch, + "test", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner", + ) + expectedRecovery := patchValueString( + patch, + "test", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + ) + updatedRecovery := patchValueString( + patch, + "replace", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + ) + if nodeName == os.Getenv("FAKE_RECOVERY_PHASE_FAIL_NODE") || + expectedOwner == "" || expectedOwner != markerContent("cordon-owner-"+nodeName) || + expectedRecovery == "" || expectedRecovery != markerContent("cordon-recovery-"+nodeName) || + updatedRecovery == "" || + !hasPatchOperation(patch, "test", "/metadata/uid", nodeName+"-uid") || + !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) { + return commandFailure(56, "invalid bootstrap recovery phase update") + } + var recoveryRecord map[string]any + if err := json.Unmarshal([]byte(updatedRecovery), &recoveryRecord); err != nil { + return commandFailure(56, "invalid bootstrap recovery JSON") + } + phase, _ := recoveryRecord["phase"].(string) + if phase != "rollback-safe" && phase != "active" && phase != "retain" && phase != "release-ready" { + return commandFailure(56, "invalid bootstrap recovery phase") + } + setMarkerContent("cordon-recovery-"+nodeName, updatedRecovery) + setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) + appendEnvFile("OPERATION_LOG", "recovery-phase:"+nodeName+":"+phase+"\n") + return 0 + } if isClaim { if nodeName == os.Getenv("FAKE_CORDON_BEFORE_CLAIM_NODE") { touchMarker("cordoned-" + nodeName) @@ -494,9 +704,25 @@ func fakeKubectlPatchNode(args []string, patchFile string) int { return commandFailure(56, "atomic cordon claim omitted node UID") } setMarkerContent("cordon-owner-"+nodeName, owner) + if recovery := patchValueString( + patch, + "add", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + ); recovery != "" { + setMarkerContent("cordon-recovery-"+nodeName, recovery) + } touchMarker("cordoned-" + nodeName) setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) appendEnvFile("OPERATION_LOG", "node-claim-cordon:"+nodeName+"\n") + if os.Getenv("FAKE_SYNC_LEASE_LOST_AFTER_FIRST_CLAIM") == "true" && + !markerExists("sync-lease-lost-after-claim") { + setMarkerContent("sync-lease-holder", "newer-transaction") + setMarkerContent( + "sync-lease-resource-version", + incrementDecimal(defaultString(markerContent("sync-lease-resource-version"), "10")), + ) + touchMarker("sync-lease-lost-after-claim") + } return 0 } @@ -511,15 +737,33 @@ func fakeKubectlPatchNode(args []string, patchFile string) int { patch[0].Path != "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner" || !hasPatchOperation(patch, "test", "/metadata/uid", nodeName+"-uid") || !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) || - !hasPatchOperation(patch, "add", "/spec/unschedulable", false) || !hasPatchPath(patch, "remove", "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-owner") { return commandFailure(56, "invalid atomic cordon release") } - appendEnvFile("OPERATION_LOG", "node-uncordon:"+nodeName+"\n") + currentRecovery := markerContent("cordon-recovery-" + nodeName) + if currentRecovery != "" && + (!hasPatchOperation( + patch, + "test", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + currentRecovery, + ) || !hasPatchPath( + patch, + "remove", + "/metadata/annotations/platform.devantler.tech~1ghcr-auth-drain-recovery", + )) { + return commandFailure(56, "atomic cordon release omitted recovery journal") + } setMarkerContent("resource-version-"+nodeName, incrementDecimal(currentResourceVersion)) removeMarker("cordon-owner-" + nodeName) - removeMarker("cordoned-" + nodeName) - touchMarker("uncordoned-" + nodeName) + removeMarker("cordon-recovery-" + nodeName) + if hasPatchOperation(patch, "add", "/spec/unschedulable", false) { + appendEnvFile("OPERATION_LOG", "node-uncordon:"+nodeName+"\n") + removeMarker("cordoned-" + nodeName) + touchMarker("uncordoned-" + nodeName) + } else { + appendEnvFile("OPERATION_LOG", "node-release-cordon-owner:"+nodeName+"\n") + } return 0 } @@ -671,8 +915,21 @@ func fakeKubectlGetRuntimeProbe(args []string) int { if (wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_NODES"), probeNode) && !markerExists("talos-reboot-"+probeIP)) || wordListContains(os.Getenv("FAKE_RUNTIME_PULL_FAIL_IMAGES"), probeImage) { + failureMessage := os.Getenv("FAKE_RUNTIME_PULL_FAILURE_MESSAGE") + if failureMessage == "__EMPTY__" { + failureMessage = "" + } else { + failureMessage = defaultString( + failureMessage, + "failed to authorize: unexpected status from GET request to https://ghcr.io/token?scope=repository%3Adevantler-tech%2Fwedding-app%3Apull: 403 Forbidden", + ) + } status["containerStatuses"] = []any{map[string]any{ - "state": map[string]any{"waiting": map[string]any{"reason": "ImagePullBackOff"}}, + "name": "pull-probe", + "state": map[string]any{"waiting": map[string]any{ + "reason": "ImagePullBackOff", + "message": failureMessage, + }}, }} } else { if os.Getenv("FAKE_LOG_RUNTIME_PROBE_SUCCESS") == "true" { @@ -682,6 +939,7 @@ func fakeKubectlGetRuntimeProbe(args []string) int { ) } status["containerStatuses"] = []any{map[string]any{ + "name": "pull-probe", "imageID": "ghcr.io/private@sha256:runtime-probe", "state": map[string]any{ "terminated": map[string]any{"reason": "Completed", "exitCode": 0}, @@ -712,10 +970,20 @@ func fakeKubectlGetRootSecret() int { "ghcr.io": map[string]any{"username": "devantler", "password": token}, }, }) - encoded := base64.StdEncoding.EncodeToString([]byte(config)) + encoded := defaultString( + markerContent("root-secret-value"), + base64.StdEncoding.EncodeToString([]byte(config)), + ) fmt.Println(encodeJSON(map[string]any{ + "metadata": map[string]any{ + "resourceVersion": defaultString(markerContent("root-secret-resource-version"), "20"), + }, "data": map[string]any{".dockerconfigjson": encoded}, })) + fakeLoseSyncLeaseAfterSecretRead( + "FAKE_SYNC_LEASE_LOST_AFTER_ROOT_SECRET_GET", + "sync-lease-lost-after-root-secret-get", + ) return 0 } @@ -731,44 +999,146 @@ func fakeKubectlAPIResources(args []string) int { } func fakeKubectlPatchRootSecret(args []string, patchFile string) int { - if !containsArg(args, "--type=merge") || patchFile == "" { - return commandFailure(91, "invalid root secret patch") - } - if err := copyFile(patchFile, os.Getenv("PATCH_CAPTURE")); err != nil { - return commandFailure(91, "capture root patch: %v", err) - } - appendEnvFile("OPERATION_LOG", "root-patch\n") - if os.Getenv("FAKE_KUBECTL_FAIL") == "true" { - return commandFailure(43, "cluster patch failed") - } - fmt.Println("secret/ksail-registry-credentials patched") - return 0 + return fakeKubectlPatchSecretWithCAS(fakeSecretCASPatch{ + args: args, + patchFile: patchFile, + dataPath: "/data/.dockerconfigjson", + dataKey: ".dockerconfigjson", + resourceVersionMarker: "root-secret-resource-version", + valueMarker: "root-secret-value", + conflictEnvironment: "FAKE_ROOT_SECRET_CAS_CONFLICT_ONCE", + conflictMarker: "root-secret-cas-conflict", + conflictLiveValue: "newer-root-secret-value", + captureEnvironment: "PATCH_CAPTURE", + operation: "root-patch", + resourceName: "ksail-registry-credentials", + }) } func fakeKubectlGetVariablesBase(args []string) int { - if !containsArg(args, "--ignore-not-found") { - return commandFailure(91, "variables-base lookup must tolerate a fresh cluster") + if os.Getenv("FAKE_VARIABLES_BASE_ABSENT") == "true" { + if containsArg(args, "--ignore-not-found") { + return 0 + } + return commandFailure(44, "variables-base not found") + } + if containsSequence(args, "-o", "json") { + value := defaultString(markerContent("variables-secret-value"), "previous-variables-value") + fmt.Println(encodeJSON(map[string]any{ + "metadata": map[string]any{ + "resourceVersion": defaultString(markerContent("variables-secret-resource-version"), "30"), + }, + "data": map[string]any{"ghcr_dockerconfigjson": value}, + })) + fakeLoseSyncLeaseAfterSecretRead( + "FAKE_SYNC_LEASE_LOST_AFTER_VARIABLES_SECRET_GET", + "sync-lease-lost-after-variables-secret-get", + ) + return 0 } - if os.Getenv("FAKE_VARIABLES_BASE_ABSENT") != "true" { - fmt.Println("secret/variables-base") + if !containsArg(args, "--ignore-not-found") || !containsSequence(args, "-o", "name") { + return commandFailure(91, "variables-base discovery must tolerate a fresh cluster") } + fmt.Println("secret/variables-base") return 0 } func fakeKubectlPatchVariablesBase(args []string, patchFile string) int { - if !containsArg(args, "--type=merge") || patchFile == "" { - return commandFailure(91, "invalid variables-base patch") + result := fakeKubectlPatchSecretWithCAS(fakeSecretCASPatch{ + args: args, + patchFile: patchFile, + dataPath: "/data/ghcr_dockerconfigjson", + dataKey: "ghcr_dockerconfigjson", + resourceVersionMarker: "variables-secret-resource-version", + valueMarker: "variables-secret-value", + conflictEnvironment: "FAKE_VARIABLES_SECRET_CAS_CONFLICT_ONCE", + conflictMarker: "variables-secret-cas-conflict", + conflictLiveValue: "newer-variables-secret-value", + captureEnvironment: "VARIABLES_PATCH_CAPTURE", + operation: "variables-patch", + resourceName: "variables-base", + }) + if result == 0 { + count := parseInt(markerContent("variables-patch-count"), 0) + 1 + setMarkerContent("variables-patch-count", strconv.Itoa(count)) } - if err := copyFile(patchFile, os.Getenv("VARIABLES_PATCH_CAPTURE")); err != nil { - return commandFailure(91, "capture variables-base patch: %v", err) + return result +} + +type fakeSecretCASPatch struct { + args []string + patchFile string + dataPath string + dataKey string + resourceVersionMarker string + valueMarker string + conflictEnvironment string + conflictMarker string + conflictLiveValue string + captureEnvironment string + operation string + resourceName string +} + +func fakeKubectlPatchSecretWithCAS(request fakeSecretCASPatch) int { + if !containsArg(request.args, "--type=json") || request.patchFile == "" { + return commandFailure(91, "invalid %s CAS patch", request.resourceName) + } + var patch []jsonPatchOperation + if err := json.Unmarshal([]byte(mustReadCommandFile(request.patchFile)), &patch); err != nil { + return commandFailure(91, "parse %s CAS patch: %v", request.resourceName, err) + } + currentResourceVersion := defaultString(markerContent(request.resourceVersionMarker), secretResourceVersionDefault(request.resourceName)) + value := patchValueString(patch, "add", request.dataPath) + if !hasPatchOperation(patch, "test", "/metadata/resourceVersion", currentResourceVersion) || value == "" { + return commandFailure(56, "%s resourceVersion CAS failed", request.resourceName) + } + if os.Getenv(request.conflictEnvironment) == "true" && !markerExists(request.conflictMarker) { + setMarkerContent(request.resourceVersionMarker, incrementDecimal(currentResourceVersion)) + setMarkerContent(request.valueMarker, request.conflictLiveValue) + setMarkerContent(request.conflictMarker+"-live-value", request.conflictLiveValue) + touchMarker(request.conflictMarker) + return commandFailure(56, "simulated stale %s writer", request.resourceName) + } + if request.resourceName == "ksail-registry-credentials" && os.Getenv("FAKE_KUBECTL_FAIL") == "true" { + mustWriteCommandFile(os.Getenv(request.captureEnvironment), encodeJSON(map[string]any{ + "data": map[string]any{request.dataKey: value}, + })) + appendEnvFile("OPERATION_LOG", request.operation+"\n") + return commandFailure(43, "cluster patch failed") } - count := parseInt(markerContent("variables-patch-count"), 0) + 1 - setMarkerContent("variables-patch-count", strconv.Itoa(count)) - appendEnvFile("OPERATION_LOG", "variables-patch\n") - fmt.Println("secret/variables-base patched") + setMarkerContent(request.resourceVersionMarker, incrementDecimal(currentResourceVersion)) + setMarkerContent(request.valueMarker, value) + mustWriteCommandFile(os.Getenv(request.captureEnvironment), encodeJSON(map[string]any{ + "data": map[string]any{request.dataKey: value}, + })) + appendEnvFile("OPERATION_LOG", request.operation+"\n") + fmt.Printf("secret/%s patched\n", request.resourceName) return 0 } +func secretResourceVersionDefault(resourceName string) string { + if resourceName == "ksail-registry-credentials" { + return "20" + } + return "30" +} + +func fakeLoseSyncLeaseAfterSecretRead(environment, marker string) { + currentHolder := markerContent("sync-lease-holder") + processHolder := os.Getenv("FLUX_GHCR_SYNC_LEASE_HOLDER") + if os.Getenv(environment) != "true" || markerExists(marker) || + processHolder == "" || currentHolder != processHolder { + return + } + setMarkerContent("sync-lease-holder", "newer-transaction") + setMarkerContent( + "sync-lease-resource-version", + incrementDecimal(defaultString(markerContent("sync-lease-resource-version"), "10")), + ) + touchMarker(marker) +} + func fanoutResource(args []string) (string, string) { if containsSequence(args, "pushsecret", "seed-ghcr") { return "pushsecret", "seed-ghcr" diff --git a/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go b/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go index 80b05aab7..0596c604f 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/fixture_test.go @@ -193,9 +193,26 @@ func validConfig() map[string]any { } func (f *fixture) runHelper(config any, helperArgs []string, overrides map[string]string) commandResult { + return f.runHelperWithClusterState(config, helperArgs, overrides, false) +} + +func (f *fixture) runHelperPreservingClusterState( + config any, + helperArgs []string, + overrides map[string]string, +) commandResult { + return f.runHelperWithClusterState(config, helperArgs, overrides, true) +} + +func (f *fixture) runHelperWithClusterState( + config any, + helperArgs []string, + overrides map[string]string, + preserveClusterState bool, +) commandResult { f.t.Helper() mustWriteJSON(f.t, f.decryptedConfig, config) - f.clearRunState(true) + f.clearRunStatePreservingCluster(true, preserveClusterState) username, token := expectedCredentials(config) env := f.baseEnvironment() env["EXPECTED_PULL_USERNAME"] = username @@ -252,6 +269,10 @@ func (f *fixture) baseEnvironment() map[string]string { } func (f *fixture) clearRunState(helper bool) { + f.clearRunStatePreservingCluster(helper, false) +} + +func (f *fixture) clearRunStatePreservingCluster(helper, preserveClusterState bool) { f.t.Helper() paths := []string{ f.outputPathLog, @@ -280,7 +301,7 @@ func (f *fixture) clearRunState(helper bool) { f.t.Fatalf("clear %s: %v", path, err) } } - if helper { + if helper && !preserveClusterState { entries, err := os.ReadDir(f.syncStateDir) if err != nil { f.t.Fatalf("read sync state: %v", err) diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go index 5b28c8c0d..48d1c83df 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_convergence_test.go @@ -1,6 +1,8 @@ package refreshfluxghcrauth import ( + "encoding/json" + "os" "path/filepath" "strings" "testing" @@ -188,6 +190,9 @@ func TestRevokedPreviousCredentialBootstrapsThroughEmptyWorker(t *testing.T) { if claim >= seedDrain { t.Fatalf("stale node %s was not quarantined before seed drain: claim=%d drain=%d", nodeName, claim, seedDrain) } + if pathExists(filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName)) { + t.Fatalf("successful bootstrap left a recovery journal on %s", nodeName) + } } requireLine(t, operations, "root-patch") } @@ -206,6 +211,40 @@ func TestAllStaleRuntimesWithoutEmptyWorkerFailClosed(t *testing.T) { requireNoLine(t, operations, "root-patch") } +func TestAmbiguousRuntimePullFailureDoesNotBootstrap(t *testing.T) { + for name, message := range map[string]string{ + "missing message": "__EMPTY__", + "dns failure": "dial tcp: lookup ghcr.io: no such host", + "rate limit": "unexpected status from HEAD request to https://ghcr.io/v2/private/manifests/latest: 429 Too Many Requests", + "network timeout": "net/http: request canceled while waiting for connection", + "missing image": "manifest unknown", + "signature validation": "signature verification failed", + "token prefix": "dial tcp https://ghcr.io/token-proxy: 403 Forbidden", + "compound status": "unexpected status from GET request to https://ghcr.io/token?scope=private: 429 Too Many Requests; fallback: 403 Forbidden", + "embedded generic": "unauthorized: authentication required while signature verification failed", + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_RUNTIME_PULL_FAILURE_MESSAGE": message, + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "refusing to drain workloads onto peers with unproved runtime auth") + if message != "__EMPTY__" { + requireNotContains(t, result.stdout+result.stderr, message) + } + operations := readLines(f.operationLog) + requireNotContains(t, strings.Join(operations, "\n"), "node-claim-cordon:") + requireNotContains(t, strings.Join(operations, "\n"), "node-drain:") + requireNoLine(t, operations, "root-patch") + }) + } +} + func TestBootstrapRejectsUnprovedCurrentMarkedDestination(t *testing.T) { f := newFixture(t) ready := []any{map[string]any{"type": "Ready", "status": "True"}} @@ -338,6 +377,20 @@ func TestBootstrapPullFailureRetainsOnlyUnprovedSeedCordon(t *testing.T) { if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-2")) { t.Fatal("unproved bootstrap seed did not retain its owned cordon") } + recoveryPath := filepath.Join(f.syncStateDir, "cordon-recovery-prod-worker-2") + if !pathExists(recoveryPath) { + t.Fatal("unproved bootstrap seed did not retain its durable recovery journal") + } + var recovery map[string]any + if err := json.Unmarshal([]byte(mustRead(recoveryPath)), &recovery); err != nil { + t.Fatalf("parse retained recovery journal: %v", err) + } + if recovery["phase"] != "retain" { + t.Fatalf("retained recovery phase = %v, want retain", recovery["phase"]) + } + if strings.Contains(mustRead(recoveryPath), "fixture-secret-token") { + t.Fatal("durable recovery journal contained decrypted registry credentials") + } for _, nodeName := range []string{ "prod-worker-1", "prod-control-plane-1", @@ -348,7 +401,212 @@ func TestBootstrapPullFailureRetainsOnlyUnprovedSeedCordon(t *testing.T) { if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName)) { t.Fatalf("unprocessed stale peer %s kept a bootstrap-only cordon", nodeName) } + if pathExists(filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName)) { + t.Fatalf("unprocessed stale peer %s kept a bootstrap recovery journal", nodeName) + } } + + retry := f.runHelperPreservingClusterState(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + }) + requireFailureResult(t, retry) + requireContains(t, retry.stdout+retry.stderr, "refusing to release any node in that batch") + retryOperations := readLines(f.operationLog) + requireNoLine(t, retryOperations, "node-uncordon:prod-worker-2") + requireNoLine(t, retryOperations, "root-patch") +} + +func TestBootstrapCleanupFailureRetainsDurableRecoveryAndNextRunReconciles(t *testing.T) { + f := newFixture(t) + first := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + "FAKE_TALOS_FAIL_NODE": "10.0.0.5", + "FAKE_TALOS_FAIL_OPERATION": "auth", + "FAKE_UNCORDON_FAIL_NODE": "prod-worker-1", + }) + requireFailureResult(t, first) + requireContains(t, first.stdout+first.stderr, "Bootstrap quarantine cleanup was incomplete") + firstOperations := readLines(f.operationLog) + requireNoLine(t, firstOperations, "root-patch") + + recoveryPath := filepath.Join(f.syncStateDir, "cordon-recovery-prod-worker-1") + ownerPath := filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1") + if !pathExists(ownerPath) || !pathExists(recoveryPath) { + t.Fatal("failed cleanup did not preserve its durable owner and recovery journal") + } + var recovery map[string]any + if err := json.Unmarshal([]byte(mustRead(recoveryPath)), &recovery); err != nil { + t.Fatalf("parse durable recovery journal: %v", err) + } + if recovery["phase"] != "rollback-safe" { + t.Fatalf("durable recovery phase = %v, want rollback-safe", recovery["phase"]) + } + if strings.Contains(mustRead(recoveryPath), "fixture-secret-token") { + t.Fatal("durable recovery journal contained decrypted registry credentials") + } + for _, nodeName := range []string{ + "prod-worker-2", + "prod-control-plane-1", + "prod-control-plane-2", + "prod-control-plane-3", + } { + if pathExists(filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName)) { + t.Fatalf("cleanup stopped before releasing %s", nodeName) + } + } + + second := f.runHelperPreservingClusterState(validConfig(), nil, map[string]string{ + "FAKE_ALL_TALOS_NODES_STALE": "true", + "FAKE_BOOTSTRAP_WORKER_NAME": "prod-worker-2", + "FAKE_RUNTIME_PULL_FAIL_NODES": "prod-worker-1 prod-worker-2 prod-control-plane-1 prod-control-plane-2 prod-control-plane-3", + "FAKE_EMPTY_WORKLOAD_NODES": "prod-worker-2", + }) + requireSuccessResult(t, second) + secondOperations := readLines(f.operationLog) + reconcileRelease := lineIndex(t, secondOperations, "node-uncordon:prod-worker-1") + firstNewClaim := lineIndex(t, secondOperations, "node-claim-cordon:prod-worker-1") + if reconcileRelease >= firstNewClaim { + t.Fatalf("durable recovery was not reconciled before the new rollout: release=%d claim=%d", reconcileRelease, firstNewClaim) + } + if pathExists(ownerPath) || pathExists(recoveryPath) { + t.Fatal("successful retry left the reconciled owner or recovery journal behind") + } + requireLine(t, secondOperations, "root-patch") +} + +func TestRecoveryReconciliationRejectsConcurrentPhaseAdvance(t *testing.T) { + f := newFixture(t) + const nodeName = "prod-worker-1" + const owner = "previous-roll-owner" + recoveryPath := filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName) + mustWriteJSON(t, recoveryPath, map[string]any{ + "v": 1, + "owner": owner, + "uid": nodeName + "-uid", + "desiredRevision": f.expectedRevision(), + "wasCordoned": 0, + "initialTaints": []any{}, + "phase": "rollback-safe", + }) + for path, contents := range map[string]string{ + filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName): owner, + filepath.Join(f.syncStateDir, "cordoned-"+nodeName): "", + } { + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("seed recovery marker %s: %v", path, err) + } + } + + result := f.runHelperPreservingClusterState(validConfig(), nil, map[string]string{ + "FAKE_RECOVERY_ADVANCES_BEFORE_RELEASE_NODE": nodeName, + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "Recovery journal changed") + operations := readLines(f.operationLog) + requireLine(t, operations, "concurrent-recovery-phase:"+nodeName+":active") + requireNoLine(t, operations, "node-uncordon:"+nodeName) + requireNoLine(t, operations, "node-claim-cordon:"+nodeName) + requireNoLine(t, operations, "root-patch") + + var recovery map[string]any + if err := json.Unmarshal([]byte(mustRead(recoveryPath)), &recovery); err != nil { + t.Fatalf("parse concurrently advanced recovery journal: %v", err) + } + if recovery["phase"] != "active" { + t.Fatalf("concurrent recovery phase = %v, want active", recovery["phase"]) + } +} + +func TestRecoveryReconciliationKeepsActiveOwnerBatchQuarantined(t *testing.T) { + f := newFixture(t) + const owner = "active-bootstrap-owner" + for nodeName, phase := range map[string]string{ + "prod-worker-1": "rollback-safe", + "prod-control-plane-1": "active", + } { + recoveryPath := filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName) + mustWriteJSON(t, recoveryPath, map[string]any{ + "v": 1, + "owner": owner, + "uid": nodeName + "-uid", + "desiredRevision": f.expectedRevision(), + "wasCordoned": 0, + "initialTaints": []any{}, + "phase": phase, + }) + for path, contents := range map[string]string{ + filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName): owner, + filepath.Join(f.syncStateDir, "cordoned-"+nodeName): "", + } { + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("seed active owner batch marker %s: %v", path, err) + } + } + } + + result := f.runHelperPreservingClusterState(validConfig(), nil, nil) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "refusing to release any node in that batch") + operations := readLines(f.operationLog) + for _, nodeName := range []string{"prod-worker-1", "prod-control-plane-1"} { + requireNoLine(t, operations, "node-uncordon:"+nodeName) + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName)) || + !pathExists(filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName)) { + t.Fatalf("active owner batch released durable quarantine for %s", nodeName) + } + } + requireNoLine(t, operations, "root-patch") +} + +func TestReleaseReadyRecoveryPreservesPreExistingCordonBeforeNewRoll(t *testing.T) { + f := newFixture(t) + const nodeName = "prod-worker-1" + const owner = "completed-roll-owner" + recoveryPath := filepath.Join(f.syncStateDir, "cordon-recovery-"+nodeName) + ownerPath := filepath.Join(f.syncStateDir, "cordon-owner-"+nodeName) + cordonPath := filepath.Join(f.syncStateDir, "cordoned-"+nodeName) + mustWriteJSON(t, recoveryPath, map[string]any{ + "v": 1, + "owner": owner, + "uid": nodeName + "-uid", + "desiredRevision": f.expectedRevision(), + "wasCordoned": 1, + "initialTaints": []any{}, + "phase": "release-ready", + }) + for path, contents := range map[string]string{ + ownerPath: owner, + cordonPath: "", + } { + if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { + t.Fatalf("seed release-ready marker %s: %v", path, err) + } + } + + result := f.runHelperPreservingClusterState(validConfig(), nil, nil) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + releases := lineIndices(operations, "node-release-cordon-owner:"+nodeName) + if len(releases) != 2 { + t.Fatalf("pre-existing cordon owner releases = %d, want reconcile and rollout releases", len(releases)) + } + claim := lineIndex(t, operations, "node-claim-cordon:"+nodeName) + if releases[0] >= claim { + t.Fatalf("release-ready journal was not reconciled before the new claim: release=%d claim=%d", releases[0], claim) + } + if pathExists(ownerPath) || pathExists(recoveryPath) { + t.Fatal("successful release-ready reconciliation left owner or recovery state") + } + if !pathExists(cordonPath) { + t.Fatal("release-ready reconciliation removed the pre-existing cordon") + } + requireLine(t, operations, "root-patch") } func TestTaintedPeersDoNotCountAsRuntimePullCapacity(t *testing.T) { @@ -400,6 +658,12 @@ func TestRuntimeProbeRetriesTransientAdmissionTimeout(t *testing.T) { "FAKE_RUNTIME_PROBE_CREATE_TIMEOUT_ONCE_NODES": "prod-control-plane-2", }) requireSuccessResult(t, result) + if !pathExists(filepath.Join( + f.syncStateDir, + "runtime-probe-create-timeout-once-prod-control-plane-2", + )) { + t.Fatal("transient runtime-probe timeout was not exercised") + } operations := readLines(f.operationLog) requireLine(t, operations, "node-drain:prod-worker-1") requireLine(t, operations, "root-patch") diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go index a129ca803..4504f0ae0 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_core_test.go @@ -145,24 +145,24 @@ func TestStagesKubernetesConsumersBeforeTalosDrains(t *testing.T) { "fanout:externalsecret/wedding-app/ghcr-auth", "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", "node-claim-cordon:prod-worker-1", + "talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", "talos-remove:10.0.0.2:" + target, "talos-pull:10.0.0.2:" + target, - "node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", - "talos-auth:10.0.0.1", + "node-uncordon:prod-worker-1", "node-claim-cordon:prod-control-plane-1", + "talos-auth:10.0.0.1", "node-drain:prod-control-plane-1", "talos-reboot:10.0.0.1", "node-ready:prod-control-plane-1", "talos-remove:10.0.0.1:" + target, "talos-pull:10.0.0.1:" + target, - "node-uncordon:prod-control-plane-1", "talos-revision:10.0.0.1", + "node-uncordon:prod-control-plane-1", "variables-patch", "fanout:pushsecret/flux-system/seed-ghcr", "fanout:externalsecret/wedding-app/ghcr-auth", @@ -174,6 +174,9 @@ func TestStagesKubernetesConsumersBeforeTalosDrains(t *testing.T) { if pathExists(temporaryPatch) { t.Errorf("temporary Talos patch still exists: %s", temporaryPatch) } + if holder := mustRead(filepath.Join(f.syncStateDir, "sync-lease-holder")); holder != "" { + t.Fatalf("successful run left synchronization lease holder %q", holder) + } requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") } @@ -265,8 +268,9 @@ func TestPreExistingCordonSurvivesTheAuthReboot(t *testing.T) { requireSuccessResult(t, result) operations := readLines(f.operationLog) requireLine(t, operations, "node-drain:prod-worker-1") - requireNoLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "node-claim-cordon:prod-worker-1") requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireLine(t, operations, "node-release-cordon-owner:prod-worker-1") requireLine(t, operations, "node-claim-cordon:prod-control-plane-1") requireLine(t, operations, "node-uncordon:prod-control-plane-1") } @@ -281,7 +285,7 @@ func TestSchedulableNodeIsUncordonedAfterTheAuthReboot(t *testing.T) { pull := lineIndex(t, operations, "talos-pull:10.0.0.2:"+ksailTargetImage) uncordon := lineIndex(t, operations, "node-uncordon:prod-worker-1") revision := lineIndex(t, operations, "talos-revision:10.0.0.2") - if claim >= drain || drain >= pull || pull >= uncordon || uncordon >= revision { + if claim >= drain || drain >= pull || pull >= revision || revision >= uncordon { t.Errorf("unsafe worker ordering: claim=%d drain=%d pull=%d uncordon=%d revision=%d", claim, drain, pull, uncordon, revision) } if actual := mustRead(filepath.Join(f.syncStateDir, "resource-version-prod-worker-1")); actual != "12" { @@ -309,7 +313,7 @@ func TestConcurrentCordonBeforeAtomicClaimStopsTheRoll(t *testing.T) { requireContains(t, result.stdout+result.stderr, "Could not atomically claim and cordon") operations := readLines(f.operationLog) requireLine(t, operations, "operator-cordon:prod-worker-1") - for _, unexpected := range []string{"node-claim-cordon:prod-worker-1", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "root-patch"} { + for _, unexpected := range []string{"node-claim-cordon:prod-worker-1", "talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "root-patch"} { requireNoLine(t, operations, unexpected) } if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { @@ -344,9 +348,10 @@ func TestPDBBlockedDrainPreservesPreExistingCordon(t *testing.T) { }) requireFailureResult(t, result) operations := readLines(f.operationLog) - requireNoLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "node-claim-cordon:prod-worker-1") requireLine(t, operations, "node-drain:prod-worker-1") requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireLine(t, operations, "node-release-cordon-owner:prod-worker-1") requireNoLine(t, operations, "talos-reboot:10.0.0.2") } diff --git a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go index 648b379a4..68aabe194 100644 --- a/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go +++ b/scripts/tests/refresh-flux-ghcr-auth/rollout_safety_test.go @@ -1,12 +1,13 @@ package refreshfluxghcrauth import ( + "encoding/json" "path/filepath" "strings" "testing" ) -func TestRevisionFailureDoesNotLeaveOwnedCordonBehind(t *testing.T) { +func TestRevisionFailureKeepsOwnedCordonAndBlocksRetry(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{ "FAKE_TALOS_FAIL_NODE": "10.0.0.2", @@ -14,12 +15,316 @@ func TestRevisionFailureDoesNotLeaveOwnedCordonBehind(t *testing.T) { }) requireFailureResult(t, result) operations := readLines(f.operationLog) - uncordon := lineIndex(t, operations, "node-uncordon:prod-worker-1") - revision := lineIndex(t, operations, "talos-revision:10.0.0.2") - if uncordon >= revision { - t.Errorf("uncordon index %d is not before revision index %d", uncordon, revision) + requireLine(t, operations, "talos-revision:10.0.0.2") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Fatal("revision-marker failure did not retain the owned cordon") } requireNoLine(t, operations, "root-patch") + + retry := f.runHelperPreservingClusterState(validConfig(), nil, nil) + requireFailureResult(t, retry) + requireContains(t, retry.stdout+retry.stderr, "Could not select Talos nodes requiring GHCR synchronization") + if pathExists(f.talosLog) { + t.Fatal("retry mutated Talos while residual bridge ownership remained") + } +} + +func TestCredentialPatchOccursUnderOwnedCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, nil) + requireSuccessResult(t, result) + operations := readLines(f.operationLog) + claim := lineIndex(t, operations, "node-claim-cordon:prod-worker-1") + auth := lineIndex(t, operations, "talos-auth:10.0.0.2") + if claim >= auth { + t.Fatalf("credential patch was not protected by cordon ownership: claim=%d auth=%d", claim, auth) + } +} + +func TestLiveSynchronizationLeaseBlocksEveryMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_HELD_SYNC_LEASE": "true", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "holds the synchronization lease") + if pathExists(f.variablesPatchCapture) || pathExists(f.patchCapture) || pathExists(f.talosLog) { + t.Fatal("live synchronization lease did not block every cluster mutation") + } +} + +func TestExpiredSynchronizationLeaseRequiresExplicitRecovery(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_HELD_SYNC_LEASE": "true", + "FAKE_EXPIRED_SYNC_LEASE": "true", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "holds the synchronization lease") + if pathExists(f.variablesPatchCapture) || pathExists(f.patchCapture) || pathExists(f.talosLog) { + t.Fatal("expired synchronization lease was taken over without explicit recovery") + } +} + +func TestSameHolderLeaseRenewalRaceDoesNotAbortTheTransaction(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_SYNC_LEASE_RENEW_CONFLICT_ONCE": "true", + }) + requireSuccessResult(t, result) + if !pathExists(filepath.Join(f.syncStateDir, "sync-lease-renew-conflict")) { + t.Fatal("fixture did not exercise the same-holder lease renewal conflict") + } + requireLine(t, readLines(f.operationLog), "root-patch") +} + +func TestCurrentLeaseHolderRetriesSecretCASConflicts(t *testing.T) { + for name, test := range map[string]struct { + environment string + marker string + operation string + liveValue string + }{ + "root Secret": { + environment: "FAKE_ROOT_SECRET_CAS_CONFLICT_ONCE", + marker: "root-secret-cas-conflict", + operation: "root-patch", + liveValue: "newer-root-secret-value", + }, + "variables-base Secret": { + environment: "FAKE_VARIABLES_SECRET_CAS_CONFLICT_ONCE", + marker: "variables-secret-cas-conflict", + operation: "variables-patch", + liveValue: "newer-variables-secret-value", + }, + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + test.environment: "true", + }) + requireSuccessResult(t, result) + if !pathExists(filepath.Join(f.syncStateDir, test.marker)) { + t.Fatal("fixture did not exercise the concurrent stale Secret writer") + } + requireLine(t, readLines(f.operationLog), test.operation) + capturePath := f.patchCapture + dataKey := ".dockerconfigjson" + valueMarker := "root-secret-value" + if test.operation == "variables-patch" { + capturePath = f.variablesPatchCapture + dataKey = "ghcr_dockerconfigjson" + valueMarker = "variables-secret-value" + } + var capture map[string]any + if err := json.Unmarshal([]byte(mustRead(capturePath)), &capture); err != nil { + t.Fatalf("decode successful Secret patch capture: %v", err) + } + data, ok := capture["data"].(map[string]any) + if !ok { + t.Fatal("successful Secret patch capture omitted data") + } + wantValue, ok := data[dataKey].(string) + if !ok || wantValue == "" { + t.Fatal("successful Secret patch capture omitted credential value") + } + if value := mustRead(filepath.Join(f.syncStateDir, valueMarker)); value != wantValue { + t.Fatalf("final live Secret value = %q, want current transaction value %q", value, wantValue) + } + if value := mustRead(filepath.Join(f.syncStateDir, test.marker+"-live-value")); value != test.liveValue { + t.Fatalf("live Secret value = %q, want newer transaction value %q", value, test.liveValue) + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") + }) + } +} + +func TestLeaseLossDuringSecretReadStopsBeforePatch(t *testing.T) { + for name, test := range map[string]struct { + environment string + marker string + operation string + capture func(*fixture) string + }{ + "root Secret": { + environment: "FAKE_SYNC_LEASE_LOST_AFTER_ROOT_SECRET_GET", + marker: "sync-lease-lost-after-root-secret-get", + operation: "root-patch", + capture: func(f *fixture) string { return f.patchCapture }, + }, + "variables-base Secret": { + environment: "FAKE_SYNC_LEASE_LOST_AFTER_VARIABLES_SECRET_GET", + marker: "sync-lease-lost-after-variables-secret-get", + operation: "variables-patch", + capture: func(f *fixture) string { return f.variablesPatchCapture }, + }, + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + overrides := map[string]string{test.environment: "true"} + if test.operation == "root-patch" { + // Skip the earlier overlap-read path so the fake replaces the + // Lease specifically inside the root Secret CAS helper. + overrides["FAKE_TALOS_NODES_CURRENT"] = "true" + } + result := f.runHelper(validConfig(), nil, overrides) + requireFailureResult(t, result) + if !pathExists(filepath.Join(f.syncStateDir, test.marker)) { + t.Fatal("fixture did not replace the Lease during the Secret read") + } + if pathExists(f.operationLog) { + requireNoLine(t, readLines(f.operationLog), test.operation) + } + if pathExists(test.capture(f)) { + t.Fatalf("%s was captured after the transaction lost its Lease", test.operation) + } + requireNotContains(t, result.stdout+result.stderr, "fixture-secret-token") + }) + } +} + +func TestLeaseLossAfterNodeClaimStopsBeforeTalosMutation(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_SYNC_LEASE_LOST_AFTER_FIRST_CLAIM": "true", + }) + requireFailureResult(t, result) + if !pathExists(filepath.Join(f.syncStateDir, "sync-lease-lost-after-claim")) { + t.Fatal("fixture did not replace the synchronization lease after the node claim") + } + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + requireNoLine(t, operations, "talos-auth:10.0.0.2") + requireNoLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "root-patch") + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Fatal("Lease loss before Talos mutation left a newly-owned cordon behind") + } +} + +func TestLeaseLossAfterPreCordonedNodeClaimPreservesSchedulingIntent(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CORDONED_NODES": "prod-worker-1", + "FAKE_SYNC_LEASE_LOST_AFTER_FIRST_CLAIM": "true", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + requireLine(t, operations, "node-release-cordon-owner:prod-worker-1") + requireNoLine(t, operations, "talos-auth:10.0.0.2") + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Fatal("Lease loss left ownership on a pre-existing cordon") + } + if !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Fatal("Lease-loss rollback removed the pre-existing cordon") + } +} + +func TestCredentialPatchFailureReleasesOwnedCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_TALOS_FAIL_NODE": "10.0.0.2", + "FAKE_TALOS_FAIL_OPERATION": "auth", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + claim := lineIndex(t, operations, "node-claim-cordon:prod-worker-1") + auth := lineIndex(t, operations, "talos-auth:10.0.0.2") + release := lineIndex(t, operations, "node-uncordon:prod-worker-1") + if claim >= auth || auth >= release { + t.Fatalf("unsafe credential-patch failure ordering: claim=%d auth=%d release=%d", claim, auth, release) + } + requireNoLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "root-patch") + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) || + pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Fatal("credential-patch failure left the worker owned-cordoned") + } +} + +func TestCredentialPatchFailurePreservesPreExistingCordon(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_CORDONED_NODES": "prod-worker-1", + "FAKE_TALOS_FAIL_NODE": "10.0.0.2", + "FAKE_TALOS_FAIL_OPERATION": "auth", + }) + requireFailureResult(t, result) + operations := readLines(f.operationLog) + claim := lineIndex(t, operations, "node-claim-cordon:prod-worker-1") + auth := lineIndex(t, operations, "talos-auth:10.0.0.2") + release := lineIndex(t, operations, "node-release-cordon-owner:prod-worker-1") + if claim >= auth || auth >= release { + t.Fatalf("unsafe pre-existing-cordon auth failure ordering: claim=%d auth=%d release=%d", claim, auth, release) + } + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "node-drain:prod-worker-1") + if pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { + t.Fatal("credential-patch failure left bridge ownership on the pre-existing cordon") + } + if !pathExists(filepath.Join(f.syncStateDir, "cordoned-prod-worker-1")) { + t.Fatal("credential-patch failure removed the pre-existing cordon") + } +} + +func TestSchedulingDriftDuringCredentialPatchStopsBeforeDrain(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + "FAKE_EXTERNAL_UNCORDON_AFTER_AUTH_NODE": "prod-worker-1", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, "scheduling safety state changed before drain") + operations := readLines(f.operationLog) + requireLine(t, operations, "node-claim-cordon:prod-worker-1") + requireLine(t, operations, "talos-auth:10.0.0.2") + requireLine(t, operations, "operator-uncordon-after-auth:prod-worker-1") + requireNoLine(t, operations, "node-drain:prod-worker-1") + requireNoLine(t, operations, "talos-reboot:10.0.0.2") + requireNoLine(t, operations, "root-patch") +} + +func TestSchedulingDriftDuringImageMutationStopsAtNextGuard(t *testing.T) { + for name, test := range map[string]struct { + environment string + marker string + guard string + pullRan bool + }{ + "after remove": { + environment: "FAKE_EXTERNAL_UNCORDON_AFTER_REMOVE_NODE", + marker: "operator-uncordon-after-remove:prod-worker-1", + guard: "before image pull", + }, + "after pull": { + environment: "FAKE_EXTERNAL_UNCORDON_AFTER_PULL_NODE", + marker: "operator-uncordon-after-pull:prod-worker-1", + guard: "before runtime pull proof", + pullRan: true, + }, + } { + t.Run(name, func(t *testing.T) { + f := newFixture(t) + result := f.runHelper(validConfig(), nil, map[string]string{ + test.environment: "prod-worker-1", + }) + requireFailureResult(t, result) + requireContains(t, result.stdout+result.stderr, test.guard) + operations := readLines(f.operationLog) + requireLine(t, operations, "talos-remove:10.0.0.2:"+ksailTargetImage) + requireLine(t, operations, test.marker) + if test.pullRan { + requireLine(t, operations, "talos-pull:10.0.0.2:"+ksailTargetImage) + } else { + requireNoLine(t, operations, "talos-pull:10.0.0.2:"+ksailTargetImage) + } + requireNoLine(t, operations, "talos-revision:10.0.0.2") + requireNoLine(t, operations, "node-uncordon:prod-worker-1") + requireNoLine(t, operations, "root-patch") + }) + } } func TestChangedCordonOwnerIsNeverUncordoned(t *testing.T) { @@ -166,14 +471,14 @@ func TestReadyFalseWithoutLifecycleTaintKeepsNodeCordoned(t *testing.T) { } } -func TestReplacementAfterUncordonBlocksRevisionMarker(t *testing.T) { +func TestReplacementAfterUncordonBlocksConvergence(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_NODE_REPLACED_AFTER_UNCORDON_NODE": "prod-worker-1"}) requireFailureResult(t, result) requireContains(t, result.stdout+result.stderr, "identity changed") operations := readLines(f.operationLog) requireLine(t, operations, "node-uncordon:prod-worker-1") - requireNoLine(t, operations, "talos-revision:10.0.0.2") + requireLine(t, operations, "talos-revision:10.0.0.2") requireNoLine(t, operations, "root-patch") } @@ -200,13 +505,14 @@ func TestTalosConvergenceBudgetRequiresTargetAndTwoCleanReads(t *testing.T) { } } -func TestUncordonFailureKeepsRevisionMarkerStale(t *testing.T) { +func TestUncordonFailureKeepsRevisionMarkerAndOwnerFailClosed(t *testing.T) { f := newFixture(t) result := f.runHelper(validConfig(), nil, map[string]string{"FAKE_UNCORDON_FAIL_NODE": "prod-worker-1"}) requireFailureResult(t, result) operations := readLines(f.operationLog) requireLine(t, operations, "node-claim-cordon:prod-worker-1") - for _, unexpected := range []string{"node-uncordon:prod-worker-1", "talos-revision:10.0.0.2", "root-patch"} { + requireLine(t, operations, "talos-revision:10.0.0.2") + for _, unexpected := range []string{"node-uncordon:prod-worker-1", "root-patch"} { requireNoLine(t, operations, unexpected) } if !pathExists(filepath.Join(f.syncStateDir, "cordon-owner-prod-worker-1")) { @@ -224,8 +530,8 @@ func TestUnreadyNodeAfterRebootStopsTheRoll(t *testing.T) { "fanout:externalsecret/wedding-app/ghcr-auth", "fanout:externalsecret/ascoachingogvaner/ghcr-auth", "fanout:externalsecret/kyverno/ghcr-auth", - "talos-auth:10.0.0.2", "node-claim-cordon:prod-worker-1", + "talos-auth:10.0.0.2", "node-drain:prod-worker-1", "talos-reboot:10.0.0.2", "node-ready:prod-worker-1", From 65c8c8e534e9279d4ac006968e9bd25e72234609 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Fri, 17 Jul 2026 13:06:42 +0200 Subject: [PATCH 22/22] fix(deploy): initialize optional recovery state --- scripts/refresh-flux-ghcr-auth.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/refresh-flux-ghcr-auth.sh b/scripts/refresh-flux-ghcr-auth.sh index c36b710ba..066708a7b 100755 --- a/scripts/refresh-flux-ghcr-auth.sh +++ b/scripts/refresh-flux-ghcr-auth.sh @@ -1596,7 +1596,10 @@ process_talos_node_target() { local cordon_owner_token="" local initial_node_uid="" initial_node_taints="[]" local bootstrap_state_file="${bootstrap_cordon_dir}/${node_name}.json" - local probe_image recovery_record + # Bash 5 keeps an unassigned `local` nounset; Bash 3 expands it as empty. + # Initialise the optional recovery payload so CI and operators get the same + # fail-closed cleanup path when no bootstrap recovery record was required. + local probe_image recovery_record="" assert_sync_lease_held || return 1