~60 min read · updated 2026-05-12

Policies: governance, risk, and compliance across the fleet

How a Policy on the hub becomes a compliance result on every selected spoke — the propagation pipeline, ConfigurationPolicy as the workhorse, inform vs enforce, PolicyGenerator, and the failure modes.

A Policy on the hub is a declaration of what must be true on every cluster the policy targets. “Every cluster has etcd encryption enabled.” “Every namespace has a default-deny NetworkPolicy.” “No Deployment runs as root.” “The OperatorHub source list is the lab’s catalog, not the default.” You write the policy once on the hub; ACM propagates it to every spoke a Placement selects, evaluates it locally on each spoke, and reports compliance back to a single dashboard.

The mistake to avoid is treating Policy like an admission controller. It isn’t — it doesn’t block writes at API admission time. It’s a reconciler. ConfigurationPolicy controllers wake up every 30 seconds (default) on each spoke, look at the world, and either report drift (inform) or fix it (enforce). If you need synchronous deny at admission, write a Kyverno or Gatekeeper ValidatingAdmissionPolicy; ACM Policy is the converge-toward-state tool.

The propagation pipeline

The path a Policy takes from authored YAML to a compliance result on the dashboard:

  1. You write a Policy on the hub in a namespace dedicated to governance (the lab uses openshift-gitops; many teams use policies or open-cluster-management-policy).
  2. A PlacementBinding ties the Policy to a Placement, which produces a PlacementDecision listing the selected ManagedClusters.
  3. governance-policy-propagator on the hub watches Policy + PlacementBinding + PlacementDecision. For each decision, it wraps the Policy as a ManifestWork and writes it into the matched spoke’s hub-side namespace.
  4. The klusterlet’s work-agent on the spoke pulls the ManifestWork outbound. The Policy CR is materialized in the open-cluster-management-policies namespace on the spoke.
  5. governance-policy-framework on the spoke expands the Policy’s policy-templates into concrete ConfigurationPolicy (or CertificatePolicy, IamPolicy, OperatorPolicy) CRs.
  6. config-policy-controller on the spoke evaluates each ConfigurationPolicy every 30 seconds. It compares declared object-templates against live cluster state. The result is a compliance status — Compliant or NonCompliant.
  7. The status flows back via governance-policy-status-sync (spoke) → governance-policy-status-sync (hub) → the original Policy’s .status.status[] list, with one entry per managed cluster.

The ACM Governance dashboard reads the hub-side Policy .status to render compliance per cluster, per policy. The lab’s policies-and-placement doc walks through the five CR types and the spoke-side controllers in detail.

Policy templates

A Policy is a wrapper. The work is done by one or more templates inside spec.policy-templates. The four template kinds:

TemplateWhat it does
ConfigurationPolicyDeclare required state for arbitrary Kubernetes objects via object-templates. The workhorse.
CertificatePolicyCheck certificate expiry, signer, and CA across the cluster.
OperatorPolicyManage operator installs and upgrade approvals via OLM.
IamPolicyAudit cluster-admin role assignments and similar high-privilege bindings.

You will spend 90% of your policy time in ConfigurationPolicy. Its object-templates field lets you say things like:

object-templates:
  - complianceType: musthave
    objectDefinition:
      apiVersion: networking.k8s.io/v1
      kind: NetworkPolicy
      metadata:
        name: default-deny-all
        namespace: '{{ fromConfigMap "openshift-config" "managed-namespaces" "name" }}'
      spec:
        podSelector: {}
        policyTypes: [Ingress, Egress]

complianceType has three values:

  • musthave — the object must exist and its spec must match (or be a superset of) the template.
  • mustonlyhave — the object must exist and its spec must match exactly (no extra fields).
  • mustnothave — the object must not exist.

mustnothave is the one people forget. It’s how you write “there is no Pod in any namespace running as securityContext.runAsUser: 0 without having to enumerate every namespace.

Inform vs enforce

Policy.spec.remediationAction (and the same field on each template) takes two values:

  • inform — measure-only. The spoke evaluates the policy and reports the result. It does not change anything.
  • enforce — converge. If the spoke is NonCompliant, the config-policy-controller patches the live state to match the template.

The mistake to avoid is shipping enforce on day one. The lab’s production convention — and Red Hat’s — is always ship as inform, validate, then flip:

  1. Author the Policy with remediationAction: inform.
  2. Apply via GitOps. Wait one evaluation cycle (~30 seconds for ConfigurationPolicy).
  3. Inspect Policy.status.status[] on the hub. Confirm the per-cluster compliance reports match what you expected.
  4. If the report is right and the desired state is genuinely what you want, change remediationAction: enforce in Git and merge.

