diff --git a/.github/workflows/cd.yaml b/.github/workflows/cd.yaml index 1f8d18d8de..061cc80399 100644 --- a/.github/workflows/cd.yaml +++ b/.github/workflows/cd.yaml @@ -51,6 +51,12 @@ jobs: with: persist-credentials: false + - name: ⚙️ Setup Go + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + - name: 💾 Validate two-stage PVC retirement env: PVC_PRUNE_BASE_SHA: ${{ github.sha }} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f04d3bfea0..85b1b4b557 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,12 @@ jobs: go-version-file: go.mod cache: false + - name: 💾 Validate Flux replacement safety + run: | + go test ./scripts/validate-flux-force-safety + shellcheck scripts/tests/test-flux-force-safety.sh + bash scripts/tests/test-flux-force-safety.sh + - name: 🧭 Validate GitHub bootstrap isolation run: | shellcheck scripts/guard-github-bootstrap-isolation.sh scripts/tests/test-github-bootstrap-isolation.sh diff --git a/AGENTS.md b/AGENTS.md index cb23cb323c..aa9dfb3da4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -546,7 +546,10 @@ verify that deployment before treating the production lane as clean. **Persistence retirement is always two-stage.** A merge-group artifact is speculative, but Kubernetes PVC deletion is irreversible once `deletionTimestamp` is set: queue eviction and the heal job cannot un-delete it. The production persistence-safety component therefore disables Flux -pruning on every PVC, HelmRelease, and Namespace, and disables Flux force replacement on PVCs. +pruning on every PVC, HelmRelease, and Namespace. Platform and generated tenant Flux layers set +`spec.force: false`; only individual Jobs that need recreation carry `force: enabled`. +`force: disabled` on a resource cannot override a forcing layer. The replacement-safety guard +checks layer defaults, both tenant template branches, and rendered patches before publication. HelmRelease protection prevents chart uninstall from deleting chart-owned claims; Namespace protection prevents cascading deletion from bypassing a claim's own annotation. To retire any of these objects, first merge and deploy that protection in its own revision; only a later PR may diff --git a/docs/TENANTS.md b/docs/TENANTS.md index f2471fa2e8..5a56958981 100644 --- a/docs/TENANTS.md +++ b/docs/TENANTS.md @@ -250,9 +250,12 @@ carry itself**: only example in the repo: `wedding-app`'s CNPG `Cluster` `storage.storageClass: longhorn` (local/docker has no longhorn, so the artifact stays class-agnostic and the overlay supplies the class). -- **Operational-safety annotations** — platform-enforced guards such as - `kustomize.toolkit.fluxcd.io/{force,prune}: disabled` on a stateful resource to prevent a - Flux delete+recreate data-loss event. +- **Operational-safety settings** — `kustomize.toolkit.fluxcd.io/prune: disabled` preserves + a stateful resource when its manifest is removed. Platform and generated tenant Flux layers + set `spec.force: false` so immutable conflicts fail rather than replace resources. Jobs that + need recreation can opt in with `kustomize.toolkit.fluxcd.io/force: enabled`; persistent claims + and database clusters must not opt in. A resource's `force: disabled` annotation cannot + override a forcing layer. **Everything a tenant can express in its own `deploy/` is tenant-owned and must NOT be patched here** — **hostnames**, **`gethomepage.dev/*` dashboard annotations**, routes, and app config: diff --git a/docs/deletion-and-data-retention.md b/docs/deletion-and-data-retention.md index aa20d88abf..64ab5e2473 100644 --- a/docs/deletion-and-data-retention.md +++ b/docs/deletion-and-data-retention.md @@ -76,11 +76,13 @@ Read from the charts this repository references and from the released source of `Released` and the volume controller does nothing more with it. - **CSI provisioner.** It deletes the storage behind a volume only when the volume is `Released` and its policy is `Delete`. -- **Flux kustomize-controller 1.8.** All four layers set `force: true`, so an object whose change - the API server rejects as invalid, which is what an immutable field produces, is deleted and - created again. The annotation `kustomize.toolkit.fluxcd.io/force` is read only as an opt-in - (`enabled`): `force: disabled` does not exempt an object from a layer that forces. Within one - Kustomization, classes are applied in an earlier stage than HelmReleases. +- **Flux kustomize-controller 1.8.** Platform and generated tenant layers set `force: false`: + an immutable conflict fails without replacing the object. Three setup Jobs explicitly opt + into recreation with `kustomize.toolkit.fluxcd.io/force: enabled`. That annotation is only an + opt-in; `force: disabled` cannot exempt an object from a forcing layer. The repository guard + rejects forcing layers, including tenant templates and rendered patches, and force opt-ins + on manifest-owned claims or database clusters. Within one Kustomization, classes are applied + in an earlier stage than HelmReleases. - **Flux helm-controller 1.5 (Helm 4.2).** Helm updates an object in place and never deletes it to recreate it, so a changed `reclaimPolicy` fails the upgrade. Helm creates an object that is missing, and deletes one that left the chart unless it carries `helm.sh/resource-policy: keep`. @@ -154,11 +156,12 @@ would keep creating `Delete` volumes until the workload itself is recreated. rebuilt cluster. It only ever moves `Delete` to `Retain`. It leaves Velero's temporary volumes alone, which are the ones bound to claims in Velero's own namespace. And it leaves a deliberate way to discard a single released volume. -5. **Forced replacement is a separate hazard with the same backstop.** All four layers force, and - `kustomize.toolkit.fluxcd.io/force: disabled` on a claim or a database cluster does not exempt - it. A change to such an object that the API server rejects is therefore answered by deleting - and recreating it. `Retain` turns that from data loss into an outage with recoverable data. It - does not prevent it. Preventing it is tracked in #4448 and is not part of this rollout. +5. **Forced replacement is a separate hazard with the same backstop.** Platform and generated + tenant layers set `force: false`, and the repository guard rejects forcing layers or force + opt-ins on manifest-owned claims and database clusters. Three setup Jobs explicitly opt into + recreation. `force: disabled` still cannot exempt an object from a forcing layer, while + `Retain` protects data but not service availability. The force-safety safeguard tracked by + #4448 is enforced separately from the storage-retention rollout described here. 6. **The order is fixed:** retain the data, prove it can be brought back, stop evicted candidates deleting anything, and only then remove the opt-outs. Steps 2 and 3 of the order first written on #3369 are swapped; the reason is under "Before the opt-outs are removed". diff --git a/k8s/bases/apps/ascoachingogvaner/flux-kustomization.yaml b/k8s/bases/apps/ascoachingogvaner/flux-kustomization.yaml index a405901a33..3c7d219901 100644 --- a/k8s/bases/apps/ascoachingogvaner/flux-kustomization.yaml +++ b/k8s/bases/apps/ascoachingogvaner/flux-kustomization.yaml @@ -15,7 +15,8 @@ spec: path: . prune: true wait: true - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false serviceAccountName: ascoachingogvaner sourceRef: kind: OCIRepository diff --git a/k8s/bases/apps/backstage/cluster.yaml b/k8s/bases/apps/backstage/cluster.yaml index 43bcec930b..bd4086265e 100644 --- a/k8s/bases/apps/backstage/cluster.yaml +++ b/k8s/bases/apps/backstage/cluster.yaml @@ -13,11 +13,8 @@ metadata: name: backstage-db namespace: backstage annotations: - # Never let Flux force-delete+recreate this DB: the apps Flux Kustomization - # runs force + prune and longhorn PVCs are reclaimPolicy=Delete, so a - # force-recreate would wipe the data (the failure that hit coroot-db on - # 2026-06-18). Flux can still update it in place. - kustomize.toolkit.fluxcd.io/force: disabled + # The owning Flux layer has force=false, so immutable conflicts fail rather + # than replacing this database. Preserve it when its manifest is removed. kustomize.toolkit.fluxcd.io/prune: disabled spec: # Pin the PostgreSQL operand image to clear fixable base-OS + PostgreSQL CVEs in diff --git a/k8s/bases/apps/github-config/flux-kustomization.yaml b/k8s/bases/apps/github-config/flux-kustomization.yaml index 7a4b655f57..08a5e13ac6 100644 --- a/k8s/bases/apps/github-config/flux-kustomization.yaml +++ b/k8s/bases/apps/github-config/flux-kustomization.yaml @@ -18,7 +18,8 @@ spec: # operations (including manual repository bootstrap) cannot gate app delivery. # Crossplane's Synced condition is monitored by crossplane-sync-alert instead. wait: false - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false # Runs as the app SA: the GitHub managed resources are applied with only the # namespace-scoped authority in rbac.yaml. This Kustomization is itself applied # by the `apps` Flux Kustomization; it must NOT dependsOn it (deadlock) — it diff --git a/k8s/bases/apps/umami/cluster.yaml b/k8s/bases/apps/umami/cluster.yaml index 5f8acb3368..9240a2f122 100644 --- a/k8s/bases/apps/umami/cluster.yaml +++ b/k8s/bases/apps/umami/cluster.yaml @@ -14,12 +14,8 @@ metadata: name: umami-db namespace: umami annotations: - # Never let Flux delete+recreate this database: the apps Flux Kustomization - # runs force: true + prune: true, and the longhorn PVCs are reclaimPolicy= - # Delete, so a force-recreate would wipe the data (the failure that hit - # coroot-db on 2026-06-18). Flux can still update it; immutable changes must - # be made by an operator with a backup/restore plan. - kustomize.toolkit.fluxcd.io/force: disabled + # The owning Flux layer has force=false: immutable conflicts require an + # operator's backup/restore plan. Keep this database when its manifest is removed. kustomize.toolkit.fluxcd.io/prune: disabled spec: # Pin the PostgreSQL operand image explicitly instead of riding the CNPG operator @@ -32,7 +28,8 @@ spec: # 11 PostgreSQL CVEs incl. the RCE-class CVE-2026-6473. Renovate keeps this current # via the cluster.yaml customManager in .github/renovate.json; CNPG rolls # the image in place (replicas first, then a primary switchover), so the >=2-instance - # HA Clusters stay available with no data-loss path (force/prune are disabled above). + # HA Clusters remain available; Flux force=false and the prune annotation + # prevent automatic replacement and garbage collection of this Cluster. # renovate: datasource=docker depName=ghcr.io/cloudnative-pg/postgresql imageName: ghcr.io/cloudnative-pg/postgresql:18.4-system-trixie # Run 3 instances (1 primary + 2 streaming replicas) for high availability. diff --git a/k8s/bases/components/annotations-transformers/annotations-transformer-production-pvc-force.yaml b/k8s/bases/components/annotations-transformers/annotations-transformer-production-pvc-force.yaml deleted file mode 100644 index ecfd4b4fb5..0000000000 --- a/k8s/bases/components/annotations-transformers/annotations-transformer-production-pvc-force.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -apiVersion: builtin -kind: AnnotationsTransformer -metadata: - name: production-pvc-force-protection -annotations: - kustomize.toolkit.fluxcd.io/force: disabled -fieldSpecs: - - kind: PersistentVolumeClaim - path: metadata/annotations - create: true diff --git a/k8s/bases/components/annotations-transformers/kustomization.yaml b/k8s/bases/components/annotations-transformers/kustomization.yaml index 99bab55d59..844e92e111 100644 --- a/k8s/bases/components/annotations-transformers/kustomization.yaml +++ b/k8s/bases/components/annotations-transformers/kustomization.yaml @@ -3,4 +3,3 @@ apiVersion: kustomize.config.k8s.io/v1alpha1 kind: Component transformers: - annotations-transformer-production-persistence-prune.yaml - - annotations-transformer-production-pvc-force.yaml diff --git a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml index 086f63fbef..5b4bf97ab5 100644 --- a/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml +++ b/k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml @@ -304,7 +304,7 @@ spec: path: . prune: true wait: true - force: true + force: false # A tenant that ships a CNPG database (wedding-app does) would # otherwise fail this Kustomization for the whole of any rolling # update — CNPG rolls every instance on an operator, barman-cloud @@ -341,7 +341,7 @@ spec: path: . prune: true wait: true - force: true + force: false # A tenant that ships a CNPG database (wedding-app does) would # otherwise fail this Kustomization for the whole of any rolling # update — CNPG rolls every instance on an operator, barman-cloud diff --git a/k8s/clusters/base/flux-kustomization-apps.yaml b/k8s/clusters/base/flux-kustomization-apps.yaml index 65f7e73d7d..46230a52b3 100644 --- a/k8s/clusters/base/flux-kustomization-apps.yaml +++ b/k8s/clusters/base/flux-kustomization-apps.yaml @@ -56,4 +56,5 @@ spec: kind: Cluster current: has(status.readyInstances) && status.readyInstances >= 1 && has(status.currentPrimary) && status.currentPrimary != '' prune: true - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false diff --git a/k8s/clusters/base/flux-kustomization-bootstrap.yaml b/k8s/clusters/base/flux-kustomization-bootstrap.yaml index e4a078b4a2..532270d547 100644 --- a/k8s/clusters/base/flux-kustomization-bootstrap.yaml +++ b/k8s/clusters/base/flux-kustomization-bootstrap.yaml @@ -25,4 +25,5 @@ spec: name: sops-age wait: true prune: true - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false diff --git a/k8s/clusters/base/flux-kustomization-infrastructure-controllers.yaml b/k8s/clusters/base/flux-kustomization-infrastructure-controllers.yaml index 8148388733..98b3d1c968 100644 --- a/k8s/clusters/base/flux-kustomization-infrastructure-controllers.yaml +++ b/k8s/clusters/base/flux-kustomization-infrastructure-controllers.yaml @@ -37,4 +37,5 @@ spec: name: variables-cluster wait: true prune: true - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false diff --git a/k8s/clusters/base/flux-kustomization-infrastructure.yaml b/k8s/clusters/base/flux-kustomization-infrastructure.yaml index 74b2190879..b5559085c5 100644 --- a/k8s/clusters/base/flux-kustomization-infrastructure.yaml +++ b/k8s/clusters/base/flux-kustomization-infrastructure.yaml @@ -55,4 +55,5 @@ spec: kind: Cluster current: has(status.readyInstances) && status.readyInstances >= 1 && has(status.currentPrimary) && status.currentPrimary != '' prune: true - force: true + # Immutable conflicts require an operator plan; individual Jobs opt into recreation. + force: false diff --git a/k8s/providers/hetzner/apps/aws/policy-eks-ci-smoke-boundary.yaml b/k8s/providers/hetzner/apps/aws/policy-eks-ci-smoke-boundary.yaml index b1bf0e0f80..fbeeae7ea7 100644 --- a/k8s/providers/hetzner/apps/aws/policy-eks-ci-smoke-boundary.yaml +++ b/k8s/providers/hetzner/apps/aws/policy-eks-ci-smoke-boundary.yaml @@ -55,16 +55,8 @@ metadata: namespace: aws labels: app.kubernetes.io/managed-by: ksail - annotations: - # The top-level `apps` Kustomization runs force: true, but this is a - # Crossplane managed resource with the default deletionPolicy: Delete — a - # force delete/recreate on immutable-field drift would cascade into DELETING - # the backing AWS IAM policy (the same class of failure that wiped coroot-db - # on 2026-06-18). The tenant Kustomization avoids force for exactly this - # reason; these platform-owned MRs opt out per-resource instead (#2631 - # review). Flux can still update them in place; a genuine immutable conflict - # is an operator decision, not an automatic replace. - kustomize.toolkit.fluxcd.io/force: disabled + # Both platform and tenant Flux layers have force=false. An immutable conflict + # is an operator decision rather than recreation of this managed IAM policy. spec: forProvider: description: >- diff --git a/k8s/providers/hetzner/apps/aws/role-eks-ci.yaml b/k8s/providers/hetzner/apps/aws/role-eks-ci.yaml index a70ff406c6..0be726fc89 100644 --- a/k8s/providers/hetzner/apps/aws/role-eks-ci.yaml +++ b/k8s/providers/hetzner/apps/aws/role-eks-ci.yaml @@ -47,11 +47,8 @@ metadata: namespace: aws labels: app.kubernetes.io/managed-by: ksail - annotations: - # See policy-eks-ci-smoke-boundary.yaml: the `apps` Kustomization runs - # force: true, and a force-recreate of a Crossplane MR with the default - # deletionPolicy: Delete would DELETE the backing AWS IAM role. Opt out. - kustomize.toolkit.fluxcd.io/force: disabled + # The owning Flux layer has force=false; an immutable conflict cannot + # automatically recreate this managed resource and delete its backing IAM role. spec: forProvider: description: >- diff --git a/k8s/providers/hetzner/apps/wedding-app/patches/flux-kustomization-protect-wedding-db.yaml b/k8s/providers/hetzner/apps/wedding-app/patches/flux-kustomization-protect-wedding-db.yaml index 440b771f01..746828daa8 100644 --- a/k8s/providers/hetzner/apps/wedding-app/patches/flux-kustomization-protect-wedding-db.yaml +++ b/k8s/providers/hetzner/apps/wedding-app/patches/flux-kustomization-protect-wedding-db.yaml @@ -21,11 +21,8 @@ spec: metadata: name: wedding-db annotations: - # Never let Flux delete+recreate this database (force-recreate + - # reclaimPolicy=Delete PVCs = data loss, the failure that hit - # coroot-db on 2026-06-18). Flux can still update it; immutable - # changes need a manual backup/restore by an operator. - kustomize.toolkit.fluxcd.io/force: disabled + # The tenant's Flux layer has force=false. Preserve the database + # if its manifest is removed; immutable changes need an operator plan. kustomize.toolkit.fluxcd.io/prune: disabled spec: # Quorum-based SYNCHRONOUS replication, same rationale as umami-db: a diff --git a/k8s/providers/hetzner/infrastructure/coroot/cluster.yaml b/k8s/providers/hetzner/infrastructure/coroot/cluster.yaml index fc67950ba3..8d43af271c 100644 --- a/k8s/providers/hetzner/infrastructure/coroot/cluster.yaml +++ b/k8s/providers/hetzner/infrastructure/coroot/cluster.yaml @@ -31,15 +31,9 @@ metadata: name: coroot-db namespace: observability annotations: - # Never let Flux delete+recreate this database. The infrastructure Flux - # Kustomization runs force: true + prune: true, so an immutable-field change - # or a live/Git drift on this Cluster would otherwise make kustomize- - # controller recreate it — and with the longhorn PVCs on reclaimPolicy= - # Delete that wipes the data via a fresh initdb (this happened 2026-06-18). - # force: disabled makes Flux surface the apply error for an operator to - # resolve deliberately (with a backup/restore plan) instead of recreating; - # prune: disabled stops GC if the Cluster is ever dropped from a kustomization. - kustomize.toolkit.fluxcd.io/force: disabled + # The owning Flux layer has force=false: immutable conflicts fail for an + # operator to resolve with a backup/restore plan. This annotation separately + # prevents garbage collection if the database manifest is removed. kustomize.toolkit.fluxcd.io/prune: disabled # CloudNativePG treats this supported annotation as a requested rolling # restart. The timestamp is fixed declarative desired state: changing it is diff --git a/k8s/providers/hetzner/infrastructure/patches/store-vault-snapshots-on-hcloud.yaml b/k8s/providers/hetzner/infrastructure/patches/store-vault-snapshots-on-hcloud.yaml index fab5a444a5..fb396d1777 100644 --- a/k8s/providers/hetzner/infrastructure/patches/store-vault-snapshots-on-hcloud.yaml +++ b/k8s/providers/hetzner/infrastructure/patches/store-vault-snapshots-on-hcloud.yaml @@ -25,8 +25,6 @@ kind: PersistentVolumeClaim metadata: name: vault-snapshots namespace: openbao - annotations: - kustomize.toolkit.fluxcd.io/force: disabled spec: # Hetzner Cloud Volumes attach reliably via the API to any node in the # location, unlike a Longhorn RWO volume mounted by node-mobile Jobs. diff --git a/scripts/rgd-template-static-scan-baseline.tsv b/scripts/rgd-template-static-scan-baseline.tsv index cc4d4f6d7b..4f6822a4fa 100644 --- a/scripts/rgd-template-static-scan-baseline.tsv +++ b/scripts/rgd-template-static-scan-baseline.tsv @@ -12,15 +12,15 @@ # change; a new finding is fixed, not baselined. # Measured with Trivy v0.74.0 and the policies embedded in that binary. CI pins the binary on pull # requests, merge groups, and direct main pushes; the gate rejects other versions and policy updates. -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 01953c88886b926f0fdbfa2063cecd0139f63cd801ebc5bd59ce57e2e11eb92e +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml RGD-CONTENT SHA256 164441249612faccef24576f8cec7c7c70a527b81f45574f13dec917439c26d1 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0039 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/defaultDeny.yaml KSV-0040 LOW CAUSE-SHA256:6246c5e7ede528806f1f84a2b466b1a22c061b7d7f2ad38e18dc4c722ea7b32b 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ghcrAuth.yaml KSV-0039 LOW CAUSE-SHA256:f3c7ced815c76aaba6407cf691461a7e46b0037f888f32d760286b0fde1bd286 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ghcrAuth.yaml KSV-0040 LOW CAUSE-SHA256:f3c7ced815c76aaba6407cf691461a7e46b0037f888f32d760286b0fde1bd286 -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomization.yaml KSV-0039 LOW CAUSE-SHA256:1ebd8487222e03b649f95b96fb502578bd5f3fee3a775f5b54b450ec12ea9f81 -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomization.yaml KSV-0040 LOW CAUSE-SHA256:1ebd8487222e03b649f95b96fb502578bd5f3fee3a775f5b54b450ec12ea9f81 -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomizationSops.yaml KSV-0039 LOW CAUSE-SHA256:62bdc59f0e694bd4baccf8f6c2523696b1afd6ad47c6a53be670419a1e42038d -1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomizationSops.yaml KSV-0040 LOW CAUSE-SHA256:62bdc59f0e694bd4baccf8f6c2523696b1afd6ad47c6a53be670419a1e42038d +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomization.yaml KSV-0039 LOW CAUSE-SHA256:2b3834d0822048e2aaefbd8076a28785cf8781b65230ea8ea37eb854e1169bc4 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomization.yaml KSV-0040 LOW CAUSE-SHA256:2b3834d0822048e2aaefbd8076a28785cf8781b65230ea8ea37eb854e1169bc4 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomizationSops.yaml KSV-0039 LOW CAUSE-SHA256:59d21ae5decd83ec9c1e66036a71db7aa29ca574b4e3ef0fb803a8b45d7fac61 +1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/kustomizationSops.yaml KSV-0040 LOW CAUSE-SHA256:59d21ae5decd83ec9c1e66036a71db7aa29ca574b4e3ef0fb803a8b45d7fac61 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/limitRange.yaml KSV-0039 LOW CAUSE-SHA256:c37b17db55af250264e52c9089e25decadde834c9ebae540db73774a4f2b5702 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/limitRange.yaml KSV-0040 LOW CAUSE-SHA256:c37b17db55af250264e52c9089e25decadde834c9ebae540db73774a4f2b5702 1 k8s/bases/infrastructure/resource-graph-definitions/tenant/resource-graph-definition.yaml.resources/ociRepository.yaml KSV-0039 LOW CAUSE-SHA256:7655439f41ac63530bbbacc158dbf9a0ea3b63d14d1e7135ce6b25e2218adb42 diff --git a/scripts/tests/test-flux-force-safety.sh b/scripts/tests/test-flux-force-safety.sh new file mode 100755 index 0000000000..268827cc53 --- /dev/null +++ b/scripts/tests/test-flux-force-safety.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +readonly root_dir +render_dir="$(mktemp -d)" +readonly render_dir +trap 'rm -rf "${render_dir}"' EXIT + +cd "${root_dir}" +# Source checks cover both KRO tenant branches; rendered checks cover patches. +kubectl kustomize k8s/clusters/prod >"${render_dir}/cluster.yaml" +for overlay in bootstrap infrastructure/controllers infrastructure apps; do + kubectl kustomize "k8s/providers/hetzner/${overlay}" >"${render_dir}/${overlay//\//-}.yaml" +done +go run ./scripts/validate-flux-force-safety k8s "${render_dir}" diff --git a/scripts/tests/test-pvc-prune-safety.sh b/scripts/tests/test-pvc-prune-safety.sh index 1c1abc8833..d914aaab51 100755 --- a/scripts/tests/test-pvc-prune-safety.sh +++ b/scripts/tests/test-pvc-prune-safety.sh @@ -129,12 +129,9 @@ if [[ -n "${unprotected_current}" ]]; then fail "every rendered production PVC, HelmRelease, and Namespace must disable Flux pruning; missing on: ${unprotected_current//$'\n'/, }" fi -force_enabled_current="$(awk -F '\t' ' - $1 == "PersistentVolumeClaim" && $5 != "disabled" {print $2 "/" $3} -' "${current_resources}")" -if [[ -n "${force_enabled_current}" ]]; then - fail "every rendered production PVC must disable Flux force replacement; missing on: ${force_enabled_current//$'\n'/, }" -fi +# A disabled resource annotation cannot override spec.force=true on its layer. +# Validate the actual layer/template defaults and rendered patch results instead. +bash "${root_dir}/scripts/tests/test-flux-force-safety.sh" cut -f1-3 "${base_resources}" >"${temp_dir}/base-identities.tsv" cut -f1-3 "${current_resources}" >"${temp_dir}/current-identities.tsv" diff --git a/scripts/validate-eks-ci-role-policy/approved-surface.txt b/scripts/validate-eks-ci-role-policy/approved-surface.txt index 417705c1fe..6bed7e803c 100644 --- a/scripts/validate-eks-ci-role-policy/approved-surface.txt +++ b/scripts/validate-eks-ci-role-policy/approved-surface.txt @@ -98,29 +98,29 @@ helm.toolkit.fluxcd.io/v2|HelmRelease|vertical-pod-autoscaler|vertical-pod-autos helm.toolkit.fluxcd.io/v2|HelmRelease|whoami|whoami daa7d5894e48ee981cc577597042cb89a089818efd700c41997d2b23034f2b7c -iam.aws.m.upbound.io/v1beta1|Policy|aws|eks-ci-smoke-boundary d2066f1187d62c5b76ef5648194da3da995654d238e22c6c27e931bbb352a8e4 +iam.aws.m.upbound.io/v1beta1|Policy|aws|eks-ci-smoke-boundary 019210611429956763cab81a883bc6cf1faf53caf89b5e9274e8ec69cad01b66 -iam.aws.m.upbound.io/v1beta1|Role|aws|eks-ci bcb863bac5f1cf5f3d482df7da16104bb79363ced42bb8ba6ae57c0e592a85f0 +iam.aws.m.upbound.io/v1beta1|Role|aws|eks-ci 878e257591c0e0dd69ebf1398211feadfc06f857665e453a87815c361c2529cd -kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 7b4d7416659c7d3b208d35089626438dd9ee16df784857a9c83322d20c23334a +kro.run/v1alpha1|ResourceGraphDefinition||tenant.kro.run 1ee1880dfe992996a7480cceb143bd2520b0cbb87170eb50acda2d214be5abb4 -kustomize.toolkit.fluxcd.io/v1|Kustomization|ascoachingogvaner|ascoachingogvaner 66e4abc40b745483e006985894d5a66c3ad806effb9b532b02bc929a58b892f4 +kustomize.toolkit.fluxcd.io/v1|Kustomization|ascoachingogvaner|ascoachingogvaner ac00ca29c04de8d2c30a045d583570536a97fc305a1b06c4300da018ecdfb864 kustomize.toolkit.fluxcd.io/v1|Kustomization|aws|aws 88fe1a404c036bfe73b4eda38a33ac31f4546573c9daaebebaae173c23844cba -kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|apps 6ae44604b2e403bb15470af9afa767c20d46277d5446cf98226f8439af132c72 +kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|apps 239f6440d5f3057898b6e791f953392bb7219f8a066471f7109d4cd73ddc5fcc -kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|bootstrap 1eb5bf573d09b51326f13e513f1db266d7d7eba7b95eb7e60b32b37731318e3f +kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|bootstrap df15a74c32342880c533cc49f41971742874791e306e3edd95603a98396a1612 -kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|infrastructure 9bcbbc41e702c9d8f204747e927f856ade73b61ae45818abc8d84c9b99538967 +kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|infrastructure be1fbaa7ee876dd766dd17532647946988895ed9d52723f8175a88e428c37a22 -kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|infrastructure-controllers e2c18185ed7c0082c075000dbcd7f9eb8354b7566823a6a32554fb1c3e00d937 +kustomize.toolkit.fluxcd.io/v1|Kustomization|flux-system|infrastructure-controllers 9956b47124048a33b0c5d04192e8103bf96c748dc8347d282410a475d2724fa9 -kustomize.toolkit.fluxcd.io/v1|Kustomization|github-config|github-config 5b4f05a9a4b845f91264751628d2c653459fca822c1f23c6d860441c08d20b32 +kustomize.toolkit.fluxcd.io/v1|Kustomization|github-config|github-config dc10cfc1ffeb139e69a5ac205f9bce35628dec215fe892e1871145f333d363b2 kustomize.toolkit.fluxcd.io/v1|Kustomization|unifi|unifi e3b3caea1b9c5a3fc994d9f68c9609d7f77843f24dae91c567e4dd7b5051868a -kustomize.toolkit.fluxcd.io/v1|Kustomization|wedding-app|wedding-app 1b680e05b65c96f2f91d709e8dd838dea064b15a132fcab2520606c9467241fe +kustomize.toolkit.fluxcd.io/v1|Kustomization|wedding-app|wedding-app 93ed553970290529db7159ccc4cc138d429d7b9072f3da334b4a55c9d928228f kustomize.toolkit.fluxcd.io/v1|Kustomization|world-at-ruin|world-at-ruin 9c3f8221e384c5bb93de6056be60f16f69de3b08898efc4f3760944f225d15b2 diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index e2f4a5fdbe..ac6ce4fcfb 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -42,8 +42,8 @@ const ( expectedKubectlVersion = "v1.36.2" expectedKustomizeVersion = "v5.8.1" - expectedRoleManifestSHA = "96a77d18160c450340e65b0953f44016a01a08429416f7a82142c3f90a61ca07" - expectedBoundarySHA = "6e79792b08aa023900734d31c45d6abe1765991ad16b63e84598cc8d7d5b05af" + expectedRoleManifestSHA = "75acb24fe16f4ae53107f4a07c514b79f2f8659b674191c794ba49076ac5c0dd" + expectedBoundarySHA = "0f7b67f3434201a7bd4a2433ade6d8718da4e6cd60fd51ae4570cac88a240dee" expectedTrustPolicySHA = "85d5d45343f9eac5fdc35717c85c88c5b0f8fde9eddffb169c3a223617fd0a5e" expectedInlinePolicySHA = "60e3086a6d3dac0092ffe8264c04ebae783c0d38f19a3cf073ed8991085a4df8" expectedBoundaryJSONSHA = "2c9bc1ce56efeb6fa30d885d5f9dff8d5d8129a07d9393ccdeb376605cbc5ad8" @@ -256,8 +256,8 @@ func validateUnifiPruneExemption(document map[string]any, identity resourceIdent // EKS CI identities while the aggregate surface hash pins every selected // source, controller, binding, and indirect authorization object. var expectedRenderedHashes = map[resourceIdentity]string{ - {apiVersion: "iam.aws.m.upbound.io/v1beta1", kind: "Role", namespace: "aws", name: "eks-ci"}: "0967890d16316a8cfcb1cca8a52085c6989c42000fafbbd0ada6323d4e15c97c", - {apiVersion: "iam.aws.m.upbound.io/v1beta1", kind: "Policy", namespace: "aws", name: "eks-ci-smoke-boundary"}: "6f14b5243c945d0d2230821733ea12096d6e92ab155a35482b20a6080c03c037", + {apiVersion: "iam.aws.m.upbound.io/v1beta1", kind: "Role", namespace: "aws", name: "eks-ci"}: "224fe726e49cf9327588731605cfbbd751fffae05de91f14078df1db05f2279a", + {apiVersion: "iam.aws.m.upbound.io/v1beta1", kind: "Policy", namespace: "aws", name: "eks-ci-smoke-boundary"}: "9eb6e5e1b43a470a02cb292f8a449a51b7792e50220c7c3d5c08fe6b73e62f02", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "Role", namespace: "aws", name: "aws-managed-resources"}: "ff4c3264c519b1b4a7ec9b5145412f39ea2ba7b6163d8dc50fb029b1460edcda", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "RoleBinding", namespace: "aws", name: "aws-managed-resources"}: "d846c8d9810dd7c0cba33612d2de63183403ccb07c4d5a5c90d0563a444cd714", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRole", name: "kro-tenant-rgd"}: "4447f41c03e8297fafdabcadf4fdd8ca3260f2c84264c531b2179cb7df2c1556", @@ -265,16 +265,16 @@ var expectedRenderedHashes = map[resourceIdentity]string{ {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "crossview-view"}: "536a4baa1970100ea117d1655f80e06ed874e2248b75f33f161e8b44ca3df50c", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-cluster-reader"}: "7d896404f02d6418c289065d73f9ad79345217d76c8d89eadca2c06e6066b487", {apiVersion: "rbac.authorization.k8s.io/v1", kind: "ClusterRoleBinding", name: "oidc-view"}: "4d07ba3a995cfc139351b4227739efeba9348777f7fe47ac69b87d08e70bd45f", - {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "072e4478cdad39c0a7d9f5119cad63d4c56a9fc96ba88d657fef97f6b91bae31", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "ascoachingogvaner", name: "ascoachingogvaner"}: "89ea0484e37b691594b7a72be2ca2de285697818bf88a5b37b4fa8a9161c54fa", + {apiVersion: "kro.run/v1alpha1", kind: "ResourceGraphDefinition", name: "tenant.kro.run"}: "5cce66713183807de0141b78e3d0e8792f78660102cca1c0b9ad59b51e1cbc77", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "ascoachingogvaner", name: "ascoachingogvaner"}: "f921a4720a182b24f289c6a5e1ae684a174ed2f5a748c01cc6bc6bc14c644c03", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "aws", name: "aws"}: "7bde9c682a81b752bdf9d2b14ce69ca1690008a39f2562d4887f8200447dea71", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "apps"}: "ee11a54686a68eb49b833b234949f9d21a7b8106c1b3ae677e5c205e5506f6ac", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "bootstrap"}: "7f674a1762f298330c7c9e4d9d4e8bf46108b10727e02a25ca5096d7913cc0a7", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "infrastructure"}: "d1bc403b6458bd22cf967bd570e24718341cbd584f58e7f0069aaffe1e187945", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "infrastructure-controllers"}: "9d9b62d3221442d6355d16a34d31c198619fb3b3728df960fd67222a531ece7b", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "github-config", name: "github-config"}: "785aec7ef00ace6439055d5338cb6d15ef9e3fc56d5b3b13d2f4eccff1d10b09", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "apps"}: "b7dd0ad507a26f2ebd8ddf472a68a638b41dc2f2f1b476d0a6291f15332efe5d", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "bootstrap"}: "1223b3713886063661be32b3e82a8615856e3e01c526180f0d0d5b2bfade331d", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "infrastructure"}: "c366251c694a1647636cc029822fe23da00390dc55337463e30d637cbea375e7", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "flux-system", name: "infrastructure-controllers"}: "062eb303b9d92367e27f7617469375e5145c80728116d987a903f06ce594ad92", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "github-config", name: "github-config"}: "325a2db2626235b38b7122c10c06642697aee92f88f33a34423b6c72f3fb680a", {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "unifi", name: "unifi"}: "5af50c107c3ca59ea39bf2fa334fb99b5372acc720e469d0a07f52ec97242440", - {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "wedding-app", name: "wedding-app"}: "bf8c003eae3ed35d44b2a54aa73065e429b0c7b463af2efabb722fa709bc3365", + {apiVersion: "kustomize.toolkit.fluxcd.io/v1", kind: "Kustomization", namespace: "wedding-app", name: "wedding-app"}: "c4ad24936e0e320b41e6742846487e5da26116f719f07a7e683edd1ec800d4ec", } // fingerprint returns the SHA-256 identity used for byte-exact source checks. diff --git a/scripts/validate-flux-force-safety/main.go b/scripts/validate-flux-force-safety/main.go new file mode 100644 index 0000000000..a898a34892 --- /dev/null +++ b/scripts/validate-flux-force-safety/main.go @@ -0,0 +1,175 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +func resolve(node *yaml.Node) (*yaml.Node, error) { + seen := map[*yaml.Node]bool{} + for node != nil && node.Kind == yaml.AliasNode { + if seen[node] { + return nil, errors.New("cyclic YAML alias") + } + seen[node] = true + node = node.Alias + } + if node == nil { + return nil, errors.New("missing YAML alias target") + } + return node, nil +} + +func field(node *yaml.Node, keys ...string) (*yaml.Node, error) { + for _, key := range keys { + var err error + node, err = resolve(node) + if err != nil { + return nil, err + } + if node.Kind != yaml.MappingNode { + return nil, fmt.Errorf("field %q: expected YAML mapping", key) + } + found := false + for i := 0; i < len(node.Content); i += 2 { + if node.Content[i].Value == key { + node = node.Content[i+1] + found = true + break + } + } + if !found { + return &yaml.Node{}, nil + } + } + return resolve(node) +} + +func inspect(node *yaml.Node, count *int, ancestors map[*yaml.Node]bool) error { + node, err := resolve(node) + if err != nil { + return err + } + if ancestors[node] { + return errors.New("cyclic YAML collection") + } + ancestors[node] = true + defer delete(ancestors, node) + if node.Kind == yaml.MappingNode { + keys := map[string]bool{} + for i := 0; i < len(node.Content); i += 2 { + key := node.Content[i].Value + if node.Content[i].Tag == "!!merge" { + return errors.New("YAML merge keys are not supported by the replacement guard; make settings explicit") + } + if keys[key] { + return fmt.Errorf("duplicate YAML key %q", key) + } + keys[key] = true + } + api, err := field(node, "apiVersion") + if err != nil { + return err + } + kind, err := field(node, "kind") + if err != nil { + return err + } + if kind.Value == "Kustomization" && strings.HasPrefix(api.Value, "kustomize.toolkit.fluxcd.io/") { + (*count)++ + force, err := field(node, "spec", "force") + if err != nil { + return err + } + if force.Kind != 0 { + if force.Kind != yaml.ScalarNode || force.Tag != "!!bool" { + return errors.New("flux force must be a literal boolean") + } + var enabled bool + if err := force.Decode(&enabled); err != nil { + return err + } + if enabled { + return errors.New("layer-wide force replacement is unsafe; opt individual Jobs in instead") + } + } + } + if (kind.Value == "PersistentVolumeClaim" && api.Value == "v1") || (kind.Value == "Cluster" && strings.HasPrefix(api.Value, "postgresql.cnpg.io/")) { + force, err := field(node, "metadata", "annotations", "kustomize.toolkit.fluxcd.io/force") + if err != nil { + return err + } + if force.Kind != 0 && (force.Kind != yaml.ScalarNode || force.Tag != "!!str" || strings.Contains(force.Value, "${")) { + return errors.New("persistent force annotation must be a literal string") + } + if strings.EqualFold(force.Value, "enabled") { + return errors.New("persistent resource cannot opt into force replacement") + } + } + } + // Traverse embedded KRO templates and Kubernetes Lists as well as documents. + // Scalar strings (including scripts and CEL expressions) are never parsed as YAML. + for _, child := range node.Content { + if err := inspect(child, count, ancestors); err != nil { + return err + } + } + return nil +} + +func verify(paths ...string) error { + count := 0 + for _, root := range paths { + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || (filepath.Ext(path) != ".yaml" && filepath.Ext(path) != ".yml") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + decoder := yaml.NewDecoder(strings.NewReader(string(data))) + for { + var node yaml.Node + err := decoder.Decode(&node) + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("%s: %w", path, err) + } + if err := inspect(&node, &count, map[*yaml.Node]bool{}); err != nil { + return fmt.Errorf("%s: %w", path, err) + } + } + }) + if err != nil { + return err + } + } + if count == 0 { + return errors.New("no Flux Kustomization examined") + } + return nil +} + +func main() { + paths := os.Args[1:] + if len(paths) == 0 { + paths = []string{"k8s"} + } + if err := verify(paths...); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("Verified Flux layers and tenant templates cannot force replacement; persistent resources cannot opt in.") +} diff --git a/scripts/validate-flux-force-safety/main_test.go b/scripts/validate-flux-force-safety/main_test.go new file mode 100644 index 0000000000..b01303f441 --- /dev/null +++ b/scripts/validate-flux-force-safety/main_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +const layer = "apiVersion: kustomize.toolkit.fluxcd.io/v1\nkind: Kustomization\nmetadata: {name: apps}\nspec: {force: false}\n" + +func TestForceBoundary(t *testing.T) { + for _, tt := range []struct{ name, data, want string }{ + {"safe-layer", layer, ""}, + {"forcing-layer", strings.Replace(layer, "force: false", "force: true", 1), "layer-wide force"}, + {"disabled-annotation-does-not-override", strings.Replace(layer, "force: false", "force: true", 1) + "---\napiVersion: postgresql.cnpg.io/v1\nkind: Cluster\nmetadata: {name: db, annotations: {kustomize.toolkit.fluxcd.io/force: disabled}}\n", "layer-wide force"}, + {"forcing-tenant-template", "apiVersion: kro.run/v1alpha1\nkind: ResourceGraphDefinition\nspec:\n resources:\n - id: tenant\n template:\n " + strings.ReplaceAll(strings.Replace(layer, "force: false", "force: true", 1), "\n", "\n "), "layer-wide force"}, + {"safe-tenant-template", "kind: ResourceGraphDefinition\nspec:\n resources:\n - template:\n " + strings.ReplaceAll(layer, "\n", "\n "), ""}, + {"invalid-force", strings.Replace(layer, "force: false", "force: '${schema.spec.force}'", 1), "flux force must be a literal boolean"}, + {"claim-opt-in", layer + "---\napiVersion: v1\nkind: PersistentVolumeClaim\nmetadata: {name: data, annotations: {kustomize.toolkit.fluxcd.io/force: enabled}}\n", "persistent resource"}, + {"database-opt-in", layer + "---\napiVersion: postgresql.cnpg.io/v1\nkind: Cluster\nmetadata: {name: db, annotations: {kustomize.toolkit.fluxcd.io/force: ENABLED}}\n", "persistent resource"}, + {"job-opt-in", layer + "---\napiVersion: batch/v1\nkind: Job\nmetadata: {name: setup, annotations: {kustomize.toolkit.fluxcd.io/force: enabled}}\n", ""}, + {"helm-force-is-separate", layer + "---\napiVersion: helm.toolkit.fluxcd.io/v2\nkind: HelmRelease\nspec: {upgrade: {force: true}}\n", ""}, + {"no-census", "kind: ConfigMap\n", "no Flux Kustomization"}, + {"partial-yaml", layer + "---\nkind: [\n", "yaml"}, + {"duplicate-force", strings.Replace(layer, "spec: {force: false}", "spec: {force: false, force: true}", 1), "duplicate"}, + {"merge-hidden-force", "defaults: &defaults {force: true}\n" + strings.Replace(layer, "spec: {force: false}", "spec: {<<: *defaults}", 1), "merge keys"}, + {"aliased-force", "enabled: &enabled true\n" + strings.Replace(layer, "force: false", "force: *enabled", 1), "layer-wide force"}, + {"cyclic-list", layer + "---\n&cycle\nkind: List\nitems: [*cycle]\n", "cyclic"}, + } { + t.Run(tt.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(path, []byte(tt.data), 0600); err != nil { + t.Fatal(err) + } + err := verify(path) + if tt.want == "" && err != nil { + t.Fatal(err) + } + if tt.want != "" && (err == nil || !strings.Contains(err.Error(), tt.want)) { + t.Fatalf("wanted %q, got %v", tt.want, err) + } + }) + } +} + +func TestPersistentForceRequiresInspectableValues(t *testing.T) { + for _, resource := range []string{"apiVersion: v1\nkind: PersistentVolumeClaim", "apiVersion: postgresql.cnpg.io/v1\nkind: Cluster"} { + for _, tc := range []struct{ name, metadata, want string }{ + {"absent", "{name: data}", ""}, + {"disabled", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: disabled}}", ""}, + {"unrelated-expression", "{name: data, annotations: {description: '${schema.description}'}}", ""}, + {"force-expression", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: '${schema.force}'}}", "literal string"}, + {"force-map", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: {value: enabled}}}", "literal string"}, + {"force-list", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: [enabled]}}", "literal string"}, + {"force-boolean", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: false}}", "literal string"}, + {"force-null", "{name: data, annotations: {kustomize.toolkit.fluxcd.io/force: null}}", "literal string"}, + {"annotations-expression", "{name: data, annotations: '${schema.annotations}'}", "expected YAML mapping"}, + {"metadata-expression", "'${schema.metadata}'", "expected YAML mapping"}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + template := resource + "\nmetadata: " + tc.metadata + "\n" + data := layer + "---\napiVersion: kro.run/v1alpha1\nkind: ResourceGraphDefinition\nspec:\n resources:\n - id: data\n template:\n " + strings.ReplaceAll(template, "\n", "\n ") + path := filepath.Join(t.TempDir(), "manifest.yaml") + if err := os.WriteFile(path, []byte(data), 0600); err != nil { + t.Fatal(err) + } + err := verify(path) + if tc.want == "" && err != nil { + t.Fatal(err) + } + if tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)) { + t.Fatalf("wanted %q, got %v", tc.want, err) + } + }) + } + } +} + +func TestRenderedOverride(t *testing.T) { + dir := t.TempDir() + for name, data := range map[string]string{ + "layer.yaml": layer, + "kustomization.yaml": "apiVersion: kustomize.config.k8s.io/v1beta1\nkind: Kustomization\nresources: [layer.yaml]\npatches:\n- target: {kind: Kustomization, name: apps}\n patch: |-\n - op: replace\n path: /spec/force\n value: true\n", + } { + if err := os.WriteFile(filepath.Join(dir, name), []byte(data), 0600); err != nil { + t.Fatal(err) + } + } + data, err := exec.Command("kubectl", "kustomize", dir).CombinedOutput() + if err != nil { + t.Fatalf("render: %v: %s", err, data) + } + path := filepath.Join(t.TempDir(), "render.yaml") + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + if err := verify(path); err == nil || !strings.Contains(err.Error(), "layer-wide force") { + t.Fatalf("rendered override passed: %v", err) + } +} + +func TestRepositoryBoundary(t *testing.T) { + if err := verify("../../k8s"); err != nil { + t.Fatal(err) + } +} + +func TestRequiredGuard(t *testing.T) { + data, err := os.ReadFile("../../.github/workflows/ci.yaml") + if err != nil { + t.Fatal(err) + } + var workflow struct { + On map[string]yaml.Node `yaml:"on"` + Jobs map[string]struct { + If string `yaml:"if"` + Needs []string `yaml:"needs"` + Steps []struct { + If string `yaml:"if"` + Continue bool `yaml:"continue-on-error"` + Run string `yaml:"run"` + } `yaml:"steps"` + } `yaml:"jobs"` + } + if err := yaml.Unmarshal(data, &workflow); err != nil { + t.Fatal(err) + } + for _, event := range []string{"pull_request", "merge_group"} { + if _, exists := workflow.On[event]; !exists { + t.Fatalf("missing %s trigger", event) + } + } + changes := workflow.Jobs["changes"] + if changes.If != "" { + t.Fatal("replacement guard job cannot be conditional") + } + guarded := false + for _, step := range changes.Steps { + if step.If == "" && !step.Continue && strings.Contains(step.Run, "bash scripts/tests/test-flux-force-safety.sh") { + guarded = true + } + } + if !guarded { + t.Fatal("missing unconditional rendered replacement guard") + } + for _, need := range workflow.Jobs["ci-required-checks"].Needs { + if need == "changes" { + return + } + } + t.Fatal("required CI does not wait for the replacement guard") +} diff --git a/tests/wedding-backup-staging/wiring.test.mjs b/tests/wedding-backup-staging/wiring.test.mjs index 53b93a9670..d7715b0f5b 100644 --- a/tests/wedding-backup-staging/wiring.test.mjs +++ b/tests/wedding-backup-staging/wiring.test.mjs @@ -19,7 +19,9 @@ test('production patches select the dedicated archive without replacing the data assert.deepEqual(rendered.spec.plugins,[{name:'barman-cloud.cloudnative-pg.io',enabled:true,isWALArchiver:true,parameters:{barmanObjectName:'wedding-db-dedicated',serverName:'wedding-db-20260909'}}]); assert.equal(rendered.spec.instances,3); assert.equal(rendered.metadata.annotations['kustomize.toolkit.fluxcd.io/prune'],'disabled'); - assert.equal(rendered.metadata.annotations['kustomize.toolkit.fluxcd.io/force'],'disabled'); + assert.notEqual(rendered.metadata.annotations['kustomize.toolkit.fluxcd.io/force'],'enabled'); + // Flux has no per-resource force opt-out: the owning layer must disable it. + assert.equal(yaml('k8s/clusters/base/flux-kustomization-apps.yaml').spec.force,false); }); // Test-only resolution of scalar context references; the production proposal // contains no expression language beyond these direct input/step handoffs.