From aa67d138f8258aa5a6208553ad4537e01bc07c5a Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 16:49:30 +0200 Subject: [PATCH 1/3] test(kyverno): pin enforce-flux-best-practices before its ValidatingPolicy rewrite Part of #5128. Co-Authored-By: Claude Opus 5.5 --- .../kyverno-test.yaml | 57 +++ .../resources.yaml | 374 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 tests/enforce-flux-best-practices/kyverno-test.yaml create mode 100644 tests/enforce-flux-best-practices/resources.yaml diff --git a/tests/enforce-flux-best-practices/kyverno-test.yaml b/tests/enforce-flux-best-practices/kyverno-test.yaml new file mode 100644 index 000000000..ce6279146 --- /dev/null +++ b/tests/enforce-flux-best-practices/kyverno-test.yaml @@ -0,0 +1,57 @@ +# Pins the policy's behaviour across its ValidatingPolicy rewrite (#5128). +# A Kustomization needs a non-empty interval, timeout and retry interval, +# prune: true, wait: true and a named source; a HelmRelease needs an interval, +# a timeout, install and upgrade remediation retries (at least 1, or -1 for +# unlimited) and upgrade remediateLastFailure: true. The base policy has no +# UniFi exemption (the Hetzner overlay adds it), and the flux-system bootstrap +# exclusion covers only the Kustomization, never a HelmRelease of that name. +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: enforce-flux-best-practices +policies: + - ../../k8s/bases/infrastructure/cluster-policies/flux/enforce-flux-best-practices.yaml +resources: + - resources.yaml +results: + - policy: enforce-flux-best-practices + rule: kustomization-recommended-settings + resources: + - tenant/ks-compliant + kind: Kustomization + result: pass + - policy: enforce-flux-best-practices + rule: kustomization-recommended-settings + resources: + - tenant/ks-missing-interval + - tenant/ks-missing-timeout + - tenant/ks-missing-retry-interval + - tenant/ks-prune-disabled + - tenant/ks-missing-prune + - tenant/ks-missing-wait + - tenant/ks-missing-source-name + - tenant/ks-empty-interval + - unifi/unifi + kind: Kustomization + result: fail + - policy: enforce-flux-best-practices + rule: helmrelease-recommended-settings + resources: + - tenant/hr-compliant + - tenant/hr-unlimited-retries + kind: HelmRelease + result: pass + - policy: enforce-flux-best-practices + rule: helmrelease-recommended-settings + resources: + - tenant/hr-missing-interval + - tenant/hr-missing-timeout + - tenant/hr-missing-install-remediation + - tenant/hr-zero-install-retries + - tenant/hr-missing-upgrade-remediation + - tenant/hr-zero-upgrade-retries + - tenant/hr-no-remediate-last-failure + - tenant/hr-missing-remediate-last-failure + - flux-system/flux-system + kind: HelmRelease + result: fail diff --git a/tests/enforce-flux-best-practices/resources.yaml b/tests/enforce-flux-best-practices/resources.yaml new file mode 100644 index 000000000..5e5523791 --- /dev/null +++ b/tests/enforce-flux-best-practices/resources.yaml @@ -0,0 +1,374 @@ +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-compliant + namespace: tenant +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: true + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-interval + namespace: tenant +spec: + timeout: 5m + retryInterval: 2m + prune: true + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-timeout + namespace: tenant +spec: + interval: 1m + retryInterval: 2m + prune: true + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-retry-interval + namespace: tenant +spec: + interval: 1m + timeout: 5m + prune: true + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-prune-disabled + namespace: tenant +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: false + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-prune + namespace: tenant +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-wait + namespace: tenant +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-missing-source-name + namespace: tenant +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: true + wait: true + sourceRef: + kind: OCIRepository +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: ks-empty-interval + namespace: tenant +spec: + interval: "" + timeout: 5m + retryInterval: 2m + prune: true + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: unifi + namespace: unifi +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: false + wait: true + sourceRef: + kind: OCIRepository + name: platform +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-compliant + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-unlimited-retries + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: -1 + upgrade: + remediation: + retries: -1 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-missing-interval + namespace: tenant +spec: + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-missing-timeout + namespace: tenant +spec: + interval: 1h + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-missing-install-remediation + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + upgrade: + remediation: + retries: 3 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-zero-install-retries + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 0 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-missing-upgrade-remediation + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-zero-upgrade-retries + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 0 + remediateLastFailure: true +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-no-remediate-last-failure + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: false +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: hr-missing-remediate-last-failure + namespace: tenant +spec: + interval: 1h + timeout: 10m + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 +--- +apiVersion: helm.toolkit.fluxcd.io/v2 +kind: HelmRelease +metadata: + name: flux-system + namespace: flux-system +spec: + interval: 1h + chart: + spec: + chart: app + sourceRef: + kind: HelmRepository + name: app + install: + remediation: + retries: 3 + upgrade: + remediation: + retries: 3 + remediateLastFailure: true From ee12eb587a3bdfba4c52851cc17bece16db3efe5 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 17:01:05 +0200 Subject: [PATCH 2/3] feat(kyverno): migrate enforce-flux-best-practices to ValidatingPolicy Rewrites the Flux reliability policy as a ValidatingPolicy with the same Deny action, kinds, background evaluation and exclusions, and deletes the ClusterPolicy in the same change. The Hetzner UniFi exemption becomes an appended matchCondition and the EKS CI validator pins that new shape. Fixes #5128 Co-Authored-By: Claude Opus 5.5 --- .../flux/enforce-flux-best-practices.yaml | 166 +++++++++--------- .../hetzner/infrastructure/kustomization.yaml | 16 +- scripts/validate-eks-ci-role-policy/main.go | 98 ++++++----- .../validate-eks-ci-role-policy/main_test.go | 95 ++++++---- .../kyverno-test.yaml | 4 - .../kyverno-test.yaml | 3 - .../missing-reliability/kyverno-test.yaml | 1 - tests/policy-failure-actions.json | 3 +- 8 files changed, 203 insertions(+), 183 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/flux/enforce-flux-best-practices.yaml b/k8s/bases/infrastructure/cluster-policies/flux/enforce-flux-best-practices.yaml index 96958c6d9..e47471cba 100644 --- a/k8s/bases/infrastructure/cluster-policies/flux/enforce-flux-best-practices.yaml +++ b/k8s/bases/infrastructure/cluster-policies/flux/enforce-flux-best-practices.yaml @@ -5,11 +5,15 @@ # https://fluxcd.io/flux/components/kustomize/kustomizations/#recommended-settings # https://fluxcd.io/flux/components/helm/helmreleases/ # -# Runs in Enforce mode — non-compliant resources are denied at admission (and -# at Flux's server-side dry-run), so new Flux resources must declare all the +# Runs with Deny — non-compliant resources are refused at admission (and at +# Flux's server-side dry-run), so new Flux resources must declare all the # recommended fields up front. -apiVersion: kyverno.io/v1 -kind: ClusterPolicy +# +# Exclusions are matchConditions, so an excluded object is out of scope rather +# than a pass. Each one names the Kustomization kind: a HelmRelease of the same +# name is still validated. The Hetzner overlay appends the UniFi exclusion. +apiVersion: policies.kyverno.io/v1 +kind: ValidatingPolicy metadata: name: enforce-flux-best-practices annotations: @@ -17,7 +21,7 @@ metadata: policies.kyverno.io/category: Flux, Best Practices policies.kyverno.io/severity: medium policies.kyverno.io/subject: Kustomization, HelmRelease - policies.kyverno.io/minversion: 1.13.0 + policies.kyverno.io/minversion: 1.17.0 policies.kyverno.io/description: >- Validates that Flux Kustomization and HelmRelease resources declare the reliability-critical fields recommended by the Flux documentation: @@ -26,83 +30,75 @@ metadata: misconfigured resources at admission instead of waiting for a runtime failure. spec: - background: true - rules: - - name: kustomization-recommended-settings - match: - any: - - resources: - kinds: - - kustomize.toolkit.fluxcd.io/v1/Kustomization - exclude: - any: - # The bootstrap flux-system Kustomization is created and managed by - # `flux bootstrap` / ksail and not by GitOps from this repo, so we - # cannot set the recommended fields on it. Exclude it from the policy. - - resources: - namespaces: - - flux-system - names: - - flux-system - validate: - failureAction: Enforce - message: >- - Flux Kustomization must set the recommended reliability fields: - `spec.interval`, `spec.timeout`, `spec.retryInterval`, - `spec.prune: true` (garbage collection), and `spec.sourceRef`. - `spec.wait: true` is required except for the platform-owned GitHub - configuration, whose asynchronous API operations do not gate delivery. - See https://fluxcd.io/flux/components/kustomize/kustomizations/#recommended-settings. - anyPattern: - - spec: - interval: "?*" - timeout: "?*" - retryInterval: "?*" - prune: true - wait: true - sourceRef: - kind: "?*" - name: "?*" - # Bound the alternative to this exact platform-owned identity. Keep - # every other reliability requirement; this is not a policy exclusion. - - metadata: - name: github-config - namespace: github-config - spec: - interval: "?*" - timeout: "?*" - retryInterval: "?*" - prune: true - wait: false - sourceRef: - kind: "?*" - name: "?*" - - name: helmrelease-recommended-settings - match: - any: - - resources: - kinds: - # v2 only: the v2beta1/v2beta2 APIs were removed from the - # cluster (Flux ≥ 2.6 serves only helm.toolkit.fluxcd.io/v2). - # Matching the dead versions makes Kyverno's GVK→GVR lookup - # fail on every admission review and background scan - # ("unable to convert GVK to GVR ... resource not found"). - - helm.toolkit.fluxcd.io/v2/HelmRelease - validate: - failureAction: Enforce - message: >- - Flux HelmRelease must set the recommended reliability fields: - `spec.interval`, `spec.timeout`, install + upgrade remediation - retries, and `spec.upgrade.remediation.remediateLastFailure: true`. - See https://fluxcd.io/flux/components/helm/helmreleases/. - pattern: - spec: - interval: "?*" - timeout: "?*" - install: - remediation: - retries: ">=1 | -1" - upgrade: - remediation: - retries: ">=1 | -1" - remediateLastFailure: true + validationActions: + - Deny + evaluation: + background: + enabled: true + matchConstraints: + resourceRules: + - apiGroups: ["kustomize.toolkit.fluxcd.io"] + apiVersions: ["v1"] + operations: ["CREATE", "UPDATE"] + resources: ["kustomizations"] + # v2 only: the v2beta1/v2beta2 APIs were removed from the cluster + # (Flux ≥ 2.6 serves only helm.toolkit.fluxcd.io/v2). Matching the dead + # versions makes Kyverno's GVK→GVR lookup fail on every admission review + # and background scan ("unable to convert GVK to GVR ... resource not found"). + - apiGroups: ["helm.toolkit.fluxcd.io"] + apiVersions: ["v2"] + operations: ["CREATE", "UPDATE"] + resources: ["helmreleases"] + matchConditions: + # The bootstrap flux-system Kustomization is created and managed by + # `flux bootstrap` / ksail and not by GitOps from this repo, so we cannot + # set the recommended fields on it. + - name: not-flux-bootstrap-kustomization + expression: >- + !(object.kind == 'Kustomization' && + object.metadata.?namespace.orValue('') == 'flux-system' && + object.metadata.name == 'flux-system') + variables: + - name: isKustomization + expression: object.kind == 'Kustomization' + # Bound the health-gating alternative to this exact platform-owned + # identity, whose asynchronous API operations do not gate delivery. It + # keeps every other reliability requirement; this is not an exclusion. + - name: isGitHubConfig + expression: >- + object.metadata.name == 'github-config' && + object.metadata.?namespace.orValue('') == 'github-config' + - name: installRetries + expression: object.spec.?install.?remediation.?retries.orValue(0) + - name: upgradeRetries + expression: object.spec.?upgrade.?remediation.?retries.orValue(0) + validations: + - expression: >- + !variables.isKustomization || ( + object.spec.?interval.orValue('') != '' && + object.spec.?timeout.orValue('') != '' && + object.spec.?retryInterval.orValue('') != '' && + object.spec.?prune.orValue(false) == true && + (object.spec.?wait.orValue(false) == true || + (variables.isGitHubConfig && object.spec.?wait.orValue(true) == false)) && + object.spec.?sourceRef.?kind.orValue('') != '' && + object.spec.?sourceRef.?name.orValue('') != '') + message: >- + Flux Kustomization must set the recommended reliability fields: + `spec.interval`, `spec.timeout`, `spec.retryInterval`, + `spec.prune: true` (garbage collection), and `spec.sourceRef`. + `spec.wait: true` is required except for the platform-owned GitHub + configuration, whose asynchronous API operations do not gate delivery. + See https://fluxcd.io/flux/components/kustomize/kustomizations/#recommended-settings. + - expression: >- + variables.isKustomization || ( + object.spec.?interval.orValue('') != '' && + object.spec.?timeout.orValue('') != '' && + (variables.installRetries >= 1 || variables.installRetries == -1) && + (variables.upgradeRetries >= 1 || variables.upgradeRetries == -1) && + object.spec.?upgrade.?remediation.?remediateLastFailure.orValue(false) == true) + message: >- + Flux HelmRelease must set the recommended reliability fields: + `spec.interval`, `spec.timeout`, install + upgrade remediation + retries, and `spec.upgrade.remediation.remediateLastFailure: true`. + See https://fluxcd.io/flux/components/helm/helmreleases/. diff --git a/k8s/providers/hetzner/infrastructure/kustomization.yaml b/k8s/providers/hetzner/infrastructure/kustomization.yaml index 66092641b..201739acd 100644 --- a/k8s/providers/hetzner/infrastructure/kustomization.yaml +++ b/k8s/providers/hetzner/infrastructure/kustomization.yaml @@ -214,19 +214,19 @@ patches: # remains strict for every other environment. The authorization validator pins # the rendered exception set and the reconciler's immutable source/delete bounds. - target: - group: kyverno.io + group: policies.kyverno.io version: v1 - kind: ClusterPolicy + kind: ValidatingPolicy name: enforce-flux-best-practices patch: | - op: add - path: /spec/rules/0/exclude/any/- + path: /spec/matchConditions/- value: - resources: - namespaces: - - unifi - names: - - unifi + name: not-unifi-kustomization + expression: >- + !(object.kind == 'Kustomization' && + object.metadata.?namespace.orValue('') == 'unifi' && + object.metadata.name == 'unifi') # gateway-patch self-identifies (Gateway/platform/kube-system), so a path is # enough — no explicit target, matching the coroot patch below. - path: patches/attach-hcloud-load-balancer.yaml diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index a25a020e6..52cbdb3f1 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -184,72 +184,76 @@ func validateUnifiSigningKey(documents []map[string]any) error { return nil } +// unifiPruneExemptionError is the single diagnostic for every shape the UniFi +// prune exemption must not take. +var unifiPruneExemptionError = errors.New( + "enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement", +) + +// fluxPolicyExclusionExpression is the matchCondition expression that leaves +// exactly one namespaced Kustomization out of enforce-flux-best-practices. +func fluxPolicyExclusionExpression(namespace, name string) string { + return fmt.Sprintf( + "!(object.kind == 'Kustomization' && "+ + "object.metadata.?namespace.orValue('') == '%s' && "+ + "object.metadata.name == '%s')", + namespace, name, + ) +} + // validateUnifiPruneExemption keeps the deliberate non-pruning reconciler // deployable while requiring the admission-policy exception to remain scoped -// to exactly one namespaced Kustomization. +// to exactly one namespaced Kustomization. The policy's exclusions are its +// matchConditions, so the rendered policy must carry exactly the bootstrap and +// UniFi conditions and no other way to narrow its scope. func validateUnifiPruneExemption(document map[string]any, identity resourceIdentity) error { wantIdentity := resourceIdentity{ - apiVersion: "kyverno.io/v1", - kind: "ClusterPolicy", + apiVersion: "policies.kyverno.io/v1", + kind: "ValidatingPolicy", name: "enforce-flux-best-practices", } if identity != wantIdentity { return nil } - exactSingleton := func(value any, want string) bool { - values, ok := value.([]any) - return ok && len(values) == 1 && values[0] == want - } spec, ok := document["spec"].(map[string]any) if !ok { - return errors.New("enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement") + return unifiPruneExemptionError } - rules, ok := spec["rules"].([]any) + constraints, ok := spec["matchConstraints"].(map[string]any) if !ok { - return errors.New("enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement") + return unifiPruneExemptionError } - for _, ruleValue := range rules { - rule, ok := ruleValue.(map[string]any) - if !ok || rule["name"] != "kustomization-recommended-settings" { - continue - } - exclude, ok := rule["exclude"].(map[string]any) - if !ok { - break - } - exclusions, ok := exclude["any"].([]any) - if !ok { - break + for _, field := range []string{"excludeResourceRules", "namespaceSelector", "objectSelector"} { + if _, present := constraints[field]; present { + return unifiPruneExemptionError } - seenFluxSystem := false - seenUnifi := false - for _, exclusionValue := range exclusions { - exclusion, ok := exclusionValue.(map[string]any) - if !ok { - break - } - resources, ok := exclusion["resources"].(map[string]any) - if !ok { - break - } - switch { - case exactSingleton(resources["namespaces"], "flux-system") && - exactSingleton(resources["names"], "flux-system") && !seenFluxSystem: - seenFluxSystem = true - case exactSingleton(resources["namespaces"], "unifi") && - exactSingleton(resources["names"], "unifi") && !seenUnifi: - seenUnifi = true - default: - return errors.New("enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement") - } + } + conditions, ok := spec["matchConditions"].([]any) + if !ok || len(conditions) != 2 { + return unifiPruneExemptionError + } + want := map[string]string{ + "not-flux-bootstrap-kustomization": fluxPolicyExclusionExpression("flux-system", "flux-system"), + "not-unifi-kustomization": fluxPolicyExclusionExpression("unifi", "unifi"), + } + for _, conditionValue := range conditions { + condition, ok := conditionValue.(map[string]any) + if !ok || len(condition) != 2 { + return unifiPruneExemptionError } - if seenFluxSystem && seenUnifi && len(exclusions) == 2 { - return nil + name, _ := condition["name"].(string) + expression, _ := condition["expression"].(string) + wantExpression, known := want[name] + if !known || strings.Join(strings.Fields(expression), " ") != wantExpression { + return unifiPruneExemptionError } - break + delete(want, name) + } + if len(want) != 0 { + return unifiPruneExemptionError } - return errors.New("enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement") + return nil } // expectedRenderedHashes preserves object-specific diagnostics for the core diff --git a/scripts/validate-eks-ci-role-policy/main_test.go b/scripts/validate-eks-ci-role-policy/main_test.go index 338d8db6f..0036db430 100644 --- a/scripts/validate-eks-ci-role-policy/main_test.go +++ b/scripts/validate-eks-ci-role-policy/main_test.go @@ -437,27 +437,26 @@ func TestValidateUnifiSigningKey(t *testing.T) { // for any other Flux Kustomization. func TestValidateUnifiPruneExemption(t *testing.T) { targetIdentity := resourceIdentity{ - apiVersion: "kyverno.io/v1", - kind: "ClusterPolicy", + apiVersion: "policies.kyverno.io/v1", + kind: "ValidatingPolicy", name: "enforce-flux-best-practices", } - policy := func(exclusions ...any) map[string]any { + condition := func(name, namespace, object string) map[string]any { return map[string]any{ - "spec": map[string]any{ - "rules": []any{ - map[string]any{ - "name": "kustomization-recommended-settings", - "exclude": map[string]any{"any": exclusions}, - }, - }, - }, + "name": name, + "expression": fluxPolicyExclusionExpression(namespace, object), } } - resourceExclusion := func(namespace, name string) map[string]any { + bootstrap := condition("not-flux-bootstrap-kustomization", "flux-system", "flux-system") + unifi := condition("not-unifi-kustomization", "unifi", "unifi") + policy := func(constraints map[string]any, conditions ...any) map[string]any { + if constraints == nil { + constraints = map[string]any{"resourceRules": []any{}} + } return map[string]any{ - "resources": map[string]any{ - "namespaces": []any{namespace}, - "names": []any{name}, + "spec": map[string]any{ + "matchConstraints": constraints, + "matchConditions": conditions, }, } } @@ -471,41 +470,71 @@ func TestValidateUnifiPruneExemption(t *testing.T) { { name: "exact narrow exemption", identity: targetIdentity, - document: policy( - resourceExclusion("flux-system", "flux-system"), - resourceExclusion("unifi", "unifi"), - ), + document: policy(nil, bootstrap, unifi), + }, + { + name: "folded expression whitespace is accepted", + identity: targetIdentity, + document: policy(nil, bootstrap, map[string]any{ + "name": "not-unifi-kustomization", + "expression": "!(object.kind == 'Kustomization' &&\n" + + "object.metadata.?namespace.orValue('') == 'unifi' &&\n" + + "object.metadata.name == 'unifi')", + }), }, { name: "missing exemption", identity: targetIdentity, - document: policy(resourceExclusion("flux-system", "flux-system")), + document: policy(nil, bootstrap), wantError: true, }, { - name: "namespace wildcard is not narrow", + name: "namespace wildcard is not narrow", + identity: targetIdentity, + document: policy(nil, bootstrap, map[string]any{ + "name": "not-unifi-kustomization", + "expression": "!(object.kind == 'Kustomization' && object.metadata.name == 'unifi')", + }), + wantError: true, + }, + { + name: "additional broad exemption is rejected", + identity: targetIdentity, + document: policy(nil, bootstrap, unifi, map[string]any{"name": "everything", "expression": "false"}), + wantError: true, + }, + { + name: "duplicate exemption is rejected", identity: targetIdentity, - document: policy(resourceExclusion("*", "unifi")), + document: policy(nil, unifi, unifi), wantError: true, }, { - name: "additional broad exemption is rejected", + name: "exclude resource rules are rejected", identity: targetIdentity, - document: policy( - resourceExclusion("flux-system", "flux-system"), - resourceExclusion("unifi", "unifi"), - resourceExclusion("*", "*"), - ), + document: policy(map[string]any{ + "resourceRules": []any{}, + "excludeResourceRules": []any{map[string]any{"resourceNames": []any{"apps"}}}, + }, bootstrap, unifi), + wantError: true, + }, + { + name: "namespace selector is rejected", + identity: targetIdentity, + document: policy(map[string]any{ + "resourceRules": []any{}, + "namespaceSelector": map[string]any{}, + }, bootstrap, unifi), wantError: true, }, { name: "unrelated policy", identity: resourceIdentity{ - apiVersion: "kyverno.io/v1", - kind: "ClusterPolicy", + apiVersion: "policies.kyverno.io/v1", + kind: "ValidatingPolicy", name: "other", }, - document: policy(), + document: policy(nil), }, } @@ -564,8 +593,8 @@ func TestUnifiPruneExemptionIsHetznerScoped(t *testing.T) { found := false for _, document := range renderedDocuments { identity := identityOf(document) - if identity.apiVersion != "kyverno.io/v1" || - identity.kind != "ClusterPolicy" || + if identity.apiVersion != "policies.kyverno.io/v1" || + identity.kind != "ValidatingPolicy" || identity.name != "enforce-flux-best-practices" { continue } diff --git a/tests/enforce-flux-best-practices/kyverno-test.yaml b/tests/enforce-flux-best-practices/kyverno-test.yaml index ce6279146..79fc84ae4 100644 --- a/tests/enforce-flux-best-practices/kyverno-test.yaml +++ b/tests/enforce-flux-best-practices/kyverno-test.yaml @@ -15,13 +15,11 @@ resources: - resources.yaml results: - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - tenant/ks-compliant kind: Kustomization result: pass - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - tenant/ks-missing-interval - tenant/ks-missing-timeout @@ -35,14 +33,12 @@ results: kind: Kustomization result: fail - policy: enforce-flux-best-practices - rule: helmrelease-recommended-settings resources: - tenant/hr-compliant - tenant/hr-unlimited-retries kind: HelmRelease result: pass - policy: enforce-flux-best-practices - rule: helmrelease-recommended-settings resources: - tenant/hr-missing-interval - tenant/hr-missing-timeout diff --git a/tests/flux-bootstrap-health-gating/kyverno-test.yaml b/tests/flux-bootstrap-health-gating/kyverno-test.yaml index c7e1a445b..d16a5e303 100644 --- a/tests/flux-bootstrap-health-gating/kyverno-test.yaml +++ b/tests/flux-bootstrap-health-gating/kyverno-test.yaml @@ -8,14 +8,12 @@ resources: - resources.yaml results: - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - github-config/github-config - tenant/healthy kind: Kustomization result: pass - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - tenant/github-config - github-config/sibling @@ -23,7 +21,6 @@ results: kind: Kustomization result: fail - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - flux-system/flux-system kind: Kustomization diff --git a/tests/flux-bootstrap-health-gating/missing-reliability/kyverno-test.yaml b/tests/flux-bootstrap-health-gating/missing-reliability/kyverno-test.yaml index 334b156b9..0a4827eb6 100644 --- a/tests/flux-bootstrap-health-gating/missing-reliability/kyverno-test.yaml +++ b/tests/flux-bootstrap-health-gating/missing-reliability/kyverno-test.yaml @@ -8,7 +8,6 @@ resources: - resources.yaml results: - policy: enforce-flux-best-practices - rule: kustomization-recommended-settings resources: - github-config/github-config kind: Kustomization diff --git a/tests/policy-failure-actions.json b/tests/policy-failure-actions.json index a3f37c33c..f4639eb50 100644 --- a/tests/policy-failure-actions.json +++ b/tests/policy-failure-actions.json @@ -41,8 +41,7 @@ "validate-pod-security/deny-container-seccomp-unconfined": "Enforce", "validate-replica-ceiling": "Audit", "validate-replica-floor": "Audit", - "enforce-flux-best-practices/kustomization-recommended-settings": "Enforce", - "enforce-flux-best-practices/helmrelease-recommended-settings": "Enforce", + "enforce-flux-best-practices": "Enforce", "restrict-arc-retirement/fence-pool-retirement": "Enforce", "restrict-arc-retirement/fence-scale-set-retirement": "Enforce", "restrict-arc-retirement/fence-listener-retirement": "Enforce", From 265a7b26d46b775ec00745d498f5837faf1af4d8 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 20 Oct 2026 17:18:19 +0200 Subject: [PATCH 3/3] fix(kyverno): pin the flux policy's full match scope in the EKS CI validator Compare matchConstraints with the reviewed shape so added resourceNames, dropped operations or dropped rules cannot narrow the policy, name the error variable per Go convention, and pin that the github-config alternative still needs wait set to false. Part of #5128 Co-Authored-By: Claude Opus 5.5 --- scripts/validate-eks-ci-role-policy/main.go | 44 +++++++++++----- .../validate-eks-ci-role-policy/main_test.go | 51 +++++++++++++++---- .../kyverno-test.yaml | 3 ++ .../resources.yaml | 14 +++++ 4 files changed, 91 insertions(+), 21 deletions(-) diff --git a/scripts/validate-eks-ci-role-policy/main.go b/scripts/validate-eks-ci-role-policy/main.go index 52cbdb3f1..9d44947ac 100644 --- a/scripts/validate-eks-ci-role-policy/main.go +++ b/scripts/validate-eks-ci-role-policy/main.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "reflect" "regexp" "sort" "strings" @@ -184,12 +185,33 @@ func validateUnifiSigningKey(documents []map[string]any) error { return nil } -// unifiPruneExemptionError is the single diagnostic for every shape the UniFi +// errUnifiPruneExemption is the single diagnostic for every shape the UniFi // prune exemption must not take. -var unifiPruneExemptionError = errors.New( +var errUnifiPruneExemption = errors.New( "enforce-flux-best-practices must exempt only unifi/unifi from prune enforcement", ) +// fluxPolicyMatchConstraints is the only scope enforce-flux-best-practices may +// render with: an added resourceNames, a dropped operation or rule, or any +// exclude or selector field would take objects out of scope without a +// matchCondition. +func fluxPolicyMatchConstraints() map[string]any { + return map[string]any{"resourceRules": []any{ + map[string]any{ + "apiGroups": []any{"kustomize.toolkit.fluxcd.io"}, + "apiVersions": []any{"v1"}, + "operations": []any{"CREATE", "UPDATE"}, + "resources": []any{"kustomizations"}, + }, + map[string]any{ + "apiGroups": []any{"helm.toolkit.fluxcd.io"}, + "apiVersions": []any{"v2"}, + "operations": []any{"CREATE", "UPDATE"}, + "resources": []any{"helmreleases"}, + }, + }} +} + // fluxPolicyExclusionExpression is the matchCondition expression that leaves // exactly one namespaced Kustomization out of enforce-flux-best-practices. func fluxPolicyExclusionExpression(namespace, name string) string { @@ -218,20 +240,18 @@ func validateUnifiPruneExemption(document map[string]any, identity resourceIdent spec, ok := document["spec"].(map[string]any) if !ok { - return unifiPruneExemptionError + return errUnifiPruneExemption } constraints, ok := spec["matchConstraints"].(map[string]any) if !ok { - return unifiPruneExemptionError + return errUnifiPruneExemption } - for _, field := range []string{"excludeResourceRules", "namespaceSelector", "objectSelector"} { - if _, present := constraints[field]; present { - return unifiPruneExemptionError - } + if !reflect.DeepEqual(constraints, fluxPolicyMatchConstraints()) { + return errUnifiPruneExemption } conditions, ok := spec["matchConditions"].([]any) if !ok || len(conditions) != 2 { - return unifiPruneExemptionError + return errUnifiPruneExemption } want := map[string]string{ "not-flux-bootstrap-kustomization": fluxPolicyExclusionExpression("flux-system", "flux-system"), @@ -240,18 +260,18 @@ func validateUnifiPruneExemption(document map[string]any, identity resourceIdent for _, conditionValue := range conditions { condition, ok := conditionValue.(map[string]any) if !ok || len(condition) != 2 { - return unifiPruneExemptionError + return errUnifiPruneExemption } name, _ := condition["name"].(string) expression, _ := condition["expression"].(string) wantExpression, known := want[name] if !known || strings.Join(strings.Fields(expression), " ") != wantExpression { - return unifiPruneExemptionError + return errUnifiPruneExemption } delete(want, name) } if len(want) != 0 { - return unifiPruneExemptionError + return errUnifiPruneExemption } return nil } diff --git a/scripts/validate-eks-ci-role-policy/main_test.go b/scripts/validate-eks-ci-role-policy/main_test.go index 0036db430..0b58064f0 100644 --- a/scripts/validate-eks-ci-role-policy/main_test.go +++ b/scripts/validate-eks-ci-role-policy/main_test.go @@ -449,9 +449,19 @@ func TestValidateUnifiPruneExemption(t *testing.T) { } bootstrap := condition("not-flux-bootstrap-kustomization", "flux-system", "flux-system") unifi := condition("not-unifi-kustomization", "unifi", "unifi") + withConstraint := func(change func(map[string]any)) map[string]any { + constraints := fluxPolicyMatchConstraints() + change(constraints) + return constraints + } + kustomizationRule := func(constraints map[string]any) map[string]any { + rules, _ := constraints["resourceRules"].([]any) + rule, _ := rules[0].(map[string]any) + return rule + } policy := func(constraints map[string]any, conditions ...any) map[string]any { if constraints == nil { - constraints = map[string]any{"resourceRules": []any{}} + constraints = fluxPolicyMatchConstraints() } return map[string]any{ "spec": map[string]any{ @@ -512,19 +522,42 @@ func TestValidateUnifiPruneExemption(t *testing.T) { { name: "exclude resource rules are rejected", identity: targetIdentity, - document: policy(map[string]any{ - "resourceRules": []any{}, - "excludeResourceRules": []any{map[string]any{"resourceNames": []any{"apps"}}}, - }, bootstrap, unifi), + document: policy(withConstraint(func(c map[string]any) { + c["excludeResourceRules"] = []any{map[string]any{"resourceNames": []any{"apps"}}} + }), bootstrap, unifi), wantError: true, }, { name: "namespace selector is rejected", identity: targetIdentity, - document: policy(map[string]any{ - "resourceRules": []any{}, - "namespaceSelector": map[string]any{}, - }, bootstrap, unifi), + document: policy(withConstraint(func(c map[string]any) { + c["namespaceSelector"] = map[string]any{} + }), bootstrap, unifi), + wantError: true, + }, + { + name: "resource names narrow the Kustomization rule", + identity: targetIdentity, + document: policy(withConstraint(func(c map[string]any) { + kustomizationRule(c)["resourceNames"] = []any{"apps"} + }), bootstrap, unifi), + wantError: true, + }, + { + name: "a dropped UPDATE operation narrows the scope", + identity: targetIdentity, + document: policy(withConstraint(func(c map[string]any) { + kustomizationRule(c)["operations"] = []any{"CREATE"} + }), bootstrap, unifi), + wantError: true, + }, + { + name: "a dropped Kustomization rule narrows the scope", + identity: targetIdentity, + document: policy(withConstraint(func(c map[string]any) { + rules, _ := c["resourceRules"].([]any) + c["resourceRules"] = rules[1:] + }), bootstrap, unifi), wantError: true, }, { diff --git a/tests/enforce-flux-best-practices/kyverno-test.yaml b/tests/enforce-flux-best-practices/kyverno-test.yaml index 79fc84ae4..d56b3afb0 100644 --- a/tests/enforce-flux-best-practices/kyverno-test.yaml +++ b/tests/enforce-flux-best-practices/kyverno-test.yaml @@ -5,6 +5,8 @@ # unlimited) and upgrade remediateLastFailure: true. The base policy has no # UniFi exemption (the Hetzner overlay adds it), and the flux-system bootstrap # exclusion covers only the Kustomization, never a HelmRelease of that name. +# The github-config health-gating alternative still needs wait set to false: +# omitting it fails. apiVersion: cli.kyverno.io/v1alpha1 kind: Test metadata: @@ -30,6 +32,7 @@ results: - tenant/ks-missing-source-name - tenant/ks-empty-interval - unifi/unifi + - github-config/github-config kind: Kustomization result: fail - policy: enforce-flux-best-practices diff --git a/tests/enforce-flux-best-practices/resources.yaml b/tests/enforce-flux-best-practices/resources.yaml index 5e5523791..e5998e367 100644 --- a/tests/enforce-flux-best-practices/resources.yaml +++ b/tests/enforce-flux-best-practices/resources.yaml @@ -372,3 +372,17 @@ spec: remediation: retries: 3 remediateLastFailure: true +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: + name: github-config + namespace: github-config +spec: + interval: 1m + timeout: 5m + retryInterval: 2m + prune: true + sourceRef: + kind: OCIRepository + name: platform