This catches policy bugs before they mutate cluster state. The classic failure: a policy that says “every namespace must have label cost-center with enforce: true and a typo in complianceType: mustonlyhave — the controller obliterates every other namespace label on every cluster in scope. Mustonlyhave means “this is the entire spec; remove anything else”. Catching that in inform mode costs nothing; catching it in enforce mode costs an outage.

The corollary: keep inform-only policies in production indefinitely if all you want is observability. ACM’s audit posture doesn’t require enforce.

A worked example — disable default OperatorHub sources

This is the lab’s policies-and-placement doc example trimmed to its essence. The goal: every spoke must have its default OperatorHub sources disabled (because the lab’s Nexus image-supply rule requires it).

apiVersion: policy.open-cluster-management.io/v1
kind: Policy
metadata:
  name: disable-default-operatorhub
  namespace: openshift-gitops
spec:
  remediationAction: inform
  disabled: false
  policy-templates:
    - objectDefinition:
        apiVersion: policy.open-cluster-management.io/v1
        kind: ConfigurationPolicy
        metadata:
          name: disable-default-operatorhub
        spec:
          remediationAction: inform
          severity: high
          object-templates:
            - complianceType: musthave
              objectDefinition:
                apiVersion: config.openshift.io/v1
                kind: OperatorHub
                metadata:
                  name: cluster
                spec:
                  disableAllDefaultSources: true

And the binding:

apiVersion: policy.open-cluster-management.io/v1
kind: PlacementBinding
metadata:
  name: disable-default-operatorhub
  namespace: openshift-gitops
placementRef:
  apiGroup: cluster.open-cluster-management.io
  kind: Placement
  name: gitops-managed
subjects:
  - apiGroup: policy.open-cluster-management.io
    kind: Policy
    name: disable-default-operatorhub

When merged, the propagator wraps the Policy as ManifestWork, the klusterlet pulls it, and within 30 seconds the spoke reports compliance — whether the OperatorHub/cluster resource has disableAllDefaultSources: true or not. If it doesn’t, the report is NonCompliant and you see a line in the Governance dashboard. Flip to enforce once you trust the report.

PolicyGenerator

Maintaining hundreds of policies as raw YAML is a maintenance burden. Each policy needs its own wrapper boilerplate; you copy-paste the same spec.disabled, spec.policy-templates, the same wrapping ConfigurationPolicy. By 50 policies the repetition has become noise.

The PolicyGenerator is a Kustomize plugin that lets you author the desired state as plain Kubernetes manifests and wraps them into Policies at kustomize-build time. The input:

# policy-generator.yaml
apiVersion: policy.open-cluster-management.io/v1
kind: PolicyGenerator
metadata:
  name: pci-baseline
policyDefaults:
  namespace: openshift-gitops
  remediationAction: inform
  severity: medium
  placement:
    placementName: gitops-managed
policies:
  - name: require-default-deny-ingress
    manifests:
      - path: input/default-deny-ingress-netpol.yaml
  - name: disable-default-operatorhub
    manifests:
      - path: input/operatorhub-disable-defaults.yaml

Where input/default-deny-ingress-netpol.yaml is just a NetworkPolicy manifest. The generator runs at kustomize build and produces the wrapped Policy + ConfigurationPolicy + PlacementBinding YAML. Argo CD reconciles the generated output as if you’d written it by hand.

Use PolicyGenerator once your policy count crosses ~10 and you start copy-pasting wrappers. The author-as-manifest model makes the policies grep-able by what they declare instead of by their wrapping boilerplate.

PolicySet

A PolicySet is a named group of Policies — used to bind a coherent audit posture (PCI-DSS, NIST 800-53, internal-baseline) to a Placement with one PlacementBinding instead of N:

apiVersion: policy.open-cluster-management.io/v1beta1
kind: PolicySet
metadata:
  name: pci-dss-fleet
  namespace: openshift-gitops
spec:
  description: PCI-DSS-aligned fleet policies for the v6 OpenShift fleet
  policies:
    - disable-default-operatorhub
    - require-default-deny-ingress-netpol
    - require-fips-mode
    - require-etcd-encryption

