Target Clusters
Twelve workload CRDs carry an optional spec.targetClusterRef: Keystone, Barbican, Horizon, Glance, Placement, Cinder, Nova, NovaCompute, OVNCentral, OVNChassis, Neutron, and NeutronMetadataAgent. The field names a registered target cluster that receives every child the CR projects: Deployments, ConfigMaps, Secrets, and, for the services that have one, the database CRs. The CR itself does not move. It is created, reconciled, and deleted on the management cluster, and so are its status, its finalizers, and the webhooks that admit it. Four attachment kinds reach a target cluster through a parent instead of a ref of their own: BarbicanSecretStore, GlanceBackend, CinderBackend, and CinderBackupBackend.
Omitting the field selects the local cluster, the one the operator runs on. The children are created there and the deployment behaves like a single-cluster one, so an existing CR keeps its behavior without an edit.
In the figure of the implemented topology, target clusters are the dashed box on the right: optional, registered through kubeconfig Secrets, and the place where projected workloads land.
The ControlPlane carries the ref per service instead of once per CR: services.keystone, services.horizon, services.glance, services.placement, services.barbican, services.neutron, services.cinder, and services.nova each take one, so one control plane can run its identity service on one cluster and its dashboard on another. Everything on this page applies to it, and what is specific to it is collected under ControlPlane placement.
The field
name is the only key. It must be a non-empty DNS-1123 subdomain (MinLength=1 plus the Kubernetes object-name pattern) and it must match the name of a registration Secret.
spec:
targetClusterRef:
name: edge-1The ref is immutable once the CR exists. Adding it, removing it, or renaming it is rejected by two spec-level CEL transition rules with the message targetClusterRef is immutable, and again by the validating webhook, which appends the reason to that same text. The rules are evaluated only on UPDATE, and being schema-level they hold when the webhook is down. Each of those edits would strand the children already created on the previously selected cluster. Moving a service between clusters means deleting the CR and creating it anew.
Registering a target cluster
A target cluster is registered by a kubeconfig Secret on the management cluster. The Secret's name is the cluster name a CR references.
The figure shows where the Secret sits, what the target cluster grants in return, and what crosses between the two clusters.
apiVersion: v1
kind: Secret
metadata:
name: edge-1
namespace: c5c3-clusters
labels:
sigs.k8s.io/multicluster-runtime-kubeconfig: "true"
type: Opaque
data:
kubeconfig: <base64-encoded kubeconfig>
namespaces: <base64-encoded comma-separated namespace list>
assignedNamespaces: <base64-encoded comma-separated namespace list>Three parts of that Secret are contractual, and two more are optional:
| Part | Value |
|---|---|
| Namespace | The operator's --clusters-namespace flag, default c5c3-clusters. The Secret informer is widened to this one namespace, and clearing the flag switches target clusters off entirely: no cluster is engaged and no Secret is read outside the watched namespace |
| Label | sigs.k8s.io/multicluster-runtime-kubeconfig, with the string value "true". The label being present is not enough; any other value leaves the Secret unregistered |
| Data key | kubeconfig |
Data key namespaces | Optional. A comma-separated list of DNS-1123 namespace names. Present, it restricts the engaged cluster's cache to exactly those namespaces: every LIST and WATCH the operator issues there is namespaced, so namespace-scoped RBAC on the target is enough for every namespaced kind. Absent, the cluster is cached cluster-wide |
Data key assignedNamespaces | Optional, and only beside namespaces. A comma-separated list of DNS-1123 names of the namespaces a ControlPlane assigns to service owners on this cluster. The c5c3-operator's cache covers them for the seven order kinds and Secrets; every other operator's cache stays off them, so the access chart's assigned Roles are all those namespaces need. An empty value, an entry that is no label, a namespace also under namespaces, or the key without namespaces refuses the engagement |
Any number of clusters may be registered at the same time, and each CR resolves its own by name. Writing a new kubeconfig into the Secret reconnects: the provider hashes the kubeconfig together with the namespaces and assignedNamespaces values, sees the hash change, drops the existing connection, and engages a fresh one. Editing any of the three re-engages the cluster, which is also the only way its cache's namespaces change, since they are fixed when the cache is built. Only the c5c3-operator hashes the assignedNamespaces value: every other operator builds the same cache whatever it says, so an edit of that key alone leaves their engaged cluster as it is. Deleting the Secret deregisters the cluster.
Engagement is asynchronous. A CR created moments before its cluster engages reports TargetClusterUnavailable briefly and heals on the next requeue.
Who may name a cluster
Registering a cluster hands it to every CR the operator watches
spec.targetClusterRef is validated for shape and immutability, and for nothing else. Nothing binds a CR, its namespace, or its author to the set of clusters it may name, so once edge-1 is registered, anyone who can create one of these CRs in a watched namespace can direct the operator's stored credentials for edge-1 at that cluster — with an image and a configuration of their choosing. Treat write access to the clusters namespace, and create access to these CRDs on the management cluster, as equally privileged until an authorization model lands.
Four grants therefore need locking down on the management cluster:
| Grant | Why |
|---|---|
create/update on Secrets in c5c3-clusters | A labelled Secret here registers a cluster. Whoever can write one decides which clusters the operator holds credentials for |
create on keystones, barbicans, glances, horizons, placements, cinders, novas, controlplanes | A CR author picks the cluster its children land on, from every name registered. A ControlPlane picks one per service |
create on novacomputes | Root on every node the author's spec.nodeSelector and spec.tolerations reach: the pool runs a privileged, host-network nova-compute as uid 0, with /dev and /var/lib/nova mounted, from the image spec.image names. That holds on any registered cluster, and on the management cluster when targetClusterRef is unset. A pool of a ControlPlane's Nova naming a cluster also makes the ControlPlane copy the compute contract there, the bus transport_url with the RabbitMQ password and the service password among it, whether or not the pool selects a node. With hypervisorTraits on, the pool also sets the custom Placement traits of every host it holds |
create/update on neutronmetadataagents in a ControlPlane's OVN central namespace | Root on the chassis's nodes: the agent runs privileged on their host network. An agent that names a target cluster and {controlplane.Name}-nova-metadata-agent-secret in spec.novaMetadata.sharedSecretRef also makes the ControlPlane copy the metadata shared secret onto the cluster it names, into the same namespace there |
An install that needs no target clusters carries none of the cross-cluster exposure: a namespace-scoped install clears --clusters-namespace (see below), the operator engages nothing, and a targetClusterRef naming any cluster reports TargetClusterUnavailable. The novacomputes grant still reaches the management cluster's own nodes. A standalone webhook release (webhook.standalone=true, which starts the manager with --enable-controllers=false) engages nothing either: it runs no reconciler and reads no registration Secret.
Registration does not validate credentials
A kubeconfig that does not parse never engages, and the cluster name stays unresolvable. A kubeconfig that parses but carries credentials the target rejects engages silently: nothing logs in at registration time. The failure surfaces on the first child write, as the target API server's own error.
the server has asked for the client to provide credentialsA wrong token therefore shows up on the first CR that uses the cluster, not when the Secret is applied.
A kubeconfig whose user carries an exec or an auth-provider block is refused outright, with the cluster left unresolvable. Both are credential plugins, and honoring one would have client-go run the named binary inside the operator's own pod. A target cluster authenticates with a bearer token or a client certificate.
Rotating and revoking the target token
The token the registration kubeconfig carries is a long-lived ServiceAccount token, minted by the chart's Secret of type kubernetes.io/service-account-token. It does not expire, and nothing on the management cluster renews it — a bound token would, and no controller there issues one. It is therefore at rest in two clusters' etcd, and a copy that leaks stays valid until it is invalidated on the target.
Rotate it by discarding the Secret it lives in and re-applying the release, which re-creates the Secret for the token controller to fill in. Nothing re-creates it on its own:
kubectl --context "$TARGET" -n c5c3-access delete secret target-cluster-access-token
helm upgrade --kube-context "$TARGET" --reuse-values \
target-cluster-access deploy/target-cluster/target-cluster-access -n c5c3-accessThen rebuild the registration kubeconfig from the new token, exactly as Deploy to a Target Cluster assembles it the first time, and write it back over the registration Secret:
kubectl --context "$MGMT" -n c5c3-clusters create secret generic "$CLUSTER" \
--from-file=kubeconfig=./registration.kubeconfig \
--from-literal=namespaces=openstack \
--dry-run=client -o yaml | kubectl --context "$MGMT" apply -f -The new kubeconfig changes the registration's hash, so the operator disengages the cluster and engages it again under the new credential, exactly as a scope change does. The old token stops working the moment its Secret is gone, so run the two halves back to back: between them, every placed CR reports TargetClusterUnavailable or a credentials error.
Revoking is not the same operation. Deleting the token Secret alone invalidates that token, but a ServiceAccount re-created under the same name mints tokens that are valid again, because the binding is to the account's name and the old token's UID no longer matches. To revoke the access itself, delete the ServiceAccount — helm uninstall target-cluster-access -n c5c3-access does, along with the Roles and the ClusterRole, and leaves the namespaces and every child placed in them where they are.
When the name does not resolve
An unregistered name, or one whose Secret was deleted while CRs still pointed at it, surfaces on the CR's first gate condition:
| CR | Condition | Status | Reason | Message |
|---|---|---|---|---|
| Keystone, Barbican, Horizon, Glance, Placement, Neutron, Cinder, Nova | SecretsReady | False | TargetClusterUnavailable | The resolver's error, cluster not found for a name that was never registered |
| BarbicanSecretStore, GlanceBackend, CinderBackend, CinderBackupBackend | CredentialsReady | False | TargetClusterUnavailable | Same |
| ControlPlane | NamespacesReady usually, since it is the first sub-reconciler that reaches a cluster; otherwise whichever sub-reconciler reaches the cluster first, out of InfrastructureReady, ESOTenantStoreReady, DBCredentialsReady, AdminPasswordReady, GlanceReady, PlacementReady, BarbicanReady, NeutronReady, CinderReady, NovaReady, ServiceAccountsReady, and KORCReady | False | TargetClusterUnavailable | Same |
The pass ends there. The CR requeues after 15 seconds, on a flat poll rather than a backoff, and nothing is created on any cluster. Resolution runs before the operator installs a finalizer for the target, so a workload CR naming an unresolvable cluster carries none: nothing was created for it, and a finalizer would only block its deletion. A ControlPlane carries c5c3.io/orc-teardown either way and takes openstack.c5c3.io/remote-children only once one cluster it names resolves. A ControlPlane ends the pass when the sub-reconciler that reached the cluster belongs to the blocking prefix. A member of the tail group reports the reason on its own condition while the other members still run. Either way the CR requeues on that sub-reconciler's interval, 15 seconds on the namespace and backing-service legs and 10 on the credential ones.
A cluster that is deregistered under a running CR flips the same condition, and the children already written to it stay where they are. The reconciler never reaches its sub-reconcilers without a client, so nothing deletes them.
Deleting such a CR still works, after a grace period. The deletion path resolves ahead of everything else and tolerates a target that is gone, but not right away: while the grace period runs, an unresolvable target only requeues the pass, every 15 seconds, the finalizers stay on, and a workload CR reports SecretsReady=False, and a ControlPlane NamespacesReady=False, with reason TargetClusterUnavailable and a message naming the cluster it is waiting for, so the hold is not just a log line.
Two five-minute windows have to run out before the operator gives up: five minutes since the CR was marked for deletion, and five minutes since that operator process first failed to resolve the cluster. Engaging a cluster is asynchronous, and rotating a registration Secret makes the provider drop the cluster and build it again, so a registered cluster is indistinguishable from a deregistered one for a moment after an operator restart or a rotation. Measuring only from the deletion timestamp would give up on the first pass after such an event whenever the CR had already been terminating for longer — a blocked cleanup makes that ordinary — and strand the children of a CR whose cluster is in fact perfectly reachable. A restarted operator therefore starts its own window over.
Once both windows are out, the operator releases the finalizers, emits a RemoteChildrenAbandoned warning naming what it could not delete, and lets the CR leave etcd. The MariaDB CRs and the workload on the deregistered cluster are left behind — unreachable, so nothing else was ever possible — and have to be removed on that cluster by hand.
BarbicanSecretStore, GlanceBackend, CinderBackend and CinderBackupBackend carry no targetClusterRef of their own. Each resolves the target of the parent named by spec.barbicanRef, spec.glanceRef or spec.cinderRef, so an attachment lands on the same cluster as its parent's children. A parent that does not exist — a dangling ref, or a GitOps apply that has not landed it yet — leaves the target unknown, and all four hold their first gate condition at WaitingForParent rather than falling back to the management cluster.
Prerequisites on the target cluster
For the twelve workload CRDs, the CR's namespace must already exist on the target. Their operators do not create it, and a child write into a missing namespace fails. A ControlPlane is the exception: it ensures the namespaces it places services in, on both clusters (see ControlPlane placement).
The access a target cluster grants is packaged as the deploy/target-cluster/target-cluster-access chart: a ServiceAccount, a long-lived token Secret the registration kubeconfig carries, one Role per entry in values.namespaces, and a ClusterRole for the kinds that have no namespace. values.namespaces, together with values.assignedNamespaces (see Assigned namespaces), has to equal the registration Secret's namespaces key, because one grants what the other scopes; a namespace granted but not declared is never watched, and one declared but not granted answers every read with forbidden. createNamespaces decides whether the chart creates those namespaces or expects them to exist; they are annotated helm.sh/resource-policy: keep, so uninstalling the release or dropping an entry from values.namespaces takes the access away and leaves the placed workloads and their volumes standing. Among its grants is create on serviceaccounts/token, which is what admits the token a Barbican secret store mints through the target's TokenRequest API. Deploy to a Target Cluster walks the install, the kubeconfig, and the registration on two kind clusters.
One grant is off by default. authDelegatorBinding adds create, patch and delete on ClusterRoleBindings, which is what a ControlPlane needs to bind a dedicated OpenBao instance to system:auth-delegator — the binding that lets that instance run the TokenReview every Kubernetes-auth login is validated with. The binding's name carries a hash of the instance's namespace and name, so resourceNames cannot narrow the grants to it and they cover every ClusterRoleBinding on the cluster, cluster-admin included. Turn them on only for a cluster a ControlPlane places such a Barbican on; every other placement, a secret store pointed at an OpenBao instance that already runs there included, works without them. Turning them off again while such a ControlPlane still exists leaves its binding behind: the teardown reads the binding, is denied the delete, and releases the ControlPlane anyway rather than holding it in Terminating, recording an AuthDelegatorBindingNotReclaimed event that names the binding to remove by hand.
Read-only get on ClusterRoleBindings is granted either way. Every ControlPlane teardown reads that binding by name before it releases its finalizer, because it is the one child no namespace sweep and no owner-reference cascade can reach. Without the grant the API server answers forbidden rather than not-found, and the teardown retries the error instead of releasing, so a placed ControlPlane would never leave Terminating.
A second grant is off by default. hypervisorTraits adds get and patch on hypervisors.kvm.cloud.sap, the Hypervisor objects openstack-hypervisor-operator keeps one per compute node. A NovaCompute placed on the cluster that declares spec.traits or spec.hostTraits writes each node's custom traits into the spec.customTraits of its Hypervisor (see The traits). Without the grant such a pool reports TraitsReady=False with reason HypervisorsForbidden. A pool that neither declares a trait nor records one in status.nodes[].traits reads no Hypervisor. A pool that dropped its traits still clears each node's list, so keep the grant until its TraitsReady reads NoTraitsDeclared. Kubernetes cannot narrow patch to that one field. The verb carries spec.maintenance and spec.lifecycleEnabled with it, so it lets whoever holds the registration kubeconfig evict and offboard every hypervisor of the cluster. Turn it on for a compute cluster whose pools declare traits:
helm upgrade --kube-context "$TARGET" --reuse-values \
target-cluster-access deploy/target-cluster/target-cluster-access -n c5c3-access \
--set hypervisorTraits=trueThe same holds for passwords in generators.external-secrets.io, which the chart gained with the compute service. Every placed ControlPlane needs it, not only one that places Nova: the teardown sweeps Password generators in every placed namespace, and a forbidden list fails the sweep and holds the ControlPlane in Terminating. Upgrade the release on every registered target cluster before the c5c3 operator:
helm upgrade --kube-context "$TARGET" --reuse-values \
target-cluster-access deploy/target-cluster/target-cluster-access -n c5c3-accessThe same holds for verticalpodautoscalers in autoscaling.k8s.io, which the chart gained with the opt-in VerticalPodAutoscaler. On a target cluster that serves that kind, every service operator watches, lists and sweeps VPAs there, whether or not a CR opts in (see Per-cluster capabilities). Without the grant the VPA watch never syncs, which fails the cluster's engagement, and a forbidden list fails the teardown sweep and holds every placed CR in Terminating. Upgrade the release on every registered target cluster before the service operators, with the command above.
The same holds for the order kinds in c5c3.io (keystoneusers, keystoneprojects, keystoneroleassignments, keystonecatalogentries, keystoneapplicationcredentials and mariadbdatabases), which the chart gained with the orders. On a target cluster that serves one of them, the c5c3-operator lists and watches its orders in every namespace of the registration. Without read on it in every placed Role the order watch never syncs, which fails the cluster's engagement. Assigned namespaces gives the order in which to upgrade.
The OVN chassis layer (issue #903) needs a namespace it can run a node-level workload in, and privilegedNamespaces is the one value that provides it. Every entry is a namespace from values.namespaces; each gets its Namespace labelled pod-security.kubernetes.io/enforce: privileged, and its per-namespace Role is the only one that carries daemonsets alongside deployments. OVS and ovn-controller run with hostNetwork, hostPath and added capabilities, so on a cluster whose PodSecurity admission defaults to baseline or restricted a namespace missing from the list has its chassis pods rejected at admission and the DaemonSet reports FailedCreate events. A namespace not on the list has no daemonsets grant either, so a cluster that hosts only service APIs carries neither half. createNamespaces has to be true, because the chart cannot label a namespace it does not create; either violation fails the render.
Read the label as the one grant that leaves the namespace. PodSecurity admission is what confines this account's workload grants to the namespace they are scoped to; without it, create on deployments or daemonsets there is enough to schedule a pod with hostPath and a privileged securityContext — one per node, for a DaemonSet — which is root on every schedulable node, and from there the kubelet client certificate and every other pod's projected token. Whoever holds the registration kubeconfig can do that. No narrower grant projects a chassis: a node-level workload needs exactly the posture PodSecurity refuses. So list a namespace the chassis has to itself. The chart sees namespace names, not what is placed in them, and cannot check this for you — a namespace that also holds Keystone, Glance or Barbican loses enforcement for their Deployments, Jobs and CronJobs too.
A NeutronMetadataAgent's namespace needs the same entry. Its neutron-ovn-metadata-agent DaemonSet runs as root on the host network with a bidirectional /run/netns mount, which is why the kind is projected into the namespace of the OVNChassis it attaches to: one entry covers both node-level workloads, and the Neutron API needs none.
A compute cluster holds all three node-level workloads, the chassis, the metadata agent and a NovaCompute pool, and Namespaces on a compute cluster lists what its namespaces receive and need.
A placed Cinder mounts every backend export and the backup share as an inline CSI volume in the pod spec, so the target cluster needs an NFS CSI mounter of its own. The kind devstack installs csi-driver-nfs with feature.enableInlineVolume: true, and its CSIDriver object has to list the Ephemeral lifecycle mode. spec.volumeLifecycleModes is immutable, so a driver that predates the flag is deleted and recreated; hack/deploy-infra.sh does that on kind. The mount asks nothing more of the access chart: no volume claim objects are created, and the volume rides in the pod template the namespaced deployments grant already covers. The nodes that run the volume and backup pods need the nfs and nfsv4 kernel modules. The node plugin image brings its own mount.nfs.
The Ephemeral lifecycle mode is a cluster-wide grant
It is a property of the CSIDriver object, not of a namespace. Once nfs.csi.k8s.io lists the mode, a pod in any namespace on that cluster may carry volumes[].csi naming the driver with a server and a share of its own. No PersistentVolume, no claim and no StorageClass sit in that path, so there is no object left to bind RBAC to: create on pods in one namespace is read/write access to every export the nodes can reach, every other tenant's raw volume file and the backup share included.
Kubernetes contains this nowhere by default, and Pod Security admission does not reach it: the restricted profile's volume allow-list carries csi, so enforcing it changes nothing here. Upstream names two mechanisms — dropping Ephemeral from the CSIDriver's spec.volumeLifecycleModes, and an admission policy — and the first is unavailable on a cluster that runs a Cinder, because its pods need the mode. That leaves one gate: deny the driver outside the block-storage namespaces with a ValidatingAdmissionPolicy, and apply it before enabling the mode.
validations:
- expression: "!has(object.spec.volumes) || !object.spec.volumes.exists(v, has(v.csi) && v.csi.driver == 'nfs.csi.k8s.io')"
message: inline nfs.csi.k8s.io volumes are confined to the block-storage namespacesThe kind devstack enables the mode without that policy, which is one more reason it is a devstack.
All four Cinder workloads dial the message bus: the API hands a volume request to the scheduler, the scheduler to a cinder-volume, and the backup service takes its jobs the same way. A ControlPlane delivers the bus into the service's namespace on its own cluster, as the Secret <cp>-cinder-messaging, while the broker stays on the management cluster. The transport URL in it has to name an address the target's pods can open a connection to: an in-cluster Service DNS name resolves on one cluster only. A Cinder placed without a ControlPlane names the bus with spec.messaging.secretRef unless a broker runs in its own namespace on the target, because the managed mode reads the RabbitmqCluster through the target's client, in the CR's namespace. No e2e suite runs that crossing; Cinder placement is covered by envtest alone.
A placed Nova meets the same constraint. All five control-plane workloads and the migration Jobs read the transport URL, and the managed mode reads the RabbitmqCluster through the target's client in the CR's namespace, so a Nova whose broker runs on the management cluster names the bus with spec.messaging.secretRef. The compute-config Secret that status.computeConfigSecretRef names is written on the target, next to the workloads. While spec.remoteCompute is set, {name}-remote-compute-config is written there beside it, and the transport URL Secret that block names is read from the target as well. Nova placement, too, is covered by envtest alone.
The verdict of the migrate Job's cinder-status upgrade check comes from the pod's termination message. The operator lists the Job's pods through the target's credentials and reads the message off the terminated container, so it needs get and list on pods in the service's namespace; the access chart's per-namespace Role carries both. A target whose RBAC was assembled by hand without them runs the check and loses its verdict: the operator records an UpgradeCheckCompleted event reading cinder-status upgrade check exit code unavailable. The phases the Job runs in are described under Cinder Upgrade Flow.
patch on Nodes is deliberately granted nowhere in this chart, for the same reason. Kubernetes cannot narrow the verb to one annotation key: it carries labels and taints with it, so it would let the same holder lift node-role.kubernetes.io/control-plane:NoSchedule and land one of those pods on the control-plane node, next to /etc/kubernetes/pki/ca.key. Per-node state an operator has to persist goes into a namespaced object keyed by node name, which the per-namespace Role already covers — see Adding a new operator.
Taking the label away again has one supported path: drop the entry from privilegedNamespaces, keep it in values.namespaces, keep createNamespacestrue, and helm upgrade. Helm's three-way merge then removes the label and the daemonsets grant with it. The other three revocations do not, because each one stops the chart from rendering the Namespace at all while helm.sh/resource-policy: keep holds the live one in place: helm uninstall, dropping the entry from values.namespaces itself, and setting createNamespaces to false. The label then survives on a namespace no release writes any more, and PodSecurity stays off in it for whoever reuses the name. Under createNamespaces: false the per-namespace Role is still rendered, so this account keeps create on deployments, jobs and cronjobs in that unenforced namespace; helm uninstall and dropping the entry from values.namespaces take the Role with them and leave only the label. In those three cases remove it by hand:
kubectl label namespace <ns> pod-security.kubernetes.io/enforce-Which is also why an empty privilegedNamespaces proves nothing while createNamespaces is false. In that mode the chart never writes the Namespace, so it neither sets the label nor clears one that is already there, and it does not read the live one either — an empty list says only that the chart is not asking for the label, not that PodSecurity is enforcing. That is the mode the multi-cluster CI job installs in, and the mode a cluster whose namespaces the platform team owns installs in. Read the posture off the cluster instead:
kubectl get namespace <ns> -o jsonpath='{.metadata.labels}'Placing an OVNCentral needs the chart revision that grants statefulsets and persistentvolumeclaims. Without the first, the write of the Northbound StatefulSet comes back forbidden and NorthboundReady holds the API server's message under reason StatefulSetError. Without the second, the write of the <name>-backup claim comes back forbidden and BackupReady holds it.
An OVNCentral also needs cert-manager on the cluster its children land on, and a CA-capable issuer for spec.tls.issuerRef to name. The ref takes kind ClusterIssuer by default and accepts Issuer. Give OVN an issuer of its own: OVS authenticates a peer by checking that its certificate chains to the configured CA, with no per-certificate allowlist and no SAN check, so every leaf that CA signs is a valid client of both databases, and reusing the database CA would let a MariaDB client certificate open an OVSDB session (decision D9 of #898). Without cert-manager on that cluster the CR holds TLSReady=False under reason CertManagerUnavailable and polls, and since TLS is the first gate of the pipeline nothing is written. An issuer that is not CA-backed clears that gate and fails the next one, because the client Secret it produces carries no ca.crt. TLSReady then holds reason CertificateError and the message issuer <kind>/<name> issued no ca.crt; spec.tls.issuerRef must name a CA issuer. The devstack ships the ClusterIssuer openstack-ovn-ca-issuer from deploy/flux-system/infrastructure/ovn-ca-issuer.yaml, a CA issuer whose root is a self-signed CA Certificate in the cert-manager namespace. The field is described under OVNTLSSpec.
The nodes an OVNChassis selects have to be labelled ahead of it. spec.nodeSelector is what selects them, and the CRD fixes no keys: the suites and the guides use openstack.c5c3.io/chassis=true for the chassis nodes and openstack.c5c3.io/gateway=true for the gateway subset. Each selected node also has to carry the openvswitch and geneve kernel modules in its /lib/modules tree, which the privileged host-prepare init container loads with modprobe openvswitch && modprobe geneve (reconcile_nodes.go). The operator never writes a Node. Its RBAC on nodes is get, list and watch, matching the access chart's refusal to grant patch there, so both the labels and the module tree are the platform's job. The two DaemonSets run in the node's network namespace and mount /run/openvswitch, /run/ovn and /lib/modules, which is the posture the privilegedNamespaces entry above admits. Node contract has the mount table and the per-container capabilities.
A placed CR's API health probe does not resolve over Service DNS from the management cluster, so it runs through the target's API server instead. The same credentials therefore need get on services/proxy in every namespace a service is placed in; without it the probe holds the CR's API-ready condition at APIUnhealthy with the API server's forbidden message.
The proxy authorizes each request by its HTTP method, so the route needs more than get. The federation-objects teardown of a KeystoneIdentityBackend attached to a placed Keystone takes the same route: it authenticates with a token request, a POST authorized as create, and then deletes the identity provider, the mapping and the protocol, each a DELETE authorized as delete. The chart grants all three, because without them the teardown is refused on its first call and, since it retries an error rather than releasing the finalizer, the backend never leaves Terminating.
A placed BarbicanSecretStore cannot take that route to its OpenBao. The operator verifies the instance's server certificate against the instance's own CA bundle, and the URL it dials is the SAN that certificate carries, so the handshake has to reach the instance itself. Through the service proxy it would terminate at the API server. The connection is tunnelled at the TCP level instead, over pods/portforward, which leaves the URL and the certificate check as they are for an unplaced store. Those credentials need create on pods/portforward in the namespace the OpenBao instance runs in, plus get on services and list on endpointslices there to find the pod behind the Service. Without them the store holds ProvisioningReady at OpenBaoUnreachable, carrying the API server's forbidden message. The same condition and reason carry no ready endpoints for service <namespace>/<name> when the grants are in place but nothing ready sits behind the Service, for instance an OpenBao mid-rollout. A brownfield store whose spec.openBao.server.url names a server outside the cluster dials it directly and needs none of the three.
Both routes carry the operator's own calls and nothing else. Traffic between workloads on different clusters, a service on one cluster reaching Keystone on another, takes neither: it rides the public URLs the operator composes, and providing a route to them is the deployment's job, a gateway or a load balancer per target cluster.
How much of a target cluster an operator caches follows the registration Secret's namespaces key. Declared, each operator's cache on that cluster covers those namespaces and nothing else, since every LIST and WATCH it issues there is namespaced. That is what the access chart's per-namespace Roles are enough for, secrets included. What stays cluster-scoped is what has no namespace to be scoped to: namespaces themselves, clustersecretstores, nodes, and the auth-delegator ClusterRoleBinding a dedicated OpenBao instance is bound through — read-only always, writable behind authDelegatorBinding. nodes are read-only with no writable half at all. hypervisors.kvm.cloud.sap appears only behind hypervisorTraits, with get and patch. That set is the chart's ClusterRole.
Omit the key and the cluster is engaged with a cluster-wide cache, which is what every registration written before the key existed still gets. Each operator then watches the kinds it projects there and the inputs it reads (both enumerated in the next section) across every namespace, so its credentials need cluster-wide list and watch on all of them, secrets, roles and rolebindings included, and each operator process holds every object of every watched kind on that cluster resident for its lifetime. Two consequences come with that posture, and a hardened cluster should not accept them:
- Anyone who obtains the operator's target-cluster credentials can read every Secret in every namespace of that cluster, and a heap dump or an exec into one operator pod yields the same material.
- The memory the operator needs scales with the whole cluster, not with the namespaces it projects into.
A namespaces key that is present is also checked. A value that is empty, or that carries an entry which is not a DNS-1123 label, refuses the engagement: the provider logs the reason and builds no cluster, so every CR naming it reports TargetClusterUnavailable the way an unregistered name does.
The assignedNamespaces key names the namespaces a ControlPlane assigns to service owners on the cluster (see Assigned namespaces). It extends the scoped cache of the c5c3-operator alone, and only for the kinds that operator reads in an owner's namespace: the seven order kinds and Secrets. Every other informer of the c5c3-operator, and every informer of every other operator, stays on the namespaces set, so a service operator's Deployment or ConfigMap informer never lists a namespace where the access chart's assigned Role grants nothing. The key is checked like namespaces, and two more cases refuse the engagement: the key without namespaces beside it, because the assigned scope extends a placed one, and a namespace listed under both keys, because under namespaces it stays in every operator's cache for every kind. controller-runtime resolves each of those eight kinds on the target when it builds the cache, so with the key present the c5c3-operator engages only a target that serves the seven order CRDs. On any other target it logs failed to determine if ... is namespaced and retries with backoff.
A CR whose namespace is outside a declared set fails differently, on a cluster that engaged perfectly well. Its first cached read on the target returns controller-runtime's unknown namespace for the cache, which the credential gate records on the CR's first gate condition, carrying that message. That is SecretsReady on a Keystone, Barbican, Horizon, Glance, Placement, Neutron, Cinder or Nova. The other four start their pipeline on a different condition: TLSReady on an OVNCentral, CentralReady on an OVNChassis, ChassisReady on a NeutronMetadataAgent, and NovaReady on a NovaCompute. Nothing is created on the target: the reconciler writes nothing it could not first read. Neither retrying nor waiting changes a cache's scope, so the condition holds until the registration or the CR moves.
Assigned namespaces
A ControlPlane may assign a namespace on a target cluster to a service owner, with a spec.namespaceAssignments entry naming the cluster. The owner orders Keystone pieces there: a user with a KeystoneUser, whose credentials Secret the c5c3-operator writes beside it, and a KeystoneProject, a KeystoneRoleAssignment, a KeystoneCatalogEntry and a KeystoneApplicationCredential, whose Secret the c5c3-operator writes beside it as well. A MariaDBDatabase orders a database on the ControlPlane's MariaDB and receives its Secret there too, with the address the ControlPlane publishes in spec.infrastructure.publishedDatabaseEndpoint. The owner orders a vhost on the ControlPlane's managed message bus with a RabbitMQVhost, whose Secret carries the address spec.infrastructure.publishedMessagingEndpoint publishes. Every order lives on the target cluster. The chart serves that with three things:
values.assignedNamespaceslists the assigned namespaces. Each gets a Role<release>-target-cluster-access-assignedthat grants Secret writes and the order verbs (get,list,watch,updateandpatchonkeystoneapplicationcredentials,keystonecatalogentries,keystoneprojects,keystoneroleassignments,keystoneusers,mariadbdatabasesandrabbitmqvhosts, with theirstatusandfinalizerssubresources), and no workload kind. The chart creates none of these namespaces, and an entry may not also be invalues.namespaces: a namespace is either placed into or assigned. The Role of every placed namespace grants read on the seven order kinds as well, because the operator's order watches list every namespace of the registration. The Role of an assigned namespace grants read (get,listandwatch) on the mariadb-operator'sdatabasesfor a registration that still lists the assigned namespaces undernamespaces: the operator then watches the Database CRs of its MariaDBDatabase orders there too. A MariaDBDatabase's Database CR lives beside the MariaDB, in a placed namespace whose Role already grants the writes ondatabases. A Database CR in an assigned namespace wakes no order, whatever its labels: the operator maps one back to its order only from the namespace a ControlPlane places Keystone in, on the cluster it places it on.- The registration Secret's
assignedNamespaceskey lists every assigned namespace, and itsnamespaceskey the placed ones. A registration written before the second key existed, with the assigned namespaces undernamespaces, keeps working for the c5c3-operator and keeps failing the engagement of every service operator, so move those namespaces to the second key on such a cluster, in one edit that also removes them fromnamespaces. Upgrade the c5c3-operator before the move: a c5c3-operator without the key ignores it without a log line, its cache then leaves every assigned namespace, and no order there is reconciled. A rollback of the c5c3-operator past the key needs the namespaces moved back undernamespaces. Part 2 of the Quick Start (Orders) registers a target this way, andtests/e2e-orders-multicluster/orders/proves it on thee2e-orders-multiclusterjob. - The chart ships the CRDs of the order kinds in
crds/:c5c3.io_keystoneusers.yaml,c5c3.io_keystoneprojects.yaml,c5c3.io_keystoneroleassignments.yaml,c5c3.io_keystonecatalogentries.yaml,c5c3.io_keystoneapplicationcredentials.yaml,c5c3.io_mariadbdatabases.yamlandc5c3.io_rabbitmqvhosts.yaml. Helm installs a chart'scrds/directory on the first install and never upgrades it, so apply the files withkubectl apply -fon an upgrade that changes one.
Install the chart, CRDs included, before the cluster is registered. The operator decides whether to watch a kind on a cluster when it engages that cluster, so a CRD installed afterwards is watched only once the registration Secret changes, which engages the cluster again.
On a cluster registered before the chart shipped a CRD, keep this order:
- Run
helm upgrade. It adds read on the order kinds to every placed Role and widens the Roles of the assigned namespaces. - Apply the CRDs with
kubectl apply -f deploy/target-cluster/target-cluster-access/crds/. - Change the registration Secret, which engages the cluster again.
A cluster that serves an order kind while the Role of a registration namespace lacks the read fails its whole engagement, because the order watch never syncs.
values.namespaces still has to name at least one namespace, so a cluster used only for assignments declares a placed namespace too.
Namespaces on a compute cluster
A compute cluster runs nova-compute on its hypervisor nodes, beside the OVN chassis, the metadata agent, openstack-hypervisor-operator (hvo) and kvm-node-agent. The pool's nova-compute DaemonSet runs privileged as root on the host network, with a bidirectional /var/lib/nova mount and the node's libvirt and Open vSwitch sockets (see the node contract), so its namespace needs the same privilegedNamespaces entry as the chassis. Connect a Compute Cluster walks the attachment.
The figure shows what crosses between the two clusters. The table lists what each namespace on the compute cluster needs.
| Namespace | What runs there | What the operators write there | What it needs |
|---|---|---|---|
The Nova namespace: services.nova.namespace.name, else the ControlPlane's. novaRef is namespace-local, so every pool of the Nova runs there | The NovaCompute pods | The c5c3 operator writes the compute contract <nova>-compute-config and hvo's credentials <cp>-nova-hypervisor-operator-auth, both labelled nova.openstack.c5c3.io/compute-config-mirror: "true" | An entry in values.namespaces and in the registration's namespaces; an entry in privilegedNamespaces; an entry in hvo's --agent-namespaces (chart value controllerManager.manager.env.agentNamespaces, comma-separated, default monsoon3); no pod of the pool that tolerates kvm.cloud.sap/offboarding:NoExecute indefinitely |
The OVN central namespace: services.neutron.ovn.centralRef.namespace, else the ControlPlane's | The OVNChassis and NeutronMetadataAgent pods | The c5c3 operator writes <cp>-nova-metadata-agent-secret for the agents that name it, with the single key shared_secret and the label neutron.openstack.c5c3.io/metadata-shared-secret-mirror: "true" (see On a compute cluster). While the central runs on another cluster, the ovn-operator writes the chassis's copy of the central's client identity, <chassis>-ovn-client | Entries in values.namespaces and privilegedNamespaces, and the CA bundle the agent verifies the metadata Gateway with, which is not delivered and which the owner places. An entry in --agent-namespaces too, so hvo waits for the metadata agent |
hvo's --certificate-namespace | The libvirt-<node> Certificates and their tls-libvirt-<node> Secrets | Nothing | No entry in values.namespaces: the access chart grants Secret reads in every entry, and reading one of these Secrets opens every libvirtd of the migration domain (see Live migration) |
Both of the first two rows default to the ControlPlane's namespace, so one namespace usually holds all three node-level workloads. hvo then waits for the chassis and agent pods at offboarding too, and the offboarding taint evicts them unless the OVNChassis tolerates it. The agent takes its chassis's tolerations.
The c5c3 operator writes these Secrets with the registration's credentials. A registration scoped by namespaces whose Role cannot write Secrets in one of the namespaces reports the refused write on the ControlPlane's NovaReady: NovaComputeConfigError for the contract mirror, HypervisorOperatorError for the auth copy, and NovaMetadataAgentSecretError for the metadata copy.
The last pool of the Nova on the cluster deletes the two Secrets of the Nova namespace, the teardown of the last agent that names the metadata copy deletes that one, and the namespaces themselves stay, annotated helm.sh/resource-policy: keep.
Prerequisites on the management cluster
Target clusters need a cluster-scoped operator install. The registration Secrets live outside the operator's own namespace, and a namespace-scoped install (rbac.namespaceScoped: true) renders a Role in the release namespace and nothing anywhere else. Its chart therefore passes --clusters-namespace=, switching target clusters off: the operator engages no cluster and reads no Secret it has no grant for. Left on, the widened Secret informer would fail to sync and the manager would not start.
Every service operator watches, on each registered cluster, the kinds it projects there and the inputs it reads: the credential Secrets, the database CR for the services that have one, the OpenBao instance a Barbican secret store waits on, and the ESO SecretStores and ClusterSecretStores. A child event maps back to the owning CR through the ownership labels, an input event through the same mappers the management-side watches use. A Deployment deleted on a target, or a database password rotated there, is corrected within watch latency, whatever cluster the infrastructure runs on. Engagement is provider-level: a registered cluster serves one informer per watched kind to each service operator, whether or not a CR names it — see the target-cluster prerequisites above for what that costs. A watch is set up on a cluster only when that cluster serves the kind, and the check runs once, as the cluster is engaged. A CRD installed on a target after that is watched from the next engagement on, after a registration-Secret rotation or an operator restart.
A child is matched to its owner only in the namespace that owner lives in, which is the namespace its children land in. The ownership labels are readable and writable by anyone with access to the target namespace, so an object carrying them anywhere else is not treated as a child at all.
Nor is an event on a cluster the CR does not name. Because a leg is engaged on every registered cluster, an event only reaches a CR if that CR's targetClusterRef names the cluster the event came from — read from the CR on the management cluster, never from the object that raised the event. Both the child watches and the input watches apply it: without it, anyone able to create an object in one shared namespace on any registered cluster could name any CR in the fleet and have the operator reconcile it on their timing.
The watches against the management cluster register at builder time, so a controller whose children all live on a target still needs the child kinds installed at home. The four third-party CRD sets are therefore required on the management cluster whatever targetClusterRef says.
| CRD set | Installed in this repo from |
|---|---|
| mariadb-operator | The mariadb-operator-crds chart on https://mariadb-operator.github.io/mariadb-operator |
| external-secrets | The external-secrets chart on https://charts.external-secrets.io, which bundles its CRDs |
| openbao-operator | The digest-pinned chart artifact oci://ghcr.io/dc-tec/charts/openbao-operator |
| rabbitmq-cluster-operator | The upstream rabbitmq/cluster-operator Git repository at tag v2.22.5, applied as a Flux Kustomization over config/installation |
The RabbitMQ Cluster Operator is installed in full there, controller included. The shared message bus a ControlPlane declares at spec.infrastructure.messaging is provisioned in the ControlPlane's own namespace, so the controller that turns that RabbitmqCluster into a broker has to run on the management cluster. No part of the bus is written to a target. The RabbitMQ Messaging Topology Operator runs on the management cluster only, beside the Cluster Operator: the Vhost, User and Permission CRs of a RabbitMQVhost order live in the ControlPlane's namespace there, and nothing of the topology operator is installed on a target.
See Infrastructure Manifests for the Flux sources and the dependency order these four ride in.
Ownership and teardown on the target
A child written to a target cluster carries no owner references at all. Nothing on that cluster can resolve an owner into the management cluster, so ownership is recorded in three labels the operator stamps on every remote child:
| Label | Value |
|---|---|
openstack.c5c3.io/owner-kind | The owning CR's kind: Keystone, Barbican, Horizon, Glance, Placement, Cinder, Nova, NovaCompute, OVNCentral, OVNChassis, Neutron, NeutronMetadataAgent, or ControlPlane |
openstack.c5c3.io/owner-name | The owning CR's name |
openstack.c5c3.io/owner-namespace | The owning CR's namespace. For the twelve workload CRDs that is also the namespace the child lands in. A ControlPlane's remote children land in the namespace of the service it placed, so for them the label names the ControlPlane's namespace and the child sits elsewhere |
One remote child carries an owner reference: the Secret a KeystoneUser order delivers. The order lives on the target cluster itself, so the reference resolves there and the garbage collector reaps the Secret with the order.
The kind is part of the key because a Keystone and a Barbican of the same name in the same namespace project into one target namespace, and each has to select only its own. Installing the service CRDs on a target cluster is safe: no child there points at an owner, so no garbage collector has one to resolve.
A label value may not exceed 63 characters, so the name of a CR that names a target cluster may not either. A longer one is refused at the first child write, before anything is created.
One target namespace belongs to one management cluster
The three labels name the owning CR and say nothing about where it lives. Two management clusters that each run these operators, each hold a CR of the same kind and name in the same namespace, and each name the same target cluster would project into that namespace under one identity: either would take the other's children for its own, overwrite their specs, and delete them when its own CR is deleted. Give each management cluster its own namespace on a shared target, or its own target cluster.
Deleting a CR that names a target cluster tears its children down explicitly. The finalizer openstack.c5c3.io/remote-children goes on once targetClusterRef is set and the cluster it names resolves, and holds the CR in etcd until the sweep has run. The sweep deletes every object of that operator's projected kinds the CR owns, in the CR's namespace on the target — by the three labels, or by a controller owner reference an older operator left on it. It runs after the cleanup flows that delete objects by name, the MariaDB CRs and, for Keystone, the backup PushSecrets. That PushSecret flow holds the CR across passes until ESO has purged the kv-v2 paths, and a sweep running first would delete the PushSecrets out from under it. The order also matches the local one, where the cascade starts only once every finalizer has released.
The sweep does not wait for the deleted objects to leave etcd. A child holding a finalizer of its own, the MariaDB CRs being the slow ones, finishes terminating on the target after the CR is gone, the way it does under the local cascade.
Horizon needs no finalizer for a local deletion, since the cascade reclaims every child it composes. A Horizon that names a target cluster carries the remote-children finalizer and the same sweep.
A BarbicanSecretStore whose parent Barbican resolves to a target cluster carries that finalizer too, plus the annotation openstack.c5c3.io/children-cluster naming the cluster. Both are written before the first credentials write. Its deletion deletes the AppRole credentials Secret on the cluster its parent names and on the annotated one — the same cluster on every ordinary teardown — so neither a parent deleted first nor a parent re-created against a different target strands it. Only a Secret carrying this store's ownership labels is taken. The AppRole on the OpenBao instance stays: it is shared instance state, owned by the self-init contract rather than by one store.
A cluster deregistered under a CR that is already being deleted holds the deletion open. The pass requeues and the finalizer stays on until the two five-minute windows run out. Then it is released without a sweep, under a RemoteChildrenAbandoned warning event naming what went undeleted, and the children stay on the unreachable cluster, to be removed there by hand.
A placed NovaCompute comes down before the Nova it joins and before the OVNChassis on its nodes. Its teardown drains every node it holds through the Nova API, so the Nova has to still answer, and its pods wait on the chassis's /run/openvswitch socket. The drain holds the deletion until Nova reports each host empty (see The drain and Drain a Compute Node).
The four CRs of a placed network service come down in one order, because each of the first three reads something a later one owns. Delete the NeutronMetadataAgent first: its pods mount the chassis's /run/openvswitch socket. Then drain the OVNChassis before deleting it. Take the gateway label off the gateway nodes and wait for the evacuate Jobs, then take the chassis label off and wait for the chassis-del Jobs. Deleting the CR outright removes the DaemonSets and leaves the Southbound Chassis rows behind, the way deleting a Deployment leaves the rows its pods wrote (ovnchassis_controller.go); Drain a chassis node walks the drain. A chassis on another cluster than its central also takes its copy of the central's client Secret, <chassis>-ovn-client, with the DaemonSets: the sweep removes it on a target, and the garbage collector through its owner reference on the management cluster. The Neutron goes third, since it mounts the client Secret the central publishes, and the OVNCentral last. PersistentVolumeClaim is on that CR's sweep list for the sake of the <name>-backup claim, which no controller owns (ovncentral_controller.go), so on a target cluster the snapshots are deleted with the CR, as they are under the local cascade. Copy them off the claim first, or have spec.backup.s3 write each run to a bucket.
Children written once keep an owner reference from an older operator
A child written by an operator predating this contract carries an owner reference to the management-cluster CR. Children on the Server-Side Apply and CreateOrUpdate paths shed it on the operator's first pass over them. Children created once and never rewritten do not: the immutable config ConfigMaps and Secrets, the derived db-connection Secret, the fernet and credential key Secrets, the Certificates, and completed Jobs keep the stale reference for as long as they exist. The sweep still reaches them — it recognizes the reference as readily as the labels — so deleting the CR removes them. Until it is deleted the reference is a hazard on a target cluster that has the service CRDs installed: that cluster's garbage collector resolves it to a missing object and collects the child as an orphan. Delete such a CR and create it anew before the CRDs go onto its target cluster; everything it writes afterwards carries the labels alone.
ControlPlane placement
A ControlPlane names a cluster per service. Each of the eight service blocks takes its own ref, and a block without one keeps its service on the management cluster:
spec:
services:
keystone:
namespace:
name: identity
publicEndpoint: https://keystone.example.com/v3
targetClusterRef:
name: edge-1Six rules apply at admission on top of the name-only shape. A placed service needs a namespace block of its own, because a namespace exists on exactly one cluster and the ControlPlane's own namespace stays where the ControlPlane is. A placed catalog service (keystone, glance, placement, barbican, neutron, cinder, nova) needs a publicEndpoint or a gateway, since its catalog entry would otherwise advertise an in-cluster Service DNS name that resolves nowhere else; the dashboard is exempt, being reached by a browser rather than looked up in the catalog. And services sharing a namespace must name the same cluster, an unplaced one counting as naming the local one. An External-mode keystone may carry no ref at all: no workload is deployed there, so there is nothing to place.
The remaining two are Keystone's, because every other service validates its tokens against it. Keystone has to be published as soon as any other service is placed away from it, whether or not Keystone itself carries a ref: that service reaches Keystone over the public URL, and the rule above only demands publication of a service carrying a ref of its own. And Keystone's publicEndpoint must use https as soon as a cluster boundary separates it from anything — Keystone placed, or a service placed away from an unplaced Keystone. Either way that URL is the auth_url the operator renders the admin password and every service-account password next to, and those credentials cross the boundary to reach it on every mint, re-mint and delivery. Which of the two ends moved changes nothing about that.
A placed Keystone behind a private CA needs its anchor, which reaches K-ORC alone
K-ORC stays on the management cluster and dials the placed Keystone's https endpoint from there with nothing but the container's system trust store. A target published with a cert-manager-issued private CA — the default posture of this stack — therefore fails verification on every mint, and K-ORC swallows the resulting list failures into imports that hang on "Waiting for OpenStack resource to be created externally" rather than failing loud. Supply the anchor through services.keystone.caBundleSecretRef, a Secret in the ControlPlane's own namespace; the mint waits on KORCReady=False/WaitingForCABundle until it exists.
That bundle reaches K-ORC and nothing else: it is projected as the inline cacert key into the two K-ORC credentials Secrets, while a service that does not share Keystone's cluster gets the same https URL as its spec.keystoneEndpoint and renders it into [keystone_authtoken], which carries no option for a trust anchor. So admission forbids the bundle while any service sits on another cluster than Keystone. Placing Keystone away from a service requires a publicly trusted certificate on its endpoint; a Keystone every service is co-located with may use a private CA, because only K-ORC then dials the https URL at all.
The ref is frozen once the service exists. Adding it, removing it, or renaming it is rejected with targetClusterRef is immutable, here by the validating webhook alone: no CEL transition rule backs it, which leaves room to turn a move between clusters into a gated migration later. A service the previous revision did not declare may appear placed, that being the service's creation rather than a move. What the freeze prevents is a re-point leaving the workload, the database, the tenant store, and the credential material behind on a cluster nothing points at any more.
Whether the named cluster is registered is not checked at admission. An unknown name surfaces per CR as a condition instead, with reason TargetClusterUnavailable and the resolver's cluster not found.
The figure shows the three places a child of a ControlPlane can live and how each is tied to it. The table after it lists every object.
What a placed service takes with it, and what stays behind:
| Object | Created on |
|---|---|
The eight projected service CRs, each carrying spec.targetClusterRef verbatim | The management cluster |
The BarbicanSecretStore, GlanceBackend, CinderBackend and CinderBackupBackend CRs, which carry no ref and follow their parent's | The management cluster |
Every K-ORC CR: the admin ApplicationCredential, the catalog Service and Endpoint rows, the adopted Region, and the service accounts' User, Project, Domain, Role, and RoleAssignment | The management cluster |
The admin-credential chain: the minted application-credential Secret, its backup PushSecret, and the clouds.yaml ExternalSecret | The management cluster |
The per-generation service-account password Secrets the K-ORC User CRs reference | The management cluster |
| The tenant-store trio of the ControlPlane's own namespace | The management cluster |
MariaDB and Memcached | The service's cluster |
The ESO tenant-store trio: ServiceAccount, Certificate, SecretStore | The service's cluster |
The database-credential quartet: ServiceAccount, Certificate, VaultDynamicSecret, ExternalSecret | The service's cluster |
The admin-password ExternalSecret | The service's cluster |
Barbican's dedicated OpenBao ensemble, including the auth-delegator ClusterRoleBinding | The service's cluster |
A registration's service-account delivery objects: source Secret, PushSecret, ExternalSecret, and the Secret ESO materializes from it | The management cluster |
The mirrored ExternalSecret a placed built-in service reads, and the Secret ESO materializes from it | The service's cluster |
The metadata shared secret's Password generator and ExternalSecret, and the Secret ESO materializes from it | The service's cluster |
The hypervisor operator's auth Secret, <cp>-nova-hypervisor-operator-auth (while services.nova.hypervisorOperator is set) | The Nova's cluster, and a copy on every compute cluster a NovaCompute of the Nova runs on |
The metadata shared-secret copy, <cp>-nova-metadata-agent-secret | Every cluster a NeutronMetadataAgent in the OVN central's namespace names it on, in that namespace |
The OVN client-identity copy, <chassis>-ovn-client | The chassis's cluster, while its central projects onto another |
| The namespace a service is placed in | Both |
The namespace is on both because both sides need it: the projected CR lives in it on the management cluster, and the objects that follow the service land in it on the target. It is created under whichever lifecycle the block declares, and a Managed one is labelled and owned on both.
A Managed namespace and a scoped registration do not combine. RBAC cannot pre-scope writes to a namespace that does not exist yet, and a Managed namespace is created at reconcile time, after the grant would have had to name it; pre-creating it to get ahead of that parks the condition on NamespaceNotOwned, because the UID mark that would make it this ControlPlane's is absent. A service placed onto a cluster whose registration declares namespaces therefore takes the External lifecycle, against namespaces the target's owner created and granted. ControlPlane placement also reaches past the access chart in one place: Barbican's dedicated OpenBao ensemble reads the kubernetes EndpointSlices in the target's default namespace to compute the API server endpoint IPs the instance is configured with, and the kubernetes Service there for the address its Kubernetes auth method reviews tokens against, and default is not a namespace a service is placed in. Both reads are get on the object named kubernetes and run on every pass of a dedicated store, so a target that grants the EndpointSlice read alone, as operators before the Service read required, fails every pass with BarbicanReady=False/BarbicanOpenBaoError until the Service read is granted too.
Every remote child carries the three ownership labels above, with owner-kind: ControlPlane, plus the c5c3.io/controlplane-name and c5c3.io/controlplane-namespace pair the operator's own watches map a child back by, and no owner reference. A Managed namespace carries one mark more, on both clusters: the annotation c5c3.io/controlplane-uid, holding the owning CR's UID. Adoption requires it on a target cluster. The labels name a ControlPlane by name and namespace, and a target cluster is registerable from any number of management clusters — each able to run an openstack ControlPlane in an openstack namespace, the quickstart defaults. The UID is what tells those apart, and it is also the only part of the claim a target cluster cannot produce on its own: both labels are derived from the CR's name and namespace, so anyone holding patch on a namespace there can write them, while the UID is minted by the management cluster's API server. A namespace on a target whose mark names a different ControlPlane is neither adopted nor deleted, whatever its labels say.
A namespace whose mark is missing is not adopted either. Nothing left on the object says whether something stripped the mark off a namespace the operator created — the annotation is an ordinary, mutable one on a cluster the operator does not own, so a mutating admission policy or an annotation pruner can take it off — or whether the labels were written onto a namespace it never created. Adopting on the labels alone would let that single patch hand the operator a foreign namespace to project into and, at teardown, to cascade away. So the condition parks on NamespaceNotOwned and names both remedies: restore the annotation to this ControlPlane's UID, or pick a free name.
Teardown draws that one line lower. A namespace carrying the labels whose mark is missing is still deleted, because teardown is the last pass anything makes over it and refusing there would leak the namespace — with the service's database, PVC and tenant store in it — permanently. A mark naming a different ControlPlane still refuses on both sides.
The dedicated OpenBao instance's unseal Secret is the one exception: it keeps the OpenBaoCluster's controller reference, and both objects sit in the same namespace on the same cluster, so that reference resolves.
Where a URL points depends on where its two ends sit. korcAuthURL answers for the cluster a credentials document is read on. K-ORC runs on the management cluster, so the admin documents carry the Keystone public endpoint (services.keystone.publicEndpoint, else https://{gateway.hostname}/v3 derived from the gateway) as soon as Keystone is placed. A registration's clouds.yaml is rendered with no target-cluster ref at all, so it carries the management-cluster answer: the in-cluster Keystone URL while Keystone runs there, the public one once Keystone is placed, and the external authURL verbatim in External mode. A placed built-in service reads only the password key of that Secret, so the document's own auth_url never reaches it. The Keystone URL a dependent service configures comes from its own placement instead (keystoneEndpointFor with that service's targetClusterRef): the in-cluster one only when it and Keystone resolve to the same cluster, the public one otherwise. A placed catalog service registers its public URL on every interface it has, so the internal rows of the image, placement, and key-manager entries carry the public form too. Composing those URLs is all this does. Whether they are reachable from the other cluster is a routing question the deployment answers.
Deleting a ControlPlane that placed a service runs the same openstack.c5c3.io/remote-children finalizer the workload CRDs carry. It goes on once at least one service is placed and at least one cluster the spec names resolves — the namespaces on a resolvable cluster are created on that very pass, whatever a sibling ref does. The teardown that releases it keeps a fixed order. The projected KeystoneService registrations go first and are waited for, then the K-ORC CRs and the owned PushSecrets, on each placed cluster as well as at home, while the tenant store their OpenBao purge authenticates through is still alive. Then, per placed namespace: the service CRs, deleted on the management cluster and waited for, which is also what waits out each service operator's own remote sweep; then fourteen kinds selected by ownership label and deleted through the target's own credentials (MariaDB, Memcached, SecretStore, Certificate, ServiceAccount, Role, RoleBinding, Secret, ExternalSecret, PushSecret, VaultDynamicSecret, Password, OpenBaoTenant, OpenBaoCluster); then, under the Managed lifecycle, the namespace itself, on the target as well as at home. An External namespace survives on both, its residue deleted by name ahead of the label sweep so that its tenant-store trio goes last. The Barbican auth-delegator ClusterRoleBinding is deleted through Barbican's cluster, and both finalizers are released in one update. Because the sweep runs on the registered kubeconfig's credentials rather than on the management ClusterRole, Role and RoleBinding are swept although the operator holds no list verb on them at home.
A cluster that stops resolving while the ControlPlane is terminating holds the deletion open for the abandon window described above. Past it the operator emits the RemoteChildrenAbandoned warning naming the cluster and the namespace whose objects stay behind, and releases anyway. Only that cluster's copy stays: the Managed namespace on the management cluster is reachable and owned, so it is deleted as it always is — abandoning the unreachable half does not license leaking the other.
The operators that act on the kinds a placed service takes with it have to run on that service's cluster: mariadb-operator, memcached-operator, external-secrets, cert-manager, and, for a dedicated Barbican secret store, openbao-operator. The service operators are not among them. The Keystone, Horizon, Glance, Placement, Barbican, Neutron, Cinder, and Nova CRs stay on the management cluster, and their operators project onto the target from there. The OVNCentral a placed network service references is placed the same way: the ovn-operator reconciles it on the management cluster and projects its children onto the target its own targetClusterRef names. When the central and the network service land on different clusters, the central has to publish both databases with externallyReachable: true, because the Neutron pods then reach them over the node network rather than through cluster DNS. The same holds for an OVNChassis and the NeutronMetadataAgents beside it on another cluster than their central. The chassis also dials the relay when the central sets spec.relay.externallyReachable, and mounts a copy of the central's client Secret the ovn-operator writes onto the chassis's cluster.
The Secrets a placed service reads but does not create have to exist in its namespace on its own cluster: the dashboard's SECRET_KEY Secret, every Glance backend's S3 credentials Secret, the transport-URL Secret a placed Cinder's spec.messaging.secretRef names, and, on a brownfield database, the database credential Secret and the admin-password Secret the Keystone bootstrap reads. The transport URL is the one of them a ControlPlane delivers: it writes <cp>-cinder-messaging into that namespace, and <cp>-cinder-messaging-ca with the broker's CA bundle under ca.crt beside it while the bus runs TLS. The admin-password one is needed on both clusters, because the ControlPlane reads it in its own namespace at home to mint the K-ORC application credential.
Per-cluster capabilities
Three of the kinds these operators project are optional: the Gateway API HTTPRoute, the cert-manager Certificate Keystone issues for a managed database client keypair, and the autoscaling.k8s.io/v1VerticalPodAutoscaler of a workload that opts into vertical autoscaling. Whether a kind can be written is a property of the cluster the children land on, so that is the cluster asked. A CR without targetClusterRef takes the answer from the latch its operator probed against the management cluster's RESTMapper at setup, and a CRD installed there afterwards is picked up on the next operator restart. A CR that names a target cluster is probed against that cluster's RESTMapper on every pass, with nothing memoized in between. Install Gateway API on the target and the next reconcile writes the route.
The verdict decides what the pass does. A spec.gateway set against a cluster that does not serve HTTPRoute holds HTTPRouteReady=False with reason GatewayAPINotInstalled, under a message naming the cluster that lacks the CRD. An opt-in verticalAutoscaling block against a cluster that does not serve the VPA holds VPAReady=False with reason VPANotInstalled, naming the workloads that opt in; nothing is listed or deleted there, and the other conditions still converge. Keystone's Certificate delete, the one that runs when database TLS is switched off or pointed at a brownfield database, is skipped on a target without cert-manager, where no Certificate can exist. A probe that fails instead of answering is its own outcome: a target API server that is unreachable, or throttling the discovery request, sets the sub-reconciler's own condition (HTTPRouteReady, DatabaseTLSReady or VPAReady) to False with reason CapabilityProbeFailed, and the pass is retried with backoff. That condition is what keeps an aborted pass honest, since the aggregate Ready is re-computed and status.observedGeneration stamped on every exit path.
A watch leg is fixed when its cluster is engaged (see above); the probe is not. A CRD installed on a target after that takes effect on the next reconcile, while the drift watch for that kind stays absent until the registration Secret is rotated: an HTTPRoute deleted by hand on that cluster is corrected on the next periodic requeue instead of within watch latency.
Field indexes are registered on the management cluster only. Every index is on a CR kind, and the CRs exist there alone; a remote event finds its CR through the local cache, because the mappers pin every request they emit to the management cluster. Registering the indexes on the fleet would break cluster engagement outright, since the kubeconfig provider applies its stored indexes while engaging a cluster.
The API Service selector latch reads the target too: it goes through that cluster's own uncached API reader, so a lagging cache cannot re-widen the selector.
Interim constraints
KeystoneIdentityBackendcarries notargetClusterRef, and its reconciler stays management-side. A backend attached to a Keystone that names a target cluster looks for the parent's Deployment and projection Secret locally, where they do not exist, and holdsConfigProjected=Falsewith reasonWaitingForProjection.