deploy/crds/ contains hub-side CRDs (GitOpsCluster, ManagedServiceAccount, etc.) generated by make manifests. During operator manifest updates, CRDs go in gitopsaddon/charts/openshift-gitops-operator/templates/crds/ — that's the embedded chart. Verify before touching deploy/crds/ — it's almost never needed for operator bumps. Only modify it if make manifests or a hub-side CRD change requires it.
The multiclusterhub-operator-pull-secret on the hub MUST include registry.redhat.io in BOTH multicluster-engine and open-cluster-management namespaces. Without it, ALL non-OCP managed clusters (Kind, EKS) get ImagePullBackOff on every Red Hat image. OCP hubs mask this because nodes have registry.redhat.io at the node level.
# Check
kubectl get secret multiclusterhub-operator-pull-secret -n multicluster-engine -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | python3 -c "import json,sys; print(list(json.load(sys.stdin)['auths'].keys()))"
# Fix: merge OCP global pull-secret into both copies
kubectl get secret pull-secret -n openshift-config -o jsonpath='{.data.\.dockerconfigjson}' | base64 -d > /tmp/ocp-pull.json
# merge auths from /tmp/ocp-pull.json into multiclusterhub-operator-pull-secret in BOTH namespaces
# then delete {cluster}-import secrets to force regeneration- Fix pull secret chain (above) — do this BEFORE anything else for non-OCP clusters
- Install OpenShift GitOps operator on hub (OLM,
openshift-gitops-operatorns,ARGOCD_CLUSTER_CONFIG_NAMESPACES=openshift-gitops,DISABLE_DEFAULT_ARGOCD_INSTANCE=false) - Wait for ArgoCD server pod Running in
openshift-gitops - Patch ArgoCD CR:
principal.enabled: true,destinationBasedMapping: true,controller.enabled: true,sourceNamespaces: ["*"]— hybrid mode: the app-controller stays enabled and coexists with the principal in the same namespace/instance; it never conflicts because agent-routed cluster secrets carryskip-reconcile: "true"(see "Hybrid Mode" below) - Create
ManagedClusterSetBindingforglobalinopenshift-gitops - Patch default AppProject:
destinations: [{name:"*", namespace:"*", server:"*"}]—name:"*"is critical, without it principal won't propagate AppProjects - Apply addon templates:
kubectl apply -f gitopsaddon/addonTemplates/addonTemplates.yaml - Create Placement (excluding
local-cluster) + GitOpsCluster withargoCDAgent.enabled: true, mode: managed—local-clustermust never be in this Placement; it's not an addon-install target (it already has its own ArgoCD instance) - Wait for GitOpsCluster
Ready=Trueand PolicyCompliant - Patch Policy with cluster-admin RBAC for app-controller BEFORE creating AppSet — if apps sync before RBAC exists, ArgoCD exhausts 5 retries and gives up permanently
- Grant the hub's own app-controller ServiceAccount (
openshift-gitops-argocd-application-controller) cluster-admin too — needed for any Application targetinglocal-cluster/in-cluster to create resources in its destination namespace; the operator's default ClusterRole is read-only outsideopenshift-gitopsitself. Do this before creating the AppSet for the same reason as step 10 (5-retry exhaustion). - If ApplicationSets should also target
local-cluster(hybrid mode), create a separate Placement that includeslocal-clusterand point the AppSet'sclusterDecisionResourcegenerator at it — never reuse the addon-install Placement from step 8 for this - Create ApplicationSet(s)
- Verify apps Synced on hub (including any
local-cluster-targeted app, reconciled directly by the hub's own app-controller) and guestbook pods on spokes
The import secret has TWO files. Apply CRDs first or the Klusterlet CR fails:
kubectl -n <cluster> get secret <cluster>-import -o jsonpath='{.data.crds\.yaml}' | base64 -d | kubectl apply -f -
kubectl -n <cluster> get secret <cluster>-import -o jsonpath='{.data.import\.yaml}' | base64 -d | kubectl apply -f -Then install governance addons (governance-policy-framework + config-policy-controller ManagedClusterAddOns) or Policies will never be enforced.
- Principal CrashLoopBackOff: Normal when GitOpsCluster is created after ArgoCD CR. Secrets (
argocd-agent-ca,argocd-agent-principal-tls,argocd-agent-resource-proxy-tls,argocd-agent-jwt) are generated by the hub controller on GitOpsCluster reconciliation. The JWT secret is required by the v1.21.0+ principal for token signing. Principal recovers automatically — can take 5+ minutes due to exponential backoff. - Spoke agent CrashLoopBackOff with
[FATAL]: Error creating remote: could not read secret: secrets "argocd-agent-ca" not found: Looks like a config error but is the same class of startup race as the principal one above, just the next hop over. The hub'sargocd-agent-casecret has to reach the spoke'sopenshift-gitopsnamespace via aManifestWork(argocd-agent-ca-mw, created byPropagateHubCAinpropagate_hub_ca.go) delivered by the klusterlet work-agent — this is a second, independent propagation delay on top of (not instead of) the principal's own cert-generation delay in the line above. Confirmed benign: the agent pod simply restarts until the secret lands, then starts normally with no other action needed. Before assuming something is actually broken, check: (1) hub has the source secret (oc get secret argocd-agent-ca -n openshift-gitopson the hub), (2) theManifestWorkexists and showsApplied=True/Available=True(oc get manifestwork argocd-agent-ca-mw -n <managed-cluster-name> -o yamlon the hub), (3) the secret eventually appears on the spoke (oc get secret argocd-agent-ca -n openshift-gitopson the managed cluster). If all three are healthy, just wait out the agent's crash-loop backoff — observed taking several minutes end-to-end, longer than the principal's own cert-generation window since it's gated behind it (the hub can't propagate a CA secret that doesn't exist yet) plus the klusterlet work-agent's own sync interval. - Policy NonCompliant on Kind: The addon agent must install the embedded operator first (creates CRDs +
openshift-gitopsnamespace). Takes 2-5 minutes. The Policy can't create the ArgoCD CR until the CRD exists. - OCP OLM timing: Catalog resolution + InstallPlan + CSV = 30-120s even when healthy. Use 600s timeouts.
Every binary below is built from the same image (quay.io/stolostron/multicloud-integrations),
but shipped as three separate Deployments on the hub — don't assume "the controller" means only
one of them:
-
gitopscluster — Hub-side controller in the
multicluster-operators-applicationdeployment. ReconcilesGitOpsClusterCRs, creates Policies, MCAs, ADCs, AddOnTemplates, cluster secrets, TLS certs, and handles agent version drift healing. This is the argocd-agent (principal/agent) integration — see "Agent Modes" and "Hybrid Mode" below. -
gitopsaddon — Addon agent on each managed cluster. Installs OpenShift GitOps operator (OLM on OCP, embedded Helm chart on non-OCP), creates ArgoCD CR, manages image pull secrets, patches ServiceAccounts, and optionally deploys the argocd-agent. Deployed by the OCM addon framework — runs regardless of which integration model (agent or basic pull model) is in use, since both need an ArgoCD instance actually installed on the spoke.
-
gitopssyncresc / multiclusterstatusaggregation / propagation — the older, still-shipping basic pull model integration (in the
multicluster-integrationsdeployment on the hub, a third, separate Deployment from the two above). Predates argocd-agent, has no dedicated spoke agent process, and reuses the standard OCM klusterlet work-agent (ManifestWork) that every managed cluster already has. See "Basic Pull Model" under Architecture, and "Basic Pull Model Setup — Full YAML" below, for the full picture.maestropropagation/maestroaggregationare an opt-in, Maestro-backed alternative to this same model for very large scale (not deployed by default).
make build # Build all binaries
make build-images # Build container images
make test # Unit tests (self-provisions envtest/kubebuilder assets via setup-envtest)
make lint # Linting
make generate # DeepCopy methods after API changes
make manifests # CRD manifests after RBAC/CRD annotation changes
# E2E (creates Kind clusters, builds image, runs tests)
make test-local-e2e-gitopsaddon-embedded
make test-local-e2e-gitopsaddon-embedded-agent
make test-local-e2e-gitopsaddon-olm-override
make test-local-e2e-gitopsaddon-embedded-autonomous
make clean-e2e # Delete Kind clusters# 1. Configure hub ArgoCD as principal (hybrid mode: controller stays enabled)
kubectl patch argocd openshift-gitops -n openshift-gitops --type=merge -p '{
"spec": {
"controller": {"enabled": true},
"argoCDAgent": {
"principal": {
"enabled": true,
"auth": "mtls:CN=system:open-cluster-management:cluster:([^:]+):addon:gitops-addon:agent:gitops-addon-agent",
"destinationBasedMapping": true,
"namespace": {"allowedNamespaces": ["*"]}
}
},
"sourceNamespaces": ["*"]
}
}'
# 2. Apply addon templates
kubectl apply -f gitopsaddon/addonTemplates/addonTemplates.yaml
# 3. Create ManagedClusterSetBinding
kubectl apply -f - <<'EOF'
apiVersion: cluster.open-cluster-management.io/v1beta2
kind: ManagedClusterSetBinding
metadata:
name: global
namespace: openshift-gitops
spec:
clusterSet: global
EOF
# 4. Patch default AppProject (name:"*" is critical)
kubectl patch appproject default -n openshift-gitops --type=merge -p '{
"spec": {
"destinations": [{"name":"*","namespace":"*","server":"*"}],
"clusterResourceWhitelist": [{"group":"*","kind":"*"}],
"sourceNamespaces": ["*"],
"sourceRepos": ["*"]
}
}'
# 5. Create Placement + GitOpsCluster
kubectl apply -f - <<'EOF'
apiVersion: cluster.open-cluster-management.io/v1beta1
kind: Placement
metadata:
name: agent-placement
namespace: openshift-gitops
spec:
clusterSets: [global]
predicates:
- requiredClusterSelector:
labelSelector:
matchExpressions:
- key: name
operator: NotIn
values: [local-cluster]
---
apiVersion: apps.open-cluster-management.io/v1beta1
kind: GitOpsCluster
metadata:
name: mc-gitops-agent
namespace: openshift-gitops
spec:
argoServer:
cluster: local-cluster
argoNamespace: openshift-gitops
placementRef:
kind: Placement
apiVersion: cluster.open-cluster-management.io/v1beta1
name: agent-placement
gitopsAddon:
enabled: true
argoCDAgent:
enabled: true
mode: managed
EOF
# 6. Wait for Ready + Compliant
kubectl get gitopscluster mc-gitops-agent -n openshift-gitops -w
kubectl get policy mc-gitops-agent-argocd-policy -n openshift-gitops -w
# 7. RBAC Policy patch — DO THIS BEFORE creating AppSet
kubectl patch policy mc-gitops-agent-argocd-policy -n openshift-gitops --type=json -p='[
{"op":"add","path":"/spec/policy-templates/-","value":{
"objectDefinition":{
"apiVersion":"policy.open-cluster-management.io/v1",
"kind":"ConfigurationPolicy",
"metadata":{"name":"mc-gitops-agent-argocd-policy-rbac"},
"spec":{
"remediationAction":"enforce","severity":"medium",
"object-templates":[{
"complianceType":"musthave",
"objectDefinition":{
"apiVersion":"rbac.authorization.k8s.io/v1",
"kind":"ClusterRoleBinding",
"metadata":{"name":"acm-openshift-gitops-cluster-admin"},
"roleRef":{"apiGroup":"rbac.authorization.k8s.io","kind":"ClusterRole","name":"cluster-admin"},
"subjects":[{"kind":"ServiceAccount","name":"acm-openshift-gitops-argocd-application-controller","namespace":"openshift-gitops"}]
}
}]
}
}
}}
]'
# 8. Grant the hub's own app-controller RBAC too (needed for local-cluster/in-cluster targets) —
# DO THIS BEFORE creating any AppSet that targets local-cluster, same 5-retry-exhaustion reason.
kubectl create clusterrolebinding openshift-gitops-appcontroller-cluster-admin \
--clusterrole=cluster-admin \
--serviceaccount=openshift-gitops:openshift-gitops-argocd-application-controller
# 9. (Hybrid mode only) Create a SEPARATE Placement that includes local-cluster, used only by
# ApplicationSets that need to target it. Never add local-cluster to "agent-placement" above.
kubectl apply -f - <<'EOF'
apiVersion: cluster.open-cluster-management.io/v1beta1
kind: Placement
metadata:
name: agent-placement-appset
namespace: openshift-gitops
spec:
clusterSets: [global]
predicates:
- requiredClusterSelector:
labelSelector:
matchExpressions:
- key: name
operator: In
values: [local-cluster] # plus every managed cluster name you want the AppSet to target
EOF
# 10. Create ApplicationSet (AFTER RBAC is in place). Point at "agent-placement-appset" if it
# should also generate an Application for local-cluster; point at "agent-placement" if it should
# stay agent-spokes-only.
kubectl apply -f - <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: guestbook-appset
namespace: openshift-gitops
spec:
generators:
- clusterDecisionResource:
configMapRef: acm-placement
labelSelector:
matchLabels:
cluster.open-cluster-management.io/placement: agent-placement-appset
requeueAfterSeconds: 30
template:
metadata:
name: '{{name}}-guestbook'
namespace: openshift-gitops
spec:
project: default
source:
repoURL: https://github.com/argoproj/argocd-example-apps
targetRevision: HEAD
path: guestbook
destination:
name: '{{name}}'
namespace: guestbook
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [CreateNamespace=true]
EOFThe basic pull model needs no agent, no principal, no certs — just a GitOpsCluster with
gitopsAddon.enabled: true and no argoCDAgent block at all, so the addon installs a plain
(non-agent) ArgoCD instance on the spoke, plus an ApplicationSet (or a single Application)
carrying the pull label/annotation. OCP-only: this integration is only supported for OCP
managed clusters (OLM-installed operator) — it is not validated or supported on non-OCP (embedded
Helm chart) spokes.
# 1. Placement selecting only the target (OCP) managed cluster(s) — reuse the same
# ManagedClusterSetBinding/AppProject setup as agent mode (see "Agent Mode Setup" above).
kubectl apply -f - <<'EOF'
apiVersion: cluster.open-cluster-management.io/v1beta1
kind: Placement
metadata:
name: pull-model-placement
namespace: openshift-gitops
spec:
clusterSets: [global]
predicates:
- requiredClusterSelector:
labelSelector:
matchExpressions:
- key: name
operator: In
values: [<ocp-managed-cluster-name>]
EOF
# 2. GitOpsCluster with gitopsAddon enabled and argoCDAgent OMITTED (not disabled — omitted).
# This forces createBlankClusterSecrets=true internally and installs a plain, non-agent
# ArgoCD via the static "gitops-addon" AddOnTemplate + OLM subscription.
kubectl apply -f - <<'EOF'
apiVersion: apps.open-cluster-management.io/v1beta1
kind: GitOpsCluster
metadata:
name: pull-model-gitops
namespace: openshift-gitops
spec:
argoServer:
cluster: local-cluster
argoNamespace: openshift-gitops
placementRef:
kind: Placement
apiVersion: cluster.open-cluster-management.io/v1beta1
name: pull-model-placement
gitopsAddon:
enabled: true
EOF
# 3. Wait for the spoke's ArgoCD (application-controller, redis, repo-server — "server" is
# disabled by default for both agent and pull-model spokes, this is expected, see Architecture).
# Then create an ApplicationSet whose template carries the pull label/annotation:
kubectl apply -f - <<'EOF'
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: pull-model-guestbook-appset
namespace: openshift-gitops
spec:
generators:
- clusterDecisionResource:
configMapRef: acm-placement
labelSelector:
matchLabels:
cluster.open-cluster-management.io/placement: pull-model-placement
requeueAfterSeconds: 30
template:
metadata:
name: '{{name}}-guestbook'
namespace: openshift-gitops
labels:
apps.open-cluster-management.io/pull-to-ocm-managed-cluster: 'true'
annotations:
# Required whenever the hub's own app-controller is enabled (hybrid mode) and the
# template's destination.server is the in-cluster address — otherwise the hub tries to
# reconcile this placeholder Application locally in ADDITION to it being propagated.
argocd.argoproj.io/skip-reconcile: 'true'
apps.open-cluster-management.io/ocm-managed-cluster: '{{name}}'
apps.open-cluster-management.io/ocm-managed-cluster-app-namespace: openshift-gitops
spec:
project: default
source:
repoURL: https://github.com/argoproj/argocd-example-apps.git
targetRevision: HEAD
path: guestbook
destination:
server: https://kubernetes.default.svc # rewritten to the spoke's in-cluster server on delivery
namespace: guestbook
syncPolicy:
automated: {prune: true, selfHeal: true}
syncOptions: [CreateNamespace=true]
EOF
# 4. Verify (see "Basic Pull Model" under Architecture for what each layer means):
kubectl get applications.argoproj.io -n openshift-gitops # hub placeholder — always specify the group, see Troubleshooting
kubectl get manifestwork -n <ocp-managed-cluster-name> | grep guestbook # delivery vehicle
kubectl get multiclusterapplicationsetreports -n openshift-gitops # aggregated status back on the hub
# on the spoke itself:
kubectl get applications.argoproj.io -n openshift-gitops # propagated copy, should be Synced
kubectl get pods -n guestbookConfirmed end-to-end on a real OCP-managed cluster: the ApplicationSet generated the hub
placeholder Application, the propagation controller wrapped it into a ManifestWork, the
spoke's existing klusterlet work-agent delivered it with no new agent process involved, the
spoke's ArgoCD application-controller reconciled it (Synced), and both status paths reported
back correctly — the ManifestWork status-feedback path fed multiclusterstatusaggregation,
which produced a MulticlusterApplicationSetReport with syncStatus: Synced and the real
guestbook-ui Deployment/Service listed under resources, and application_status_controller
separately reflected the same sync/health/operation state onto the hub's own Application.status
(with a synthetic AdditionalStatusReport condition pointing at the report and the spoke
Application for further detail). The demo guestbook-ui pod itself does not reach Healthy on
OCP — this is the same pre-existing, non-pull-model-related restricted-SCC limitation already
documented under "E2E Testing Gotchas" (non-root process binding port 80); Synced is the correct
success bar for this specific demo image on OCP, not Healthy.
The basic pull model can be turned off hub-wide via a ConfigMap, without deleting anything or disrupting real workloads. This is the first step of the migration runbook below, but it's a standalone feature in its own right — flip it whenever you want the basic-pull-model controllers to stop doing any work.
- ConfigMap name:
multicluster-integrations-config. Namespace: the same namespace themulticluster-integrationspod itself runs in — resolved dynamically at runtime (POD_NAMESPACEenv var, falling back to reading the pod's mounted service-account namespace file), never hardcoded toopen-cluster-management. Package:pkg/pullmodelconfig. - Self-provisioning: if the ConfigMap doesn't exist, whichever of the three containers starts
first creates it with defaults (
pullModel.basic.disabled: false) — race-safe across the sibling containers in the same pod (Create, falling back to Get onAlreadyExists). - Schema (
data["config.yaml"], parsed as YAML):Nested underpullModel: basic: disabled: true
pullModel.basicspecifically to disambiguate from argocd-agent, which is also a pull-based model in the generic sense — this switch only ever affects the classic ManifestWork-based basic pull model, never argocd-agent. - Checked live, not cached at startup. Each of the three components re-reads this ConfigMap
(a real
Get, viapullmodelconfig.LoadOrCreate) at the point where the decision matters —propagation-controller/application's two reconcilers check it as the first thing inside every singleReconcile()call, andgitopssyncresc/multiclusterstatusaggregationcheck it on every tick of their periodic loop, not once when the process started. This was a deliberate fix, not the original design: an earlier version cached the flag once at process startup, which left a real race during akubectl rollout restart— the OLD (not-yet-disabled) pod and the NEW (disabled, already-swept) pod briefly run side by side (RollingUpdate's defaultmaxSurge), and the old pod's cached-falsereconciler could process one more in-flight event and recreate aManifestWorkwithServerSideApplyafter the new pod's sweep had just set it toReadOnly. Reading live on every check closes this window regardless of which pod (old or new) happens to process a given event, since both read the same up-to-date ConfigMap. Toggling the flag therefore takes effect within one reconcile/tick — no restart needed for the guards themselves. - The one-time
ManifestWorksweep is the one exception that still needs a restart. It only runs once, at process startup in the propagation container'smain.go, gated on the config value read at that moment — so to sweep anyManifestWorkthat predates flipping the flag, you still needkubectl rollout restart deployment multicluster-integrations -n <namespace>. - What
disabled: trueactually does, at the code level, across all three containers in themulticluster-integrationsDeployment:propagation-controller/application'sApplicationReconcilerandApplicationStatusReconcilerboth returnctrl.Result{}, nilas soon as a live config check says disabled — for every event, not just filtered out at the watch/predicate level. NoManifestWorkis created, updated, or deleted by this component while disabled.gitopssyncresc's periodic loop (Start()'swait.Untilbody) skipssyncResources()for any tick where the live check says disabled — no ACM Search API polling that tick.multiclusterstatusaggregation's periodic loop skipshouseKeeping()the same way — noMulticlusterApplicationSetReportgeneration/cleanup that tick.- Once, at process startup (in the propagation container's
main.go, before the reconcilers are registered),application.SweepManifestWorksToReadOnlylists everyManifestWorkcluster-wide, content-matches the pull-model-owned ones (has the hub-application-name annotation, and carries either the ApplicationSet-owned or standalone-pull label), and — for each one'sapplications-resourcemanifestConfigsentry specifically, never by positional index — setsupdateStrategy: ReadOnlyif it isn't already. This is what stops the spoke's klusterlet work-agent from continuing to enforce already-delivered Application specs, without deleting any ManifestWork. Idempotent: a re-run (e.g. next pod restart) finds nothing left to do and is a fast no-op. - None of this imports or touches
pkg/controller/gitopscluster— verified zero coupling in either direction. Disabling the basic pull model cannot affect argocd-agent.
- This is a hub-wide switch, not per-app or per-cluster: it affects every basic-pull-model app across every managed cluster simultaneously. There is no code path to disable it for one app or one cluster only.
- Re-enabling (
disabled: false+ restart) resumes normal reconciliation, but does not automatically revert anyManifestWorkthe sweep already set toReadOnly— there is no "undo" sweep. If you want a specific app'sManifestWorkback under active enforcement, patch itsupdateStrategyback explicitly. - RBAC: no changes needed —
multicloud-integrations-rolealready grants full CRUD onconfigmapsandmanifestworks/manifestworks/statuscluster-wide (confirmed indeploy/controller/role.yaml).
A step-by-step operator runbook with copy-pasteable commands lives in
docs/basic-pull-to-argocd-agent-migration.md. This works whether the pull model was set up via
gitopsAddon.enabled: true or via GitOpsCluster.spec.createBlankClusterSecrets: true directly —
the latter being how the pull model predates and can exist entirely independently of the
addon/agent machinery (see "Basic Pull Model" under Architecture).
Goal: argocd-agent takes over the same Application object — same name, same UID, no new
Application or ApplicationSet ever created — with zero pod restart/recreation, and without the
old pull-model controllers (multicluster-integrations's propagation controller) continuing to
touch it afterward. Two earlier, more roundabout designs were tried and rejected; see "Designs
that were rejected" below for why creating a second Application (even under a different name) is
unnecessary and worse than this one. Getting genuine agent management (not just a hub Application
that merely looks migrated) additionally requires step 5 below — see it for why.
Not every pull-model app has an ApplicationSet owner. The propagation controller only
requires the pull label + ocm-managed-cluster*/skip-reconcile annotations on the Application
itself — a hand-created, standalone Application carrying them gets pulled and reconciled exactly
like an ApplicationSet-generated one (the only difference: multiclusterstatusaggregation keys
its MulticlusterApplicationSetReport generation off the application-set label, which only an
ApplicationSet-owned app has — see Known Limitations #13). Steps 1-3 below are identical for both
kinds; only step 4 (the takeover) branches, since a standalone app has no template to edit.
The pull-model ApplicationSet's own template is already the durable, self-enforcing source of
truth for the generated Application object (that's why Step 2 below works at all — anything you
change in the template, the ApplicationSet controller keeps re-asserting on the live object,
including fields you didn't touch). Migrating to argocd-agent means changing what that template
says — from "pull-model, hub-side placeholder" to "agent-routed, hub-dispatched" — never
replacing the object it manages.
-
Disable the basic pull model hub-wide via the
multicluster-integrations-configConfigMap (see "Disabling the Basic Pull Model" above) and restart the pod. This freezes every existing pull-modelManifestWork'sapplicationsmanifestConfig toupdateStrategy: ReadOnlyin one pass — no need to find and patch it by hand per app. This stops the spoke's klusterlet work-agent from re-asserting/enforcing the delivered Application's spec, without deleting anything: drifting the spoke Application'sspec.syncPolicyor a label afterward is not reverted, whilestatusFeedBackRules(health/sync/operation status) keeps flowing to the hub the whole time. Never delete any pull-model ManifestWork, at any point, ever — see "Designs that were rejected" below; this holds regardless of which overall design you use. -
Enable
argoCDAgenton the SAMEGitOpsCluster(patch it in place; if the cluster never hadgitopsAddonat all, this is the first time it gets set, which is fine) — do this before touching theApplicationSettemplate's destination (step 4), so the agent cluster secret already exists by the time the destination needs to resolve against it. Wait forAddOnDeploymentConfig'sARGOCD_AGENT_ENABLED=true(a few reconcile passes — cert chain generation is sequential, this is the "Expected Waits (Not Bugs)" pattern, not a stuck reconcile) and forcluster-<name>to appear in the ArgoCD namespace with theargocd-agent.argoproj-labs.io/agent-namelabel. -
If the managed cluster already had its OWN, differently-named ArgoCD CR (e.g. a manually installed default
openshift-gitopsinstance, as opposed to the addon-managedacm-openshift-gitopsone that argocd-agent mode creates) — delete that old ArgoCD CR now. ArgoCD's core settings (argocd-cm,argocd-rbac-cm,argocd-secret, etc.) are shared per-namespace, not per-CR-instance; two ArgoCD CRs in the same namespace fight over them continuously (observed live:argocd-serverstuck restarting on"url modified. restarting"forever). Deleting the old ArgoCD CR is safe — it does not cascade to the Applications or real workloads it was reconciling (those aren't Kubernetes-owned by the ArgoCD CR itself), only to the ArgoCD control-plane components (application-controller, repo-server, redis, server). Confirmed live: the managed pod'suidwas unchanged across this deletion. This step does not apply if the cluster's ArgoCD instance was already the addon-managedacm-openshift-gitopsone. Then wait for the agent pod to reach 2 consecutive stableRunningsamples. -
Take over the app — this is the actual takeover, and the only step that touches the app's own identity. Which object you patch depends on whether the app has an
ApplicationSetowner (kubectl get applications.argoproj.io $APP_NAME -n $ARGOCD_NS -o jsonpath='{.metadata.ownerReferences}'):ApplicationSet-owned: patch theApplicationSet's template in place — theApplicationSetcontroller re-asserts the change onto the existingApplication:- remove the pull-specific label (
apps.open-cluster-management.io/pull-to-ocm-managed-cluster) - remove the two
ocm-managed-cluster/ocm-managed-cluster-app-namespaceannotations - remove
argocd.argoproj.io/skip-reconcile(this one now needs to come OFF, unlike the rejected designs below — the whole point is for this Application to actually be reconciled now, by the agent, instead of sitting inert) - change
spec.destinationfrom the literalserver: https://kubernetes.default.svctoname: '{{name}}', which resolves through the agent cluster secret from step 2
kubectl patch applicationset $PULL_APPSET -n $ARGOCD_NS --type=json -p='[ {"op":"remove","path":"/spec/template/metadata/labels/apps.open-cluster-management.io~1pull-to-ocm-managed-cluster"}, {"op":"remove","path":"/spec/template/metadata/annotations/apps.open-cluster-management.io~1ocm-managed-cluster"}, {"op":"remove","path":"/spec/template/metadata/annotations/apps.open-cluster-management.io~1ocm-managed-cluster-app-namespace"}, {"op":"remove","path":"/spec/template/metadata/annotations/argocd.argoproj.io~1skip-reconcile"}, {"op":"remove","path":"/spec/template/spec/destination/server"}, {"op":"add","path":"/spec/template/spec/destination/name","value":"{{name}}"} ]'
Standalone (no
ApplicationSet): there's no template — patch theApplicationobject itself, directly and once (nothing else re-asserts its spec, so this is permanent). Same fields, but on/metadataand/specdirectly, anddestination.nameis the literal cluster name (there's no{{name}}generator to template it):kubectl patch applications.argoproj.io $APP_NAME -n $ARGOCD_NS --type=json -p='[ {"op":"remove","path":"/metadata/labels/apps.open-cluster-management.io~1pull-to-ocm-managed-cluster"}, {"op":"remove","path":"/metadata/annotations/apps.open-cluster-management.io~1ocm-managed-cluster"}, {"op":"remove","path":"/metadata/annotations/apps.open-cluster-management.io~1ocm-managed-cluster-app-namespace"}, {"op":"remove","path":"/metadata/annotations/argocd.argoproj.io~1skip-reconcile"}, {"op":"remove","path":"/spec/destination/server"}, {"op":"add","path":"/spec/destination/name","value":"'"$CLUSTER_NAME"'"} ]'
The hub
Application's.metadata.uidmust not change across this patch — it's a genuine in-place field update, not a delete/recreate: for theApplicationSet-owned case, theApplicationSetcontroller reconciles an existing same-name Application by patching it, not by replacing it; for the standalone case, it's simply the same directkubectl patchtarget. If the uid changes, something deleted and recreated the object instead of updating it — stop and investigate before proceeding. The Application goesSyncedwithin seconds — but see step 5 below before trusting that this means the agent is actually managing it. Also confirm the propagation controller is permanently inert with a touch-test (annotate the app, confirm the ManifestWork's.metadata.generationdoes not bump). - remove the pull-specific label (
-
Confirm genuine agent management — do not skip this. The
Syncedstatus from step 4 can be misleading. This app was, by definition, delivered to the spoke at least once via the classic pull model — the klusterlet work-agent applied it there directly as a plain object. That pre-existing spoke-side copy is invisible to argocd-agent's own bookkeeping (it has no record of ever creating it), so the agent cannot take real control of it. Step 4 only changes the hub's view; the spoke's copy is not automatically replaced and can keep being reconciled by whatever was already reconciling it before, which still reports backSyncedeven though the agent isn't managing anything. Check directly:kubectl --kubeconfig $SPOKE_KUBECONFIG get applications.argoproj.io $APP_NAME -n $ARGOCD_NS -o jsonpath='{.spec.destination}'
{"name":"in-cluster", ...}(or another agent-resolved name) — genuine agent control already achieved, nothing more to do.{"server":"https://kubernetes.default.svc", ...}(the old pull-model value) — not yet taken over. Replace the stale spoke copy: removesyncPolicy.automatedon the spoke's copy first (so the delete below doesn't prune the real resources), then remove onlyresources-finalizer.argocd.argoproj.iofrom itsfinalizers— preserve any other, unrelated finalizer the object might carry rather than clearing the whole list — and read.metadata.finalizersback before deleting — theApplicationSetcontroller (or ArgoCD's own defaulting) can silently re-addresources-finalizer.argocd.argoproj.iomoments later, the same behavior documented in "Designs that were rejected" below. If the read-back is non-empty, stop and do not delete — automated is off, so there is no destructive cascade in that scenario, but a delete against a re-added finalizer just times out.kubectlhas no direct delete-precondition flag, so as the closest practical equivalent, also capture.metadata.resourceVersionfrom that same read-back and re-check it immediately before deleting — if it changed in that window (e.g. a finalizer got re-added right after the clean read-back), stop instead of deleting an object no longer confirmed clean. Only once both checks pass, delete it directly on the spoke, confirm the real workload's poduidis unchanged, then nudge a fresh dispatch from the hub (any harmless annotation touch — the first create attempt right after the stale copy disappears can race and silently fail once). Recheckspec.destinationon the spoke again after ~30s; it should now bename-based. Confirmed live for bothApplicationSet-owned and standalone apps: the workload's poduidnever changes across this replacement, becauseautomated(and itsprune: true) was removed from the spoke copy before it was deleted.
That's the whole migration. No new Application, no new ApplicationSet, nothing to detach or
delete afterward — for the ApplicationSet-owned case, the same ApplicationSet that generated
the pull-model app is now the permanent, ongoing manager of the agent-dispatched one, exactly like
any other agent-mode ApplicationSet; for the standalone case, the Application you patched is now
a normal, permanent agent-dispatched app with nothing else managing it. The old, now-ReadOnly
ManifestWork is the only leftover either way, and it's inert — leave it in place permanently (see
below for why deleting it later is still a bad idea).
Two earlier designs were tried, both created a second Application/ApplicationSet instead of transforming the original in place, and both were rejected once their actual failure modes were understood:
- Strip the old Application's
ownerReferences, delete the oldApplicationSet, then create a NEW agentApplicationSetreusing the exact same Application name. This passed every immediate, automated check across 3 script cycles and then destroyed the live Deployment/Service when the identical steps were replayed manually with realistic pauses between them. Root cause: deleting anApplicationSetthat still (or very recently did) own an Application carryingresources-finalizer.argocd.argoproj.iotriggers a delete-cascade that is not instantaneous — it can take real minutes to finish. The fast automated checks sampled a window where the bookkeeping objects were already gone but the cascade toward the real resources hadn't arrived yet. - Create a NEW agent
ApplicationSetwith a DIFFERENT Application name (e.g.<name>-agent), leaving the old pull-modelApplicationSet/Application alone, then separately detach the old one (finalizer-strip, then delete directly on the spoke). This one was actually safe — proven with 3 full cycles and extended waits — but is objectively worse than transforming in place: it creates a second, differently-namedApplicationobject for something that is, from the user's perspective, the same app; it needs an explicit detach step for the old one; and — discovered while tearing down THIS test's own demo apps afterward — deleting anApplicationSetwhose generated Application hasresources-finalizer.argocd.argoproj.ioandautomated.prune: trueis always dangerous, permanently, not just during migration — the exact same destructive cascade from design #1 reproduced again, unrelated to migration, just from ordinary test cleanup. Transforming the existingApplicationSetin place (the sequence above) has noApplicationSetdeletion anywhere in it, so this whole class of hazard never comes up.
Standing caution, not specific to migration: never delete an ApplicationSet whose generated
Application has an active resources-finalizer.argocd.argoproj.io while syncPolicy.automated
is still set — that combination is what deletes the real managed resources. This applies to
decommissioning a still-pull-model app (one you've decided not to migrate): disable
syncPolicy.automated on the ApplicationSet's template first, confirm that reached the live
Application (spec.syncPolicy no longer has automated), then delete the ApplicationSet,
and only then strip the orphaned Application's finalizers to [] and delete it directly.
Confirmed live this order the other way around does not reliably work: with the ApplicationSet
still present, stripping the Application's finalizers to [] gets silently reverted — something
(the ApplicationSet controller's own periodic template reconcile, independent of automated)
re-adds resources-finalizer.argocd.argoproj.io moments later, and a kubectl delete --wait=true
issued right after the strip times out waiting on a finalizer that's already back. Deleting the
ApplicationSet first removes whatever keeps re-adding it, and is safe specifically because
automated (and therefore the prune-on-delete cascade) was already confirmed off the live
Application before that delete — the real workload's pod uid was confirmed unchanged across this
sequence.
Force-stripping the finalizer is wrong once an app has already been migrated — prefer a normal
delete, and always verify the spoke copy is actually gone afterward regardless. The force-strip
technique above exists because a still-pull-model app's destination is a fake, unreachable URL, so
ArgoCD's own finalize-then-remove-finalizer flow can never complete on its own. Once an app is
agent-routed (post-migration), its destination is real and reachable, so a normal
kubectl delete applications.argoproj.io $APP_NAME -n $ARGOCD_NS --wait=true should be preferred
(force-stripping pre-emptively guarantees the relay-to-agent never gets a chance to happen at all).
But even a clean, non-force-stripped delete is not guaranteed to relay to the agent — always
explicitly verify the spoke's copy is actually gone, every time. Confirmed live, for apps that
existed before argoCDAgent was ever enabled on their cluster (true of any migrated pull-model app,
by definition): in one case the relay was rejected outright, logged on the agent as source UID annotation is not found for app: <name>, leaving the hub object stuck Terminating until its
finalizer was cleared manually; in another, a --wait=true delete completed on the hub with no
error at all, yet the spoke's mirrored Application (and its real workload) was silently left
behind regardless. Both were only caught by directly checking the spoke:
kubectl --kubeconfig $SPOKE_KUBECONFIG get applications.argoproj.io $APP_NAME -n $ARGOCD_NS
# if found with no corresponding hub object, it's an orphan -- delete it directly, on the spoke:
kubectl --kubeconfig $SPOKE_KUBECONFIG delete applications.argoproj.io $APP_NAME -n $ARGOCD_NS --wait=trueAn orphaned spoke copy isn't just inert leftover clutter — it keeps actively reconciling its
resources and will fight any later, unrelated app that reuses the same destination namespace and
resource names, both apps repeatedly overwriting each other's argocd.argoproj.io/tracking-id,
with the affected app flapping Synced/OutOfSync indefinitely until the orphan is removed.
- An immediate post-action check is not sufficient proof of safety — always re-verify after a
real wait. This is the single biggest lesson from the rejected design #1 above: a pod
uidunchanged 1 second after a risky operation does not mean the operation was safe, only that nothing had cascaded yet. Every step in the sequence above was re-confirmed after an explicit ~3 minute wait, not just immediately. - Deleting the wrong Policy name leaves the ArgoCD CR fighting cleanup forever. The governance
root
Policylives in the ArgoCD namespace as<gitopscluster-name>-argocd-policy; the replica in the managed cluster's own namespace is named<argocd-namespace>.<gitopscluster-name>-argocd-policy(root namespace prefixed). Deleting only the replica — or using the un-prefixed name in the managed cluster's namespace — does nothing: the rootPolicykeeps re-enforcing, soconfig-policy-controlleron the spoke recreates the ArgoCD CR every time aManagedClusterAddOnpre-delete cleanup Job deletes it, until the Job's own timeout is exhausted and it fails permanently, wedging the MCA inTerminating. Always delete the rootPolicy+ itsPlacementBindingin the ArgoCD namespace, before deleting the MCA, per the documented cleanup order. - A default OLM install of the operator does NOT get the same RBAC the addon normally
provisions. A manually-installed (not addon-managed) ArgoCD instance's application-controller
ServiceAccount is scoped read-only outside its own namespace by default — the very first sync of
anything into another namespace fails with
services is forbidden/deployments.apps is forbiddenand exhausts ArgoCD's 5-retry budget. Grantcluster-admin(or a narrower equivalent) to<argocd-name>-argocd-application-controllerbefore the first sync, same pattern as the Hybrid Mode / RBAC-before-first-sync guidance elsewhere in this doc — and note the SA name changes when the ArgoCD instance's own name changes (e.g. after step 4 above swapsopenshift-gitopsforacm-openshift-gitops). - A shell script's
wait_for-style helper that shells out viabash -c "..."does not inherit parent shell functions or variables. Ifwait_for(or any retry loop) spawns a new bash process to evaluate its condition, any helper functions (e.g. ahub() { kubectl --kubeconfig ... }wrapper) and the variables they close over must beexport -f'd andexport'd respectively, or the subshell silently falls through to kubectl'slocalhost:8080default and the condition can never become true — the loop just hangs forever with no error, which looks identical to a slow reconcile. - kubectl JSONPath bracket-filters on dotted annotation/label keys are fragile (e.g.
{.items[?(@.metadata.annotations.foo\.bar=='x')]}). Prefer-o json | python3 -c "..."for anything beyond a simple field path — it's slower to write but doesn't silently return empty on syntax the kubectl JSONPath parser doesn't like. - ArgoCD component pod label selectors are not always
app.kubernetes.io/component=<name>— e.g. the application-controller StatefulSet pod carriesapp.kubernetes.io/name=<argocd-cr-name>-application-controller, not a genericcomponentlabel. When scripting a wait condition, prefer matching on the pod name (-o name | grep application-controller) over guessing a label schema. kubectl get subscriptionis ambiguous on an ACM hub, same trap askubectl get applicationdocumented under Troubleshooting — bothapps.open-cluster-management.io/v1 Subscription(OCM's classic app-lifecycle CRD) andoperators.coreos.com/v1alpha1 Subscription(OLM) register the same plural. Always usekubectl get subscriptions.operators.coreos.comwhen checking OLM install state on a hub or spoke that also has ACM's app-lifecycle CRDs installed.- A duplicate
OperatorGroupin the same namespace silently breaks OLM resolution. If a namespace already has one (e.g. a leftovergitops-addon-operator-groupfrom a previous addon-managed install), creating a second one producesMultipleOperatorGroupsFoundand theSubscriptionnever progresses pastCatalogSourcesUnhealthy: false/ noInstallPlanat all — no obvious error on theSubscriptionitself, only on theOperatorGroup's own status conditions. Checkkubectl get operatorgroup -A -o yamlfor this condition before assuming a stuck install is a catalog or network problem.
# 1. Create
kind create cluster --name kind-cluster1
kind get kubeconfig --name kind-cluster1 > ~/Desktop/kind-cluster1
# 2. Register on hub
KUBECONFIG=~/Desktop/hub kubectl apply -f - <<'EOF'
apiVersion: cluster.open-cluster-management.io/v1
kind: ManagedCluster
metadata:
name: kind-cluster1
spec:
hubAcceptsClient: true
leaseDurationSeconds: 60
EOF
# 3. Import (CRDs FIRST, then import.yaml)
KUBECONFIG=~/Desktop/hub kubectl -n kind-cluster1 get secret kind-cluster1-import # wait for it
KUBECONFIG=~/Desktop/hub kubectl -n kind-cluster1 get secret kind-cluster1-import -o jsonpath='{.data.crds\.yaml}' | base64 -d | KUBECONFIG=~/Desktop/kind-cluster1 kubectl apply -f -
KUBECONFIG=~/Desktop/hub kubectl -n kind-cluster1 get secret kind-cluster1-import -o jsonpath='{.data.import\.yaml}' | base64 -d | KUBECONFIG=~/Desktop/kind-cluster1 kubectl apply -f -
# 4. Wait for Available
KUBECONFIG=~/Desktop/hub kubectl get managedcluster kind-cluster1 -w
# 5. Install governance addons (without these, Policies are NEVER enforced)
KUBECONFIG=~/Desktop/hub kubectl apply -f - <<'EOF'
apiVersion: addon.open-cluster-management.io/v1alpha1
kind: ManagedClusterAddOn
metadata:
name: governance-policy-framework
namespace: kind-cluster1
spec:
installNamespace: open-cluster-management-agent-addon
---
apiVersion: addon.open-cluster-management.io/v1alpha1
kind: ManagedClusterAddOn
metadata:
name: config-policy-controller
namespace: kind-cluster1
spec:
installNamespace: open-cluster-management-agent-addon
EOFGitOpsCluster deletion is a no-op (no finalizer). You must delete resources in this order:
- Delete ApplicationSet (removes ArgoCD apps)
- Delete GitOpsCluster (stops controller from re-creating MCAs)
- Delete Policy + PlacementBinding (stops ArgoCD CR enforcement on spokes)
- Delete MCAs for each managed cluster (triggers pre-delete cleanup Job)
- Wait for MCAs to fully delete (2-5 minutes)
- Delete Placement and remaining resources
- Optionally delete orphaned ADCs and PlacementDecisions
Key constraints: GitOpsCluster BEFORE MCAs. Policy BEFORE MCAs. ADC/AddOnTemplate must still exist when MCAs are deleted.
Confirm an Application is actually gone (not just --wait=false "issued") before deleting the
GitOpsCluster/agent/cluster-secret underneath it — for an agent-routed (post-migration) app,
skipping this can wedge it in Terminating forever. If the Application still carries
resources-finalizer.argocd.argoproj.io when you request its deletion, some ArgoCD app-controller
has to actually finish pruning against its spec.destination before the finalizer clears — for a
destination.name-based (agent-routed) app, that means the agent's own ArgoCD instance on the
spoke, not the hub. If you delete the GitOpsCluster/MCA (tearing down the agent) or the cluster
secret the destination name resolves through while that finalizer is still pending, nothing can
ever finish the prune, and the Application sits Terminating indefinitely with
status.conditions[].type: DeletionError referencing a destination that no longer exists.
Confirmed live: with two migrated Applications from two different test cycles both targeting a
cluster named ocp-cluster1, letting cycle 1's stuck deletion linger while cycle 2 created its own
(differently-shaped) ocp-cluster1-named cluster secret caused cycle 1's app-controller retry to
resolve ocp-cluster1 to cycle 2's secret and report DeletionErrors against a completely
unrelated fake URL — a confusing, misleading symptom that's really just "the destination this app
needs to reach doesn't exist by that name anymore." Once the real workload is independently
confirmed gone (e.g. manually verified on the spoke), it's safe to force-clear the stuck
finalizer directly (kubectl patch applications.argoproj.io <name> -n <ns> --type=merge -p '{"metadata":{"finalizers":null}}') — same "target confirmed dead, mechanism to clear it
naturally can't work anymore" reasoning as the documented ManagedClusterAddOn
addon-pre-delete-finalizer exception above. Prefer not needing this: always
kubectl delete applications.argoproj.io <name> -n <ns> --wait=true (or poll for NotFound)
before proceeding to the next cleanup step, not --wait=false.
ArgoCD auto-sync won't retry a revision that failed 5 times. Fix: delete Application copies on spoke clusters (strip finalizers first). Agent re-dispatches fresh copies.
Almost always the MCH pull-secret chain. Check: kubectl get secret open-cluster-management-image-pull-credentials -n openshift-gitops-operator on the spoke, decode it, verify registry.redhat.io is present. See "Pull Secret Chain" at top.
Missing secrets. Create a GitOpsCluster with argoCDAgent.enabled: true — the hub controller generates argocd-agent-ca, argocd-agent-principal-tls, argocd-agent-resource-proxy-tls, and argocd-agent-jwt. Principal auto-recovers.
Check replicated policy: kubectl get policy -n <cluster-name>. Common causes: openshift-gitops namespace doesn't exist yet (addon still installing operator), ArgoCD CRD not registered yet (operator starting up).
A ManagedClusterAddOn's addon.open-cluster-management.io/addon-pre-delete finalizer only
clears once the pre-delete cleanup ManifestWork/Job reports success back to the hub. If the
managed cluster is genuinely gone (deregistered, deleted, unreachable — e.g. a Kind cluster whose
container was removed outside of OCM's knowledge), that feedback can never arrive and the MCA (and
its addon Deployment/Job ManifestWorks) will sit deletionTimestamp-set forever. This is one
of the few legitimate exceptions to "never force-strip finalizers": once the target is confirmed
dead (not just slow — check ManagedCluster Available condition and try reaching the spoke's
own kubeconfig directly first), delete the addon's ManifestWorks in that cluster's namespace and
then merge-patch the MCA's finalizers to [] to unblock cleanup. If the whole ManagedCluster
is also being decommissioned, deleting the ManagedCluster object itself (which tears down its
namespace) is cleaner than clearing individual finalizers one at a time.
StorageError: invalid object, UID mismatch after rapid delete/recreate. Fix:
kubectl delete lease multicloud-operators-gitopscluster-leader.open-cluster-management.io -n kube-system --ignore-not-found
kubectl -n ocm rollout restart deployment/multicluster-operators-application- DBM mismatch: Principal has
destinationBasedMapping: truebut agent doesn't. Fix: addspec.argoCDAgent.agent.destinationBasedMapping.enabled: trueto agent ArgoCD CR in Policy (sibling ofagent.client, not nested inside it — confirmed againstgenerateArgoCDSpecinargocd_policy.goand a live managed cluster'sArgoCDCR). - Agent SA missing view ClusterRole:
kubectl create clusterrolebinding acm-openshift-gitops-argocd-agent-cluster-reader --clusterrole=view --serviceaccount=openshift-gitops:acm-openshift-gitops-agent-agenton spoke.
Hybrid Mode: local-cluster's ArgoCD UI Resource-Tree/Manifest Drill-Down Is Broken (upstream argocd-agent bug, not fixed here yet)
Symptom: in an ApplicationSet that targets both local-cluster and real managed clusters (the
standard hybrid-mode pattern), the managed-cluster app's resource tree/live manifest drill-down
works fine in the ArgoCD UI, but local-cluster's does not — clicking into it shows no
nodes/manifest data. Sync and health status on the Application object look completely normal the
whole time (Synced, real health), which is what makes this easy to miss: it's specifically the
UI's resource-tree/manifest API that's broken, not reconciliation.
Confirmed live against the actual ArgoCD /api/v1/applications/{app}/resource-tree endpoint the
UI calls (not just the Application's status fields) — this is a real, 100%-reproducible bug in
the deployed argocd-agent principal (v0.9.0, logged at principal startup as Starting argocd-agent (server) v0.9.0), not a bug in this repo's code, and not flakiness (ruled out:
stale/corrupted Redis data — the cached blob decompresses to valid JSON when read directly with
redis-cli; a stale connection pool — a fresh argocd-server restart reproduces it immediately;
version skew — argocd-server and application-controller run the identical image digest):
- With
destinationBasedMapping: true, the principal'snewAppCallbackandupdateAppCallback(principal/callbacks.goin argocd-agent) both calltrackAppToAgent(app, app.Spec.Destination.Name)to record which agent an Application's Redis cache traffic should be routed to. Neither validated thatDestination.Nameactually corresponds to a connected agent in the deployed v0.9.0 (noclusterMgr.HasMapping()check in either callback). Upstreammainhas since closed the direct create/update path: commit0554ad8("fix: cleanup agent resources after the deletion of the agent cluster secret", #1034, merged 2026-07-29) added aHasMappingguard to bothnewAppCallbackandupdateAppCallback's own bodies — so a freshlocal-clusterApplication created withdestination.name: local-clusterfrom the start is already safe on currentmain(still not on any released version, including the deployed v0.9.0). The guard does not coverhandleAppAgentChange(also inprincipal/callbacks.go, called from insideupdateAppCallbackbefore its own guard runs), which handles the case where an existing app'sdestination.Namechanges — e.g. an ApplicationSet template edit, or any directkubectl patch, moving an app from a real agent's name ontolocal-cluster. That function still callstrackAppToAgentfor the new agent name unconditionally, so the bug persists specifically for apps that get migrated ontolocal-clusterafter creation, even on currentmain. Reproduced 100% with a from-scratch Kind-based argocd-agent-only setup (no ACM/OCM): create an app targeting a real agent, confirm its resource tree loads, then patchspec.destination.nametolocal-cluster— the resource tree breaks immediately after. A fix (add the sameHasMappingguard tohandleAppAgentChange's new-agent branch) has been written and verified end-to-end (bug reproduced pre-fix, confirmed resolved post-fix, twice, across full Kind-cluster teardown/rebuild cycles) but exists only as an unpushed local commit (a2af478) on a fork checkout at/home/ming/argocd-agent— it is not part of any upstream PR,main, or release yet. Sincelocal-cluster's Application legitimately hasdestination.name: local-cluster(a real, valid cluster secret — seeensureLocalClusterSecret— just not an agent-connected one), this poisons the principal's in-memoryappToAgentmap for it as soon as a destination-changing update lands (which happens quickly after creation whenever an ApplicationSet template is what actually setsdestination.name: local-cluster, since the generator/template rendering path goes through an update rather than staying on the original create). - The principal's Redis proxy (
principal/redisproxy/redisproxy.go,extractAgentNameFromRedisCommandKey) consults that same poisoned map for everyapp|resources-tree|.../app|managed-resources|...Redis key belonging to that app, and — believinglocal-clusteris an agent — tries to forward the read to that (nonexistent) agent connection instead of the principal's own Redis. The agent never responds (confirmed in principal logs:msg="no response from agent" ... getKey="app|resources-tree|guestbook-local-cluster|..."followed bymsg="exit due to unable to handle agent get" error="unexpected nil in get response"), and argocd-server ends up gunzip'ing an effectively-empty response, producing exactlyerror getting cached app resource tree: EOF— an empty byte slice hittinggzip.NewReader()returnsio.EOF, which matches precisely. - This is a name collision, not a DBM correctness issue: the moment
destination.Nameis non-empty and matches any real cluster secret name (agent-connected or not), the bug fires. A real spoke's Application is unaffected only because for spokes, being routed to its own agent is the correct behavior — the missing validation happens to not matter there. Restartingargocd-server, hard-refreshing the Application, or waiting does not clear it, because the poisoned mapping lives in the principal's in-memory state, not in argocd-server or Redis itself — nothing in the normal reconcile/refresh loop ever un-poisons it once set. - Confirmed (but not yet implemented anywhere) workaround: give
local-cluster's generated Application adestination.server: https://kubernetes.default.svcinstead ofdestination.name: local-cluster— both resolve to the exact same physical destination. SincegetAgentNameForAppreturnsapp.Spec.Destination.Namedirectly when DBM is enabled, an emptyDestination.Name(server-based destination) means the resolved agent name is"", and both callbacks' existingif agentName == "" { return }guard correctly skipstrackAppToAgententirely for it. Verified live: 10/10 consecutive resource-tree fetches succeeded after manually switching an ApplicationSet's template to this shape (usinggoTemplate: trueand a Go-template conditional keyed on the cluster name), versus 0/10 before, with the running workload's pod UID confirmed unchanged across the switch (no disruption). This is deliberately not yet applied togitopsaddon/test-cycle.shor anywhere else in this repo — the real fix belongs upstream in argocd-agent (adding the missingHasMappingguard tohandleAppAgentChange's new-agent branch — see above; a candidate fix already exists as an unpushed local commit, not yet a PR), and that upstream work is still to be done. Only every other (real, agent-routed) cluster should ever usedestination.name— switching those to server-based destinations too would break event dispatch to their agents entirely, since destination name is what the principal's dispatch queueing keys off of, not just the Redis proxy lookup. - Nothing in
pkg/controller/gitopsclustercan fix this: no code in this repo generates the ApplicationSet'sdestinationfield — that YAML is authored by whoever creates the ApplicationSet (a real user, orgitopsaddon/test-cycle.sh's own hardcoded template for testing). A code-level fix is not possible from this repo's side; only a workaround at the ApplicationSet-authoring layer (see above) or an upstream argocd-agent fix resolves it.
- Check principal pod:
kubectl get pods -n openshift-gitops -l app.kubernetes.io/component=principal - Check principal logs:
kubectl logs -n openshift-gitops -l app.kubernetes.io/component=principal --tail=50 - Check agent logs on spoke:
kubectl logs -n openshift-gitops -l app.kubernetes.io/part-of=argocd-agent --tail=50 - If the agent log shows
[FATAL]: Error creating remote: could not read secret: secrets "argocd-agent-ca" not found— see "Expected Waits (Not Bugs)" above; this is almost always the hub→spoke CAManifestWorkpropagation still in flight, not a config error.
Basic Pull Model: Blank Cluster Secret Creation Fails ("found existing non-ACM ArgoCD clusters secrets")
Symptom: GitOpsCluster status shows ClustersRegistered: False, message found existing non-ACM ArgoCD clusters secrets for cluster: <name>. Cause: a stale/orphaned cluster secret for that
cluster already exists in the ArgoCD namespace — most commonly an old argocd-agent secret
(argocd-agent.argoproj-labs.io/agent-name label, managed-by: argocd-agent annotation) left
behind by a previously deleted GitOpsCluster that had agent mode enabled for the same cluster
name. The blank-secret creation path refuses to overwrite a secret it doesn't recognize as its own
placeholder. Fix: confirm the existing secret really is orphaned (its owning GitOpsCluster is
gone), then delete it — the next reconcile creates a fresh blank secret.
An ACM hub has more than one CRD registered under the bare kind Application (ArgoCD's
applications.argoproj.io and the older applications.app.k8s.io from the classic
subscription/channel model). An unqualified kubectl get application can resolve to the wrong one
and print No resources found even though the ArgoCD Application you're looking for exists.
Always use the fully-qualified kubectl get applications.argoproj.io when inspecting pull-model
or agent-mode Applications on a real hub.
Symptom: an autonomous-mode Application mirrored to the hub flips status.sync.status back to
Unknown on every refresh cycle even though the spoke's own copy is genuinely Synced, and the hub
app-controller logs error getting app project "<agent>-default": appproject.argoproj.io "<agent>-default" not found for it. This is a real ArgoCD version gap, not a bug in this repo's
code: skip-reconcile on the agent's cluster secret (confirmed correctly set by
CreateArgoCDAgentClusters for every agent-connected cluster, managed or autonomous) and the
principal's destination-rewrite to that named secret (confirmed correct in argocd-agent's
principal/event.go) are both wired correctly - but versions of ArgoCD prior to
argo-cd#26442 (merged 2026-02-19, reported as
argo-cd#26425) only had skip-reconcile gate
the sync/reconcile-action trigger, not the periodic status-refresh/comparison path - so the hub's
own app-controller still attempts to resolve the mirrored app's spec.project (rewritten by the
principal to <agent>-<project>, which only ever exists on the spoke for autonomous mode) and
errors on every attempt. Fix is an ArgoCD version bump to one that includes that PR - not a hub
app-controller toggle or a code change here. Confirm which ArgoCD version you're running via the
application-controller pod's startup log (level=info msg="ArgoCD Application Controller is starting" ... version=vX.Y.Z).
The gitopscluster controller (hub) creates: AddOnDeploymentConfig (env vars per cluster), ManagedClusterAddOn (triggers addon deployment), AddOnTemplate (agent manifests), ArgoCD Policy (ArgoCD CR enforcement), PlacementBinding, cluster secrets (with agentName for agent routing).
The gitopsaddon agent (spoke) detects cluster type (OCP/non-OCP/hub), installs the GitOps operator, waits for Policy-deployed ArgoCD CR, patches ServiceAccounts with pull secrets (non-OCP only).
OCP and non-OCP managed clusters run different operator versions:
-
OCP: gitopsaddon creates an OLM Subscription (
latestchannel,redhat-operatorscatalog). OLM resolves to whatever version is current in the catalog. All component images (operator, ArgoCD, redis, dex) are determined by the OLM CSV. The hub controller has zero control over these. When Red Hat publishes a new version, OCP clusters auto-upgrade. -
Kind/EKS (non-OCP): gitopsaddon deploys the embedded Helm chart (
gitopsaddon/charts/openshift-gitops-operator/). The version is inChart.yaml. All component images are hardcoded SHA digests inpkg/utils/config.goDefaultOperatorImages. These never change until someone runs the Manifest Update Procedure. -
Agent image is the exception: On both OCP and Kind, the argocd-agent image comes from the hub controller's drift heal — it reads the principal pod's image and writes it into each managed cluster's
AddOnDeploymentConfigARGOCD_AGENT_IMAGE(not the shared ArgoCD Policy). So the agent image is always consistent with the hub principal regardless of spoke operator version, and still flows through per-clusterManagedClusterImageRegistrymirroring. Annotate the GitOpsCluster withskip-agent-version-heal: "true"to opt out; a user-set Policyspec.argoCDAgent.agent.imageis never overwritten either way. -
The version gap means: the embedded operator on Kind/EKS may lack features, CRD fields, or RBAC rules added in newer OCP versions. When bumping, update:
Chart.yamlversion, CRDs (extract from hub), ClusterRole rules (from CSV), image SHAs inconfig.go. See Manifest Update Procedure. -
Manifest update lessons: (1) ClusterRole rules extracted from CSV contain
*wildcards that MUST be quoted as"*"in YAML — unquoted*is a YAML alias and causes silent parse failures (the ClusterRole is never created, operator crashes with informer cache sync timeouts). (2) Route and Monitoring CRD stubs are NOT needed — the operator has proper API discovery (argoutil.VerifyAPI()/InspectCluster()) that gracefully skips Route and Monitoring watches when the APIs don't exist on non-OCP. (3) Runtest-cycle.shfor at least 2 cycles to catch inter-cycle state issues. (4) Do NOT modify anything from the source of truth — if the CSV has duplicate rules, empty apiGroups, OLM labels on CRDs, or other quirks, keep them in the chart exactly as extracted. The chart must match the source of truth (hub cluster, orb bundle, upstream repos). Fixing upstream issues in the chart causes it to go out of sync and makes future verification impossible. The same applies to CRD metadata labels — if the hub CRDs haveoperators.coreos.comlabels, keep them. The gitopsaddon label (apps.open-cluster-management.io/gitopsaddon: "true") is added at deploy time bytemplateAndApplyChart, not in the chart YAML itself.
| Mode | Template Name | When Used |
|---|---|---|
| Static | gitops-addon |
gitopsAddon.enabled=true, argoCDAgent.enabled=false |
| Dynamic | gitops-addon-{ns}-{name} |
argoCDAgent.enabled=true (includes RegistrationSpec for client cert provisioning) |
| Aspect | Managed | Autonomous |
|---|---|---|
| Source of truth | Hub (principal) | Spoke |
| App creation | Hub; principal dispatches | Spoke (Policy/Git/kubectl) |
| Hub capability | Full control | Read-only mirror |
| AppProject in Policy | Not included (principal propagates) | Included |
| Best for | Centralized control | App of Apps, GitOps-first |
The hub runs a single ArgoCD instance in openshift-gitops that hosts both the argocd-agent
principal (dispatching to managed clusters) and its own application controller (controller.enabled: true) reconciling anything targeting local-cluster/in-cluster directly. There is no separate
ArgoCD instance or namespace for the hub — that legacy design (a dedicated local-cluster
namespace with its own agent-connected ArgoCD instance) has been removed.
- Isolation mechanism: every agent-routed cluster secret (
cluster-<name>for real spokes) carriesargocd.argoproj.io/skip-reconcile: "true", so the hub's app-controller ignores anything dispatched to an agent and lets the principal own it exclusively.local-cluster's own cluster secret (cluster-local-cluster, created byensureLocalClusterSecretinargocd_agent_clusters.go) carries no skip-reconcile and no agent-name label — it's a plain cluster secret pointing athttps://kubernetes.default.svc, so the hub app-controller reconciles it like any other in-cluster ArgoCD destination. local-clusteris never an addon-install target: it already has GitOps operator + ArgoCD installed independently (a prerequisite, not addon-managed). TheGitOpsCluster's ownplacementRefmust always excludelocal-cluster— the addon-install loop (CreateAddOnDeploymentConfig/EnsureManagedClusterAddon) and the ArgoCD-CR-enforcing Policy both skip/must never select it (IsLocalClusterguards this ingitopscluster_controller.go/argocd_agent_clusters.go; a warning is logged if the Placement ever resolves to include it, since the Policy would otherwise deploy a second, conflicting ArgoCD CR intoopenshift-gitops).- ApplicationSets targeting
local-cluster: use a Placement separate from the GitOpsCluster's ownplacementRef— this one is free to includelocal-clusteralongside real managed clusters. The generated Application resolvesdestination: {name: 'local-cluster'}against the plaincluster-local-clustersecret and gets created inopenshift-gitops, reconciled by the hub's app-controller. - RBAC: the operator's default ClusterRole for the app-controller is read-only outside
openshift-gitopsitself. Any Application targetinglocal-clusterneeds the hub app-controller's own ServiceAccount (openshift-gitops-argocd-application-controller) granted elevated RBAC (e.g.cluster-admin, matching the same pattern already used for spoke app-controllers) in whatever destination namespaces those apps create — do this before creating the AppSet, or the first sync attempt exhausts ArgoCD's 5-retry budget and self-heal won't retry a revision that already failed that many times.
The original (2023) integration, predating argocd-agent and still shipping by default alongside
it — the two are intentionally coexisting alternatives, not a deprecated/replacement pair.
Nothing in the code or git history marks the pull model itself as deprecated (only the
MulticlusterApplicationSetReport v1alpha1 CRD carries a Kubernetes-convention deprecation
warning — a data-model sunset note, not a statement that the mechanism is going away).
Key distinction from argocd-agent: no new agent process, no gRPC, no principal/cert
machinery. It reuses the klusterlet work-agent every managed cluster already has (the same
channel used for Policies and classic Subscriptions) via the standard ManifestWork API.
Data flow:
- Hub creates a blank/dummy cluster secret per target cluster (
createBlankClusterSecrets, forcedtruewhenevergitopsAddonis enabled) —server: https://<cluster>-control-plane, a deliberately fake/unreachable URL. Its only purpose is making ArgoCD'sApplicationSetclusterDecisionResourcegenerator treat the cluster as a valid destination; nothing ever actually connects to that URL. - The propagation controller (
propagation-controller/application/, part of themulticluster-integrationsdeployment) watchesApplicationobjects cluster-wide for labelapps.open-cluster-management.io/pull-to-ocm-managed-cluster: "true"plus annotationapps.open-cluster-management.io/ocm-managed-cluster: <cluster-name>. On match, it wraps the Application (destination rewritten tohttps://kubernetes.default.svc, i.e. "reconcile in-cluster" — meaning in the spoke's own cluster once delivered) plus itsNamespaceandAppProject(skipped if it's just the defaultdefault/openshift-gitopspair, since every ArgoCD install already has that) into aManifestWorkin the target cluster's hub namespace, withFeedbackRulesrequesting health/sync/operation status back. - The spoke's own existing klusterlet work-agent pulls that
ManifestWorkdown and applies it — no new network channel, no addon-specific delivery path. - The spoke's own ArgoCD (installed by
gitopsaddon, same as agent mode, just without theargoCDAgentblock) reconciles the delivered Application against its local API server. - Status returns two ways:
ManifestWork.status.resourceStatusfeedback is consumed bymulticlusterstatusaggregation, merged with fine-grained detail polled from ACM'ssearch-search-apibygitopssyncresc(communicated between the two sidecar containers via a sharedemptyDir, not an API), into aMulticlusterApplicationSetReportCR — and separately,application_status_controller(samepropagation-controllerbinary) watches that report and reflects health/sync/operation state directly onto the hub's own placeholderApplicationobject, plus a syntheticAdditionalStatusReportcondition pointing users at the report and the spoke's own Application for full detail. - Only Applications generated by an
ApplicationSet(i.e. carrying anApplicationSetownerReference) get theapps.open-cluster-management.io/application-setlabel on theirManifestWork— a hand-created, non-ApplicationSet-ownedApplicationstill gets pulled and reconciled, but produces noMulticlusterApplicationSetReport(multiclusterstatusaggregationonly listsManifestWorks carrying that label).
Coexistence with hybrid mode / argocd-agent: if the hub's own app-controller is enabled
(hybrid mode, controller.enabled: true) and the pull-model placeholder Application's
destination.server is the in-cluster address, the hub app-controller would otherwise try to
reconcile that same placeholder locally, on top of the propagation controller delivering it to the
spoke. Set argocd.argoproj.io/skip-reconcile: "true" directly on the pull-model Application
itself (not just on cluster secrets — this is the same generic upstream ArgoCD annotation, applied
at a different level) to keep the hub's copy inert.
OCP-only: this integration is supported only for OCP managed clusters. Both OCP and non-OCP
spokes get a real ArgoCD instance from gitopsaddon, but the pull model has not been validated
against the non-OCP embedded-chart path.
local-cluster support is currently dormant, not actively gated: an earlier iteration
supported pulling Applications onto local-cluster itself, via a dedicated gitopsaddon-deployed
ArgoCD instance in a local-cluster namespace. That dedicated instance was removed when hybrid
mode replaced it with the plain in-cluster cluster-local-cluster secret (see "Hybrid Mode"
above), and the e2e coverage for pull-model-on-local-cluster was deleted at the same time. No
explicit code-level guard currently blocks targeting local-cluster with a pull-model Application
(unlike the Maestro variant, which explicitly skips it), but the mechanism it used to depend on no
longer exists in the same shape — treat this path as unverified.
pkg/controller/gitopscluster/— Hub controller (reconciler, policy gen, certs, addon mgmt, agent clusters)gitopsaddon/— Addon agent (install logic, controller, cleanup, secret controller, embedded Helm chart)gitopsaddon/charts/openshift-gitops-operator/— Embedded operator for non-OCPdeploy/crds/— Hub-side CRDs generated bycontroller-gen(GitOpsCluster, etc.) + some legacy operator CRDs. NOT used for embedded chart deployment — onlygitopsaddon/charts/.../templates/crds/matters for spoke operator install.test/e2e/gitopsaddon/— Ginkgo e2e tests- Reference copies of upstream repos (argocd-agent, gitops-operator, argocd-operator) are useful for cross-referencing during manifest updates — clone them fresh with
git clonewhen needed. Clone them OUTSIDE the repo tree (e.g./tmp), never into the repo root. Each has its owngo.mod, butmake manifests(a dependency of everytest-e2e-gitopsaddon-*/test-local-e2e-gitopsaddon-*target) runscontroller-gen crd webhook paths="./...", which does NOT respect nested-module boundaries the waygo build ./...does — if these repos are present under the repo root, controller-gen scans their vendored API types too and silently regenerates unrelated/bogus CRDs intodeploy/crds/(e.g.argoproj.io_argocds.yaml, a newargoproj.io_argocdexports.yaml). Always checkgit statusfor unexpecteddeploy/crds/changes after running any e2e target if reference repos were ever cloned into the tree, even temporarily. Do not edit these reference copies.
skip-agent-version-heal: "true"— Disables writing the hub principal image into each cluster's AddOnDeploymentConfigARGOCD_AGENT_IMAGE, and omits that variable from the managed ADC set entirely, so a user-set Policyspec.argoCDAgent.agent.imageor a manually-set ADC value is not overwritten. Does not touch the ArgoCD Policy (the controller never writes or clears Policy agent.image).skip-argocd-policy: "true"— Prevents Policy recreation. Independent of agent-image heal (heal writes ADC, not Policy).skip-hub-ca-propagation: "true"— Skips Hub CA via ManifestWork
apps.open-cluster-management.io/gitops-addon-image-mirroring: "true"— Opt-in. Without this, the gitops-addon image-registry controller ignores the CR (ACM's own cluster-image-registry-controller still uses it for klusterlet images). Existing disconnected-registry setups are unchanged until a user explicitly annotates the CR. Removing the annotation reverts any gitops-addon ADC mirroring this controller previously applied.
Controller watches the principal Deployment (app.kubernetes.io/component=principal) for image changes. When the image changes (operator upgrade), it writes the live principal image into each managed cluster's own AddOnDeploymentConfig ARGOCD_AGENT_IMAGE — never the shared ArgoCD Policy. That ADC value then flows through the same per-cluster ManagedClusterImageRegistry mirroring pipeline as every other addon image. findContainerImage(containers, deploymentName) checks: (1) container named "principal", (2) container matching deployment name, (3) containers[0].
skip-agent-version-heal: "true" on the GitOpsCluster opts this out: HealAgentVersionDrift returns empty, and ExtractVariablesFromGitOpsCluster drops ARGOCD_AGENT_IMAGE from the managed set so CreateAddOnDeploymentConfig will not overwrite a user-set ADC value. reconcileArgoCDPolicyAgentSpec never writes or clears spec.argoCDAgent.agent.image, so users who want to pin the agent image via Policy can do so whether or not the skip annotation is set.
A dedicated controller (imageregistry_controller.go) rewrites gitops-addon AddOnDeploymentConfig image variables to disconnected-registry mirrors. It is opt-in: only a ManagedClusterImageRegistry annotated apps.open-cluster-management.io/gitops-addon-image-mirroring: "true" is watched or reconciled. CreateAddOnDeploymentConfig preserves already-mirrored values when the recorded original still matches the desired source, so the two controllers do not fight over the ADC on the steady-state path; a genuine source change (hub image upgrade / principal heal) is written through so this controller can re-mirror it.
argocd-agent-client-tls has 24-hour validity. OCM ClientCertController rotates at ~80% lifetime. SecretReconciler propagates via: source watch (immediate), target deletion watch (instant re-copy), periodic requeue (5 min safety net). On actual cert data change, rolling-restarts all agent Deployments (app.kubernetes.io/part-of=argocd-agent).
argocd-agent-resource-proxy-tls cert must include the resource-proxy service DNS name. getResourceProxyHostNames calls FindArgoCDAgentResourceProxyService directly. deleteSecretIfSANsDrifted detects missing SANs and deletes the secret for re-issuance. Workaround: kubectl delete secret argocd-agent-resource-proxy-tls -n openshift-gitops.
Preferred: resource proxy URL https://...-resource-proxy.<ns>.svc:9090?agentName=<cluster>. Fallback: NodePort/LoadBalancer address. Routing by argocd-agent.argoproj-labs.io/agent-name label.
Agent cluster secrets are stamped with argocd.argoproj.io/skip-reconcile: "true". This tells the ArgoCD application controller on the hub to skip reconciliation for applications targeting that cluster — the agent handles reconciliation instead. Required for hybrid mode where a traditional app-controller coexists with the argocd-agent principal on the same hub. Without it, both the hub app-controller and the agent try to reconcile the same apps, causing conflicts. Set automatically by CreateArgoCDAgentClusters — no manual action needed.
Creating agent mode after non-agent creates duplicate cluster names. CreateArgoCDAgentClusters calls deleteBlankClusterSecretsForCluster to remove old blank pull-model secrets.
Always in openshift-gitops-operator ns. OperatorGroup must be AllNamespaces (not OwnNamespace). patchArgoCDCRDConversionWebhookIfNotReady patches CRD conversion to None before subscription. recoverFromFailedWebhookInstallPlan handles failed InstallPlans from webhook timeout.
gitops-addon ClusterRole (fine-grained, not cluster-admin). Defined in both addonTemplates.yaml (static) and addon_template_management.go (dynamic). Separate gitops-addon-cleanup ClusterRole annotated addon-pre-delete for cleanup Job RBAC. Includes escalate/bind for operator leader-election Role.
Both EnsureAddOnTemplate and the AddOnDeploymentConfig path in addon_management.go rebuild
their desired object from scratch every reconcile, then must compare against what's already
stored before calling Update() — an unconditional Update() bumps resourceVersion (and, for
spec changes, .metadata.generation) even when nothing meaningfully changed, and the OCM addon
framework treats a generation bump on an AddOnTemplate as "the workload changed," re-rendering
and rolling every managed cluster's addon Deployment in response. Confirmed on a real hub: an
AddOnTemplate left running for ~6 days accumulated generation 4157 (~2 min/update) purely from
this pattern, with zero real config change during that window.
How to design the comparison, not just "add one": don't reflexively deep-Equal the whole
built object — trace which of its inputs are actually capable of changing across reconciles of
the same parent resource. Identity fields (name/namespace-derived) and anything hardcoded are
permanently constant once the object exists; only genuinely external, mutable inputs (a
controller's own image, a user-editable CR field) are worth comparing, and usually there are only
one or two. Comparing just those is cheaper, isn't fooled by incidental formatting/ordering noise
in unrelated parts of the object, and can't produce false "changed" reports that bring back the
same churn under a different guise. customizedVariablesEqual (order-insensitive map comparison)
is the existing pattern for AddOnDeploymentConfig; addOnTemplateNeedsUpdate (direct comparison
of just the image and CA namespace, extracted with a narrow targeted JSON decode rather than a
full manifest unmarshal) is the pattern for AddOnTemplate.
A stale map-iteration order can independently defeat any such guard. Building an ordered
[]corev1.EnvVar (or any positional/ordered output) by ranging directly over a map[string]string
picks up Go's intentionally-randomized iteration order — the same content can serialize in a
different order on every single call, which a naive equality check (or even a correct one, if it
compares raw bytes/ordered slices instead of decoded/set content) will see as "changed" forever.
Sort the keys before iterating whenever the output must be stable across calls, independent of
whether anything is currently comparing it — otherwise a future correctness fix elsewhere can
silently stop working the moment it starts relying on this output being stable.
Verifying a no-op-update fix on a live, leader-elected controller: kubectl rollout status
reporting a deployment fully rolled out does NOT mean the new pod has taken over reconciling -
leader election makes the new pod wait out the previous holder's full lease duration
(leaseDuration, minutes) before "Successfully acquired lease" appears in its logs, regardless of
how quickly the old pod terminated. Check for that log line (or an actual "Process <resource>"
line) before concluding a just-applied config change didn't take effect - the more likely
explanation is the new pod simply hasn't started reconciling yet.
NEVER list-and-delete blindly. Only delete: (1) gitopsaddon-labeled ArgoCD CRs (waits for operator finalizer), (2) OLM Subscription/CSV on OCP, (3) gitopsaddon-labeled operator resources on embedded. ArgoCD workloads are the operator's responsibility. Never force-strip ArgoCD CR finalizer. On OCP, skip deleteOperatorResources — OLM handles it. templateAndApplyChart labels all resources with apps.open-cluster-management.io/gitopsaddon: "true".
spec.argoServer.argoNamespace overrides the namespace. GetEffectiveArgoNamespace() must be used for all operations (cert gen, CA propagation, service discovery).
Hub multiclusterhub-operator-pull-secret (in multicluster-engine + open-cluster-management) → managedcluster-import-controller bakes into {cluster}-import secret → klusterlet copies as open-cluster-management-image-pull-credentials → addon copies to ArgoCD namespaces → patchServiceAccountsWithImagePullSecrets patches all SAs. installViaEmbeddedManifests patches operator SA immediately after chart apply, deletes ImagePullBackOff pods.
# Build and push
docker build -t quay.io/stolostron/multicloud-integrations:latest -f build/Dockerfile .
docker push quay.io/stolostron/multicloud-integrations:latest
# Redeploy on hub
kubectl -n open-cluster-management set image deployment/multicluster-operators-application \
multicluster-operators-gitopscluster=quay.io/stolostron/multicloud-integrations:latest
kubectl -n open-cluster-management rollout restart deployment multicluster-operators-application
# Prevent MCH operator revert
kubectl -n open-cluster-management scale deploy multiclusterhub-operator --replicas=0
# Set CONTROLLER_IMAGE for dynamic AddOnTemplates
kubectl -n open-cluster-management set env deployment/multicluster-operators-application \
-c multicluster-operators-gitopscluster CONTROLLER_IMAGE=quay.io/stolostron/multicloud-integrations:latest
# Hub controller imagePullPolicy (for :latest tag)
kubectl get deploy multicluster-operators-application -n open-cluster-management -o json | \
python3 -c "import json,sys; d=json.load(sys.stdin); [c.__setitem__('imagePullPolicy','Always') for c in d['spec']['template']['spec']['containers'] if c['name']=='multicluster-operators-gitopscluster']; json.dump(d,sys.stdout)" | kubectl apply -f -Hub cluster is the source of truth. Upstream repos (redhat-developer/gitops-operator, argoproj-labs/argocd-operator) are for cross-referencing — clone them fresh, OUTSIDE the repo tree (e.g. /tmp or ~/), each time you need them (see the deploy/crds pollution hazard and the bundle/manifests staleness warning below). Use orb for independent bundle verification.
Always start from the source of truth (hub cluster / orb bundle / upstream repo), enumerate everything it has, then check whether the chart matches. Never start from the chart and ask "does this look right" — that misses resources that should exist but don't, and resources that should be deleted but still exist. The source tells you what SHOULD be there; the chart is what you're validating.
CRDs, ClusterRole, images, and Deployment env vars are obvious. But also check: leader-election Role rules, ConfigMap content, ServiceAccount changes, new/removed Services, new env vars in the Deployment template, new ports, new volumes, changed args/probes. The OLM bundle restructures RBAC (hashes names, combines roles) — the chart keeps them separate with readable names. Don't blindly copy OLM structure.
"Content, not count" — a rule count or file-existence match is not verification. Two RBAC rule sets with the same count can differ by a rename/removal+addition pair that cancels out numerically. Every comparison in this procedure must be a normalized content diff (compare apiGroups/resources/verbs/resourceNames sets, or full YAML equality after stripping only genuinely-dynamic fields) with an explicit list of "only in old" / "only in new" — never just "44 rules on both sides, looks fine."
The upstream repo's checked-in bundle/manifests/ can be STALE — do not trust it for CSV/RBAC/resource content. When cloning gitops-operator for cross-referencing, checking out the release branch (e.g. v1.21) does NOT guarantee bundle/manifests/gitops-operator.clusterserviceversion.yaml reflects the current released z-stream — it can be a stale fixture left over from a much earlier version (observed: a v1.21 branch checkout's bundle CSV showing version: 1.8.0). The live hub cluster CSV (kubectl get csv <name> -n openshift-gitops-operator -o yaml) is the only trustworthy source for RBAC rules, Deployment spec, images, ConfigMap/Service content — always extract from there, never from a cloned repo's bundle/manifests/. Only use the cloned repo for things that are genuinely version-controlled Go source (e.g. controllers/util/util.go's API-discovery logic), where git blame/branch state is meaningful.
Complete file inventory of gitopsaddon/charts/openshift-gitops-operator/templates/ (verified individually against a live hub multiple times across different bumps — use this as the checklist for every bump, not just the first one):
| File | Live hub equivalent | How it was verified |
|---|---|---|
crds/*.crd.yaml (14 files) |
kubectl get crd <name> -o yaml |
Full content diff after stripping runtime metadata + OLM-injected olm.managed/operatorframework.io/* labels (these are added by OLM's live reconciliation, NOT part of the source CRD — strip them; but do NOT strip operators.coreos.com/* labels, deprecated/deprecationWarning version fields, or anything else that's part of the real schema) |
openshift-gitops-operator-manager-role.clusterrole.yaml |
CSV spec.install.spec.clusterPermissions[0].rules |
Full rule-set diff (see "content, not count" above) |
openshift-gitops-operator-leader-election-role.role.yaml |
CSV spec.install.spec.permissions[0].rules |
Full rule-set diff |
openshift-gitops-operator-controller-manager.deployment.yaml |
CSV spec.install.spec.deployments[0].spec |
Full diff of args, env vars, ports, probes, volumes, securityContext against the intentional-differences list below |
openshift-gitops-operator-controller-manager.serviceaccount.yaml |
kubectl get sa openshift-gitops-operator-controller-manager -n openshift-gitops-operator -o yaml |
Name match; chart's extra imagePullSecrets: open-cluster-management-image-pull-credentials is an intentional non-OCP addition (OLM instead auto-injects its own internal-registry pull secret via annotation — OCP/OLM-only mechanism, not needed on the chart) |
openshift-gitops-operator-manager-rolebinding.clusterrolebinding.yaml |
ClusterRoleBinding with olm.owner: <csv-name> label bound to the hashed manager ClusterRole |
Confirms same roleRef target (by content, not name) + same subject SA |
openshift-gitops-operator-leader-election-rolebinding.rolebinding.yaml |
RoleBinding named <csv-name> in the operator namespace |
Confirms same roleRef target + same subject SA |
openshift-gitops-operator-manager-config.configmap.yaml |
kubectl get cm openshift-gitops-operator-manager-config -n openshift-gitops-operator -o yaml |
Byte-identical data content confirmed. Note: this file is scaffolding from controller-runtime's default manager options and is NOT actually wired to the Deployment via a --config flag — the real config comes from the CLI args, which win. Harmless to keep in sync but functionally inert either way. |
openshift-gitops-operator-webhook-service.service.yaml |
kubectl get svc openshift-gitops-operator-webhook-service -n openshift-gitops-operator -o yaml |
Port/targetPort/selector match exactly |
openshift-gitops-operator-metrics-service.service.yaml |
kubectl get svc openshift-gitops-operator-metrics-service -n openshift-gitops-operator -o yaml |
Pre-existing, long-standing mismatch, NOT version-bump-related — do not "fix" during a routine bump: chart names it openshift-gitops-operator-controller-manager-metrics-service (source: openshift-gitops-operator-metrics-service), port name https vs source metrics, targetPort: 8080 (numeric) vs source targetPort: metrics (named, resolves to the container's containerPort: 8443, name: metrics). None of this matters functionally: the manager process only binds 127.0.0.1:8080 (loopback), so no Service — regardless of exact shape — can actually reach it from outside the pod. This Service has been a functionally-inert stub across multiple bumps already, consistent with the --metrics-bind-address=127.0.0.1:8080 exception below. |
openshift-gitops-operator-metrics-reader.clusterrole.yaml |
No live equivalent by content search | Fully inert: no ClusterRoleBinding anywhere in the chart references it, so it grants nothing to anyone. Its one rule (nonResourceURLs: [/metrics], verbs: [get]) is also already present verbatim inside the 44-rule manager ClusterRole. Vestigial kubebuilder scaffolding; harmless, pre-existing, not touched. |
openshift-gitops-operator-proxy-role.clusterrole.yaml + ...-proxy-rolebinding.clusterrolebinding.yaml |
No live equivalent by content search | Active but fully redundant: correctly binds the manager SA, but both of its rules (tokenreviews: create, subjectaccessreviews: create) are already present verbatim inside the 44-rule manager ClusterRole via the same binding. Leftover from the classic kubebuilder "kube-rbac-proxy sidecar" scaffold pattern; the real Deployment has no such sidecar container. Harmless duplicate permissions, pre-existing, not touched. |
openshift-gitops-aggregate-admin.clusterrole.yaml |
No live equivalent by content search | Deliberate, narrower hand-maintained analog of OLM's own mechanism. OLM auto-generates admin/edit/view aggregated ClusterRoles per owned CRD (e.g. experiments.argoproj.io-v1alpha1-admin, labeled rbac.authorization.k8s.io/aggregate-to-admin: "true" + olm.opgroup.permissions/aggregate-to-<hash>-admin: "true") — one triplet per CRD, ~42 ClusterRoles total for this operator's 14 CRDs. The chart instead hand-picks just 3 "core" end-user-facing types (argocds, appprojects, applications) with full * verbs under one ClusterRole. This is a pre-existing, intentional simplification (not every CRD type needs end-user self-service access) — expanding it to match OLM's full per-CRD generation would be a scope-creep feature change, not a version-bump fix. |
values.yaml |
N/A | Helm chart's own user-facing schema (image overrides, proxy config) — not derived from source, nothing to diff |
Also worth knowing when comparing RBAC: on the live hub, OLM auto-promotes the namespaced permissions[0] Role into an additional, separately-hashed cluster-scoped ClusterRole (same 3 rules — configmaps, leases, events — plus one extra namespaces: get/list/watch rule), as part of wiring an AllNamespaces-mode operator. This is OLM's own internal machinery for that install mode, not a distinct RBAC item declared in the CSV's install.spec — don't chase it as something to replicate. In any case the chart's manager ClusterRole already grants a superset (namespaces with full CRUD verbs, not just get/list/watch), so there's no functional gap.
-
Verify target version:
kubectl get csv -n openshift-gitops-operator | grep openshift-gitops -
Extract CRDs:
kubectl get crd <name> -o yaml, strip runtime metadata (uid,resourceVersion,creationTimestamp,generation,managedFields, OLM annotations/labels,status), disable conversion webhooks (strategy: None, removewebhookblock), escape Go templates ({{ }}→{{ "{{" }}). Place ingitopsaddon/charts/openshift-gitops-operator/templates/crds/ONLY.deploy/crds/is unrelated. -
Update manager ClusterRole: Extract from CSV
spec.install.spec.clusterPermissions[0].rules. Quote all*wildcards as"*"— unquoted*is a YAML alias. Validate:python3 -c "import yaml; yaml.safe_load(open('file'))". -
Update leader-election Role: Extract from CSV
spec.install.spec.permissions[0].rules. Compare withtemplates/openshift-gitops-operator-leader-election-role.role.yaml. -
Update image SHAs: Extract from CSV env vars (non-
RELATED_IMAGE_ones) and container image spec. Updatepkg/utils/config.goDefaultOperatorImages. Also check for new/removed env var names. -
Update Deployment template: Compare CSV deployment spec with
templates/openshift-gitops-operator-controller-manager.deployment.yaml. Check for: new env vars, changed args, new ports, changed probes, new volumes/mounts. The chart should match the source except for these intentional differences:ENABLE_CONVERSION_WEBHOOK=false(source=true) — no OLM conversion webhook on non-OCPDISABLE_DEFAULT_ARGOCD_INSTANCE=true(source=falseon OCP) — embedded chart disables default instance--metrics-bind-address=127.0.0.1:8080(source=:8443) — kept as-is to avoid behavior change (metrics go from localhost-only to externally accessible); changing would also require updating the ConfigMapseccompProfile.type: RuntimeDefault(source doesn't have it) — extra security hardening on non-OCP, kept for defense-in-depthARGOCD_CLUSTER_CONFIG_NAMESPACES="*"(source=openshift-gitops) — chart uses wildcard so all namespaces get cluster-scoped RBAC--metrics-secure=true+ themetrics-certsvolume/volumeMount (source has both, chart has neither) — this pairs with the source's--metrics-bind-address=:8443to serve metrics over TLS using a cert from the OLM-provisionedopenshift-gitops-operator-metrics-tlssecret (backed by theservice.beta.openshift.io/serving-cert-secret-nameannotation on the source's metrics Service, an OCP/OLM-only mechanism). Since the chart already deliberately keeps metrics on127.0.0.1:8080(plaintext, loopback-only) instead of matching the source, adding--metrics-secure=truewithout that cert-issuance mechanism would break the operator (missing cert files) — so both are intentionally omitted together.
Two env vars are easy to misjudge as "changed" when they haven't:
DISABLE_DEFAULT_ARGOCD_INSTANCEgenuinely does not appear at all in the source CSV's env list on OCP — it's not "removed," it's simply never set there, which defaults tofalse(create the default instance). Its absence from a freshly-extracted CSV env dump is the expected, unchanged state, not evidence the flag was deprecated — confirmed still read by the operator's own source (common/common.go) as of this writing. The chart must keep explicitly forcing it totrue.CLUSTER_SCOPED_ARGO_ROLLOUTS_NAMESPACESis a real source env var (unlike the two above, it is NOT in the intentional-differences list — the chart must match its source value verbatim, currentlyopenshift-gitops). Check its value on every bump like any other non-image env var; don't assume because it's rollout-related that it follows the same wildcard treatment asARGOCD_CLUSTER_CONFIG_NAMESPACES.
For CRDs and RBAC rule sets specifically, a scripted structural diff (parse both sides as YAML, walk them recursively, ignore key/list order, treat rules as sets of
(apiGroups,resources,verbs,resourceNames)tuples) is far more reliable than eyeballing adiffon files that can run past 1-2MB — a manual read easily misses a single differing field buried in a huge schema. Un-escape the chart's{{ "{{" }}/{{ "}}" }}Go-template escaping back to{{/}}before parsing its copy as YAML. -
Bump Chart.yaml version
-
Check new/removed resources: Go file-by-file through the complete file inventory table above — every template file against its live hub equivalent, bidirectionally (something new on the hub with no chart file = possible addition needed; a chart file with no live equivalent and no clear rationale = possible removal candidate). Don't stop at "does the chart have a plausibly-named file" — confirm by content, using
kubectl get <kind> -n openshift-gitops-operator -o yaml(or cluster-scoped, unnamespaced for ClusterRoles/ClusterRoleBindings) as the source, since OLM hashes/renames things. The OLM bundle also carries monitoring resources (ServiceMonitor, prometheus Role/RoleBinding, bearer-token Secret) that are genuinely NOT needed in the embedded chart (no Prometheus scraping non-OCP clusters via this path). -
Cross-reference with
orb: Extract the bundle independently and verify CRD count, image SHAs, and ClusterRole rules match (see orb section below). -
Cross-reference with source repos for Go-level behavior only: Check gitops-operator and argocd-operator source (e.g.
controllers/util/util.go'sInspectCluster/VerifyAPI) for API-discovery behavior changes (e.g., how Route/Monitoring APIs are handled on non-OCP). Do NOT use these repos' checked-inbundle/manifests/for CSV/RBAC/resource content — see the staleness warning above; the live hub CSV is the only trustworthy source for that. -
Build and test:
make build && make test, then push a locally-built image to a registry you control, redeploy it on the hub (see "Build / Push / Redeploy Workflow"), and runtest-cycle.shfor at least 10 cycles with both OCP and Kind clusters. Confirming a pure image-digest bump this way still matters even when the CRD/RBAC/Deployment diffs all come back clean — runningtest-cycle.shagainst the OCP cluster alone is not sufficient coverage for a manifest bump: the non-OCP path installs via the embedded chart, a materially different code path (OLM install vstemplateAndApplyChart) that can only be exercised with a Kind (or other non-OCP) cluster in theMANAGED_CLUSTERSlist. Confirmed live: an OCP-only run can pass every cycle cleanly while the Kind path still needs its own dedicated pass to prove the new image digests are actually pullable and the operator reachesAvailablethere. Also confirmed live: running OCP and Kind together in the same cycle exercises the principal harder (more concurrent agents/events) and makes the pre-existing "Principal event-relay can get transiently stuck on one stale event" timing race (see "Real-infra timing behaviors" below) noticeably more likely to surface within a singletest-cycle.shrun than an OCP-only run does — this is the same already-documented, self-healing characteristic, not something a manifest bump introduces, but don't be surprised if a combined run hits it where a single-cluster run didn't.
go install github.com/joelanford/orb/cmd/orb@latest. Requires pkg-config and libgpgme-dev system packages. Requires DOCKER_CONFIG with registry.redhat.io auth. Extract bundle: DOCKER_CONFIG=/tmp/orb-auth orb bundle convert plain docker://registry.redhat.io/openshift-gitops-1/gitops-operator-bundle:vX.Y.Z dir:/tmp/orb-output -n openshift-gitops-operator. The output manifests.yaml contains all CRDs and non-CRD resources. Compare CRD names, image SHAs, and ClusterRole rules against the chart. The OLM bundle uses hashed ClusterRole names and combines RBAC differently — compare rule CONTENT, not names.
This is optional, independent cross-verification on top of the live-hub-CSV comparison in step 1-10
above (which remains the primary, sufficient source of truth per "Verification direction" above) —
skip it if orb isn't installed or registry.redhat.io credentials aren't configured in the
current environment, rather than blocking the rest of the procedure on it. A bump has been fully
verified end-to-end (all CRDs/RBAC/Deployment diffed against a live hub, images updated, make build && make test green, test-cycle.sh green on both OCP and Kind) without ever running orb.
GitHub Actions (.github/workflows/e2e.yml): e2e (cluster-import), e2e-gitopsaddon (embedded, embedded-agent, olm-override, embedded-autonomous). Local mirror: make test-local-e2e-gitopsaddon-*.
- CI timing: ArgoCD sync is slow on CI runners. Don't assert
Syncedwith tight timeouts. test-local-*vstest-e2e-*: Local creates fresh Kind clusters. Always use local to mirror CI.- Principal container naming: Red Hat operator:
"principal". Upstream: deployment name.findContainerImagehandles both. - Helm
{{in CRDs: Escape as{{ "{{" }}. CheckimageupdatersCRD. - Kubeconfig contamination: Unset
KUBECONFIGbeforemake test-local-e2e-*or Kind writes into hub kubeconfig. appProjectYAMLmust includename: '*': Without it, principal skips AppProject propagation → agents have no AppProject → apps stuck atsync=Unknown.sync=Unknown health=Healthy: Principal never dispatched. Check: (1) app on spoke? (2) AppProject exists? (3) agent pod logs for gRPC errors.- argocd-agent-resource-proxy-tls bootstrap: Principal FATAL if missing. Early cert generation block in controller generates before
VerifyArgocdNamespace. - Principal TLS NodePort SAN:
getPrincipalHostNamesadds node IPs when no LoadBalancer ingress exists. - UID mismatch after cleanup: Rapid delete/recreate causes stale informer cache.
isObjectGoneError()aborts reconcile. Fix: restart controller + delete lease. - OperatorGroup must be AllNamespaces:
OwnNamespacecauses CSV failure. - CSR accumulation: Rapid deploy/delete cycles exhaust CSR quota. Fix:
kubectl get csr -o name | grep gitops-addon | xargs kubectl delete. - ACM 2.16→2.17 CRB roleRef immutability:
deleteStaleClusterRoleBinding()self-heals at startup. - Addon agent race during cleanup:
scaleDownAddonAgent()scales to 0 before cleanup Job runs. - local-cluster guestbook (hybrid mode): local-cluster is never agent-dispatched, so there is no hub-vs-spoke identity conflict to work around. Its Application is verified and finalized normally through the hub's own application controller, same as any other in-cluster ArgoCD destination - see "Deploy/Delete Cycle Testing" below.
- A single momentary "Running" or "Synced" sample is not proof of health: a crash-looping container can genuinely report
status.phase: Runningfor the brief window between container start and a fatal exit, long enough for a 5-10s poll to sample mid-flicker and report a false pass. Require 2 consecutive stable samples (same phase AND same restart count) before declaring a pod healthy, not a singleEventually(...).Should(Equal("Running")). The same logic applies to any other "did it work" snapshot check - prefer confirming it stayed true, not that it was observed true once. - Test the actual function, not a proxy for it: for anything that reports status hub-side after a spoke-side action (agent-dispatched apps, autonomous mirrors), the strongest and simplest check is "did the hub receive a real update from the spoke at all" (non-empty, not stuck at the default
"Unknown"), not "does the hub's value exactly equal the spoke's value right now" and not "does a specific log string never appear." Exact-match chases a moving target - the spoke and hub progress independently, and a strict-equality check can fail forever if the spoke advances to a new state before the hub catches up, even though the reporting pipeline is working fine. A broken pipeline naturally fails the "got any real update" check on its own; you don't need a separate implementation-specific log-grep for a known error string to catch it, and log-grepping is more fragile (ties the test to exact wording) than asserting on the real observable behavior. - Don't gate a coarse readiness check on an unreachable end state: e.g. requiring
health: Healthyon the demo guestbook app before considering a deploy "done" - it structurally can't reachHealthyon OCP (the demo image runs as non-root binding port 80, which restricted SCC rejects), so that gate would time out on every run regardless of whether the actual thing under test works. Gate on something the environment can actually achieve (e.g. "has a real, non-Unknown sync status"), consistent with how the same known limitation is already tolerated (WARNING, not failure) elsewhere in the same test. - Nudge instead of waiting out a timer: when something is stuck because a component's next periodic action (a resync, a refresh) will naturally clear it, actively triggering that action (e.g. the
argocd.argoproj.io/refresh=normalannotation) resolves it far faster than passively waiting out the interval. Worth doing partway through a retry loop (e.g. attempt 3-6) rather than only at the very start or not at all.
gitopsaddon/test-cycle.sh — automated deploy/verify/delete cycling against real clusters
(not the Kind-only make test-local-e2e-* targets). This is the primary way to validate hybrid
mode end-to-end: it always includes local-cluster in the ApplicationSet target (via a dedicated
cycle-agent-appset-placement, kept separate from the addon-install cycle-agent-placement which
still excludes local-cluster), verifies the cluster-local-cluster secret shape and that the
generated app reconciles via the hub's own application controller, and tears everything down with
no manual finalizer stripping anywhere in the script: it deletes the ApplicationSet first (per the
"Cleanup (Manual Deletion Order)" section above), while agents and the hub app-controller are still
connected, and waits for every generated Application — spoke-managed and local-cluster alike — to
finalize naturally before touching GitOpsCluster/Policy/MCAs.
HUB_KUBECONFIG=/path/to/hub \
MANAGED_CLUSTERS="eks-cluster1:/path/to/eks.kc,ocp-cluster-name:/path/to/ocp.kc" \
bash gitopsaddon/test-cycle.sh 5Optional env vars: FORCE_CLEAN_FIRST (true), WAIT_DEPLOY_SECS (600), WAIT_CLEANUP_SECS (600), HUB_CONTROLLER_NS (ocm).
Prerequisite (one-time, per hub): the hub's own ArgoCD app-controller ServiceAccount needs
elevated RBAC to manage the local-cluster hybrid app's destination namespace — see "Hybrid Mode"
above. test-cycle.sh creates this itself (ensure_hub_appcontroller_rbac) so no manual step is
needed for the script specifically, but real (non-test) hybrid-mode setups must do this too.
If docker/podman/kind/go aren't visible in the shell but ARE installed on the real host,
the shell is likely running inside a Flatpak sandbox layer (check cat /etc/os-release — a
Freedesktop SDK ... (Flatpak runtime) result confirms it). Escape it with
flatpak-spawn --host <command> to run the command on the real host instead — this reaches the
host's actual podman, kind, and go installs, registry credentials, and any existing local
Kind clusters. Plain kubectl against real remote clusters typically works fine without the
escape hatch (outbound network is already shared); use flatpak-spawn --host for anything that
needs the host's container/VM tooling specifically.
Observed running many test-cycle.sh cycles against real (AWS-hosted OCP + non-OCP) clusters —
these are genuine, self-healing characteristics of the live system, not bugs to "fix" by weakening
a check, but also not things a short timeout can paper over:
- Principal event-relay can get transiently stuck on one stale event. The argocd-agent
principal's event-processor can retry one superseded status-update event against a
resourceVersion the hub has already moved past, logging
"the server rejected our request due to an error in our request"repeatedly with exponential backoff, and eventually"Event failed after N retries, giving up to unblock queue". It self-heals once the agent's own next periodic resync (ArgoCD default 180s) sends a fresh event, or its own retry budget exhausts and it moves on - either way this can take several minutes end-to-end. A short (~1-2 min) timeout on "did the hub get a status update" will false-fail here even though nothing is actually broken; budget ~400s and nudge (see above) to shorten the common case. - A freshly-started agent can drop its own managed-app state while still stabilizing. During
the already-documented CA-secret-propagation crash-loop window (see "Principal
CrashLoopBackOff"), an agent that's restarting can lose track of an app it was managing on a
reconnect/resync (
"Dropping app deletion event because the app is not managed"/"could not delete existing app: ... is not managed"), and that app is not automatically redispatched once the agent stabilizes - something has to re-trigger dispatch (e.g. a principal restart, or the next full reconcile). Waiting for the agent pod to be stable (2 consecutive Running+Ready samples with an unchanged restart count, not just "Running once") before checking application status avoids racing this window in the first place. FORCE_CLEAN_FIRST-style cleanup needs a principal restart too, not just a controller restart. Restarting the hub controller clears its own informer cache, but the principal separately needs a restart to drop stale gRPC events queued from whatever was just torn down - otherwise the next cycle's fresh agents can receive stale delete/update events and reject them as "not managed" (same failure mode as the documented "between cycles" reset, just also needed before the first cycle after a forced clean).
- Go 1.26+ (see
go.mod), Kubernetes cluster with OCM make testbefore submittingmake generateafter API type changesmake manifestsafter RBAC/CRD annotation changes- Update
CLAUDE.mdafter learning new context - Ask before acting on ambiguous tasks
Never pin a dev-tool go install to @latest in the Makefile. setup-envtest (used by
make test) was pinned to @latest and broke with requires go >= 1.26.0 (running go 1.25.11)
the moment a newer release shipped upstream - with no code change on this repo's side. Pin to a
specific version (SETUP_ENVTEST_VERSION in Makefile/Makefile.prow) compatible with this
repo's own go.mod/controller-runtime version, and key the "is it already installed" check on
the actual installed version ($(TOOL) version output), not just "does the binary file exist" -
otherwise bumping the pinned version later silently keeps using a stale cached binary from the old
one.
- Policy is user-owned: Controller recreates deleted Policies (unless
skip-argocd-policy), and reconciles agent connection fields (address, port, mode, DBM, TLS) on the ArgoCD template. It never writes or clearsargoCDAgent.agent.image— users who want to pin the agent image via Policy can do so. Agent-image auto-heal is ADC-only (ARGOCD_AGENT_IMAGE); disable it withskip-agent-version-heal. - Cleanup order matters: GitOpsCluster → Policy → MCAs (strict order or resources get recreated).
- GitOpsCluster deletion is a no-op: No finalizer, no cascade.
local-clustermust stay out of the GitOpsCluster's ownplacementRef: it's never an addon-install target (hybrid mode) — it already has its own ArgoCD instance. A separate Placement, used only by ApplicationSets, may include it.- OLM mode OCP only: Non-OCP uses embedded Helm chart.
- RBAC before first app sync: ArgoCD retry limit (5) means stuck apps if RBAC missing. Delete spoke app copies to recover. This applies to the hub's own app-controller too for any app targeting
local-cluster. - Non-OCP pull secret chain:
multiclusterhub-operator-pull-secretmust haveregistry.redhat.ioin BOTH namespaces. - OCP OLM timing: 30-120s. Use 600s timeouts.
- DBM must match principal and agent: Mismatched = "Resource not found" in UI.
- Agent SA view ClusterRole: Not auto-provisioned. Add manually or via Policy.
- Basic pull model is OCP-only: not validated/supported against non-OCP (embedded Helm chart) managed clusters. See "Basic Pull Model" under Architecture.
- Basic pull model on
local-clusteris dormant: the dedicatedlocal-cluster-namespace ArgoCD instance it depended on was removed by hybrid mode; no code-level guard blocks it, but it is unverified. See "Basic Pull Model" under Architecture. - Basic pull model status aggregation requires an
ApplicationSetowner: a hand-createdApplicationwith the pull label/annotation still gets delivered and reconciled, but produces noMulticlusterApplicationSetReport— onlyApplicationSet-generated Applications get theapplication-setlabel needed formulticlusterstatusaggregationto pick them up. - Never delete a pull-model
ManifestWorkfor an app you want to keep: proven destructive in live testing regardless ofupdateStrategy: ReadOnlyor an explicitorphaningRulesentry — see "Migrating an App from Basic Pull Model to argocd-agent" above for the actual safe sequence.
Default: quay.io/stolostron. Configure via REGISTRY and VERSION make variables (make REGISTRY=... VERSION=... — these are simple = assignments in the Makefile, so exporting them as shell env vars alone won't override the default; pass them on the make command line).