You then bind the PolicySet (not the individual Policies) to your Placement. The lab’s PCI-DSS profile baseline is the right context — ACM PolicySets pair with OpenShift Compliance Operator scans to give you both the desired state (PolicySet) and the audit evidence (compliance-operator scan results) for the same controls. They’re complementary, not redundant.

Evaluation cadence and performance

ConfigurationPolicy.spec.evaluationInterval controls how often the controller re-evaluates the policy on each spoke. The default for Compliant policies is 10 seconds; for NonCompliant policies, 30 seconds. A spoke with 50 policies, each evaluating every 30s, is doing ~100 controller wake-ups per minute — not a problem on a healthy cluster, but worth thinking about at scale.

Two performance traps:

  • Wide object-templates with no namespace selector. A template that says “every Pod must have a tier label” runs a list of every Pod on every evaluation. On a cluster with 10,000 Pods this dominates the controller’s CPU. Mitigations: narrow the namespaceSelector, or use the mustnothave form (which is cheaper because it only needs to find one counterexample to fail).
  • Templates with expensive Go-template expressions ({{ lookup }} against the API). Each evaluation hits the API server. Cache where possible; mark policies as longer-interval where compliance-quality-of-life is more important than near-real-time enforcement.

For policies that don’t need 30-second freshness, bump evaluationInterval.compliant: 5m to cut the load.

Compliance Operator across the fleet

ACM’s native ConfigurationPolicy is good at “this resource must look like this”. It is not good at “every node’s sshd is configured per CIS” or “the kube-apiserver audit policy meets PCI-DSS v4.0.1 control 10”. For those, you reach for the OpenShift Compliance Operator — and for those, applied across a fleet, you wrap the Compliance Operator in ACM policies.

The operator, briefly

The Compliance Operator is an OpenShift-Plus operator that runs per cluster in openshift-compliance. It uses OpenSCAP to scan both the OpenShift control plane (against profiles like ocp4-cis, ocp4-pci-dss-4-0, ocp4-nist-800-53, ocp4-moderate) and the RHCOS nodes (against the matching *-node-* profile). For every rule the scan evaluates, it emits a ComplianceCheckResult CR with a PASS, FAIL, or MANUAL verdict and an optional ComplianceRemediation CR that, if applied, fixes the failing config. The lab’s worked walkthrough is at /docs/openshift-platform/openshift-platform/compliance/pci-dss-profile-baseline.

The operator is per cluster because OpenSCAP needs node access to scan kernel parameters, sshd config, and file permissions. There is no fleet-wide flavour of it. What ACM contributes is the fleet-wide installation, configuration, and result aggregation layer on top.

How ACM wraps it

ACM does not install or extend the operator. It deploys and configures it across the fleet by way of Policy CRs. The lab’s pattern is a PolicySet named something like compliance-operator-baseline that contains three or four Policies:

  1. Install policy. A ConfigurationPolicy whose object-templates declare the Namespace, OperatorGroup, and Subscription for the Compliance Operator on every selected cluster. This is exactly the three-CR pattern from Chapter 3 of the DO432 source, but written once on the hub and fanned out.
  2. ScanSetting policy. A ConfigurationPolicy that creates a ScanSetting CR pinning the result-server pod onto worker nodes (avoids the RBD-on-master attach failures that bit old spoke-dc — see /docs/openshift-platform/openshift-platform/compliance/scansetting-and-bindings), specifies a schedule, sets the retention policy, and reserves storage for the results.
  3. ScanSettingBinding policy. A ConfigurationPolicy whose object-templates declare a ScanSettingBinding that ties a TailoredProfile (PCI-DSS v4 with the lab’s exclusions) to the ScanSetting from step 2.
  4. (Optional) Remediation-application policy. A ConfigurationPolicy setting applyRemediations: true on the suite when you trust the operator’s auto-fixes for low-risk findings.

All four bind to the same Placement that selects clusters needing compliance scanning — in the lab, that’s gitops-managed=true plus a compliance=pci-dss label so opt-in is explicit.

Why TailoredProfile matters

A stock profile ships rules written against an idealised cluster shape. Real clusters have legitimate exceptions: a rule that demands every Route use a custom signer when ODF’s noobaa-mgmt route is reconciled back to the cluster-issuer; a rule that flags kubernetes.io/cluster-service: "true" on a namespace that the lab actually wants tagged that way; a rule that disables IPv6 at the kernel level, which would break OVN-Kubernetes (ADR 0026).

A TailoredProfile lets you say “start from ocp4-pci-dss-4-0, disable rules X and Y, set variable Z to value W, and record why.” The disabled-rule list is small but load-bearing. The lab’s PCI-DSS v4 tailored profile excludes a handful of rules with documented rationale — the CSO (cluster-service-objects) tagging rule and the ingress-ciphers rule are two of them, each tied to a specific lab decision rather than a relaxation of the standard. The full list and the rationale-per-exclusion is at /docs/openshift-platform/openshift-platform/compliance/pci-dss-profile-baseline.

Without tailoring, you either accept perpetual FAILs that aren’t real findings, or you exclude rules cluster-by-cluster — both terrible. With tailoring, the exclusion is one CR per profile per fleet, written down, reviewable in Git.

A small Policy that wraps a ScanSettingBinding

apiVersion: policy.open-cluster-management.io/v1
kind: Policy
metadata:
  name: pci-dss-scan-binding
  namespace: openshift-gitops
spec:
  remediationAction: enforce
  disabled: false
  policy-templates:
    - objectDefinition:
        apiVersion: policy.open-cluster-management.io/v1
        kind: ConfigurationPolicy
        metadata:
          name: pci-dss-scan-binding
        spec:
          remediationAction: enforce
          severity: high
          object-templates:
            - complianceType: musthave
              objectDefinition:
                apiVersion: compliance.openshift.io/v1alpha1
                kind: ScanSettingBinding
                metadata:
                  name: pci-dss
                  namespace: openshift-compliance
                profiles:
                  - apiGroup: compliance.openshift.io/v1alpha1
                    kind: TailoredProfile
                    name: pci-dss-4-0-lab
                settingsRef:
                  apiGroup: compliance.openshift.io/v1alpha1
                  kind: ScanSetting
                  name: workers-storage

Read what’s load-bearing: the profiles list references a TailoredProfile (not a stock Profile), and settingsRef references the lab’s workers-storage ScanSetting (not default). Both are created by sibling Policies in the same set, ordered with sync waves so the operator’s CRDs land before any binding is attempted.

The feedback loop

When a ComplianceScan finishes, it emits one ComplianceCheckResult per rule. ACM’s spoke-side policy controllers don’t watch those directly. Instead, you write a follow-up ConfigurationPolicy with complianceType: mustnothave against ComplianceCheckResult objects labelled compliance.openshift.io/check-status: FAIL. “There must not exist a failing PCI-DSS result” becomes the spoke’s compliance status, which the propagator’s status-sync reports back to the hub. The Governance dashboard now shows PCI-DSS pass/fail per cluster, and the per-policy drilldown shows which rules failed where.

This is how the same dashboard you use for OperatorHub and NetworkPolicy drift also shows you PCI control coverage. The Compliance Operator is the inspector, ACM is the foreman.

Why structurally it matters

A fleet of twenty clusters without RHACM means: twenty Subscriptions, twenty OperatorGroups, twenty ScanSettingBindings, and twenty places to update when a TailoredProfile exclusion is added. With RHACM: one PolicySet, one Placement, twenty PlacementDecisions. The exclusion change is one MR in platform-gitops. The audit posture is one dashboard.

This is the structural payoff. ACM doesn’t make the Compliance Operator smarter; it makes the operation of running it across many clusters tractable.

Try this

Open the lab’s PolicySet for the compliance-operator baseline (under platform-gitops/policies/compliance-operator-baseline/). Identify:

  1. The Policy that installs the operator. Look at its object-templates — the three required objects should be there in musthave form.
  2. The Policy that creates the TailoredProfile. Find one excluded rule. Trace the rationale back to a docs page or ADR.
  3. The Placement that selects the target clusters. Confirm it carries compliance=pci-dss as a required label.
  4. The follow-up mustnothave ConfigurationPolicy that surfaces failing ComplianceCheckResults as compliance status.

If any one of those four is missing, you have a fleet that installs the operator but doesn’t use it.

External policy engines — Gatekeeper and Kyverno wrapped in Policy CRs

ConfigurationPolicy covers the “this resource must exist with this shape” case well. It does not cover everything. “No Deployment may run as UID 0 unless its namespace has a security-exception label.” “Every Pod in a tenant namespace must inherit the imagePullSecret from its ServiceAccount.” “Block Pods whose images come from registries outside the allowed prefix list.” All of these are easier to express in OPA Rego (Gatekeeper) or Kyverno’s policy DSL than in object-templates.

The two-engine pattern

ACM does not replace Gatekeeper or Kyverno. It wraps them. The pattern is two layers deep:

  • Outer ACM Policy ensures the engine’s operator is installed on each selected cluster and that a specific set of policy resources (Gatekeeper ConstraintTemplate and Constraint, or Kyverno ClusterPolicy) exists.
  • Inner engine policy is the thing that actually runs at admission time. Gatekeeper evaluates Rego per webhook call; Kyverno evaluates its DSL the same way.

ACM’s role is propagation and inventory. The engine does the validation. The two are complementary, not duplicate.

The lab runs Red Hat’s Gatekeeper Operator on spoke-dc-v6 already — see /docs/openshift-platform/openshift-platform/platform-services/gatekeeper. The install was hand-rolled because there was only one spoke; if a second spoke ever arrives, the install gets wrapped in an ACM Policy and fanned out via Placement.

A worked example using PolicyGenerator

The cleanest way to ship a Gatekeeper template + constraint via ACM is the PolicyGenerator: author the underlying manifests as plain YAML, point the generator at them, and let it wrap them as Policy at kustomize-build time.

apiVersion: policy.open-cluster-management.io/v1
kind: PolicyGenerator
metadata:
  name: require-cost-center-label
policyDefaults:
  namespace: openshift-gitops
  remediationAction: enforce
  severity: medium
  placement:
    placementName: gitops-managed
policies:
  - name: require-cost-center-on-namespace
    manifests:
      - path: input/k8srequiredlabels-template.yaml
      - path: input/cost-center-constraint.yaml

input/k8srequiredlabels-template.yaml is the same ConstraintTemplate the lab already uses (K8sRequiredLabels); input/cost-center-constraint.yaml is a constraint of that kind matching Namespace objects with parameters.labels: ["cost-center"]. At build time, PolicyGenerator produces a Policy whose policy-templates list contains both objects wrapped in ConfigurationPolicy shells. The propagator fans the result out, the spoke’s klusterlet pulls it, the config-policy-controller materialises the ConstraintTemplate and the Constraint, and Gatekeeper picks them up from that point on.

The same pattern works for Kyverno. An ACM Policy wraps a kyverno.io/v1 ClusterPolicy manifest; the spoke needs the Kyverno operator installed (also via an ACM Policy); the ClusterPolicy activates on the spoke as soon as the CRDs exist.

When to pick which engine

EngineBest atCost
ACM ConfigurationPolicyDrift detection, simple state assertions, status reportingNone beyond the policy controller
GatekeeperHardcore allow/deny at admission, audit of violations, Rego logicWebhook latency per admission
KyvernoAllow/deny plus mutation and generation (auto-inject imagePullSecret, create default NetworkPolicy on namespace creation)Webhook latency; mutation has audit implications

If the rule is “this should be true”, write a ConfigurationPolicy. If the rule is “this must be true at admission and we want a deny-with-reason”, write Gatekeeper. If the rule needs to change the request before letting it through, write Kyverno. The lab uses all three layers: ValidatingAdmissionPolicy for the cheapest cases (CEL, in-process), Gatekeeper for Rego-driven audit and deny, and ACM ConfigurationPolicy for drift detection. Defense-in-depth, not replacement.

Limitations to plan around

External engines run on each spoke. Their evaluation results live on each spoke. ACM’s Governance dashboard knows about its own Policy compliance status; it does not natively aggregate Gatekeeper Constraint.status.violations or Kyverno PolicyReport objects across the fleet. You get fleet-wide installation through ACM; you do not get fleet-wide violation reporting for free.

The workaround is to scrape constraint status into Prometheus on the spoke (Gatekeeper exposes audit metrics out of the box), federate those metrics to the hub or to a side-Grafana, and read fleet-wide constraint compliance there. The lab plans this for when the Gatekeeper deployment expands beyond one spoke; today, the spoke’s own Grafana is enough.

A second limitation: when ACM Policy is set to enforce and the wrapped Gatekeeper Constraint has enforcementAction: deny, you have two enforcing layers on the same rule. That’s fine, but if they disagree about scope — say, the ACM Policy is bound to a Placement covering only prod, but the wrapped Constraint matches all namespaces — the Constraint will keep enforcing on dev clusters until the ACM Policy converges to remove it. Match scopes carefully.

Try this

Write a small PolicyGenerator config locally that bundles:

  • A Gatekeeper ConstraintTemplate of kind K8sRequiredLabels.
  • A K8sRequiredLabels Constraint that matches Namespace resources and requires the label team.

Run kustomize build against the directory, inspect the generated Policy YAML, and confirm that both manifests appear inside policy-templates. Then apply against the hub and watch the generated Policy propagate to the spoke; on the spoke, oc get constrainttemplate,k8srequiredlabels should show both resources, and any new namespace without a team label should now be rejected at admission.

Compliance dashboards

The ACM Governance dashboard at Governance → Policies aggregates per-policy compliance across every targeted cluster. You see:

  • Which policies are Compliant vs NonCompliant cluster-wide.
  • For each NonCompliant policy, which clusters are non-compliant.
  • For each non-compliant cluster, the human-readable reason (the ConfigurationPolicy.status.compliancyDetails[].conditions[].message).

Wire this to your alerting if you care about drift. “PolicySet pci-dss-fleet has any cluster NonCompliant for more than 10 minutes” is the canonical Alertmanager rule. Cross-link /docs/openshift-platform/openshift-platform/compliance/ for how the lab pairs ACM governance with Compliance Operator scans.

Try this

Four exercises, escalating from read to break-and-fix.

1. Write a Policy that requires every namespace to have a cost-center label. Use complianceType: musthave with an object-templates entry that asserts a metadata.labels.cost-center on a Namespace object. Start with remediationAction: inform.

2. Bind it to a Placement. Write a PlacementBinding that targets the Policy at a Placement selecting role=workload. (You’ll need a Placement that selects on role=workload first — write that too.)

3. Flip from inform to enforce, observe the propagation. Watch Policy.status.status[] on the hub change from NonCompliant to Compliant as the config-policy-controller patches namespaces on each spoke. Use oc get policy <name> -n openshift-gitops -o yaml | yq '.status' on the hub to inspect.

4. Break it deliberately. On one spoke, oc label namespace some-ns cost-center- (the trailing - removes the label). Watch the Policy go NonCompliant for that cluster, then (since you’re in enforce) re-converge within 30 seconds. The Governance dashboard should show the brief blip.

Common failure modes

Policy stuck NoStatus on the hub. The policy was created but no cluster ever reported back. Almost always: the PlacementBinding is missing, or the Placement returns zero decisions. Check:

oc -n openshift-gitops get placement <name>
oc -n openshift-gitops get placementdecision -l \
  cluster.open-cluster-management.io/placement=<name> -o yaml

If PlacementDecision lists no clusters, fix the Placement selector or the ManagedCluster labels.

Policy NonCompliant but enforce doesn’t act. The most common cause: another controller owns the resource. Argo CD with selfHeal: true, an operator’s CSV-driven reconciler, the OpenShift Cluster Version Operator. ACM patches the resource; the other controller reverts it; ACM patches again; the controller reverts again. You get an infinite reconcile fight visible as the policy oscillating between Compliant and NonCompliant in dashboards. Fix: either the policy gives up ownership and informs only, or the conflicting controller is reconfigured (Argo CD ignoreDifferences, a RetainPolicy on the operator) to not fight the policy.

Policy stuck NonCompliant with enforce, no fight. The ConfigurationPolicy.object-templates references an API the spoke doesn’t have — a missing CRD, a wrong apiVersion. The controller can’t enforce what doesn’t exist. Fix: install the prerequisite (operator that ships the CRD) earlier in the GitOps sync-wave order, or condition the policy on the CRD’s existence.

Status not reporting back at all. KlusterletAddonConfig.spec.policyController.enabled: false on the spoke. Without this, ACM doesn’t install governance-policy-framework on the spoke and policies never evaluate. Fix: set enabled: true, wait one reconcile, and the framework lands.

Orphaned replicated Policies after PlacementBinding deletion. When you delete a Policy or its binding, the propagator is supposed to clean up the replicated copy in each spoke’s namespace. Sometimes it doesn’t — finalizers stuck, controller restart mid-cleanup. You see policy.open-cluster-management.io.replicated.<name> objects in spoke namespaces with no owner. Fix: oc -n <spoke-ns> delete policy <name>.replicated --grace-period=0 --force. The garbage collector will catch up.

Where this is heading

You can now write a Policy, target it at a fleet, and watch compliance flow back. The same Placement primitive you just used for Policy is what drives GitOps fan-out in the next module — one ApplicationSet on the hub, N spoke Applications, one Placement deciding which clusters get what. Policy answers “is the state correct?”; GitOps answers “how does the state get there?”.

Next: Module 05 — GitOps integration — the push vs pull model decision, the gitops-addon mechanics, the Routes CRD trap, and ApplicationSet fan-out from the hub.

References