Skip to content

ControlPlane CRD API Reference ​

Reference documentation for the c5c3-operator ControlPlane Custom Resource Definition. The ControlPlane CRD is the top-level aggregate that projects an OpenStack control plane: it owns the shared infrastructure references (database, cache), a curated set of per-service specs (today: Keystone, Horizon, Glance, Placement, and Barbican), and the K-ORC (OpenStack Resource Controller) integration that bootstraps and rotates the admin application credential. The reconciler (L2) materializes this aggregate into the individual per-service CRs — see the ControlPlane Reconciler reference for the reconciliation flow.

The figure shows where the ControlPlane CR sits on the management cluster and what the c5c3-operator creates from it.

The management cluster: GitOps (flux-operator, FluxInstance) and Secrets & PKI (cert-manager, OpenBao, External Secrets Operator) next to the c5c3-operator, whose ControlPlane CR creates infrastructure CRs, service CRs, and K-ORC resources. One service operator per service (keystone, horizon, glance, placement, barbican, neutron, cinder, nova, ovn) runs the OpenStack services, exposed via the Gateway API. The infrastructure (MariaDB Galera, Memcached, opt-in RabbitMQ, Garage S3) is managed by its own operators. Optional target clusters, registered via kubeconfig Secrets, receive projected service workloads.

The c5c3 API group also ships three companion kinds: SizingProfile (a cluster-scoped sizing profile a ControlPlane references), CredentialRotation (a one-shot credential-rotation request), and SecretAggregate (types-only at this level; the reconciler is deferred). All four are documented here. The registration kind KeystoneService and the seven order kinds, which order Keystone pieces, a database and message-bus vhosts from an assigned namespace, have pages of their own: KeystoneService CRD, KeystoneUser CRD, KeystoneProject CRD, KeystoneRoleAssignment CRD, KeystoneCatalogEntry CRD, KeystoneApplicationCredential CRD, MariaDBDatabase CRD and RabbitMQVhost CRD.

The API surface is intentionally smaller than the Keystone CRD: the ControlPlane curates a subset of each service's knobs and derives the rest from operator policy, rather than re-exposing every service field through the aggregate.

API Group and Version ​

FieldValue
Groupc5c3.io
Versionv1alpha1
ScopeNamespaced (SizingProfile: Cluster)
KindList Kind
ControlPlaneControlPlaneList
SizingProfileSizingProfileList
CredentialRotationCredentialRotationList
SecretAggregateSecretAggregateList

Import path:

go
import c5c3v1alpha1 "github.com/c5c3/cobaltcore/operators/c5c3/api/v1alpha1"

Scheme registration:

The init() functions in controlplane_types.go, sizing_types.go, credentialrotation_types.go, and secretaggregate_types.go each register their kind (and List kind) with the shared SchemeBuilder. The operator entrypoint registers the group with the manager's scheme through internal/common/bootstrap (which calls AddToScheme), so every kind in the group is available to the manager:

go
utilruntime.Must(c5c3v1alpha1.AddToScheme(scheme))

The manager runs with LeaderElectionID c5c3.openstack.c5c3.io.


Resource Shape — ControlPlane ​

yaml
apiVersion: c5c3.io/v1alpha1
kind: ControlPlane
metadata:
  name: controlplane
  namespace: openstack
spec:
  openStackRelease: "2026.1"
  region: RegionOne
  regionDescription: CobaltCore region
  infrastructure:
    database:
      clusterRef:
        name: mariadb
      database: keystone
      # credentialsMode selects how the service DB credential is provisioned.
      # Managed mode defaults to Dynamic (engine-issued, short-lived credentials
      # from the OpenBao database engine); set Static to opt out (migration
      # staging / brownfield). reconcileDBCredentials projects a per-ControlPlane
      # VaultDynamicSecret generator (Dynamic) or the stage-(a) KV-backed
      # ExternalSecret (Static). Dynamic requires clusterRef (managed mode).
      # credentialsMode: Dynamic
      # Per-service override: services.<svc>.databaseCredentialsMode (Static |
      # Dynamic) overrides this ControlPlane-wide mode for one service on the
      # shared database, so a staged migration can run one service Dynamic while
      # another stays Static; empty (the default) inherits the mode above.
      # In managed mode (clusterRef set) database.secretRef is
      # operator-owned — reconcileDBCredentials materialises a per-ControlPlane
      # Secret and the projected Keystone CR's secretRef is overridden to
      # {name}-keystone-db-credentials (key "password"); the value below is not
      # what Keystone consumes. A brownfield CR (database.host) must instead
      # supply its own database.secretRef Secret out-of-band.
      secretRef:
        name: keystone-db-credentials
        key: password
    cache:
      backend: dogpile.cache.pymemcache
      clusterRef:
        name: memcached
  services:
    keystone:
      rotationInterval: 168h
      gateway:
        parentRef:
          name: openstack-gw
        hostname: keystone.example.com
        path: /
      publicEndpoint: https://keystone.example.com/v3
  sizing:
    keystone:
      api:
        replicas: 3
  korc:
    adminCredential:
      cloudCredentialsRef:
        cloudName: admin
        secretName: k-orc-clouds-yaml
      passwordSecretRef:
        name: keystone-admin
        key: password
      applicationCredential:
        restricted: true
        rotation:
          mode: PasswordDriven
      bootstrapResources:
        - kind: Project
          name: admin
        - kind: Role
          name: admin
status:
  conditions:
    - type: Ready
      status: "True"
      reason: AllReady
      message: All sub-conditions are ready
      lastTransitionTime: "2026-06-02T00:00:00Z"
  observedGeneration: 4
  updatePhase: Idle
  services:
    - name: keystone
      ready: true
      release: "2026.1"
  adminApplicationCredential:
    id: 6f3c…
    restricted: true
    lastRotation: "2026-06-02T00:00:00Z"

Printer Columns ​

kubectl get controlplanes displays these columns:

ColumnJSON PathType
Ready.status.conditions[?(@.type=='Ready')].statusstring
Release.spec.openStackReleasestring
Age.metadata.creationTimestampdate

The status subresource is enabled via +kubebuilder:subresource:status.


Resource Shape — CredentialRotation ​

yaml
apiVersion: c5c3.io/v1alpha1
kind: CredentialRotation
metadata:
  name: rotate-admin
  namespace: openstack
spec:
  target: adminApplicationCredential
  bootstrap: false
  reMint: true
status:
  conditions:
    - type: Ready
      status: "True"
      reason: RotationTriggered
      lastTransitionTime: "2026-06-02T00:00:00Z"
  observedGeneration: 1

Printer Columns ​

kubectl get credentialrotations displays these columns:

ColumnJSON PathType
Target.spec.targetstring
Ready.status.conditions[?(@.type=='Ready')].statusstring
Age.metadata.creationTimestampdate

ControlPlaneSpec ​

FieldTypeRequiredDefaultDescription
openStackReleasestringYes—OpenStack release the control plane targets (e.g. "2026.1"). The reconciler (L2) projects this into each service CR's image tag. Must match the date-based release pattern ^\d{4}\.[12]$, enforced by both the CRD +kubebuilder:validation:Pattern marker and the validating webhook. Upgrades are allowed on update, but downgrades are rejected (Keystone DB migrations are forward-only). Stays required in both keystone modes; in External mode it is advisory — no images are deployed, so the value only needs to match the external installation's release at the phase-3 managed takeover. The validating webhook rejects a value below 2026.1, the oldest supported release, on create and on change, and warns on an unchanged one.
imagePullPolicycorev1.PullPolicyNo""Pull policy projected into spec.image.pullPolicy of every service CR the ControlPlane creates (Keystone, Horizon, Glance, Placement, Barbican, Neutron, Cinder, Nova) and into Keystone's spec.federation.proxyImage.pullPolicy. A pullPolicy on a services.<service>.image or services.keystone.federationProxyImage override wins for that image. Empty leaves the child's field absent, and the child's operator resolves the policy: its --default-image-pull-policy, else IfNotPresent for a digest and Always for a tag (see ImageSpec). The reconcilers assign the image on every pass, so clearing the field removes it from every child on the next reconcile. Enum Always, IfNotPresent, Never (schema-only, no webhook twin). Legal in External mode, where it has no effect on Keystone, which has no child. NovaCompute, OVNCentral, OVNChassis and NeutronMetadataAgent CRs are not created by the ControlPlane and keep their own setting. A child CRD older than the c5c3-operator prunes the projected pullPolicy without an error, so the setting holds only while the service operator charts come from the same release as the c5c3-operator chart.
regionstringNo"RegionOne"OpenStack region name applied across the control plane. Projected into the Keystone CR's bootstrap.region. Defaulted to RegionOne by both the +kubebuilder:default marker (normal admission) and the defaulting webhook (callers that bypass the CRD default). Bounded by pattern ^[^,]+$ and 255 characters, K-ORC's bounds on OpenStackName — the value is also projected into the adopted Region CR's spec.resource.name and into the Region import every service registration places its endpoints in, and since the field is immutable a value the Region CR rejects could not be corrected in place. Immutable after create (the projected bootstrap.region is itself immutable).
regionDescriptionstringNo""Description pushed into the Keystone region the ControlPlane adopts as a managed K-ORC Region ({controlplane.Name}-region). Empty keeps Keystone's description empty: K-ORC applies the spec value on every resync, so a description set by hand in Keystone is overwritten either way. A non-empty value is pushed once the region is adopted (the Region CR reports status.id); edits take effect on the next reconcile. Mutable. Bounded at 255 characters, K-ORC's bound on RegionResourceSpec.Description. Forbidden in External mode (webhook): no Region CR is adopted against a pre-existing installation. Upgrading an existing control plane: the Region CR drives the Keystone description whether or not this field is set, so the first reconcile after the upgrade clears a description an admin set by hand (openstack region set ... --description). Copy that value into spec.regionDescription before rolling out this version if you want to keep it; the reconciler emits a Warning RegionDescriptionCleared event on the ControlPlane when it adopts the region with this field empty on a control plane whose catalog was already registered — the upgrade case. A fresh install, whose region has no description to lose, stays silent.
infrastructure*InfrastructureSpecConditionalmanaged-mode defaultedShared backing services (database, cache) the control plane's services connect to. Required when services.keystone.mode is Managed (or unset, or services.keystone unset) — the defaulting webhook materializes a managed-mode database/cache when omitted, and the validating webhook rejects a non-External ControlPlane without it. Forbidden in External mode (an External ControlPlane provisions no backing services; phase 2 relaxes this to optional). The mode-conditional required/forbidden rule is webhook-enforced because CEL cannot span spec.infrastructure and spec.services.keystone; see InfrastructureSpec and Validation Rules.
servicesServicesSpecYes—Per-service configuration projected into the individual service CRs.
sizing*ControlPlaneSizingSpecNonil (the Standard profile)Sizes and places every component the ControlPlane creates: the service API, worker, and Job pods, the Keystone federation proxy, and the managed backing services. A built-in profile (Minimal or Standard), overlaid by a referenced cluster-scoped SizingProfile, overlaid by the values set here. Unset resolves to Standard, which projects the same children as before the field existed. Forbidden in External mode (webhook). See SizingSpec.
globalPolicyOverrides*commonv1.PolicySpecNoniloslo.policy overrides applied across every service in the control plane. Per-service overrides (e.g. services.keystone.policyOverrides) take precedence over these global rules when both are set.
globalExtraConfigmap[string]map[string]stringNonilFree-form INI sections (section → key → value) applied to every INI-configured service the control plane declares (Keystone, Glance, Placement, and Barbican today). Merged key by key with each service's own extraConfig: sections are unioned, the per-service value wins per key, and a global key with no per-service counterpart stays effective, before the merged result is projected onto that service's child. Never applies to Horizon, which renders flat Django settings rather than INI. Legal but inert in External mode, the same posture as globalPolicyOverrides. Admission validates the merged result per declared INI service against that service's option catalog and operator-owned-key registry — see ExtraConfig admission checks.
secretStoreRef*commonv1.SecretStoreRefSpecNonil (the operator-provisioned SecretStore openbao-tenant-store)Selects the External Secrets store the control plane routes its ExternalSecrets and backup PushSecrets through, and is projected onto the Keystone, Horizon, Glance, Placement, Neutron, Cinder, Nova and Barbican children — so operators normally set the store here rather than on the individual service CRs. Mutable: switching stores is supported — the operator moves the fernet/credential key material in place, never re-creating it. When omitted, the c5c3-operator provisions the namespaced SecretStore openbao-tenant-store in the ControlPlane namespace and routes the control plane through it. Set the field only to use a store you manage yourself: {kind: SecretStore, name: <store>}, resolved in the ControlPlane's own namespace; the shared openbao-cluster-store lacks the per-ControlPlane grants and is not an option. See SecretStoreRefSpec.
korcKORCSpecNodefaultedK-ORC integration used to bootstrap and rotate the admin application credential and any declared bootstrap resources. Optional — the defaulting webhook fills adminCredential (cloudCredentialsRef, passwordSecretRef, applicationCredential restriction/rotation) from well-known defaults when omitted.
namespaceAssignments[]NamespaceAssignmentSpecNonilNamespaces on the management cluster or on a registered target cluster assigned to the owners of services the control plane does not manage, each with the Keystone roles an order from it may request and whether it may order catalog entries. listType=atomic, at most 32 entries, each (namespace, targetClusterRef.name) pair unique. It sits beside korc because the consent also covers database and message-bus orders. Reported in status.namespaceAssignments and by NamespaceAssignmentsReady. Legal in both keystone modes. See NamespaceAssignmentSpec.

SecretStoreRefSpec ​

spec.secretStoreRef selects the External Secrets store the control plane and its projected children route their ExternalSecrets and backup PushSecrets through. It reuses the shared commonv1.SecretStoreRefSpec — a kind (ClusterSecretStore | SecretStore, defaulted to ClusterSecretStore) plus a required non-empty name; see the canonical two-field table in the Keystone CRD → SecretStoreRefSpec.

When omitted, the c5c3-operator provisions the namespaced SecretStoreopenbao-tenant-store in the ControlPlane namespace and routes the control plane through it. Set the field only to use a store you manage yourself: {kind: SecretStore, name: <store>}, resolved in the ControlPlane's own namespace (there is no namespace field). The store's OpenBao identity needs the grants of the eso-tenant policy. The shared ClusterSecretStoreopenbao-cluster-store is not an option: it admits only the namespaces openstack and shared-services, and its eso-management policy reads only bootstrap/* and infrastructure/*, so the ControlPlane's PushSecrets and its openstack/keystone/... reads fail. The field is mutable — switching stores is supported, and the operator moves the fernet/credential key material in place rather than re-creating it. Its value is projected onto the Keystone, Horizon, Glance, Placement, Neutron, Cinder, Nova and Barbican children, so operators normally set it on the ControlPlane rather than on the individual service CRs.


InfrastructureSpec ​

Declares the shared backing services for the control plane. All three fields reuse the canonical commonv1 shapes so the ControlPlane and the per-service CRs validate the database, cache, and messaging the same way. Database and cache are always present. messaging is an optional pointer: a ControlPlane that declares no message bus gets none.

spec.infrastructure (and each of its database / cache blocks) may be omitted entirely on a minimal managed-mode ControlPlane. The defaulting webhook constructs a managed-mode database (clusterRef: openstack-db, database: keystone, secretRef.name: keystone-db) and a managed-mode cache (clusterRef: openstack-memcached, backend: dogpile.cache.pymemcache) before validation runs. The two managed clusterRef names are only invented when the brownfield discriminator (database.host / cache.servers) is unset, so the database/cache XOR rule below still passes for a brownfield CR — the webhook never coerces an explicit brownfield endpoint into managed mode. See the Defaulting Webhook for the exact conditions and mechanism.

The defaulted database.secretRef.name (keystone-db) is a managed-mode convenience name only — in managed mode database.secretRef is operator-owned and the projected Keystone CR's secretRef is overridden to a per-ControlPlane Secret, and a brownfield CR must supply its own. See the database field notes below.

FieldTypeRequiredDefaultDescription
databasecommonv1.DatabaseSpecNomanaged clusterRef: openstack-db, database: keystone, secretRef.name: keystone-dbMariaDB connection parameters shared by the control plane. Supports managed (clusterRef) and brownfield (host) modes; exactly one must hold after defaulting (enforced by the CRD CEL XValidation rule and the validating webhook — see Validation Rules). Optional because the defaulting webhook materializes a managed-mode block when omitted. database.secretRef ownership: in managed mode this reference is operator-owned — reconcileDBCredentials materialises a per-ControlPlane DB-credential Secret and the reconciler overrides the projected Keystone CR's spec.database.secretRef to point at it, so the keystone-db default secretRef.name is only a managed-mode convenience name (it is not what Keystone consumes and no longer resolves to a cluster Secret). A brownfield ControlPlane (database.host set, no clusterRef) MUST supply its own database.secretRef Secret out-of-band — the operator projects no ExternalSecret in brownfield mode. See managed-mode provisioning below.
cachecommonv1.CacheSpecNomanaged clusterRef: openstack-memcached, backend: dogpile.cache.pymemcacheMemcached configuration shared by the control plane. Supports managed (clusterRef) and brownfield (servers) modes; exactly one must hold after defaulting (enforced by the CRD CEL XValidation rule and the validating webhook). Optional because the defaulting webhook materializes a managed-mode block when omitted.
messaging*commonv1.MessagingSpecNonil (the defaulting webhook never materializes the block)The shared RabbitMQ message bus. Opt-in: a ControlPlane that omits the block provisions no broker, and the webhook invents nothing for it, unlike database and cache. In managed mode (clusterRef) the reconciler provisions one RabbitmqCluster in the ControlPlane's own namespace whether or not a service consumes it: a bus is shared across services by nature, so declaring it is what asks for it. Brownfield mode (secretRef) attaches to an existing broker and provisions nothing. Adding the block to a live ControlPlane is allowed; removing it is rejected in both modes. See MessagingSpec.
publishedDatabaseEndpointstringNo""The host:port a consumer on another cluster than the MariaDB's reaches the shared database at (MaxLength=262, and a pattern that admits a host of [a-zA-Z0-9._-] characters or a bracketed IPv6 literal, a colon, and a port from 1 to 65535 without a leading zero). The operator publishes nothing itself: the platform operator exposes the port by their own means, for example a LoadBalancer Service or a TCPRoute. Only MariaDBDatabase orders read it: an order on a cluster other than Keystone's receives this address, and without it reports DatabaseNotPublished. Under tls.mode: verify-full the MariaDB server certificate must carry the host as a subject alternative name. No webhook rule applies.
publishedMessagingEndpointstringNo""The AMQP address, host:port, a consumer on another cluster than the broker's reaches the managed bus at (MaxLength=262, pattern ^([a-zA-Z0-9._-]+|\[[0-9a-fA-F:.]+\]):[0-9]{1,5}$, an IPv6 host in brackets). The operator publishes nothing itself: the platform operator exposes the broker port by their own means and records the address here. RabbitMQVhost orders on a target cluster alone read it; without it they report MessagingNotPublished. No webhook rule.

In managed mode the reconciler provisions an owned MariaDB CR (named after database.clusterRef.name) and an owned Memcached CR (named after cache.clusterRef.name) in the ControlPlane's own namespace. The Keystone CR the reconciler projects points at the same DatabaseSpec / CacheSpec verbatim, so the aggregate and the projected service agree on the backing services.

The managed-mode MariaDB topology is derived from spec.infrastructure.database.replicas (minimum 1). An unset value is taken from the ControlPlane's sizing at admission and stored: 3 under the Standard profile, a production-shaped Galera HA cluster with galera.enabled, and 1 under Minimal, a single-instance, non-Galera MariaDB that schedules on a constrained cluster such as a single-node kind. database.storageSize is resolved the same way (100Gi under Standard, 512Mi under Minimal). cache.replicas drives the Memcached replica count; an unset value follows the sizing on every pass (3 under Standard). Both are only honoured in managed mode, and a ControlPlane that adopts a pre-existing MariaDB/Memcached leaves its topology untouched. Unlike database.replicas, which is immutable after creation, cache.replicas stays mutable: a change is re-projected in place, in both directions, onto every Memcached the ControlPlane owns, whether in its own namespace, in a service namespace, or on a target cluster. The container resources and placement of the managed instances come from spec.sizing.database, .cache, and .messaging.

database.secretRef is operator-owned in managed mode. The DatabaseSpec is projected onto the Keystone CR verbatim except for its secretRef. In managed mode the reconcileDBCredentials sub-reconciler create-or-updates a per-ControlPlane DB-credential ExternalSecret named {controlplane.Name}-keystone-db-credentials in the ControlPlane namespace (reading OpenBao path openstack/keystone/{namespace}/{name}/db), and reconcileKeystone then overrides the projected Keystone CR's spec.database.secretRef to {name: "{controlplane.Name}-keystone-db-credentials", key: "password"} — the operator-owned Secret. The source cp.Spec is left untouched; only the projected child's secretRef value is reassigned. The Secret that Keystone actually consumes is therefore the one this reconciler materialises, not a Secret literally named after database.secretRef.name. Consequently the keystone-db default for database.secretRef.name is a managed-mode convenience name only: the production deploy stack ships no keystone-db ExternalSecret (only the kind overlay materialises one, for standalone Keystone instances), and a managed ControlPlane never consumes it either way.

A brownfield ControlPlane (database.host set, clusterRef == nil) MUST supply spec.infrastructure.database.secretRef pointing to a Secret it owns out-of-band. In brownfield mode reconcileDBCredentials is a no-op (it reports DBCredentialsReady=True, reason BrownfieldUserSuppliedCredential) and projects no ExternalSecret, so the operator never materialises the Secret — and the keystone-db default no longer resolves to a cluster Secret. See the ControlPlane Reconciler reference for the reconcileDBCredentials flow.


MessagingSpec ​

spec.infrastructure.messaging declares the RabbitMQ message bus the control plane's services share. The block is optional and the defaulting webhook never creates it: no messaging block, no broker.

Managed mode (clusterRef) hands the bus to the RabbitMQ Cluster Operator. The reconciler create-or-updates one owned RabbitmqCluster CR of that name. Brownfield mode (secretRef) points at a Secret that already holds a complete rabbit:// transport URL, and nothing is provisioned.

A managed bus also serves RabbitMQVhost orders: a vhost and a user of its own per order, created through the RabbitMQ Messaging Topology Operator. A brownfield bus serves none, because the operator holds no admin account on it. An order on a target cluster reaches the bus at publishedMessagingEndpoint.

The managed bus is the one backing-service class enumerated at the ControlPlane's own namespace regardless of consumers. database and cache are enumerated at the namespace of the service that resolves to them, and a shared instance every service opted out of is skipped. A message bus is shared across services by nature, so a declared bus is a wanted bus, even before the first consumer exists. See reconcileInfrastructure.

FieldTypeRequiredDefaultDescription
clusterRef*corev1.LocalObjectReferenceConditional (XOR with secretRef)openstack-rabbitmq (webhook, only when secretRef is unset)Managed mode. Names the RabbitmqCluster CR the reconciler owns in the ControlPlane's namespace.
secretRef*commonv1.SecretRefSpecConditional (XOR with clusterRef)key: transport_urlBrownfield mode. Names a Secret holding the complete rabbit://user:password@host:port/ transport URL under key. The Secret is read, never written.
replicasint32Nothe sizing's messaging.replicas (3 under Standard, Minimum 1)Number of RabbitMQ pods in the managed cluster. An unset value follows spec.sizing on every pass. Projected onto the owned CR's spec.replicas and re-projected when the value or the sizing changes, so the broker scales with the field. Growing is an in-place update; shrinking is a delete-and-recreate, because the RabbitMQ Cluster Operator refuses an in-place scale-down — the reconciler deletes the owned RabbitmqCluster and creates it again at the declared count, which loses the broker's volumes and everything on them. Because an unset value resolves to the sizing, an edit that merely drops the line off a larger broker, or switches it to a smaller profile, arrives as a scale-down nobody typed, so the recreate is opt-in: without the c5c3.io/allow-messaging-recreate: "true" annotation on the ControlPlane the reconciler refuses the shrink, leaves the broker running at its current size, and reports InfrastructureReady=False / RabbitMQError naming the annotation. Ignored in brownfield mode. Pin it to 1 at creation on a constrained cluster such as a single-node kind.
tls*MessagingTLSSpecNonil (plaintext)Client trust for the broker connection. Brownfield mode only: a managed RabbitmqCluster is provisioned without a TLS listener, so the webhook rejects tls beside a clusterRef.

Defaulting. The block is never materialized; only its leaves are filled, and only when it is present. clusterRef.name defaults to openstack-rabbitmq (DefaultMessagingClusterRefName) and is brownfield-guarded the way database.clusterRef.name is: a secretRef in the CR keeps the webhook from inventing a clusterRef beside it, so the XOR rule still passes. An empty secretRef.key becomes transport_url (commonv1.DefaultTransportURLSecretKey) and an empty tls.caBundleSecretRef.key becomes ca.crt. See the Defaulting Webhook.

Validation. A type-level CEL rule on commonv1.MessagingSpec enforces has(self.clusterRef) != has(self.secretRef) with the message exactly one of clusterRef or secretRef must be set, so the XOR holds for a webhook-bypassed CR too. The validating webhook mirrors it at spec.infrastructure.messaging and adds three checks: a brownfield secretRef.name and a tls.caBundleSecretRef.name cannot be empty (field.Required), and a tls block beside a managed clusterRef is rejected outright — the reconciler projects the replica count and the sizing but no listener onto the owned cluster, so a managed broker comes up on the RabbitMQ Cluster Operator's default, plaintext listener. External keystone mode forbids spec.infrastructure as a whole, and with it the messaging block. On update the block is a one-way add in both modes: it can be declared on a live ControlPlane and never removed again — the brownfield removal is rejected too, because admitting it would launder the mode freeze into a two-step flip. Neither the mode nor a managed clusterRef.name may change; replicas, secretRef and tls stay editable. See Update-only immutability rules.

Managed, with a single pod for a constrained cluster:

yaml
spec:
  infrastructure:
    messaging:
      clusterRef:
        name: openstack-rabbitmq
      replicas: 1

Brownfield, attaching to a broker someone else runs:

yaml
spec:
  infrastructure:
    messaging:
      secretRef:
        name: openstack-transport-url
        key: transport_url

A consumer in the ControlPlane's namespace reaches the managed bus at <clusterRef.name>.<namespace>.svc and reads the broker credential from the Secret the RabbitMQ Cluster Operator publishes at the cluster's status.defaultUser.secretReference. The shared resolver that turns both modes into one derived <instance>-transport-url Secret lands with the first consumer, together with the answer to how a service placed on a target cluster reaches a bus that runs beside the ControlPlane (issue #906).

MessagingTLSSpec ​

FieldTypeRequiredDefaultDescription
caBundleSecretRefcommonv1.SecretRefSpecYeskey: ca.crtThe Secret holding the CA bundle a consumer trusts when it verifies the broker endpoint.

The block carries client trust only: a consumer renders it into [oslo_messaging_rabbit] as ssl = true and ssl_ca_file. Server-side TLS on a managed RabbitmqCluster (its own spec.tls) is a platform concern and is not projected from here, the posture the managed MariaDB already takes on TLS and issuer refs.

That makes tls a brownfield-only leaf, and the webhook enforces it: a managed bus is provisioned without a TLS listener, so a tls block beside a clusterRef would ask for an encrypted connection nothing sets up — a mismatch that would surface only once the first consumer rendered ssl = true against a plaintext broker. In brownfield mode the broker's listeners belong to whoever runs it, so the block only says which CA the consumer trusts.

A brownfield bus with tls is also the precondition of services.nova.remoteCompute: a compute cluster reaches the bus across a cluster boundary, and this bundle is the only trust anchor the remote compute contract carries.

There is no enabled flag: any present tls block asks for an encrypted connection, a nil block for a plaintext one.


ServicesSpec ​

Declares the per-service configuration of the control plane. Today Keystone, the Horizon dashboard, the Glance image service, the Placement service, the Barbican key manager, the Neutron network service, the Cinder block-storage service, and the Nova compute service are modeled; additional services are added as fields as the operator grows.

FieldTypeRequiredDefaultDescription
keystone*ServiceKeystoneSpecNonilConfiguration for the Keystone service projected by the reconciler. Optional: when unset, this ControlPlane manages no Keystone service (staged adoption, or an externally-managed Keystone) and KeystoneReady is reported as not-managed. Flipping it from set to nil preserves the previously-projected Keystone child by default — deleting it would cascade to the child's irreplaceable <name>-credential-keys Secret (and its OpenBao backup), so an accidental unset is fail-safe. Set the c5c3.io/allow-keystone-deletion: "true" annotation on the ControlPlane to opt in to deleting the child on unset.
horizon*ServiceHorizonSpecNonilConfiguration for the Horizon dashboard projected by the reconciler. Optional: when unset, this ControlPlane manages no dashboard and HorizonReady is reported as not-managed (HorizonNotManaged), so the aggregate Ready is not blocked. Forbidden in External mode — the dashboard needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Horizon child by default; set the c5c3.io/allow-horizon-deletion: "true" annotation to opt in to deleting the child on unset.
glance*ServiceGlanceSpecNonilConfiguration for the Glance image service projected by the reconciler. Optional: when unset, this ControlPlane manages no image service and GlanceReady is reported as not-managed (GlanceNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady — Glance validates every token against the ControlPlane's Keystone child. Forbidden in External mode — Glance needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Glance child by default; set the c5c3.io/allow-glance-deletion: "true" annotation to opt in to deleting the child (and its GlanceBackend children and DB-credential ExternalSecret) on unset. The dynamic DB-credential generator is torn down on unset regardless of the annotation — preserving a running service does not imply preserving a credential minter.
placement*ServicePlacementSpecNonilConfiguration for the Placement service projected by the reconciler. Optional: when unset, this ControlPlane manages no placement service and PlacementReady is reported as not-managed (PlacementNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady (Placement validates every token against the ControlPlane's Keystone child) and on the AccountReady of the KeystoneService registration the reconciler projects for it. Forbidden in External mode: Placement needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Placement child by default; set the c5c3.io/allow-placement-deletion: "true" annotation to opt in to deleting the child (with its DB-credential ExternalSecret and the placement catalog CRs) on unset. The dynamic DB-credential generator is torn down on unset regardless of the annotation, so no credential minter outlives the service.
barbican*ServiceBarbicanSpecNonilConfiguration for the Barbican key manager projected by the reconciler. Optional: when unset, this ControlPlane manages no key manager and BarbicanReady is reported as not-managed (BarbicanNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady (Barbican validates every token against the ControlPlane's Keystone child) and on the AccountReady of the KeystoneService registration the reconciler projects for it. Forbidden in External mode: Barbican needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Barbican child by default; set the c5c3.io/allow-barbican-deletion: "true" annotation to opt in to deleting the child (with its BarbicanSecretStore, its DB-credential ExternalSecret, and the key-manager catalog CRs) on unset. The dynamic DB-credential generator is torn down on unset regardless of the annotation. Destroying a dedicated OpenBao instance and the secrets in it takes a second annotation on top, c5c3.io/allow-barbican-secret-store-data-deletion: "true"; see ServiceBarbicanSecretStoreSpec.
neutron*ServiceNeutronSpecNonilConfiguration for the Neutron network service projected by the reconciler. Optional: when unset, this ControlPlane manages no network service and NeutronReady is reported as not-managed (NeutronNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady (Neutron validates every token against the ControlPlane's Keystone child), on OVNReady (the ML2/OVN mechanism driver writes every network into the referenced central's Northbound database), and on the AccountReady of the KeystoneService registration the reconciler projects for it. It also requires spec.infrastructure.messaging: the Neutron CRD requires spec.messaging, and the child's transport URL is derived from the shared bus, so the webhook rejects a neutron block declared without one. Forbidden in External mode: Neutron needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Neutron child by default; set the c5c3.io/allow-neutron-deletion: "true" annotation to opt in to deleting the child (with its DB-credential ExternalSecret, the two messaging Secrets, and the network catalog registration) on unset. The dynamic DB-credential generator is torn down on unset regardless of the annotation. The referenced OVNCentral is never deleted: the ControlPlane only reads it.
cinder*ServiceCinderSpecNonilConfiguration for the Cinder block-storage service projected by the reconciler. Optional: when unset, this ControlPlane manages no block-storage service and CinderReady is reported as not-managed (CinderNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady (Cinder validates every token against the ControlPlane's Keystone child) and on the AccountReady of the KeystoneService registration the reconciler projects for it. It also requires spec.infrastructure.messaging: the Cinder CRD requires spec.messaging, and the child's transport URL is derived from the shared bus, so the webhook rejects a cinder block declared without one. Forbidden in External mode: Cinder needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Cinder child by default; set the c5c3.io/allow-cinder-deletion: "true" annotation to opt in to deleting the child (with its CinderBackend and CinderBackupBackend satellites, its DB-credential ExternalSecret, the two messaging Secrets, and the block-storage catalog registration) on unset. The dynamic DB-credential generator is torn down on unset regardless of the annotation.
nova*ServiceNovaSpecNonilConfiguration for the Nova compute service projected by the reconciler. Optional: when unset, this ControlPlane manages no compute service and NovaReady is reported as not-managed (NovaNotManaged), so the aggregate Ready is not blocked. The projection is gated on KeystoneReady (Nova validates every token against the ControlPlane's Keystone child), on PlacementReady (the conductor claims every instance's resources in Placement before it boots), and on the AccountReady of the KeystoneService registration the reconciler projects for it. Three sibling blocks are required beside it, services.placement, services.neutron and services.glance, and so is spec.infrastructure.messaging: the Nova CRD requires spec.messaging, and the child's transport URL is derived from the shared bus. Forbidden in External mode: Nova needs its own External-mode design. Flipping it from set to nil preserves the previously-projected Nova child by default; set the c5c3.io/allow-nova-deletion: "true" annotation to opt in to deleting the child (with its two DB-credential ExternalSecrets, the generated metadata shared secret, the three messaging Secrets, the compute catalog registration, and the hypervisor operator's registration and auth Secret) on unset. Both dynamic DB-credential generators are torn down on unset regardless of the annotation.

ServiceKeystoneSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Keystone service.

DECISION: This struct is intentionally not an import of keystonev1alpha1.KeystoneSpec. The reconciler (L2) projects this struct into a Keystone CR; the database, cache, and Fernet rotation schedule of that Keystone CR are derived from the ControlPlane (infrastructure.* and operator policy) rather than set by the user here. Keeping a curated subset avoids leaking every Keystone knob through the aggregate and keeps the L1 API package free of a dependency on the keystone module. Fields not present below (replica strategy, uWSGI, network policy, fernet key count, etc.) are governed by the Keystone operator's own defaults on the projected CR, not by the ControlPlane.

The mode discriminator gives the Keystone service three states, mirroring the managed-vs-brownfield split of the infrastructure specs at the service level:

services.keystoneMeaning
unset (nil)No Keystone at all — KeystoneReady is reported not-managed (see ServicesSpec).
mode: Managed (or unset)The reconciler deploys and owns a full Keystone workload — today's behavior, byte-identical.
mode: ExternalService-less: identity is managed against a pre-existing, externally-operated Keystone at external.authURL and no Keystone workload is deployed.

In External mode every managed-only field below (image, policyOverrides, extraConfig, rotationInterval, gateway, publicEndpoint) is forbidden, as is spec.sizing, and the typed external block is required. These intra-struct rules are enforced by type-level CEL XValidation rules (so they hold at the CRD schema layer even when the validating webhook is bypassed) and mirrored by the validating webhook; see Validation Rules.

FieldTypeRequiredDefaultDescription
modestring (Managed | External)NoManagedSelects whether the Keystone service is Managed (the reconciler deploys and owns a full Keystone workload) or External (identity is managed against a pre-existing Keystone at external.authURL and no workload is deployed). Defaulted to Managed by both the +kubebuilder:default marker and the defaulting webhook. In External mode the external block is required and every managed-only field below is forbidden.
external*ExternalKeystoneSpecConditionalnilConnection parameters for an externally-operated Keystone. Required when mode is External, forbidden otherwise (CEL + webhook enforced).
image*commonv1.ImageSpecNonilOverrides the Keystone container image. When nil, the reconciler derives the image as ghcr.io/c5c3/keystone:{spec.openStackRelease}. When set, the whole image reference is used verbatim, except that an empty pullPolicy takes spec.imagePullPolicy.
policyOverrides*commonv1.PolicySpecNonilPer-service oslo.policy overrides for Keystone. When set, these take precedence over spec.globalPolicyOverrides for the Keystone service.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the Keystone service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Keystone child's spec.extraConfig. Forbidden in External mode (CEL + webhook, message services.keystone.extraConfig is forbidden when services.keystone.mode is External): no Keystone workload is deployed, so there is no config to render. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block — see ExtraConfig admission checks.
rotationInterval*metav1.DurationNonilOverrides the Fernet / credential-key rotation interval the reconciler derives for the projected Keystone CR. When nil, the reconciler derives a default schedule. When set, the duration is converted to a cron expression and applied to both fernet.rotationSchedule and credentialKeys.rotationSchedule on the projected Keystone CR. An unconvertible interval (not a positive whole number of days) is rejected at admission by the validating webhook; if the webhook is bypassed, the reconciler surfaces KeystoneReady=False with reason InvalidRotationInterval and returns the error so the reconcile chain stops and requeues with backoff.
gateway*commonv1.GatewaySpecNonilExposes the projected Keystone API externally via a Gateway API HTTPRoute. When nil, no HTTPRoute is projected and the Keystone API is reachable in-cluster only (its ClusterIP Service). When set, the reconciler projects it onto the Keystone CR's spec.gateway, so the Keystone operator attaches an HTTPRoute to the referenced Gateway. When a gateway is set its hostname must be non-empty — enforced at admission by the validating webhook (see Validation Rules).
publicEndpointstringNo""Externally routable Keystone identity endpoint URL (e.g. https://keystone.example.com/v3). Projected into the Keystone bootstrap (--bootstrap-public-url) and used for the K-ORC identity catalog Endpoint, so external clients resolve the same URL Keystone advertises. When set, it must be an HTTP(S) URL (+kubebuilder:validation:Pattern=^https?://), so a malformed endpoint fails at admission rather than wedging the projected Keystone CR. When empty and gateway is set, the reconciler derives https://{gateway.hostname}/v3 (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
federationProxyImage*commonv1.ImageSpecNonilOverrides the mod_auth_openidc sidecar image projected onto the Keystone child's spec.federation.proxyImage. When nil the reconciler projects ghcr.io/c5c3/keystone-federation-proxy:latest. That default is a mutable tag: it names no pullPolicy, so it resolves to Always unless spec.imagePullPolicy or the keystone-operator's default names another, every node then re-pulls it on each pod start, and a locally built sidecar cannot be exercised. Override it with a digest-carrying ImageSpec for the immutable pin published images are expected to carry. Inert until a federation-typed KeystoneIdentityBackend attaches. Forbidden in External mode (CEL + webhook).
remoteCompute*ServiceNovaRemoteComputeSpecNonil (compute clusters receive the in-cluster contract)Makes the compute contract resolvable from a compute cluster. The projected child publishes a second contract whose addresses leave the cluster, and the ControlPlane mirrors that one, instead of the in-cluster one, onto compute clusters. Admitted only beside a brownfield bus with tls, a published https Keystone, and published siblings (webhook).
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Keystone on one mode while another service (e.g. Glance) stays on the other. Empty (the default) inherits the shared mode — deliberately not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when the Keystone service declares a dedicated database (dedicated is Static-only — set dedicatedBackingServices.database.credentialsMode instead; see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted. Forbidden in External mode (CEL + webhook) — no managed database is provisioned there, so there is no credentials mode to override.
dedicatedBackingServices*KeystoneDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts the Keystone service out of the shared spec.infrastructure instances and gives it backing services of its own. Forbidden in External mode (CEL + webhook): no backing services are provisioned at all there.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the Keystone service — and the backing services, secret store, and credential material that follow it — in a namespace of its own. Create-only. Forbidden in External mode (CEL + webhook): no Keystone workload is deployed, so there is nothing to place. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the Keystone service, and the backing services, secret store, and credential material that follow it, on a registered target cluster. The projected Keystone CR stays on the management cluster and carries the ref verbatim, so the keystone operator is what writes the workload to the target. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the identity catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. Forbidden in External mode (CEL + webhook): no Keystone workload is deployed, so there is nothing to place. See ControlPlane placement.
caBundleSecretRef*commonv1.SecretRefSpecNonil (the container's system trust store)The private CA bundle the operator verifies a placed Keystone's publicEndpoint with. K-ORC stays on the management cluster and dials that endpoint over https on every mint and re-mint, so a target published with a cert-manager-issued private CA needs its anchor here — the bundle is read from the ControlPlane's own namespace and projected verbatim as the inline cacert key into both K-ORC credentials Secrets, exactly as external.caBundleSecretRef is. key defaults to ca.crt (webhook-only). It reaches K-ORC and nothing else, so it is forbidden while any declared service does not share Keystone's target cluster: such a service dials the same endpoint through [keystone_authtoken], which carries no option for a trust anchor. Placing Keystone away from a service therefore requires a publicly trusted certificate on its endpoint. Also forbidden without targetClusterRef (a co-located Keystone is reached over its in-cluster Service URL, which performs no TLS handshake for a bundle to verify) and forbidden in External mode (CEL + webhook), where external.caBundleSecretRef is the field.

ServiceHorizonSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Horizon dashboard, mirroring ServiceKeystoneSpec. The reconciler projects it into a Horizon CR; the cache and the Keystone endpoint of that child are derived from the ControlPlane rather than set here.

Forbidden entirely when services.keystone.mode is External (the dashboard needs its own External-mode design), so — unlike ServiceKeystoneSpec — none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Horizon container image. When nil the reconciler derives ghcr.io/c5c3/horizon:{spec.openStackRelease}.
gateway*commonv1.GatewaySpecNonilExposes the projected dashboard externally via a Gateway API HTTPRoute. When nil the dashboard is reachable in-cluster only.
secretKeyRef*commonv1.SecretRefSpecNonilOverrides the Secret holding the Django SECRET_KEY the dashboard replicas share. When nil the reconciler defaults to the kind-infrastructure shim Secret horizon-secret-key, which is pinned to the default ControlPlane identity — multi-ControlPlane deployments MUST set this explicitly.
publicEndpointstringNo""The browser-observed dashboard base URL, without a trailing slash and including a non-default port (e.g. https://horizon.example.com:8443). The reconciler derives the WebSSO origin from it (publicEndpoint + "/auth/websso/") and projects that onto the Keystone child's spec.federation.trustedDashboards. Keystone matches the origin the dashboard sends verbatim, so the value must reproduce exactly what the browser's address bar shows. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form). Must match ^https?://, parse with a host, and be at most 499 characters — the Keystone child's 512-character bound on trustedDashboards[] minus the 13 characters /auth/websso/ appends.
extraConfigmap[string]JSONNonilFree-form flat Django settings mirroring the Horizon child's spec.extraConfig: keys are Django setting names, values arbitrary JSON. Projected verbatim (deep-copied) onto the Horizon child. Because the dashboard renders local_settings.py rather than INI, spec.globalExtraConfig never applies and there is no merge for this block. Admission rejects empty or non-Python-identifier setting names and the operator-owned settings — SECRET_KEY and every WebSSO / multi-domain setting — see ExtraConfig admission checks.
dedicatedBackingServices*HorizonDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide cache)Opts the dashboard out of the shared spec.infrastructure.cache and gives it a cache of its own. The dashboard consumes no database, so cache is the only class it can take dedicated.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the dashboard — and the cache and secret store that follow it — in a namespace of its own. Create-only. A dashboard placed apart reads its SECRET_KEY from that namespace: the default horizon-secret-key shim Secret is namespace-local, so supply the key material there (and name it via secretKeyRef). See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the dashboard, and the cache and secret material that follow it, on a registered target cluster. The projected Horizon CR stays on the management cluster and carries the ref verbatim. Requires a namespace block of its own (webhook); no public address is required, because the dashboard registers no catalog entry. Create-only: the validating webhook freezes the ref after creation. The SECRET_KEY Secret has to exist in that namespace on that cluster, where the dashboard pods mount it. See ControlPlane placement.

publicEndpoint and gateway.hostname must name the same host. Django derives the origin it sends from the request's Host header — i.e. from gateway.hostname, not from this field. A publicEndpoint whose host differs produces an origin Keystone will reject, so whenever a gateway is configured the validating webhook rejects the ControlPlane instead. The port may still differ, since Gateway API hostnames carry none. Behind a gateway the scheme must be https: the listener terminates TLS, and the browser posts the unscoped WebSSO token to this origin. See the End-to-End SSO guide.


ServiceGlanceSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Glance image service, mirroring ServiceKeystoneSpec and ServiceHorizonSpec. The reconciler (L2) projects it into a Glance CR; the database, cache, and Keystone endpoint of that child are derived from the ControlPlane (infrastructure.* and the Keystone child's naming convention) rather than set here, and only a subset of the Glance CRD's knobs is exposed. spec.apiServer on the child is deliberately not projected, so the Glance operator's own release-conditional API-server defaults stay authoritative.

Forbidden entirely when services.keystone.mode is External (Glance needs its own External-mode design), so — like ServiceHorizonSpec — none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Glance container image. When nil the reconciler derives ghcr.io/c5c3/glance:{spec.openStackRelease}.
gateway*commonv1.GatewaySpecNonilExposes the projected Glance API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the Glance API is reachable in-cluster only. When a gateway is set its hostname must be non-empty — enforced at admission by the validating webhook (see Validation Rules).
publicEndpointstringNo""Externally routable Glance image endpoint URL (e.g. https://glance.127-0-0-1.nip.io:8443). Used only for the K-ORC public image catalog Endpoint; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment — the Glance API is served at the root), and be at most 512 characters. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ) — see Validation Rules. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
backends[]GlanceBackendEntryYes—The curated list of image stores, projected one-to-one into GlanceBackend child CRs. Exactly one entry must set isDefault, promoting its store to the Glance default_backend. A listType=map list keyed on name (the API server rejects duplicate names); MinItems 1 and MaxItems 32 (every entry amplifies into one GlanceBackend CR). The single-default invariant holds at the CRD schema layer via a CEL XValidation rule and is mirrored by the validating webhook.
importFiltering*ImportFilteringSpecNonilConstrains the URIs the child's web-download image import may fetch from. Projected unconditionally onto the Glance child's spec.importFiltering, so clearing it here removes the child's field and the Glance operator's restrictive defaults (HTTPS on port 443 plus a literal host denylist) apply again instead of the last projected value staying pinned. The URI filter is platform security policy, which is why it is projected at all while spec.apiServer, whose child-side defaults stay authoritative, is not. An explicitly empty list does not survive the projection: the lists serialize with omitempty, so an empty one reaches the child as unset and resolves back to the operator default. The empty-list opt-out is therefore only expressible on the Glance CR itself, and the projection only ever resolves more restrictively than requested.
staging*StagingSpecNonilBounds the node-local scratch space an image import may consume on the child. The glance operator stamps the resolved sizeLimit on both scratch emptyDirs (the staging store and the tasks-work store), so one glance-api pod is expected to occupy at most twice the value on its node — a best-effort eviction threshold, not a filesystem quota or a scheduling reservation; see StagingSpec. Projected unconditionally onto the Glance child's spec.staging, so clearing it here removes the child's field and the glance operator's 10Gi default applies again instead of the last projected value staying pinned. Set unbounded: true instead of a sizeLimit to render the scratch volumes with no bound at all — the shape a Glance had before the block existed, and the opt-out for a deployment the 10Gi default would start evicting on upgrade. Typed as the glance module's own StagingSpec rather than a curated local mirror, and admitted through that module's exported validator, so the 1Mi floor on sizeLimit and the unbounded/sizeLimit exclusion cannot drift between the two CRDs.
imageCache*ImageCacheSpecNonilTurns on the child's per-replica local image cache: every glance-api pod then keeps a copy of the image data it has served on its own disk, so a repeat download is answered from the node instead of from the object store. An image is cached once per replica that served it, so the disk budget multiplies by replicas. sizeLimit bounds the cache emptyDir (default 10Gi, floor 1Mi), maintenanceInterval sets the pruner cadence (default 5m, floor 1m), and maintenanceResources sizes the maintenance sidecar (default 25m CPU request, 256Mi memory request and limit; a request must not exceed its limit); see ImageCacheSpec for what the cache does and does not promise. Projected unconditionally onto the Glance child's spec.imageCache, so clearing it here removes the child's field and switches the cache off again on the next rollout instead of leaving the last projected budget pinned. Typed as the glance module's own ImageCacheSpec rather than a curated local mirror, and admitted through that module's exported validator, so neither floor can drift between the two CRDs. controller-gen copies that schema into this CRD instead of resolving it against the Glance CRD at runtime, and the two ship in separate Helm charts, so "admitted here, accepted by the child" holds only while both charts come from the same release.
importPlugins*ImportPluginsSpecNonilSelects the image-import plugins the child runs: decompression unpacks a compressed image after staging (and requires staging to answer for the scratch bound, since its expansion is unbounded), conversion rewrites it into one disk format (default raw), and injectMetadata stamps a fixed set of image properties onto every image it applies to. Presence of a sub-block enables that plugin; the rendered order (image_decompression, image_conversion, inject_image_metadata) is the glance operator's and not an input, and every default resolves there at render time; see ImportPluginsSpec for what each plugin does and which upload paths bypass it. Projected unconditionally onto the Glance child's spec.importPlugins, so clearing it here removes the child's field and the glance operator's defaults (no plugin, image_import_plugins = []) apply again on the next rollout instead of leaving the last projected selection pinned. Typed as the glance module's own ImportPluginsSpec rather than a curated local mirror, and admitted through that module's exported validator, so the output-format enum and the injected-property rules cannot drift between the two CRDs. controller-gen copies that schema into this CRD too, and the two charts ship separately, so "admitted here, accepted by the child" holds only while both come from the same release.
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Glance on one mode while another service (e.g. Keystone) stays on the other. Empty (the default) inherits the shared mode — deliberately not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Glance declares a dedicated database (dedicated is Static-only — set dedicatedBackingServices.database.credentialsMode instead; see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the Glance service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Glance child's spec.extraConfig. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block; the always-rejected owned keys are [keystone_authtoken] password, the six [import_filtering_opts] keys, the three [DEFAULT] image_cache_* keys, and the four image-import plugin keys ([image_import_opts] image_import_plugins, [image_conversion] output_format, [inject_metadata_properties] inject and ignore_user_roles) — see ExtraConfig admission checks.
dedicatedBackingServices*GlanceDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Glance out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Glance consumes both classes, so it can take either or both dedicated.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the Glance service — and the database, cache, secret store, and object-store credential material that follow it — in a namespace of its own. Create-only. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the Glance service, and the database, cache, secret store, and object-store credential material that follow it, on a registered target cluster. The projected Glance and GlanceBackend CRs stay on the management cluster; the Glance carries the ref verbatim and the attached backends follow it. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the image catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. Every backend's S3 credentials Secret has to exist in that namespace on that cluster. See ControlPlane placement.

GlanceBackendEntry ​

One curated image store, projected one-to-one into a GlanceBackend child CR.

FieldTypeRequiredDefaultDescription
namestringYes—Keys the listType=map backends list (duplicates rejected) and is embedded verbatim in the projected child's name {controlplane.Name}-glance-{name}, hence the DNS-1123 label shape (^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, MinLength 1, MaxLength 63). The validating webhook additionally bounds the composed child-CR name to the 253-byte object-name limit (no CRD marker can express it).
typestring (S3)Yes—The image-store driver. Phase 1 supports S3 only (Enum: S3); the enum mirrors the GlanceBackend CR's own type enum, so an entry admitted here can never be rejected downstream.
s3*GlanceBackendS3SpecConditionalnilThe S3-compatible object store. Required exactly when type is S3, forbidden otherwise (the type/s3 union CEL rule on GlanceBackendEntry + webhook).
isDefaultboolNofalseMarks this backend as the Glance default store. Exactly one backends entry must set it (CEL + webhook enforced); the reconciler projects it onto the child GlanceBackend's spec.isDefault.

GlanceBackendS3Spec ​

The curated local S3 store shape projected onto a GlanceBackend child's spec.s3. It models only the exposed subset and defines its own field types rather than importing the glance module, keeping the L1 API package free of a dependency on it; the field bounds mirror the child's own S3 shape so a value admitted here can never be rejected downstream.

FieldTypeRequiredDefaultDescription
endpointstringYes—The S3 endpoint URL (e.g. https://s3.example.com). Pattern ^https?://, MinLength 1. Projected onto the child's spec.s3.host.
bucketstringYes—The S3 bucket images are stored in. MinLength 1. Projected onto the child's spec.s3.bucket.
regionstringNo""The S3 region the bucket lives in. Only projected onto the child's spec.s3.region when set.
bucketURLFormatstring (path | virtual)No"" (no schema default)How the bucket is addressed in request URLs: path (https://host/bucket) or virtual (https://bucket.host). Carries no schema default on purpose — when unset the projection leaves the child's field unset so the GlanceBackend CR's own default (path) applies at exactly one layer.
credentialsSecretRefSecretNameRefYes—References the Secret holding the S3 credentials, resolved in the namespace the Glance service is placed in.

SecretNameRef ​

A name-only reference to a Kubernetes Secret, resolved in the namespace the Glance service is placed in. The data keys it must expose are fixed by contract downstream (the GlanceBackend controller reads them), so there is no key to select.

FieldTypeRequiredDefaultDescription
namestringYes—The referenced Secret's name. MinLength 1, MaxLength 253.

ServicePlacementSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Placement service, mirroring ServiceKeystoneSpec and ServiceGlanceSpec. The reconciler (L2) projects it into a Placement CR; the database, cache, and Keystone endpoint of that child are derived from the ControlPlane (infrastructure.* and the Keystone child's naming convention) rather than set here. spec.apiServer on the child is deliberately not projected, so the placement operator's own uWSGI defaults stay authoritative.

The subset is smaller than the Glance one because Placement keeps its whole state in its database and serves it over HTTP: there are no image stores to curate and no scratch space to bound, so nothing here corresponds to the backends, importFiltering, staging, or imageCache blocks of ServiceGlanceSpec.

Forbidden entirely when services.keystone.mode is External (Placement needs its own External-mode design), so, like ServiceGlanceSpec, none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Placement container image. When nil the reconciler derives ghcr.io/c5c3/placement:{spec.openStackRelease}.
gateway*commonv1.GatewaySpecNonilExposes the projected Placement API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the Placement API is reachable in-cluster only. When a gateway is set its hostname must be non-empty, enforced at admission by the validating webhook (see Validation Rules).
publicEndpointstringNo""Externally routable Placement endpoint URL (e.g. https://placement.127-0-0-1.nip.io:8443). Used only for the K-ORC public placement catalog Endpoint, the URL every compute service resolves to place its allocations; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment, since the Placement API is served at the root and clients append the API path to the catalog URL), and be at most 512 characters; a single trailing slash is tolerated. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ); see Validation Rules. Without a gateway an http:// value stays admissible for development and raises an admission warning, because every allocation call carries the caller's scoped Keystone token to this URL. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Placement on one mode while another service stays on the other. Empty (the default) inherits the shared mode, and is deliberately not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Placement declares a dedicated database (dedicated is Static-only; set dedicatedBackingServices.database.credentialsMode instead, see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the Placement service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Placement child's spec.extraConfig. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block; the always-rejected owned keys are [keystone_authtoken] password and auth_strategy in both of its spellings ([api], which Placement reads, and the deprecated [DEFAULT] alias oslo.config still honors). See ExtraConfig admission checks.
dedicatedBackingServices*PlacementDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Placement out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Placement consumes both classes, so it can take either or both dedicated; a declared block must name at least one.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the Placement service, and the database, cache, secret store, and credential material that follow it, in a namespace of its own. Create-only: the validating webhook freezes the block after creation. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the Placement service, and the database, cache, secret store, and credential material that follow it, on a registered target cluster. The projected Placement CR stays on the management cluster and carries the ref verbatim. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the placement catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. See ControlPlane placement.

Setting services.placement makes the reconciler project a KeystoneService registration named {controlplane.Name}-placement into the namespace Placement is placed in: the placement catalog entry plus the placement service account, whose project service-placement is created with the role service. That account is the Keystone user the projected child authenticates as, so the Placement child is not projected until the registration reports it provisioned (PlacementReady=False/WaitingForServiceRegistration until then).

Setting services.placement bounds the ControlPlane's own name too. The projected child is {controlplane.Name}-placement, and the placement operator names its API Service after the CR without truncating, so the composed name has to fit the 63-byte cap Kubernetes puts on a Service name: the ControlPlane name may be at most 53 characters while services.placement is set. The Placement CRD declares no metadata.name bound of its own, so without this rule an overlong name reaches the placement operator, which then fails to apply the Service on every reconcile: PlacementReady parks on WaitingForPlacement, and metadata.name is immutable, so the only recovery is recreating the whole control plane. The rule runs on create and on the update that newly enables Placement.


ServiceBarbicanSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Barbican key manager, mirroring ServiceKeystoneSpec and ServicePlacementSpec. The reconciler (L2) projects it into a Barbican CR; the database, cache, and Keystone endpoint of that child are derived from the ControlPlane (infrastructure.* and the Keystone child's naming convention) rather than set here. spec.apiServer and spec.dbClean on the child are not projected, so the barbican operator's own uWSGI parameters and clean-up schedule stay authoritative.

The curated fields match the Placement ones plus secretStore, which no other service has. Barbican keeps its secret material in an OpenBao (or API-compatible Vault) KV mount rather than in its own database, so the store is part of the service's definition.

Forbidden entirely when services.keystone.mode is External (Barbican needs its own External-mode design), so, like ServicePlacementSpec, none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Barbican container image. When nil the reconciler derives ghcr.io/c5c3/barbican:{spec.openStackRelease}. When set, the validating webhook mirrors the commonv1.ImageSpec tag/digest XOR, so an override naming neither or both is rejected at admission.
gateway*commonv1.GatewaySpecNonilExposes the projected Barbican API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the Barbican API is reachable in-cluster only. When a gateway is set its hostname must be non-empty and a usable DNS name, enforced at admission by the validating webhook.
publicEndpointstringNo""Externally routable Barbican endpoint URL (e.g. https://barbican.127-0-0-1.nip.io:8443). Used only for the K-ORC public key-manager catalog Endpoint, the URL every client resolves to store and read its secret material; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment, since the Barbican API is served at the root and clients append the API path to the catalog URL), and be at most 512 characters; a single trailing slash is tolerated. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ); see Validation Rules. Without a gateway an http:// value stays admissible for development and raises an admission warning, because every call carries the caller's scoped Keystone token and the secret payload to this URL. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Barbican on one mode while another service stays on the other. Empty (the default) inherits the shared mode, and is not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Barbican declares a dedicated database (dedicated is Static-only; set dedicatedBackingServices.database.credentialsMode instead, see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the Barbican service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Barbican child's spec.extraConfig. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block; the always-rejected owned keys are the three credential ones ([keystone_authtoken] password, [vault_plugin] approle_secret_id, and [vault_plugin] root_token_id). Each arrives through an environment override at runtime, so a file value is inert and would only copy credential material into the config Secret every API pod mounts. See ExtraConfig admission checks.
secretStoreServiceBarbicanSecretStoreSpecYes—The secret-store backend the key manager writes its secret material to. Required: a Barbican with no store attached parks on SecretStoresReady=False/NoDefaultSecretStore for as long as it exists, so a store-less service block would only ever project a child that can never reach Ready.
dedicatedBackingServices*BarbicanDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Barbican out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Barbican consumes both classes, so it can take either or both dedicated; a declared block must name at least one.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the Barbican service, and the backing services, secret store, dedicated OpenBao instance, and credential material that follow it, in a namespace of its own. Create-only: the validating webhook freezes the block after creation. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the Barbican service, and the backing services, dedicated OpenBao instance, and credential material that follow it, on a registered target cluster. The projected Barbican and BarbicanSecretStore CRs stay on the management cluster; the Barbican carries the ref verbatim and the store follows it. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the key-manager catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. See ControlPlane placement.

The logical database name is not exposed here. The projection forces it to barbican on the child, because the OpenBao database engine grants the dynamic Barbican credential access to that schema alone; any other name would be issued a credential it cannot use.

Setting services.barbican makes the reconciler project a KeystoneService registration named {controlplane.Name}-barbican into the namespace Barbican is placed in: the key-manager catalog entry plus the barbican service account, whose project service-barbican is created with the role service. It creates its own project rather than reusing Glance's service-glance, since two registrations creating one project would each adopt the other's Keystone row. That account is the Keystone user the projected child authenticates as, so the Barbican child is not projected until the registration reports it provisioned (BarbicanReady=False/WaitingForServiceRegistration until then).

Setting services.barbican bounds the ControlPlane's own name too. The projected child is {controlplane.Name}-barbican, and the Barbican CRD caps metadata.name at 43 characters so the barbican operator's db-clean CronJob name ({name}-db-clean) still fits the API server's 52-character CronJob bound. The ControlPlane name may therefore be at most 34 characters while services.barbican is set. The rule runs on create and on the update that newly enables Barbican.

ServiceBarbicanSecretStoreSpec ​

Selects the secret-store backend of the Barbican service, in one of two modes. dedicated has the ControlPlane provision an OpenBao instance for this Barbican and wire the store to it; external points the service at an OpenBao or HashiCorp Vault server run outside this control plane, whose AppRole credentials the operator only reads.

The two modes address different servers with different credentials, so one of them must be set and not the other. A type-level CEL XValidation rule on ServiceBarbicanSecretStoreSpec carries the union (has(self.dedicated) != has(self.external)), with the message exactly one of dedicated or external must be set, and the validating webhook mirrors it, so an API server old enough to skip x-kubernetes-validations cannot admit a block naming neither.

FieldTypeRequiredDefaultDescription
dedicated*BarbicanDedicatedSecretStoreSpecConditionalnilHas the ControlPlane provision an OpenBao instance for this Barbican and attach the store to it. Required when external is unset, forbidden otherwise.
external*BarbicanExternalSecretStoreSpecConditionalnilAttaches the store to an OpenBao or HashiCorp Vault server provisioned outside this control plane. Required when dedicated is unset, forbidden otherwise.

BarbicanDedicatedSecretStoreSpec ​

Field-less. The instance name, its KV mount, and the AppRole credentials are all derived by convention from the ControlPlane, so there is nothing left to spell out.

The instance the operator provisions is proving-grade. It runs the openbao-operator's Development profile at a single replica with no PodDisruptionBudget, so any disruption of that one pod stops every secret read and write. It is sealed by a static key held in a plain Secret ({instance}-unseal-key) in the same namespace as the raft volume that key seals, so read access to that namespace's Secrets, or a single etcd or namespace backup, yields both the ciphertext and the key. Admission repeats this as a warning on every apply. A production key manager belongs on external, against a hardened server with a KMS unseal and a real replica count. See reconcileBarbican for the ensemble the reconciler projects around it.

The OpenBaoTenant the operator creates to admit the service namespace is a self-service tenant in that namespace. The openbao-operator accepts quota and LimitRange overrides only from a tenant in its own namespace, so a namespace a ControlPlane-created tenant admits keeps the provisioner's default LimitRange: every container there without a CPU limit gets the openbao-operator's 500m default CPU limit, service containers included. The kind overlay's tenant for openstack lives in the operator namespace and drops that limit (see OpenBao Proving Instance), and the operator creates no tenant of its own in a namespace one already admits.

BarbicanExternalSecretStoreSpec ​

Addresses an OpenBao or HashiCorp Vault server provisioned outside this control plane. The operator reads the referenced Secrets and renders the store configuration; it never creates a mount, a policy, or an AppRole on such a server. The field surface mirrors the barbican module's OpenBaoServerSpec / OpenBaoStoreSpec, so a value admitted here can never be rejected downstream by the BarbicanSecretStore CRD.

FieldTypeRequiredDefaultDescription
urlstringYes—The server's API base URL, e.g. https://openbao.example.com:8200. TLS is mandatory (MinLength 1, pattern ^https://, mirrored by the webhook): the operator's AppRole login and every secret barbican stores travel this URL, so a plaintext scheme would put the role ID, the secret ID, and the keys and certificates the service exists to protect on the wire in the clear.
credentialsSecretRefbarbicanv1alpha1.SecretNameRefSpecYes—References the Secret holding the AppRole credentials barbican authenticates with, under the fixed data keys role-id and secret-id. The barbican operator reads it from the store's namespace, so place the Secret in the namespace the Barbican service is placed in (services.barbican.namespace, or the ControlPlane's own).
caBundleSecretRef*barbicanv1alpha1.SecretNameRefSpecNonilReferences the Secret holding the PEM CA bundle that authenticates the server, under the fixed data key ca.crt. Resolves in the same namespace as credentialsSecretRef. Omit it when the server presents a certificate the pods already trust through their system store.
kvMountpointstringNobarbicanThe path the KV v2 secrets engine holding barbican's secret material is mounted at on that server. The +kubebuilder:default only fills an absent field, so the MinLength 1 marker is what rejects an explicitly empty mount path.
namespacestringNo""Scopes every request to an OpenBao/Vault namespace (the enterprise-style multi-tenancy header). Brownfield only, which is the only mode this block describes: a dedicated instance is provisioned at the root namespace.

BarbicanDedicatedBackingServicesSpec ​

Declares the backing-service instances the Barbican service gets for itself instead of the ControlPlane-wide shared ones, on the same contract as KeystoneDedicatedBackingServicesSpec. Barbican consumes both a database and a cache, so it can take either or both dedicated; a class left unset resolves to the ControlPlane-wide instance in spec.infrastructure.

The block is optional, but a declared one must name at least one class. A CEL XValidation rule (has(self.database) || has(self.cache)) rejects an empty block, which would request nothing, with the message dedicatedBackingServices must declare at least one backing-service class (database, cache). The Barbican child CRD requires spec.cache, so the effective cache has to resolve on every path the projection takes.

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Barbican its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-barbican-db. A dedicated managed database is Static-only — the defaulting webhook materializes credentialsMode: Static and an explicit Dynamic is rejected, for the same reason as Keystone (see Credential modes): the OpenBao database engine is bootstrapped once per namespace against the shared cluster, so no engine role can issue credentials for a dedicated instance. Seed and rotate the credential at the OpenBao source.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Barbican its own cache. In managed mode clusterRef.name defaults to {controlplane}-barbican-cache.

ServiceNeutronSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Neutron network service, mirroring ServiceKeystoneSpec and ServicePlacementSpec. The reconciler (L2) projects it into a Neutron CR; the database, cache, Keystone endpoint, and message bus of that child are derived from the ControlPlane (infrastructure.* and the Keystone child's naming convention) rather than set here. spec.apiServer, spec.ovnDBSync, spec.networkPolicy, spec.autoscaling, and spec.logging on the child are not projected, so the neutron operator's own uWSGI parameters, OVN schema-sync schedule, network policies, autoscaling, and logging stay authoritative.

Two fields have no counterpart on the other services. ovn is required, because the ML2/OVN mechanism driver writes every network, subnet, and port into an OVN Northbound database. The RPC worker Deployments the child runs beside its API are sized by spec.sizing.neutron.workers.

Forbidden entirely when services.keystone.mode is External (Neutron needs its own External-mode design), so, like ServicePlacementSpec, none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Neutron container image. When nil the reconciler derives ghcr.io/c5c3/neutron:{spec.openStackRelease}. When set, the validating webhook mirrors the commonv1.ImageSpec tag/digest XOR, so an override naming neither or both is rejected at admission.
gateway*commonv1.GatewaySpecNonilExposes the projected Neutron API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the Neutron API is reachable in-cluster only. When a gateway is set its hostname must be non-empty and a usable DNS name, enforced at admission by the validating webhook (see Validation Rules).
publicEndpointstringNo""Externally routable Neutron endpoint URL (e.g. https://neutron.127-0-0-1.nip.io:8443). Used only for the K-ORC public network catalog Endpoint, the URL every client resolves to create its networks, subnets, and ports; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment, since the Neutron API is served at the root and clients append the API path to the catalog URL), and be at most 512 characters; a single trailing slash is tolerated. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ); see Validation Rules. Without a gateway an http:// value stays admissible for development and raises an admission warning, because every network call carries the caller's scoped Keystone token to this URL. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Neutron on one mode while another service stays on the other. Empty (the default) inherits the shared mode, and is not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Neutron declares a dedicated database (dedicated is Static-only; set dedicatedBackingServices.database.credentialsMode instead, see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the network service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Neutron child's spec.extraConfig, which carries the neutron.conf and ml2_conf.ini sections alike. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block; the always-rejected owned keys are the two [ovn] connection strings, the six [ovn] client-certificate and CA paths, [DEFAULT] transport_url, auth_strategy and api_paste_config, [database] connection, [keystone_authtoken] password, [nova] password, and [securitygroup] enable_security_group. An update that carries [nova] password over unchanged is admitted with a warning. See ExtraConfig admission checks.
ovnNeutronOVNSpecYes—Names the OVN control plane the projected Neutron programs. Required: the ML2/OVN mechanism driver has no logical network model to write to without one, so a Neutron with no central to address would park unready for as long as it exists.
dedicatedBackingServices*NeutronDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Neutron out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Neutron consumes both classes, so it can take either or both dedicated; a declared block must name at least one.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the Neutron service, and the database, cache, secret store, and credential material that follow it, in a namespace of its own. Create-only: the validating webhook freezes the block after creation. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the Neutron service, and the database, cache, secret store, and credential material that follow it, on a registered target cluster. The projected Neutron CR stays on the management cluster and carries the ref verbatim. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the network catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. See ControlPlane placement.

The logical database name is not exposed here. The projection forces it to neutron on the child, whether Neutron shares the ControlPlane's database cluster or takes a dedicated one: that is the one schema the pre-wired OpenBao engine role grants on, so any other name would be issued a credential it cannot use.

Setting services.neutron requires spec.infrastructure.messaging beside it. The Neutron CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus, so a network service declared without one would project a child its own admission rejects on every pass; the webhook reports the omission on spec.infrastructure.messaging. The bus is declared and read in the ControlPlane's namespace on the management cluster, while Neutron may run in a namespace of its own or on another cluster, so the reconciler resolves the transport URL itself and hands the child a brownfield secretRef naming {controlplane.Name}-neutron-messaging in the Neutron's own namespace on the Neutron's own cluster. A managed clusterRef and a brownfield secretRef on the ControlPlane both arrive there as that one Secret. When the shared bus declares tls, the CA bundle is mirrored beside it as {controlplane.Name}-neutron-messaging-ca and named by the child's messaging.tls.caBundleSecretRef; a bus without tls leaves no mirror behind.

Setting services.neutron makes the reconciler project a KeystoneService registration named {controlplane.Name}-neutron into the namespace Neutron is placed in: the network catalog entry plus the neutron service account, whose project service-neutron is created with the role service. That account is the Keystone user the projected child authenticates as, so the Neutron child is not projected until the registration reports it provisioned (NeutronReady=False/WaitingForServiceRegistration until then).

Setting services.neutron bounds the ControlPlane's own name too. The projected child is {controlplane.Name}-neutron, and the Neutron CRD caps metadata.name at 40 characters so the neutron operator's ovn-db-sync CronJob name ({name}-ovn-db-sync) still fits the API server's 52-character CronJob bound. The ControlPlane name may therefore be at most 32 characters while services.neutron is set. The rule runs on create and on the update that newly enables Neutron.

NeutronOVNSpec ​

Names the OVN control plane the projected Neutron programs.

FieldTypeRequiredDefaultDescription
centralRefNeutronOVNCentralRefYes—The OVNCentral whose Northbound and Southbound databases the ML2/OVN mechanism driver connects to.

NeutronOVNCentralRef ​

Names an OVNCentral the ControlPlane only references. The central is deployed outside the plane, the way the infrastructure clusters in spec.infrastructure are: the ControlPlane never projects it, never updates it, and never deletes it. The plane reads the databases the central publishes and mirrors its readiness into the OVNReady condition.

FieldTypeRequiredDefaultDescription
namestringYes—The OVNCentral's name. MinLength 1, mirrored by the validating webhook: a reference naming nothing leaves the child with no database to program.
namespacestringNothe ControlPlane's own namespaceThe namespace the OVNCentral lives in. A lowercase alphanumeric RFC-1123 label of at most 63 characters (CRD pattern, mirrored by the webhook). The defaulting webhook fills an empty value with the ControlPlane's own namespace, which is also how the reconciler resolves an empty value, so a CR that bypassed admission addresses the same central as one that went through it. The webhook additionally bounds which namespace it may name — see below.

A central on a different cluster than the network service has to publish both databases with externallyReachable: true, because the Neutron pods then reach them over the node network rather than through cluster DNS. Until it does, OVNReady reports OVNCentralNotExternallyReachable.

The namespace must be one the ControlPlane already reaches: its own, or one it claims through a services.<service>.namespace assignment whose lifecycle is External. The webhook rejects every other value, from two directions:

  • A foreign namespace, because the reference is not read-only. The neutron-operator mirrors the central's client Secret out of that namespace into the Neutron's, so naming another plane's central hands this one a full mTLS identity for its Northbound and Southbound databases — its networks, ports and security groups, readable and writable — and OVNReady relays that central's database addresses and status message on the way. It is the isolation namespace claims enforce for service namespaces, one field over.
  • A claimed namespace with lifecycle Managed, because the teardown deletes such a namespace together with the plane, and the cascade would take the referenced central, and the logical network model in its databases, with it.

That reach rule bounds the topology: a namespace belongs to at most one ControlPlane cluster-wide, so one OVNCentral serves one ControlPlane. A central shared by several planes has no shape here — a second plane can neither claim the namespace it lives in, which is already occupied, nor reference it without claiming it. Give each ControlPlane its own OVNCentral.

The rule runs on create and on the two updates that can newly violate it, the one that enables the network service and the one that moves the ref — never on every update, for the reason the projected child-name bounds are also gated: an unconditional rule can only reject a CR a previous operator build already admitted, and one of those rejections would land on the finalizer-removal update that completes a deletion, leaving the ControlPlane in Terminating for good. reconcileOVN re-runs the same check as its controller-side backstop, so a CR that never passed admission — an unregistered webhook during install, a GitOps or etcd restore replaying stored objects — is refused at OVNReady with OVNCentralNamespaceForbidden and the central is never read.

NeutronDedicatedBackingServicesSpec ​

Declares the backing-service instances the Neutron service gets for itself instead of the ControlPlane-wide shared ones, on the same contract as KeystoneDedicatedBackingServicesSpec. Neutron consumes both a database and a cache, so it can take either or both dedicated; a class left unset resolves to the ControlPlane-wide instance in spec.infrastructure.

The block is optional, but a declared one must name at least one class. A CEL XValidation rule (has(self.database) || has(self.cache)) rejects an empty block, which would request nothing, with the message dedicatedBackingServices must declare at least one backing-service class (database, cache).

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Neutron its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-neutron-db. A dedicated managed database is Static-only: the defaulting webhook materializes credentialsMode: Static and an explicit Dynamic is rejected, for the same reason as Keystone (see Credential modes): the OpenBao database engine is bootstrapped once per namespace against the shared cluster, so no engine role can issue credentials for a dedicated instance. Seed and rotate the credential at the OpenBao source.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Neutron its own cache. In managed mode clusterRef.name defaults to {controlplane}-neutron-cache.

ServiceCinderSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Cinder block-storage service, mirroring ServiceKeystoneSpec and ServiceNeutronSpec. The reconciler (L2) projects it into a Cinder CR; the database, cache, message bus, Keystone endpoint, Glance endpoint, key manager, and internal tenant of that child are derived from the ControlPlane (infrastructure.*, the sibling children's naming convention, and operator policy) rather than set here. spec.dbPurge, spec.networkPolicy, spec.autoscaling, spec.logging, spec.api.uwsgi, and spec.policyOverrides on the child are not projected, so the cinder operator's own purge schedule, network policies, autoscaling, logging, uWSGI parameters, and policy defaults stay authoritative.

Two fields have no counterpart on the other services. backends is required with at least one entry, because a Cinder with no volume backend accepts a volume request and leaves it in error. backupBackend names the driver the one backup service writes through, a single property of that service where a volume backend is one of several stores. Both project satellite CRs of their own kinds beside the Cinder child.

Forbidden entirely when services.keystone.mode is External (Cinder needs its own External-mode design), so, like ServiceNeutronSpec, none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
image*commonv1.ImageSpecNonilOverrides the Cinder container image. When nil the reconciler derives ghcr.io/c5c3/cinder:{spec.openStackRelease}. When set, the validating webhook mirrors the commonv1.ImageSpec tag/digest XOR, so an override naming neither or both is rejected at admission.
gateway*commonv1.GatewaySpecNonilExposes the projected Cinder API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the Cinder API is reachable in-cluster only. When a gateway is set its hostname must be non-empty and a usable DNS name, enforced at admission by the validating webhook (see Validation Rules).
publicEndpointstringNo""Externally routable Cinder endpoint URL (e.g. https://cinder.127-0-0-1.nip.io:8443). Used only for the K-ORC public block-storage catalog Endpoint, the URL every client resolves to create its volumes, snapshots, and backups; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment, since the ControlPlane appends /v3 when it registers the row), and be at most 512 characters; a single trailing slash is tolerated and the reconciler trims it before appending /v3, so the row never carries //v3. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ); see Validation Rules. Without a gateway an http:// value stays admissible for development and raises an admission warning, because every volume call carries the caller's scoped Keystone token to this URL. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Cinder on one mode while another service stays on the other. Empty (the default) inherits the shared mode, and is not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Cinder declares a dedicated database (dedicated is Static-only; set dedicatedBackingServices.database.credentialsMode instead, see Credential modes) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the block-storage service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the Cinder child's spec.extraConfig, which carries the cinder.conf sections. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block. See ExtraConfig admission checks.
backends[]CinderBackendEntryYes—The curated list of volume backends, projected one CinderBackend satellite per entry. At least one entry is required: a Cinder with no backend accepts a volume request and leaves it in error, so the MinItems floor keeps that shape out of the API. MaxItems 32 bounds the child-CR amplification of one admission, since each entry carries a cinder-volume Deployment of its own. A listType=map list keyed by name, so the API server rejects duplicate names. Removing an entry is not gated by c5c3.io/allow-cinder-deletion the way removing the whole cinder block is: the next pass prunes that entry's CinderBackend and the cinder operator unregisters its cinder-volume service, leaving every volume keyed to that host unmanageable while the plane still reports CinderReady=True off the backends that remain. The update webhook therefore raises an admission warning naming each dropped entry and its type; re-adding it under the same name and type restores the host identity the volumes are keyed by. Nothing refuses a re-add under the other type, which hands those volumes to a driver that did not create them.
backupBackend*CinderBackupBackendEntryNonilThe driver the backup service writes volume backups through, projected into the one CinderBackupBackend satellite. Omitting it leaves the plane without a backup service; removing it from a ControlPlane that had one prunes the satellite, and the Cinder child drops its backup Deployment in turn. Renaming the entry replaces the satellite in one edit, an NFS target with an RBD one included: a Cinder takes one backup backend, so the reconciler prunes the old CinderBackupBackend before it projects the new one. For that one pass the plane has no backup target, and if the new satellite is refused it stays without one until the entry is fixed. A new name that a CinderBackupBackend this ControlPlane did not create already holds is refused before the prune, so the current target stays (CinderReady=False/CinderBackendError). Both drivers run under the same backup host, so after a rename that changes the type the backups the old driver wrote can be neither restored nor deleted until the old entry is declared again; the update webhook raises an admission warning naming both entries. Removing the entry raises an admission warning too, naming its type: the backups stay unreachable until a target of that type on the same storage is declared again, and nothing refuses an entry of the other type added in a later update, which hands them to a driver that did not write them. Changing type under an unchanged name is refused (type is immutable while name is unchanged).
dedicatedBackingServices*CinderDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Cinder out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Cinder consumes both classes, so it can take either or both dedicated; a declared block must name at least one.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the block-storage service, and the database, cache, secret store, and credential material that follow it, in a namespace of its own. Create-only: the validating webhook freezes the block after creation. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the block-storage service, its satellites, and the database, cache, secret store, and credential material that follow it, on a registered target cluster. The projected Cinder CR stays on the management cluster and carries the ref verbatim. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the block-storage catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. See ControlPlane placement.

The logical database name is not exposed here. The projection forces it to cinder on the child, whether Cinder shares the ControlPlane's database cluster or takes a dedicated one: that is the one schema the pre-wired OpenBao engine role grants on, so any other name would be issued a credential it cannot use.

Setting services.cinder requires spec.infrastructure.messaging beside it. The Cinder CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus, so a block-storage service declared without one would project a child its own admission rejects on every pass; the webhook reports the omission on spec.infrastructure.messaging. The bus is declared and read in the ControlPlane's namespace on the management cluster, while Cinder may run in a namespace of its own or on another cluster, so the reconciler resolves the transport URL itself and hands the child a brownfield secretRef naming {controlplane.Name}-cinder-messaging in the Cinder's own namespace on the Cinder's own cluster. A managed clusterRef and a brownfield secretRef on the ControlPlane both arrive there as that one Secret. When the shared bus declares tls, the CA bundle is mirrored beside it as {controlplane.Name}-cinder-messaging-ca and named by the child's messaging.tls.caBundleSecretRef; a bus without tls leaves no mirror behind.

Setting services.cinder makes the reconciler project a KeystoneService registration named {controlplane.Name}-cinder into the namespace Cinder is placed in: the block-storage catalog entry plus the cinder service account, whose project service-cinder is created with the roles service andadmin. That account is the Keystone user the projected child authenticates as, so the Cinder child is not projected until the registration reports it provisioned (CinderReady=False/WaitingForServiceRegistration until then). The admin role is what no peer registration holds: cinder deletes the Barbican secret of an encrypted volume as a fallback when the volume's owner cannot, and under Barbican's secure-RBAC defaults a bare admin is the reach that carries secret:get and secret:delete across projects. Both catalog rows carry the project-less /v3 path the block-storage API is served under (decision D8 of #979).

Three fields of the child are derived rather than set here. Two of them follow sibling service blocks, and neither sibling gates the projection:

  • glanceEndpoint is the in-cluster Glance API URL while services.glance is declared, and empty otherwise. It is what create-volume-from-image reads the image through.
  • keyManager names the Barbican child by naming convention while services.barbican is declared, and is absent otherwise (decision D9 of #979). It is where castellan stores the LUKS key of an encrypted volume.
  • internalTenant carries the projectID and userID the registration publishes on its status.account (decision D10 of #979). It is the identity the image-volume cache owns its cached volumes as, and the block stays unset until the account publishes both IDs, since cinder passes them to the volume API without resolving them through Keystone.

Setting services.cinder bounds the ControlPlane's own name too. The projected child is {controlplane.Name}-cinder, and the Cinder CRD caps metadata.name at 43 characters so the cinder operator's purge CronJob name ({name}-db-purge) still fits the API server's 52-character CronJob bound. The ControlPlane name may therefore be at most 36 characters while services.cinder is set. The rule runs on create and on the update that newly enables Cinder.

The projection writes the replica count onto the child's scheduler, volume, and backup Deployment blocks: 1 for the volume and backup blocks always, and for the scheduler unless spec.sizing.cinder.scheduler.replicas says otherwise. All three are struct values rather than pointers, so deployment: {} reaches the API server whatever the projection assigns, and the shared DeploymentSpec default of three lands on the wire before the cinder operator's own defaulting webhook runs. The volume and backup blocks are the ones that must be pinned: the Cinder CRD's CEL rules admit only 1 there, because the NFS drivers refuse a second process under the same host identity, so leaving them alone has the Cinder API server reject the child on every pass. The scheduler has no CEL rule, and leaving it alone runs three schedulers silently.

CinderBackendEntry ​

Declares one curated volume backend, projected one-to-one into a CinderBackend satellite CR of the entry's type.

FieldTypeRequiredDefaultDescription
namestringYes—Keys the listType=map backends list and is the projected CinderBackend's metadata.name, taken bare rather than prefixed with the ControlPlane's name: cinder keys every volume by the backend it was created on, so a rename would strand the volumes already there. It is also the backend's [<name>] section in cinder.conf and its volume_backend_name. A DNS-1123 label (^[a-z0-9]([-a-z0-9]*[a-z0-9])?$), MinLength 1, MaxLength 35, the bound cinderv1alpha1.MaxBackendNameLength applies to the satellite's own name. The validating webhook adds the two rules the markers cannot express: the name default is refused because it names cinder.conf's [DEFAULT] section, and len(controlplane.Name) + 7 + len(name) must stay at or below 47, the budget the satellite shares with its cinderRef in the <cinder>-<backend>-service-remove Job name a detach spawns.
typestring (NFS | RBD)Yes—Selects the volume driver; both are projected. The enum mirrors the CinderBackend CR's own, so an entry admitted here can never be rejected downstream by the satellite CRD. The type is frozen per entry name: a CEL transition rule (self.type == oldSelf.type) on the listType=map item, with a webhook twin, refuses a change under an unchanged name with the message type is immutable: the volumes on a backend were created by its driver; declare the other driver under a new name.
nfs*NFSShareSpecConditionalnilConfigures the NFS volume driver. A CEL XValidation rule ((self.type == 'NFS') == has(self.nfs)) requires the block exactly when type is NFS and forbids it otherwise, with the message the nfs block must be set exactly when type is NFS; the validating webhook mirrors it.
rbd*CinderBackendRBDSpecConditionalnilConfigures the Ceph RBD volume driver. A CEL XValidation rule ((self.type == 'RBD') == has(self.rbd)) requires the block when type is RBD and forbids it otherwise, with the message the rbd block must be set exactly when type is RBD; the validating webhook mirrors it.
imageVolumeCache*CinderImageVolumeCacheSpecNonil (cache off)Turns on the per-backend image-volume cache: the first volume created from a given image is kept, and later requests for the same image clone it on the backend instead of pulling through Glance again. The cached volumes are owned by the deployment rather than by the requesting tenant, so the reconciler gives the Cinder child an internalTenant as soon as any entry enables the cache.

Because the satellite carries the bare entry name, it is also the name a person naming a hand-made CinderBackend picks. ensureProjectedSatellite therefore refuses to adopt a same-named object this ControlPlane did not create in every namespace, the ControlPlane's own included, and the prune that follows each projection deletes only c5c3-owned satellites. The type freeze keys on the same name: an entry keeps its driver for as long as an entry of that name exists, so switching drivers means declaring the other driver under a new name. The freeze compares the two revisions of one update, so it ends with the entry: a name removed in one update and re-added under the other type in a later one is admitted, and the new driver takes over the host the old driver's volumes are keyed by. The removal warning names the type to restore.

The c5c3-operator and cinder-operator charts roll together. A cinder CRD older than the RBD satellites (#1341, #1342) rejects a projected RBD satellite, which shows as CinderReady=False/CinderBackendProjectionRejected. Rolling the c5c3-operator back below the release that added the RBD entries, with an RBD entry stored, leaves the older reconciler projecting every entry as NFS. The satellite refuses that change with type is immutable, so the plane stays at CinderBackendProjectionRejected while the RBD satellites keep serving, until the c5c3-operator is rolled forward again.

NFSShareSpec ​

The curated NFS export shape projected onto a satellite's spec.nfs, shared by the volume backends and the backup backend because both mount one export the same way. The field bounds mirror the satellite CRD's own NFSBackendSpec, so a value admitted here can never be rejected downstream.

FieldTypeRequiredDefaultDescription
serverstringYes—The NFS server the export lives on, as a hostname or an IP address. MinLength 1; Pattern ^[A-Za-z0-9.-]+$, which is the character set a hostname or an IPv4 address carries, because the value reaches the mount command verbatim.
pathstringYes—The absolute export path on the server. Pattern ^/[!-~]*$: printable ASCII only, since the satellite renders server:path into a config file the cinder pod reads back a line at a time and cuts at the first space. Whitespace would leave cinder addressing a shorter export than the one validated and mounted, and a newline would add a second export line outright.
mountOptionsstringNo""The comma-separated option string the export is mounted with. It carries no schema default: an unset value serializes away, so the satellite CRD's own default (nfsvers=4.1,soft,timeo=30,retrans=2) applies at one layer. Override it only where the server demands different semantics, and keep the mount soft: a hard mount blocks the cinder process on an unreachable server rather than failing the request. Pattern ^[^\n\r]*$, since either character would divide the rendered option line.

CinderBackendRBDSpec ​

The curated Ceph RBD shape projected onto a CinderBackend's spec.rbd. It is a local copy of the cinder module's RBDBackendSpec with the same markers and CEL messages, so a value admitted here is admitted by the satellite CRD; the validating webhook adds the rules no marker expresses (see Validation Rules). The c5c3-operator creates no pool and no cephx user, and it never reads the key (see The RBD key Secret).

FieldTypeRequiredDefaultDescription
poolstringYes—The Ceph pool the backend keeps its volumes in, rendered as rbd_pool. MinLength 1, MaxLength 64, Pattern ^[A-Za-z0-9._-]+$.
userstringYes—The cephx user the driver authenticates as, without its client. prefix, rendered as rbd_user. MinLength 1, MaxLength 64, Pattern ^[A-Za-z0-9._-]+$. A CEL rule (!self.startsWith('client.')) refuses the prefix with user is the cephx name without its client. prefix (write cinder, not client.cinder).
monitors[]stringYes—The Ceph monitor addresses, each a hostname or an IPv4 address with an optional :port, rendered comma-joined as mon_host in the projected ceph.conf. MinItems 1, MaxItems 16; item MinLength 1, MaxLength 253, Pattern ^[A-Za-z0-9.-]+(:[0-9]{1,5})?$. The webhook also refuses a port outside 1 to 65535.
networks[]stringYes—The IPv4 CIDRs the Ceph cluster answers on, the Ceph side of the Cinder's NetworkPolicy egress to the monitor and OSD ports. For a Rook cluster in the same Kubernetes cluster, list the pod network and the service network. MinItems 1, MaxItems 16; item Pattern ^([0-9]{1,3}\.){3}[0-9]{1,3}/[0-9]{1,2}$. The webhook also requires the canonical form with the host bits zero, because the API server refuses any other form in an ipBlock.
clusterNamestringNo"" (the satellite's ceph)The Ceph cluster name, rendered as rbd_cluster_name and the stem of the projected /etc/ceph/<cluster>.conf and keyring file names. MaxLength 63, Pattern ^[A-Za-z0-9_-]+$. It carries no schema default, so an unset value serializes away and the satellite CRD's own default applies at one layer.
keySecretRefCinderRBDKeySecretRefYes—Names the Secret that holds the cephx key of user. See The RBD key Secret.
secretUUIDstringNo"" (the driver uses the cluster FSID; the libvirt secret on the hypervisors has to carry the same UUID)The UUID of the libvirt secret the hypervisors look the key up by, rendered as rbd_secret_uuid only when set. Pattern ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$, a lowercase UUID.

CinderBackupBackendRBDSpec ​

The curated Ceph shape projected onto a CinderBackupBackend's spec.rbd, a local copy of the cinder module's RBDBackupBackendSpec. It carries the fields of CinderBackendRBDSpec with the same markers, minus secretUUID: no hypervisor attaches a backup image, so no libvirt secret looks its key up.

FieldTypeRequiredDefaultDescription
poolstringYes—The Ceph pool the backups are written to, rendered as backup_ceph_pool. MinLength 1, MaxLength 64, Pattern ^[A-Za-z0-9._-]+$.
userstringYes—The cephx user the backup driver authenticates as, without its client. prefix, rendered as backup_ceph_user. MinLength 1, MaxLength 64, Pattern ^[A-Za-z0-9._-]+$. A CEL rule refuses the prefix with user is the cephx name without its client. prefix (write cinder-backup, not client.cinder-backup).
monitors[]stringYes—The Ceph monitor addresses, with the markers and the port rule of the volume block's monitors.
networks[]stringYes—The IPv4 CIDRs the Ceph cluster answers on, with the markers and the canonical-form rule of the volume block's networks. The Cinder's NetworkPolicy unions them with the networks of its RBD volume backends into one Ceph egress rule.
clusterNamestringNo"" (the satellite's ceph)The Ceph cluster name, the stem of the projected ceph.conf and keyring file names. MaxLength 63, Pattern ^[A-Za-z0-9_-]+$, no schema default.
keySecretRefCinderRBDKeySecretRefYes—Names the Secret that holds the cephx key of user. See The RBD key Secret.

CinderRBDKeySecretRef ​

Names the Secret that holds an RBD entry's cephx key. It is a local copy of the cinder module's SecretNameRefSpec, pattern included; the shared SecretNameRef carries no pattern because the GlanceBackend child admits any non-empty name.

FieldTypeRequiredDefaultDescription
namestringYes—The Secret's name, a DNS-1123 subdomain: MinLength 1, MaxLength 253, Pattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$.

The RBD key Secret ​

An RBD entry names the Secret that holds its cephx key and carries nothing else of the key. The c5c3-operator never reads, copies or creates that Secret, and it holds no permission for it. The cinder operator reads it where the volume and backup pods run, so the Secret has to exist there:

  • It carries the raw cephx key, as ceph auth get-key client.<user> prints it, under the data key userKey.
  • It lives in the namespace the block-storage service runs in: services.cinder.namespace when set, else the ControlPlane's namespace.
  • It lives on the cluster the volume pods run on: the target cluster services.cinder.targetClusterRef names, or the management cluster when the field is unset.

An ExternalSecret through a SecretStore whose role may read the key's OpenBao path writes the Secret and keeps it in sync. The lab's deploy/lab/metal-stack/ceph/cluster/keys-pull.yaml is the worked example: it pulls both client keys into openstack. Where Rook runs in the same cluster and no ExternalSecret owns the name, a copy of Rook's Secret works too:

bash
kubectl -n rook-ceph get secret rook-ceph-client-cinder -o jsonpath='{.data.userKey}' |
  base64 -d |
  kubectl -n openstack create secret generic ceph-client-cinder --from-file=userKey=/dev/stdin

GitOps ordering may apply the Secret after the ControlPlane. Until it exists, the satellite reports CredentialsReady=False/WaitingForCredentials, the Cinder child reports BackendsReady=False/WaitingForBackends (for a backup target, BackupBackendReady=False/WaitingForBackupBackend), and the ControlPlane reports CinderReady=False/WaitingForCinder. Rotation follows the satellite's rules: see The key Secret.

CinderImageVolumeCacheSpec ​

Bounds the per-backend image-volume cache projected onto a CinderBackend's spec.imageVolumeCache. The cache trades backend capacity for create-volume-from-image latency: a cached image volume is cloned on the backend, which skips the download through Glance.

FieldTypeRequiredDefaultDescription
enabledboolYes—Turns the cache on for this backend. An explicit field rather than the presence of the block, so the bounds can be configured in a ControlPlane that keeps the cache off.
maxSizeGB*int32Nonil (unbounded by size)Caps the total size, in GiB, of the cached image volumes on this backend. Minimum 1.
maxCount*int32Nonil (unbounded by count)Caps the number of cached image volumes on this backend. Minimum 1.

Both bounds are optional and independent: whichever is reached first evicts the least recently used entry. Leaving both unset caches without a bound, which suits a backend with capacity to spare and nothing else.

CinderBackupBackendEntry ​

Declares the curated backup driver, projected one-to-one into the one CinderBackupBackend satellite CR. It is a separate shape from CinderBackendEntry because the two describe different things: a backend is one of several volume stores and gets a cinder-volume Deployment of its own, while the backup driver is a single property of the one cinder-backup Deployment, with chunking and compression knobs no volume backend carries.

FieldTypeRequiredDefaultDescription
namestringYes—Embedded verbatim in the projected CinderBackupBackend's name, hence the DNS-1123 label shape (^[a-z0-9]([-a-z0-9]*[a-z0-9])?$), MinLength 1, MaxLength 63. Unlike a volume backend's name it names no cinder.conf section, so it takes the full label bound, and it enters no Job name: detaching a backup backend runs no service-remove Job, so it carries no composed bound either.
typestring (NFS | RBD)Yes—Selects the backup driver; both are projected. The enum mirrors the CinderBackupBackend CR's own. The type is frozen while the name is unchanged: a CEL transition rule (self.name != oldSelf.name || self.type == oldSelf.type), with a webhook twin, refuses the change with the message type is immutable while name is unchanged: a restore reads a backup with the driver that wrote it; rename the entry to switch the driver. A rename may change the type.
nfs*NFSShareSpecConditionalnilConfigures the NFS backup driver. The same CEL XValidation union rule as the volume backend: required exactly when type is NFS, forbidden otherwise.
rbd*CinderBackupBackendRBDSpecConditionalnilConfigures the Ceph backup driver. The same CEL XValidation union rule as the volume backend's rbd: required when type is RBD, forbidden otherwise, with the message the rbd block must be set exactly when type is RBD.
fileSize*int64Nonil (the satellite CRD's 52428800, 50 MiB)The size in bytes of one backup chunk: cinder splits a volume into objects of this size and writes them one at a time, so it bounds both the memory a backup holds and the work a failed chunk costs. Minimum 1048576 (1 MiB) and MultipleOf 32768, the block size cinder hashes chunks in. It carries no schema default, so an unset value serializes away and the satellite CRD's own default applies at one layer. Inert on an RBD target: the satellite admits it, and the Ceph driver neither renders nor reads it.
compressionstring (none | zlib | bz2 | zstd)No"" (the satellite CRD's zlib)The algorithm each chunk is compressed with. none writes the chunks uncompressed, trading backup capacity for CPU on the backup pod. Like fileSize it carries no schema default, and like fileSize it is inert on an RBD target.

CinderDedicatedBackingServicesSpec ​

Declares the backing-service instances the block-storage service gets for itself instead of the ControlPlane-wide shared ones, on the same contract as KeystoneDedicatedBackingServicesSpec. Cinder consumes both a database and a cache, so it can take either or both dedicated; a class left unset resolves to the ControlPlane-wide instance in spec.infrastructure.

The block is optional, but a declared one must name at least one class. A CEL XValidation rule (has(self.database) || has(self.cache)) rejects an empty block, which would request nothing, with the message dedicatedBackingServices must declare at least one backing-service class (database, cache).

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Cinder its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-cinder-db. A dedicated managed database is Static-only: the defaulting webhook materializes credentialsMode: Static and an explicit Dynamic is rejected, for the same reason as Keystone (see Credential modes): the OpenBao database engine is bootstrapped once per namespace against the shared cluster, so no engine role can issue credentials for a dedicated instance. Seed and rotate the credential at the OpenBao source.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Cinder its own cache. In managed mode clusterRef.name defaults to {controlplane}-cinder-cache.

ServiceNovaSpec ​

A curated local subset of the knobs the ControlPlane exposes for the Nova compute service, mirroring ServiceKeystoneSpec and ServiceCinderSpec. The reconciler (L2) projects it into a Nova CR; the two databases, the cache, the message bus, the Keystone endpoint, the service user, and the Placement, Neutron, Glance, Cinder and Barbican endpoints of that child are derived from the ControlPlane (infrastructure.*, the sibling children's naming convention, and operator policy) rather than set here. spec.networkPolicy, spec.autoscaling, spec.logging, spec.api.uwsgi and spec.metadata.uwsgi are not projected, so the nova operator's own network policies, autoscaling, logging, and uWSGI parameters stay authoritative.

Nine fields have no counterpart on the other services. Three are replica counts, because the compute service runs a metadata API, a scheduler, and a conductor in Deployments beside its API. Two are the metadata pair, metadataGateway and metadataSharedSecretRef: the metadata API is the one endpoint dialed from a compute cluster rather than from inside the control plane, and the agent that dials it signs every request with a secret both sides have to hold. consoleProxy sizes and publishes the noVNC console proxy, a browser-facing bridge to the hypervisors no other service runs, and dbArchive tunes the archive of the rows Nova soft-deletes instead of removing. remoteCompute hands over the one address of the compute contract the ControlPlane cannot derive: the bus's external listener. hypervisorOperator provisions the Keystone account openstack-hypervisor-operator runs as on the compute clusters.

Forbidden entirely when services.keystone.mode is External (Nova needs its own External-mode design), so, like ServiceCinderSpec, none of its fields carry per-field External-mode forbid-rules.

FieldTypeRequiredDefaultDescription
consoleProxy*ServiceNovaConsoleProxySpecNonilThe nova-novncproxy Deployment that bridges a browser's noVNC session to the VNC server of the hypervisor an instance runs on. Omitting the block leaves the proxy enabled at the nova operator's own defaults.
image*commonv1.ImageSpecNonilOverrides the Nova container image. When nil the reconciler derives ghcr.io/c5c3/nova:{spec.openStackRelease}. When set, the validating webhook mirrors the commonv1.ImageSpec tag/digest XOR, so an override naming neither or both is rejected at admission.
gateway*commonv1.GatewaySpecNonilExposes the projected Nova API externally via a Gateway API HTTPRoute. When nil (the default) no HTTPRoute is projected and the API is reachable in-cluster only. A configured gateway needs a non-empty hostname that is a usable DNS name (see Validation Rules).
publicEndpointstringNo""Externally routable Nova endpoint URL (e.g. https://nova.127-0-0-1.nip.io:8443). Used only for the K-ORC public compute catalog Endpoint, the URL every client resolves to boot, list, and delete its instances; it is projected into no child CR, so the validating webhook is the only gate on it. When set, it must match ^https?://, parse to a bare origin with a host (no path, query, or fragment, since the ControlPlane appends /v2.1 when it registers the row), and be at most 512 characters; a single trailing slash is tolerated and novaCatalogURL trims it before appending /v2.1. When a gateway is configured the scheme must be https and the host must equal gateway.hostname (the port may differ); see Validation Rules. Without a gateway an http:// value stays admissible for development and raises an admission warning, because every compute call carries the caller's scoped Keystone token to this URL. When empty and gateway is set, the reconciler derives https://{gateway.hostname} (the default-443 form); set it explicitly when the externally reachable port differs (e.g. a kind host-port mapping like :8443).
metadataGateway*commonv1.GatewaySpecNonilExposes the projected metadata API on a hostname of its own (nova-metadata.<domain>), the listener a Neutron metadata agent dials from the compute cluster it runs on. It is separate from gateway because the two endpoints serve different callers: users reach the API, and only the metadata agents reach this one. When nil the metadata API is reachable in-cluster only, which is enough while the computes share the cluster the control plane runs on. It enters no catalog row. Its path must be empty or /, because the agent addresses the metadata API by scheme, host and port alone, and its hostname must not share a Gateway listener with gateway or consoleProxy.gateway (see Three gateways, one catalog row).
metadataSharedSecretRef*commonv1.SecretRefSpecNonil (the ControlPlane generates the value)References a Secret holding the value the Neutron metadata agent signs proxied requests with, which reaches the metadata API of the compute service as [neutron] metadata_proxy_shared_secret through an environment variable and is carried by every agent that proxies to it. Leaving it nil has the ControlPlane generate the secret and hand it to the child (see The generated metadata shared secret). Supply one when the value has to be seeded from outside this ControlPlane's reach: a metadata request signed with a value only one side knows is rejected, so both sides have to resolve the same Secret. Naming the generated Secret {controlplane.Name}-nova-metadata-secret itself keeps it generated. Whichever Secret it names, an agent on a compute cluster reads the value from the copy {controlplane.Name}-nova-metadata-agent-secret the ControlPlane delivers there. The shared type rejects an empty name.
databaseCredentialsModestring (Static | Dynamic)No"" (inherits spec.infrastructure.database.credentialsMode)Per-service override of the ControlPlane-wide credentials mode for the managed shared database, so a staged migration can run Nova on one mode while another service stays on the other. It applies to both database blocks the compute service holds: the Nova CRD rejects a child whose apiDatabase and database carry different modes, so one value covers both. Empty (the default) inherits the shared mode, and is not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override. A Dynamic override is rejected when Nova declares a dedicated database (dedicated is Static-only) and when the shared database is brownfield (clusterRef unset); Static is always admitted.
dbArchive*ServiceNovaDBArchiveSpecNonilTunes the recurring archive of the compute service's soft-deleted rows, projected onto the child's spec.dbArchive. A nil block resolves exactly like an empty one, so the archive runs on every projected Nova.
hypervisorOperator*ServiceNovaHypervisorOperatorSpecNonilOpt-in marker with no fields. {} provisions the Keystone account openstack-hypervisor-operator authenticates as (user hypervisor-operator, role admin) and delivers its credentials as {controlplane.Name}-nova-hypervisor-operator-auth beside the Nova and on every compute cluster a NovaCompute of it runs on. Removing the block deletes the account.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for the compute service. Merged key by key with spec.globalExtraConfig (this per-service value winning per key) and the merged result projected onto the child's spec.extraConfig, which carries the nova.conf sections. Admission runs shape, operator-owned-key, and option-catalog checks on the merged block. See ExtraConfig admission checks.
dedicatedBackingServices*NovaDedicatedBackingServicesSpecNonil (shares the ControlPlane-wide instances)Opts Nova out of the shared spec.infrastructure instances and gives it a database and/or cache of its own. Nova consumes both classes, so it can take either or both dedicated; a declared block must name at least one.
namespace*ServiceNamespaceSpecNonil (placed in the ControlPlane's namespace)Places the compute service, and the databases, cache, secret store, and credential material that follow it, in a namespace of its own. Create-only: the validating webhook freezes the block after creation. See Service Namespaces.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the local cluster the operator runs on)Places the compute service and the material that follows it on a registered target cluster. The projected Nova CR stays on the management cluster and carries the ref verbatim. Requires a namespace block of its own, plus a publicEndpoint or a gateway so the compute catalog advertises an address other clusters resolve (webhook). Create-only: the validating webhook freezes the ref after creation. See ControlPlane placement.

ServiceNovaConsoleProxySpec ​

The ControlPlane's view of the console proxy. A disabled proxy has no listener to expose, so a gateway set beside enabled: false has nowhere to land. A CEL rule on this type refuses that combination at admission (gateway must not be set when consoleProxy.enabled is false) rather than parking the projected child on a rule of its own. The proxy is sized by spec.sizing.nova.consoleProxy, which the validating webhook forbids beside a disabled proxy for the same reason: the Nova CRD rejects a spec.consoleProxy.deployment written on a disabled proxy.

FieldTypeRequiredDefaultDescription
enabled*boolNonil (projected as true)Projects the console proxy. An omitted consoleProxy block leaves the switch absent on the wire and the nova defaulting webhook enables the proxy; a block present with enabled unset is projected as true outright. Setting it to false deletes the proxy Deployment, its Service, and its HTTPRoute.
gateway*commonv1.GatewaySpecNonilExposes the console proxy externally. It takes a hostname of its own rather than a path under the API's: the noVNC client opens a WebSocket against the host the console URL names, and the API hands that URL to the browser. The path must be empty or /, since the console page and its WebSocket both open on the root of the hostname. Forbidden while the proxy is disabled.

ServiceNovaRemoteComputeSpec ​

Hands the ControlPlane the external address of the message bus. The compute contract the Nova child publishes names in-cluster addresses: the broker's Service, the in-cluster Keystone, and the internal catalog rows. A nova-compute on a compute cluster reaches none of them. The ControlPlane knows the public addresses it registers in the catalog, but not how the platform exposes the broker outside the cluster, so the platform hands over that one URL and the ControlPlane derives the rest.

FieldTypeRequiredDefaultDescription
transportURLSecretRefcommonv1.SecretRefSpecYeskey: transport_urlA Secret in the ControlPlane's namespace on the management cluster holding the complete rabbit:// URL of the broker's external TLS listener, with the same user, password and vhost as spec.infrastructure.messaging.secretRef. The ControlPlane reads it and never writes it. The listener's certificate must chain to the CA bundle spec.infrastructure.messaging.tls names, because that bundle is the only one the remote contract carries. The shared type rejects an empty name.

While the block is set, the reconciler delivers the handed URL into the Nova namespace as {controlplane.Name}-nova-remote-messaging, and projects the child's spec.remoteCompute as the public Keystone URL (publicEndpoint, else https://{gateway.hostname}/v3) paired with that Secret. The nova operator then publishes {controlplane.Name}-nova-remote-compute-config, and the compute-config mirror copies it onto compute clusters under the in-cluster contract's name (see Nova CRD: the remote contract).

The validating webhook admits the block only where the remote contract can work. Every rule is webhook-only:

FieldTypeMessage
spec.services.nova.remoteComputefield.Forbiddenrequires a brownfield bus with tls (spec.infrastructure.messaging.secretRef and spec.infrastructure.messaging.tls): a managed RabbitmqCluster is provisioned without a TLS listener, and a compute cluster reaches the bus across a cluster boundary
spec.infrastructure.messaging.tlsfield.Requiredis required when services.nova.remoteCompute is set: a compute cluster reaches the bus across a cluster boundary and verifies the broker against this CA bundle
spec.services.keystone.publicEndpoint (absent or unpublished Keystone)field.Requiredone of publicEndpoint or gateway is required when services.nova.remoteCompute is set: a compute cluster authenticates against the public Keystone URL
spec.services.keystone.publicEndpoint (http://)field.Invalidmust use scheme https when services.nova.remoteCompute is set: every compute cluster sends the nova service-user password to this URL across a cluster boundary
spec.services.<svc>.publicEndpoint for Glance, Placement and Neutron, and Cinder and Barbican when declaredfield.Requiredone of publicEndpoint or gateway is required when services.nova.remoteCompute is set: a compute cluster resolves this service through its public catalog row, which otherwise names the in-cluster Service

A missing messaging block is reported by the messaging-consumer rule alone, and an undeclared Placement, Neutron or Glance by the Nova dependency rules. A public catalog row without a publicEndpoint or a gateway falls back to the in-cluster Service URL, which no compute cluster resolves.

Clearing the block while compute clusters are attached switches the mirror back to the in-cluster contract, whose addresses those clusters cannot reach.

ServiceNovaDBArchiveSpec ​

Nova soft-deletes: a deleted instance stays in the instances table with a deleted_at stamp, so a long-lived cloud carries every instance it ever booted in the table the API queries. The archive moves those rows into the shadow tables, where they stay available for accounting. This block is a local copy of the Nova CRD's DBArchiveSpec, projected onto the child's spec.dbArchive.

FieldTypeRequiredDefaultDescription
schedulestringNo"" (the nova operator's own default, once a day)The standard cron expression the archive CronJob runs on. Checked by the validating webhook rather than by a CRD pattern: the accepted grammar includes descriptors such as @daily, which no regex expresses without also rejecting valid expressions.
maxRows*int32Nonil (the nova operator's own default, 1000)Bounds how many rows one batch moves per table, the --max_rows argument. The bound is what keeps a batch on a long-neglected database from holding table locks for the length of the backlog. A run repeats batches until nothing is left to move or its time budget is spent, and the next run picks up where it stopped. Minimum 1.
sleep*int32Nonil (the nova operator's own default, 1)How many seconds the run waits between batches. Zero runs the batches back to back, which finishes sooner at the cost of the database serving the API at the same time. Minimum 0.
retentionDays*int32Nonil (no --before, every soft-deleted row eligible)Keeps the most recent deletions out of the archive: the run passes --before with today's date minus this many days, so a row soft-deleted inside the window stays in the live table. The window also gates the task_log table, whose rows are never soft-deleted, so --task-log is passed only together with --before. Minimum 1.
suspendboolNofalsePauses the archive CronJob without deleting it. It is the escape hatch for a brownfield deployment onboarding onto this operator: the first run works through a backlog that has never been archived, so an operator who wants to stage that can suspend the CronJob, pick a retention window covering the deployment's full history, and step it down.

ServiceNovaHypervisorOperatorSpec ​

An opt-in marker with no fields. hypervisorOperator: {} has the ControlPlane provision the Keystone account openstack-hypervisor-operator authenticates as, and deliver its credentials to every cluster the hypervisor operator may run on. The ControlPlane does not deploy the hypervisor operator itself.

The account. The Nova leg projects the account-only KeystoneService{controlplane.Name}-nova-hypervisor-operator in the Nova namespace: user hypervisor-operator in project service-hypervisor-operator, which the registration creates in the ControlPlane's admin domain, holding the admin role alone. Nova 32.0.0 checks rule:context_is_admin on os-services, os-hypervisors, os-aggregates and servers:index:get_all_tenants, and no narrower default role satisfies it. Placement's resource-provider, trait and allocation rules accept admin or service, so service would add nothing. The project of its own keeps the account's credential and audit trail apart from nova-compute's in service-nova. The registration is projected only once the Nova child is Ready, so while the block is set ServiceAccountsReady waits on the compute service's rollout too.

The auth Secret. Once the account is provisioned, the ControlPlane writes {controlplane.Name}-nova-hypervisor-operator-auth (type Opaque) into the Nova namespace on the Nova's cluster. Each key feeds one value of the hypervisor operator's Helm chart:

KeyValueChart value
auth_urlThe public Keystone URL once services.keystone is published (publicEndpoint or gateway), the in-cluster one beforecontrollerManager.manager.env.osAuthUrl
usernamehypervisor-operatorcontrollerManager.manager.env.osUsername
user_domain_nameThe admin domain, spec.korc.adminCredential.domainName (Default when unset)controllerManager.manager.env.osUserDomainName
project_nameservice-hypervisor-operatorcontrollerManager.manager.env.osProjectName
project_domain_nameThe admin domain, as abovecontrollerManager.manager.env.osProjectDomainName
region_namespec.region (RegionOne when unset)controllerManager.manager.env.osRegionName
passwordKey password of the registration's consumer Secret {controlplane.Name}-nova-hypervisor-operator-credentialssecret.servicePassword

Delivery to compute clusters. Every cluster a NovaCompute of the Nova runs on, other than the Nova's own, receives a copy in the Nova namespace: the same targets the compute contract is mirrored to (see NovaReady). The copy carries nova.openstack.c5c3.io/compute-config-mirror: "true", and the last pool of the Nova on a cluster deletes it together with the contract mirror when it is torn down. A hypervisor operator on the Nova's own cluster reads the source Secret, which carries no such label.

Rotation. The ControlPlane watches the consumer Secret, so a CredentialRotation of the account rewrites the auth Secret and every copy on the next pass. The hypervisor operator reads its password once, at start, so a rotation also needs an upgrade of its Helm release. A Flux valuesFrom on the copied Secret does that on its own interval; a value set by hand has to be set again by hand.

The auth Secret carries a cloud-admin password

The account holds admin, and Nova and Placement read that role cloud-wide. Anyone who can read the auth Secret, on the Nova's cluster or on any compute cluster it is copied to, can act as a cloud administrator. Grant read access to Secrets in the Nova namespace on those clusters accordingly.

Removal. Clearing the block deletes the registration, whose finalizer removes the user and the project from Keystone, then the source Secret and every copy that carries the mirror label. For a Nova on a target cluster it also deletes the ExternalSecret that materialized the account's credentials there. A same-named object this ControlPlane did not create is left alone. Unsetting the whole services.nova block preserves the registration and the source Secret unless c5c3.io/allow-nova-deletion: "true" opts in; the copies on compute clusters then go with each NovaCompute's own teardown.

Reachability. A hypervisor operator on another cluster dials the public catalog rows of Keystone, Nova, Placement, Glance and Neutron, and of Cinder for its smoke test, so those services need a publicEndpoint or a gateway that cluster resolves. The ControlPlane does not enforce this: a hypervisor operator on the management cluster reaches the in-cluster rows, and admission cannot tell where it runs.

One region. The hypervisor operator chooses an endpoint by service type alone: it takes the first public compute and placement row of any region and never reads region_name for them. The catalog a ControlPlane builds has exactly one public row of each, in spec.region. A second public compute or placement row is unsupported for the hypervisor operator, whether it comes from a KeystoneService with serviceType: compute or placement, from a row written by hand, or from another region. With such a row the hypervisor operator may talk to the wrong service without reporting it. The full-ControlPlane e2e suite checks that the account's token carries exactly one row of each.

The test project. At start the hypervisor operator authenticates a second time as the same user, scoped to project test in domain cc3test, and exits when that fails, whether or not its smoke test is skipped. The smoke test then boots flavor ID 1 from image cirros-kvm onto a volume of type premium, on the first non-shared network that scope lists. The ControlPlane declares none of these. deploy/kind/hypervisor-operator-fixtures/ is an example that declares them as K-ORC resources, with the role assignment the user needs on test. Apply it only to a cloud that has none of its managed objects yet. K-ORC adopts an existing object with the name of a managed resource instead of creating one. On a cloud that already has a cc3test domain or a premium volume type, the overlay's documented teardown therefore deletes them: cc3test with every project, user and group in it, and premium unless a volume still uses it.

K-ORC resolves the overlay's flavor, volume type, network and subnet against the current service catalog. A resource that meets a missing endpoint fails once with No suitable endpoint could be found in the service catalog. K-ORC then drops its cached client and logs in again, and the retry succeeds, so the overlay needs no restart by hand (k-orc/openstack-resource-controller#967).

NovaDedicatedBackingServicesSpec ​

Declares the backing-service instances the compute service gets for itself instead of the ControlPlane-wide shared ones. It follows the same contract as CinderDedicatedBackingServicesSpec. Nova consumes both classes, so it can take either or both dedicated; a declared block must name at least one (CEL).

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Nova its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-nova-db. The compute service holds two schemas on it, so the instance declared here carries both. A dedicated managed database is Static-only: the defaulting webhook materializes credentialsMode: Static and an explicit Dynamic is rejected, for the same reason as Keystone (see Credential modes). Seed and rotate the credential at the OpenBao source.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Nova its own cache. In managed mode clusterRef.name defaults to {controlplane}-nova-cache.

The two databases ​

The compute service splits its state across two logical databases on one instance, and neither name is exposed here. The projection forces nova_api on spec.apiDatabase (the cell map, the flavors, the instance mappings) and nova on spec.database (the instances themselves, with the derived nova_cell0 granted to the same user). They are the schemas the pre-wired OpenBao engine roles grant on, so any other name would be issued a credential it cannot use.

Each schema takes a credential chain of its own, with its own engine role and its own ServiceAccount, so rotating one leaves the other's login untouched. The mode is decided once for both: databaseCredentialsMode and the shared spec.infrastructure.database.credentialsMode reach spec.apiDatabase and spec.database as one value, because the Nova CRD rejects a child whose two blocks disagree.

Three gateways, one catalog row ​

Nova publishes up to three hostnames, and each block is independent. gateway publishes the compute API, metadataGateway the metadata API the Neutron metadata agents dial, and consoleProxy.gateway the page a browser opens a console session on. Each hostname has to be usable as the host of the route derived from it, which the validating webhook checks for all three.

Every Nova route matches the root of its hostname. The console page and the metadata requests sit there, so both paths must be empty or /, and the catalog registers the API at https://{gateway.hostname}/v2.1 whatever gateway.path says. Two of the three blocks therefore may not share a hostname on the same Gateway listener: the routes would tie, the Gateway would hand every request to the older one, and the other endpoint would serve nothing while both routes report Accepted. The same hostname on two Gateways, or on two listeners both blocks name through parentRef.sectionName, is admitted.

Only the first of them reaches the Keystone catalog. publicEndpoint and gateway decide the public compute Endpoint; the metadata front end is dialed by the agents and the console URL is handed to a browser by the API, so neither is a catalog row and neither is read by the public-endpoint rules.

The three sibling services ​

Setting services.nova requires services.placement, services.neutron, and services.glance beside it. These are the first cross-service dependency rules on the ControlPlane, and they are errors rather than warnings because none of the three is substitutable from this CR: the projected Nova child is addressed at its siblings by the naming convention the ControlPlane projects them under, so a Nova declared without one of them reaches an endpoint no child serves and fails every boot with nothing on the plane naming the cause. The message names what the compute service does with each of them, since the remedy is to declare that service rather than to change anything on services.nova.

services.cinder and services.barbican are siblings of a different kind. They gate nothing: the projection switches the child's endpoints.cinder.enabled and endpoints.barbican.enabled on as soon as the block is declared, and a Nova without either serves every request that does not touch a volume or an encrypted volume's key.

Setting services.nova requires spec.infrastructure.messaging too. The Nova CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus, so a compute service declared without one would project a child its own admission rejects on every pass. The bus reaches the child as a brownfield secretRef naming {controlplane.Name}-nova-messaging in the Nova's own namespace on the Nova's own cluster, with {controlplane.Name}-nova-messaging-ca beside it while the shared bus declares tls.

Setting services.nova makes the reconciler project a KeystoneService registration named {controlplane.Name}-nova into the namespace Nova is placed in: the compute catalog entry plus the nova service account, whose project service-nova is created with the roles service and admin. That account is the Keystone user the projected child authenticates as, so the Nova child is not projected until the registration reports it provisioned (NovaReady=False/WaitingForServiceRegistration until then). The admin role is what service alone does not carry here (decision D10 of #1019): nova calls the block-storage API through its [cinder] service user in admin contexts, where it holds no user token of its own, and cinder refuses a caller holding service alone on a volume that belongs to a user. Both catalog rows carry the /v2.1 path the compute API is served under.

The network service takes a second registration while services.nova is set, {controlplane.Name}-neutron-nova. It carries an account and no catalog entry at all: neutron posts its port-status notifications to the compute API as a user of its own, and a user another service authenticates as answers no requests itself. See reconcileNeutron.

The generated metadata shared secret ​

The metadata shared secret is the value the Neutron metadata agent signs a proxied instance request with, and the value the compute service's metadata API checks that signature against. Both sides have to resolve the same Secret: a value only one of them knows leaves every call to 169.254.169.254 rejected.

The ControlPlane sees both sides, so it generates the value rather than asking for one. An ESO Password generator mints 32 symbol-free characters, and an ExternalSecret named {controlplane.Name}-nova-metadata-secret materialises them under the key shared_secret; generating the value takes two references and no read of it. The generator writes its value under password, and one rewrite rule on the ExternalSecret renames it: shared_secret is the key both consumers default their reference to, the Nova child's spec.metadata.sharedSecretRef and the NeutronMetadataAgent's spec.novaMetadata.sharedSecretRef. The ControlPlane creates no metadata agent, so an agent written by hand names the Secret alone:

yaml
spec:
  novaMetadata:
    sharedSecretRef:
      name: openstack-nova-metadata-secret   # {controlplane.Name}-nova-metadata-secret

The refreshInterval is 0, which turns the periodic sync off: a Password generator mints a new value on every read, so a refreshing ExternalSecret would rewrite the shared secret while every metadata agent still carried the previous one. Rotating it is a deliberate act (delete the Secret) rather than a timer.

Naming another Secret in metadataSharedSecretRef hands the child the named reference instead, and takes the generated pair down once the child has converged on that reference: the child reports Ready for the generation that re-pointed it. Until then the live metadata Deployment still reads the generated Secret, so reaping it earlier would leave every metadata pod that restarts in the meantime unable to start. Naming the generated Secret itself keeps the pair. The reference is resolved at projection time rather than materialized into the spec, so removing it reverts the child to the generated value instead of pinning the last one.

A metadata agent on a compute cluster cannot read a Secret in the Nova namespace, so the ControlPlane delivers the value to it. Every NeutronMetadataAgent of this plane lives in the OVN central's namespace (services.neutron.ovn.centralRef), because its chassisRef and that chassis's centralRef are namespace-local. For each cluster on which an agent there has a targetClusterRef, is not being deleted, and names {controlplane.Name}-nova-metadata-agent-secret in spec.novaMetadata.sharedSecretRef, the ControlPlane writes that Secret into the same namespace on that cluster:

yaml
spec:
  targetClusterRef:
    name: compute-a
  novaMetadata:
    sharedSecretRef:
      name: openstack-nova-metadata-agent-secret   # {controlplane.Name}-nova-metadata-agent-secret

The copy carries one key, shared_secret, which the agent's webhook defaults sharedSecretRef.key to. Its value is read out of the compute contract's metadata_proxy_shared_secret, so every copy has one source whichever Secret metadataSharedSecretRef names; it is the one path on which the operator reads the value. The contract's bus URL and service password stay out of the agent's privileged namespace. The copy carries the ControlPlane's ownership labels and neutron.openstack.c5c3.io/metadata-shared-secret-mirror: "true". A same-named Secret the ControlPlane did not write stays untouched and holds NovaReady on NovaMetadataAgentSecretError. A changed value is rewritten on the next Nova pass, and the agent rolls its pods onto it.

The ControlPlane never deletes a copy. The teardown of the last agent on a cluster that names it does, before the agent's finalizer is released (see On a compute cluster). A local agent gets no copy: it carries no finalizer that would reap one, and in the Nova namespace it names the generated Secret directly.

Name bound and replica pinning ​

Setting services.nova bounds the ControlPlane's own name. The projected child is {controlplane.Name}-nova, and the Nova CRD caps metadata.name at 41 characters so the nova operator's archive CronJob name ({name}-db-archive) still fits the API server's 52-character CronJob bound. The ControlPlane name may therefore be at most 36 characters while services.nova is set. The rule runs on create and on the update that newly enables Nova.

The projection writes the metadata, scheduler, and conductor replica counts explicitly, and falls back to 1 rather than the shared default of three when spec.sizing.nova sets none. All three deployment blocks are struct values, so deployment: {} reaches the API server whatever the projection assigns and the shared DeploymentSpec default of three lands on the wire before the nova operator's own defaulting webhook runs. That webhook only reaches an absent block, so leaving these alone silently runs three metadata APIs, three schedulers, and three conductors where the nova operator's standalone default is one of each. The API count keeps the shared default of three. When services.nova.consoleProxy is absent the switch stays absent on the wire, which is what lets the nova defaulting webhook enable the proxy, while the sized deployment block rides beside it (one replica under Standard).


SizingSpec ​

spec.sizing sizes and places every component the ControlPlane creates: the API, worker, and Job pods of the eight services, the Keystone federation proxy sidecar, and the managed MariaDB, Memcached, RabbitMQ, and dedicated OpenBao. The effective sizing of a ControlPlane is a built-in profile (Minimal or Standard), overlaid by the SizingProfile it references, overlaid by its own spec.sizing values, one value at a time (merge rules).

A ControlPlane that sets no spec.sizing resolves to Standard, which repeats the replica counts and the database volume size the ControlPlane has always projected and sets nothing else, so every other value stays with the child operators' render-time defaults and the backing-service operators' defaults. spec.sizing is forbidden in External mode, where no workload is deployed.

The resolved sizing is reported by the SizingReady condition. A SizingProfile read on every pass goes through the operator's cache, since the controller watches the kind; a built-in profile needs no read at all.

FieldTypeDefaultDescription
profilestring (Minimal | Standard)"" (Standard, unless profileRef is set)Selects a built-in profile as the base. Mutually exclusive with profileRef (CEL: profile and profileRef are mutually exclusive).
profileRef.namestring—Names a cluster-scoped SizingProfile, whose base then becomes the base. MinLength 1, MaxLength 253. Admission rejects a name no SizingProfile answers to, on create and when the name changes.
nodeSelector, tolerations, priorityClassNamemap, list, *string—The fallback placement of every component that sets none of its own. They are not merged into the components; the projection reads them when a component leaves the field unset.
databaseDatabaseSizingSpec—Every managed MariaDB: replicas (Minimum 1), storageSize (Pattern ^[0-9]+(Mi|Gi|Ti)$), resources, and placement.
cacheCacheSizingSpec—Every managed Memcached: replicas (Minimum 1) and resources.
messagingScaledSizingSpec—The managed RabbitmqCluster: replicas (Minimum 1), resources, and placement.
secretStoreContainerSizingSpec—The voters of Barbican's dedicated OpenBao: resources.
keystoneapi, jobs, federationProxy—The Keystone API Deployment, its Job pods, and the federation proxy sidecar (resources).
horizonapi—The dashboard Deployment.
glance, placement, barbicanapi, jobs—The API Deployment and the Job pods.
neutronapi, workers, jobs—The API Deployment, both RPC worker Deployments (replicas, resources, placement; no spread), and the Job pods.
cinderapi, scheduler, volume, backup, jobs—The API and scheduler Deployments, every volume Deployment and the backup Deployment (resources and placement; their replica count stays one), and the Job pods.
novaapi, metadata, scheduler, conductor, consoleProxy, jobs—The five process Deployments and the Job pods. consoleProxy is forbidden while services.nova.consoleProxy.enabled is false.

The component blocks are built from these shapes:

ShapeFields
api (Keystone, Glance, Placement, Barbican, Neutron, Cinder, Nova)replicas, resources, nodeSelector, tolerations, priorityClassName, spreadConstraints, processes, threads, autoscaling, verticalAutoscaling
horizon.apithe api fields without processes and threads
nova.metadatathe api fields without autoscaling
nova.scheduler, nova.conductorreplicas, resources, placement, spreadConstraints, workers, verticalAutoscaling
cinder.scheduler, nova.consoleProxyreplicas, resources, placement, spreadConstraints, verticalAutoscaling
neutron.workersreplicas, resources, placement, verticalAutoscaling
cinder.volume, cinder.backupresources, placement, verticalAutoscaling
jobsresources, priorityClassName
spreadConstraints[]maxSkew (Minimum 1), topologyKey (MinLength 1), whenUnsatisfiable (DoNotSchedule | ScheduleAnyway)

processes, threads, and workers have Minimum 1. autoscaling is the shared AutoscalingSpec, and verticalAutoscaling the shared VerticalAutoscalingSpec. Every service component that runs a Deployment carries verticalAutoscaling; database, cache, messaging and secretStore never do, because their workloads belong to their own operators. An api component rejects autoscaling beside verticalAutoscaling, by a CEL rule and by the webhook: every HPA scales on the utilization a VPA changes. A spread entry carries no label selector: the ControlPlane completes it with the pod selector of the component's Deployment, which each service's api package exports (APIPodSelector, and SchedulerPodSelector, MetadataPodSelector, ConductorPodSelector, ConsoleProxyPodSelector for the other components). Neither the ControlPlane nor a SizingProfile exposes node or pod affinity.

Built-in profiles ​

BuiltinSizing returns a fresh copy of a profile on every call; an empty or unknown name returns Standard. "none" means the profile sets nothing, so the child operator's own default applies. Neither profile sets placement, spread, autoscaling, vertical autoscaling, or a priority class. The Minimal figures are measured: they come from the VPA recommendations of a CI run, by the rules Sizing Calibration records. They keep the full stack within one 4 vCPU / 16 GiB node, which the node budget gate of the e2e-controlplane job checks.

ComponentStandardMinimal
keystone.api, glance.api, placement.api, barbican.api, neutron.api, cinder.api, nova.apireplicas 3replicas 1, processes 1, threads 1, requests.cpu 15m
horizon.apireplicas 3replicas 1, requests.cpu 15m
neutron.workersreplicas 3replicas 1, requests.cpu 15m
cinder.schedulerreplicas 1replicas 1, requests.cpu 15m
cinder.volume, cinder.backupnonerequests.cpu 15m
nova.metadatareplicas 1replicas 1, processes 1, threads 1, requests.cpu 15m
nova.scheduler, nova.conductorreplicas 1replicas 1, workers 1, requests.cpu 15m
nova.consoleProxyreplicas 1replicas 1, requests.cpu 15m
<service>.jobsnonerequests.cpu 15m
databasereplicas 3, storageSize 100Gireplicas 1, storageSize 512Mi, requests cpu 65m / memory 1Gi, limits memory 1Gi
cachereplicas 3replicas 1, requests cpu 15m / memory 96Mi, limits memory 96Mi
messagingreplicas 3replicas 1, requests cpu 815m / memory 1Gi, limits memory 1Gi
secretStorenonerequests cpu 35m / memory 64Mi, limits memory 64Mi

The Minimal broker figure follows from the broker's memory alarm. For a RabbitmqCluster with a memory limit, the RabbitMQ Cluster Operator sets total_memory_available_override_value to the limit minus a fifth, and RabbitMQ 4.3.4 raises its memory alarm at 0.6 of that value. At 1Gi the alarm sits at 491.5 MiB, about twice the 236 MiB a broker uses with every queue empty. At 512Mi it sat at 245.8 MiB, so an idle broker alarmed and blocked every publisher without a backlog (#1298). A Minimal broker provisioned with the older figure is re-projected on the operator's next pass, and the Cluster Operator rolls its StatefulSet, which restarts a single-replica broker once. A node that cannot fit the extra 512Mi leaves the broker pod Pending, and the ControlPlane reports InfrastructureReady=False with reason WaitingForMessaging until the pod schedules. A site that wants another figure sets spec.sizing.messaging.resources on the ControlPlane or in a SizingProfile.

A profile sizes service pods by counts and CPU requests only. Service memory stays with the child's per-process formula, so a lower process count lowers the memory the child renders.

The resolved sizing is validated as a whole, so a Minimal request must not exceed a limit the ControlPlane sets on the same component. A ControlPlane that sets a messaging CPU limit below 815m under Minimal is rejected on its next update of spec.sizing until the limit rises or the request is overridden. The same holds for memory: a messaging memory limit below 1Gi under Minimal, without a lower memory request beside it, fails the requests-within-limits check. The operator does not repeat that check when it resolves the sizing, so a limit between 512Mi and 1Gi admitted while the Minimal broker requested 512Mi resolves to a 1Gi request above it after the upgrade. The API server rejects a broker StatefulSet with that pair, so the broker keeps its running pod at the old figures, but the RabbitmqCluster reports ReconcileSuccess=False while the ControlPlane keeps InfrastructureReady=True: readiness follows AllReplicasReady, and the running StatefulSet still has every replica ready. Until the pair is fixed, no later broker change reaches the StatefulSet, including a messaging.replicas grow. After upgrading, kubectl get rabbitmqcluster -A lists such a broker with ReconcileSuccessFalse. Before upgrading, raise such a limit to 1Gi or set messaging.resources.requests.memory no higher than it, on the ControlPlane or in a SizingProfile with base: Minimal.

Merge rules ​

MergeSizing(base, override) overlays one sizing on another, field by field, and returns a new value that aliases neither input:

  • replicas, processes, threads, workers, priorityClassName, and storageSize: a set override wins.
  • resources: every request and every limit the override names replaces or adds the base entry of that resource name; the override's claims replace the base's when set.
  • nodeSelector, tolerations, and spreadConstraints: a non-empty override replaces the base, and an empty one inherits. The defaulting webhook's JSON round trip drops an empty map or list, so an empty value cannot mean "clear".
  • autoscaling and verticalAutoscaling: a set override replaces the whole block, since its bounds and targets only make sense together.
  • A component block: a nil override keeps the base, a nil base takes the override, and two set blocks merge recursively.

ResolveSizing applies the built-in base, then the referenced SizingProfile, then the ControlPlane's own values. A profileRef whose profile cannot be read resolves the ControlPlane's values over Standard.

The merged sizing is checked again: a component that the profile opts into a VPA and the ControlPlane puts an HPA on, or the reverse, is rejected with autoscaling and verticalAutoscaling cannot both be set on one component.

Projection ​

Every Deployment component writes its replica count (falling back to the count the ControlPlane always projected), its resources, and its node selector, tolerations, and priority class, each falling back to the top-level value when the component sets none. A resolved priority class of "" projects none, which is how a component opts out of the top-level class. Spread entries are completed with the child's pod selector. A component's verticalAutoscaling reaches the verticalAutoscaling of the deployment block it sizes (spec.deployment.* below includes it). Every field is assigned on every pass, so clearing a value on the ControlPlane clears it on the child. Affinity, the rollout strategy, and the graceful-termination timings are never written.

spec.sizing. componentChild fields written
keystone.apispec.deployment.*, spec.uwsgi, spec.autoscaling
keystone.jobs / keystone.federationProxyspec.jobs / spec.federation.proxyResources
horizon.apispec.deployment.*, spec.autoscaling
glance.apispec.deployment.*, spec.autoscaling; processes and threads as spec.apiServer.uwsgi
placement.api, barbican.api, neutron.apispec.deployment.*, spec.apiServer.uwsgi (only when a count is set), spec.autoscaling
neutron.workersspec.workers.deployment: replicas, resources, placement, verticalAutoscaling
cinder.api / cinder.schedulerspec.api.deployment.*, spec.api.uwsgi, spec.autoscaling / spec.scheduler.deployment.*
cinder.volume, cinder.backupspec.volume.deployment / spec.backup.deployment: resources, placement and verticalAutoscaling; the replica count stays 1
nova.api / nova.metadataspec.api.deployment.*, spec.api.uwsgi, spec.autoscaling / spec.metadata.deployment.*, spec.metadata.uwsgi
nova.scheduler, nova.conductorspec.<component>.deployment.*, spec.<component>.workers
nova.consoleProxyspec.consoleProxy.deployment.* whenever the proxy is enabled, including the default of an absent services.nova.consoleProxy
<service>.jobsspec.jobs (resources and priority class; the child falls back to its API Deployment for the rest)
databaseevery managed MariaDB: spec.replicas and spec.storage.size when the block leaves them unset, spec.resources, spec.nodeSelector, spec.tolerations, spec.priorityClassName
cacheevery managed Memcached: spec.replicas when the block leaves it unset, spec.resources (removed when unset). The Memcached CRD has no placement.
messagingthe managed RabbitmqCluster: spec.replicas when the block leaves it unset, spec.resources, spec.tolerations, and the node selector and priority class through spec.override.statefulSet.spec.template.spec. Unset resources leave the operator's default (1 CPU and 2Gi requested, 2 CPU and 2Gi limit).
secretStorethe dedicated OpenBaoCluster's spec.resources; the replica count stays 1

The managed database's replica count and volume size cannot change after creation, so the defaulting webhook writes the resolved values into spec.infrastructure.database and every declared dedicated database block at admission (see Defaulting Webhook). Cache and bus replica counts are resolved on every pass and follow a profile change: an owned cache scales in both directions, an owned bus grows in place, and a bus shrink still needs the c5c3.io/allow-messaging-recreate annotation.

Upgrade behavior ​

  • A ControlPlane that names no profile renders identical children, and its backing services keep their operators' defaults.
  • The service replica fields moved into spec.sizing: the replicas of every service block, Neutron's workerReplicas, Nova's metadataReplicas, schedulerReplicas and conductorReplicas, and the replicas of Nova's consoleProxy block no longer exist. A stored ControlPlane that set one loses it on the next read (schema pruning) and falls back to Standard's count; re-apply it with the value under spec.sizing.
  • Stored database replicas and storageSize values stay as they are, whether set explicitly or materialized by the old CRD default. A later profile switch never changes them.
  • cache.replicas (of spec.infrastructure.cache and of every dedicatedBackingServices.cache block) and messaging.replicas materialized as 3 by the old CRD default stay stored too, and an explicit count wins over the sizing. Remove them from the ControlPlane for the caches and the bus to follow the profile.
  • A re-apply that omits spec.infrastructure.database.replicas or storageSize (for example a server-side apply from GitOps that never owned the field) drops the stored value, and the defaulting webhook refills it from the current profile. After a profile switch that value differs from the running database and the update is rejected as a change of a frozen field. Pin both values on a live ControlPlane before switching its profile.

CRD size and server-side apply ​

spec.sizing repeats its shapes at every component, which takes the ControlPlane CRD past the 262,144-byte limit of the kubectl.kubernetes.io/last-applied-configuration annotation. A client-side kubectl apply of the CRD therefore fails. The install paths apply it server-side: Helm through Flux, and hack/ci-deploy-operator.sh with kubectl apply --server-side. Apply it the same way when installing the CRD by hand.


SizingProfile ​

A SizingProfile is a cluster-scoped, site-defined sizing profile: a built-in base overlaid by the values in its spec, in the same SizingSpec shape spec.sizing carries. A ControlPlane selects it with spec.sizing.profileRef and follows every later edit of it: the controller watches the kind and re-reconciles every ControlPlane whose profileRef.name names an edited or deleted profile.

yaml
apiVersion: c5c3.io/v1alpha1
kind: SizingProfile
metadata:
  name: site-small
spec:
  base: Minimal
  cache:
    resources:
      limits:
        memory: 192Mi
  keystone:
    api:
      replicas: 2
  neutron:
    workers:
      verticalAutoscaling:
        updateMode: "Off"
FieldTypeDefaultDescription
basestring (Minimal | Standard)StandardThe built-in profile this profile's values override. Defaulted by both the CRD marker and the defaulting webhook.
(inline)SizingSpec—The values that override the base, with the fields and rules of SizingSpec.

Printer columns: Base (.spec.base) and Age (.metadata.creationTimestamp).

Webhook. SizingProfileWebhook serves /mutate-c5c3-io-v1alpha1-sizingprofile (msizingprofile.kb.io) and /validate-c5c3-io-v1alpha1-sizingprofile (vsizingprofile.kb.io) on create and update, reading through the manager's uncached API reader:

  • Default sets an empty spec.base to Standard.
  • Create checks the values with the same rules as spec.sizing (requests within limits, label keys of node selectors and tolerations, the Galera quorum, the 96Mi Memcached floor, the spread-entry markers, the verticalAutoscaling rules), the base enum, and that every named PriorityClass exists. It then resolves the sizing of every ControlPlane that already references the profile against its values, as an update does: a restored profile meets ControlPlanes whose spec.sizing edits were admitted without the merged checks while it was missing.
  • Update runs the same checks, looks up only the PriorityClasses the old object did not name, and, when the spec changed, resolves the sizing of every ControlPlane that references the profile against the new values. A merged value that fails (a request above a limit, an autoscaling target against a zero request, a maxReplicas below the replica count, autoscaling beside verticalAutoscaling on one component) rejects the create or edit with an error naming ControlPlane <namespace>/<name>. A failed List of ControlPlanes rejects it with an internal error (listing ControlPlanes).
  • Delete is not validated. A referenced profile can be deleted; the ControlPlanes that reference it report SizingReady=False with reason SizingProfileNotFound, stop their pass, and keep their children as last projected until the profile returns or the reference changes. Until then an update of spec.sizing skips the merged-sizing checks, so restoring the profile can be rejected for a ControlPlane edited meanwhile, and an update that adds a managed dedicated database must set its replicas and storageSize explicitly.

DedicatedBackingServices ​

By default every service a ControlPlane manages connects to the shared instances declared in spec.infrastructure: one database cluster, one cache. Isolation between services is logical only — each service gets its own logical database and its own credentials on the shared MariaDB, and shares the Memcached instance.

services.<svc>.dedicatedBackingServices is the opt-in that gives a single service backing services of its own instead. It is declared per service and per backing-service class:

yaml
spec:
  services:
    keystone:
      dedicatedBackingServices:
        database:                       # Keystone gets its own database cluster
          clusterRef:
            name: prod-keystone-db
          credentialsMode: Static
          database: keystone
          secretRef:
            name: keystone-db
          replicas: 3
          storageSize: 200Gi
        cache:                          # …and its own cache
          clusterRef:
            name: prod-keystone-cache
          backend: dogpile.cache.pymemcache
          replicas: 3
    horizon:
      dedicatedBackingServices:
        cache:                          # the dashboard gets a cache of its own
          clusterRef:
            name: prod-horizon-cache
          backend: dogpile.cache.pymemcache
          replicas: 1

A replicas or storageSize a dedicated database leaves unset is taken from the ControlPlane's sizing at admission, like the shared block's, and a dedicated cache's unset replicas follows the sizing on every pass.

Omitting the block is the default and keeps today's behavior: the service shares the ControlPlane-wide instances. A class left unset inside a declared block is shared too — the Keystone service above could take a dedicated database and keep sharing the cache.

Fields ​

KeystoneDedicatedBackingServicesSpec:

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Keystone its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-keystone-db.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Keystone its own cache. In managed mode clusterRef.name defaults to {controlplane}-keystone-cache.

HorizonDedicatedBackingServicesSpec:

FieldTypeRequiredDefaultDescription
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives the dashboard its own cache. In managed mode clusterRef.name defaults to {controlplane}-horizon-cache.

GlanceDedicatedBackingServicesSpec:

FieldTypeRequiredDefaultDescription
database*commonv1.DatabaseSpecNonil (shares spec.infrastructure.database)Gives Glance its own database cluster. In managed mode clusterRef.name defaults to {controlplane}-glance-db. A dedicated managed database is Static-only — the defaulting webhook materializes credentialsMode: Static and an explicit Dynamic is rejected, for the same reason as Keystone (see Credential modes): the OpenBao database engine is bootstrapped once per namespace against the shared cluster, so no engine role can issue credentials for a dedicated instance. Seed and rotate the credential at the OpenBao source.
cache*commonv1.CacheSpecNonil (shares spec.infrastructure.cache)Gives Glance its own cache. In managed mode clusterRef.name defaults to {controlplane}-glance-cache.

Glance consumes both a database and a cache, so it can take either or both dedicated; a class left unset resolves to the ControlPlane-wide instance.

BarbicanDedicatedBackingServicesSpec carries the same two classes on the same terms and is documented under ServiceBarbicanSpec; its managed clusterRef.name defaults are {controlplane}-barbican-db and {controlplane}-barbican-cache.

Declaring the block with no class set is rejected — it would request nothing. Omit it entirely to share.

Lifecycle ​

A dedicated instance is not a second-class one. It reuses the same commonv1 shapes as the shared block, so it carries the same managed-versus-brownfield split — managed mode (clusterRef) has the ControlPlane provision the instance, brownfield mode (database.host / cache.servers) references an externally operated endpoint and provisions nothing — and the reconciler puts it through the same path as a shared instance:

GuaranteeHow it holds for a dedicated instance
ProvisioningreconcileInfrastructure ensures a MariaDB / Memcached child CR per managed instance a service resolves to, shared and dedicated alike, sized from that instance's replicas / storageSize and from its class's spec.sizing block. Opting out is a genuine opt-out: a shared instance every service has left has no consumer, so it is not provisioned. When every declared database consumer — Keystone, Glance, Placement, Barbican — takes a dedicated database, the shared cluster is never created — it would otherwise be an orphan (3 Galera replicas, 100Gi by default) that nothing talks to and readiness still waits for.
Ownership and teardownIn the ControlPlane's own namespace the child carries a controller owner reference to the ControlPlane with blockOwnerDeletion, so it is garbage-collected with the ControlPlane. In a service namespace or on a target cluster it carries the ownership labels c5c3.io/controlplane-name / c5c3.io/controlplane-namespace instead, and the finalizer-driven teardown deletes it (see Ownership and garbage collection, and for a target cluster Ownership and teardown on the target). A pre-existing CR under the same name that carries neither is adopted read-only and never GC-claimed.
Readiness gatingInfrastructureReady is True only once every managed instance is Ready. A service whose dedicated database is still converging holds the condition False, so its projection is deferred — it waits for the database it actually talks to, not just for the shared cluster.
CredentialsThe service child's spec.database is projected from the dedicated spec, so credential provisioning and rotation follow the instance the service connects to (see Credential modes below).
Network policyThe service operators derive their database/cache egress rules from the projected spec.database / spec.cache, so they follow the dedicated instance automatically.

Credential modes ​

On the managed shared database, credentialsMode: Dynamic — short-lived, engine-issued credentials from the OpenBao database engine — is the default for every database consumer: Keystone, Glance, Placement, and Barbican. Glance is no longer forced onto Static. It carries a glance-scoped engine role, the glance-db-dynamic policy, and the glance-db auth role; Placement and Barbican carry the same three under their own names (placement-{namespace}, placement-db-dynamic, placement-db and barbican-{namespace}, barbican-db-dynamic, barbican-db). All three sets come from deploy/openbao/bootstrap/setup-database-tenant.sh and setup-auth.sh, with the policies under deploy/openbao/policies/, the way Keystone's do.

The mode is resolved from ControlPlane-wide to per-service:

  1. spec.infrastructure.database.credentialsMode is the ControlPlane-wide default for the shared database — Dynamic unless you opt out to Static (migration staging / brownfield). Dynamic requires clusterRef (managed mode).
  2. spec.services.<svc>.databaseCredentialsMode overrides it for one service. Empty (the default) inherits the shared mode — deliberately not materialized by the defaulting webhook, so "inherit" stays distinguishable from an explicit override — so a staged migration can run one service (say Keystone) on Dynamic while another (Glance) stays Static, or the reverse. A Dynamic override is rejected on a service that declares a dedicated database or when the shared database is brownfield; Static is always admitted.

In the shared Static branch — the ControlPlane-wide opt-out or a per-service Static override — the operator projects a KV-backed ExternalSecret reading the per-service path (openstack/keystone/{namespace}/{name}/db for Keystone, openstack/glance/{namespace}/{name}/db for Glance), and the credential is seeded and rotated at the OpenBao source (ESO refreshes it within the hour).

A dedicated managed database uses credentialsMode: Static. The defaulting webhook materializes it, and an explicit Dynamic is rejected at admission: the OpenBao database engine carries exactly one connection and one role per namespace (deploy/openbao/bootstrap/setup-database-tenant.sh), bootstrapped against the shared cluster, so no engine role exists that could issue credentials for a dedicated instance — an admitted Dynamic dedicated database would wedge on an ExternalSecret that can never sync. It reads the same per-service KV path a shared Static opt-out does, and a brownfield dedicated database keeps the user-supplied secretRef Secret, exactly as a brownfield shared one does.

Seed the KV path before you expect Ready. It is seeded by neither the operator nor the bootstrap — the per-ControlPlane static seed was retired when managed mode moved to engine-issued credentials. A service on the Static branch (a dedicated managed database, or the shared database opted out) therefore reaches Ready only once you have seeded its per-service KV path — kv-v2/openstack/keystone/{namespace}/{name}/db for Keystone, kv-v2/openstack/glance/{namespace}/{name}/db for Glance — with username and password yourself; see Migrate the Keystone DB to dynamic credentials and Migrate the Glance DB to dynamic credentials for the exact bao kv put. Until then Keystone's DBCredentialsReady stays False with reason WaitingForDBCredentialSecret and a message naming the path, and Glance's projected child cannot resolve its DB secret, so GlanceReady stays False.

Name collisions ​

Managed clusterRef names must be unique per backing-service class across the shared block and every dedicated instance. Two instances sharing a name would resolve to a single child CR that both projections then fight over — silently voiding the isolation the opt-in exists for — so the validating webhook rejects the duplicate. The derived defaults ({controlplane}-keystone-db, {controlplane}-keystone-cache, {controlplane}-horizon-cache, {controlplane}-glance-db, {controlplane}-glance-cache, {controlplane}-barbican-db, {controlplane}-barbican-cache) never collide with each other or with the shared defaults (openstack-db, openstack-memcached).

Immutability ​

Both the per-service block and each class within it are frozen on a live ControlPlane: a service cannot be moved between shared and dedicated backing services in either direction, because the flip would re-point the consuming child's (immutable) database fields at a different instance while the previously-provisioned one keeps running with the data still on it. The create-only leaves of a declared instance (clusterRef.name, database, replicas, storageSize, and the managed-vs-brownfield mode) are frozen the same way the shared block's are; a cache's replicas stays mutable.

The freeze is webhook-only — deliberately carrying no CEL transition rule — so a later transition feature (with or without data migration) can relax it to a gated migration. An immutable CEL marker never could.

Adding a backing-service class ​

Database and cache exist today. A new class (Valkey, RabbitMQ) is added as one more optional pointer field on the per-service block, reusing its own canonical commonv1 shape. The shared-by-default / dedicated-on-request contract and the per-service opt-in surface are unchanged by that addition — which is why the classes are individual fields rather than one opaque block. A service's block only ever surfaces the classes that service actually consumes, which is why Horizon has a cache and no database.

The message bus is modeled outside this block, at spec.infrastructure.messaging: one bus per ControlPlane, enumerated at the ControlPlane's own namespace regardless of which services consume it. Neutron consumes that shared bus, and the validating webhook requires it beside services.neutron; no dedicated-bus slot exists on any service block. A dedicated per-service bus is the class this recipe would apply to for a service that needs one. It takes:

  • Messaging *commonv1.MessagingSpec on the service's dedicated block, listed in that block's at-least-one-class CEL rule;
  • an accessor Dedicated<Svc>Messaging() beside the existing Dedicated<Svc>Database() and Dedicated<Svc>Cache();
  • a <svc>MessagingDeclaredAt helper naming the spec path the instance was declared at, so a pending-instance condition message tells a dedicated bus from the shared one;
  • validateDedicated<Svc>Messaging in the webhook: the XOR, the two required Secret names, and the freeze twins of the shared block;
  • a defaultMessagingLeaves(m, obj.Name+"-<svc>-rabbitmq") call in Default;
  • an addMessaging(...) call in managedInfraInstances at the service's namespace, gated on the service being declared. That is the enumeration rule every dedicated class follows, and the one the shared bus is the single exception to.

ExternalKeystoneSpec ​

Declares how the control plane reaches a pre-existing, externally-operated Keystone in External mode. Present only under services.keystone.external and required when services.keystone.mode is External. It mirrors the brownfield infrastructure shape at the identity level: the endpoint and, optionally, a private-CA bundle are supplied here, and the reconciler manages identity against that endpoint rather than deploying a Keystone workload.

FieldTypeRequiredDefaultDescription
authURLstringYes—Identity endpoint of the external Keystone (e.g. https://keystone.example.com/v3). Must match the HTTP(S) URL pattern ^https?://[^\s/]+ — an HTTP(S) shape with a non-empty host, so a hostless endpoint is rejected at admission — and be at most 2048 characters. Both are enforced by the CRD +kubebuilder:validation:Pattern / MaxLength markers and mirrored by the validating webhook with a full net/url parse. The cap bounds the one unbounded input the reconciler interpolates into status.conditions[].message: the pattern is end-unanchored, so without it a multi-kilobyte path could push the assembled message past the apiserver's 32768-byte cap and fail the whole status.conditions write. Neither gate is an SSRF control — admission cannot resolve where the host points, so egress restrictions remain the operator's responsibility.
endpointTypestring (public | internal | admin)NopublicWhich Keystone catalog interface to authenticate against. Defaulted to public by both the +kubebuilder:default marker and the defaulting webhook. Rendered as the clouds.yaml endpoint_type key of both generated credentials Secrets. Named endpointType (not interface) because K-ORC drops gophercloud's Interface field and only honours endpoint_type (the authoritative note lives on buildAppCredCloudsYAML in the reconciler's korc_cloudsyaml.go). The selected interface must exist in the external catalog for spec.region — otherwise the control plane fails loud with KORCReady=False/CatalogEndpointMismatch.
caBundleSecretRef*commonv1.SecretRefSpecNonilReferences a Secret carrying a private CA bundle the client trusts when verifying the external endpoint. The bundle is projected verbatim as the inline cacert key into both generated K-ORC credentials Secrets — K-ORC reads that key natively from the same Secret that carries clouds.yaml, so no mount and no upstream change are needed. key defaults to ca.crt; this default is webhook-only because the shared SecretRefSpec carries no c5c3-specific marker (the same discipline as passwordSecretRef.key). When the ref is set its name must be non-empty (CRD MinLength marker + webhook). A missing Secret, a missing key, or a present-but-empty key defers the mint with KORCReady=False/WaitingForCABundle — the last shape is the normal transient of a two-step "create the Secret, then populate it" flow.
catalog*ExternalCatalogSpecNonilTunes how the control plane stewards the external Keystone's service catalog. Omitting it selects the conservative default: the existing identity service and all three of its endpoint interfaces are imported as unmanaged K-ORC CRs, and zero catalog entries are created.

spec.region must match the external catalog. region_name in both generated clouds.yaml documents comes from spec.region (defaulted RegionOne). Against an external catalog that publishes a different region, gophercloud fails loud with "No suitable endpoint could be found in the service catalog", which the reconciler classifies onto KORCReady=False/CatalogEndpointMismatch and annotates with the effective spec.region and endpointType. There is no silent fallback: the operator cannot repair an external catalog.

Rotating the CA bundle is not instantaneous. Changing (or removing) caBundleSecretRef converges both credentials Secrets on the next reconcile — the CA Secret is watched, so the ControlPlane wakes immediately. K-ORC's provider-client cache, however, keys on the parsed cloud struct only; cacert is not part of the cache key. The new trust store therefore takes effect only once the cached client expires (TTL = token lifetime / 2, ≈30 min at Keystone defaults). Nothing in this operator can shorten that window.

TLS and egress prerequisites. An IP-based authURL needs an IP SAN in the external Keystone's server certificate; hostnames resolve through the cluster DNS upstream forwarder. Nothing restricts orc-system egress today, but a cluster with restrictive egress NetworkPolicies must explicitly allow K-ORC to reach the external endpoint and port — see the reconciler reference.


ExternalCatalogSpec ​

Tunes External-mode catalog stewardship. Present only under services.keystone.external.catalog, and entirely optional: its zero value is the conservative default.

In External mode the service catalog belongs to the pre-existing installation, so the control plane is import-first. It never registers the identity service, because Keystone enforces no uniqueness on service names and a managed registration against a populated catalog would silently duplicate rows. Instead it imports the existing identity service and each of its endpoint interfaces as unmanaged K-ORC CRs, which resolve read-only and write nothing. The ControlPlane itself therefore registers zero catalog entries in External mode; a genuinely new entry is declared with a KeystoneService CR, which owns its own catalog rows and their teardown.

FieldTypeRequiredDefaultDescription
identityServiceNamestringNo""Disambiguates the identity Service import when the external catalog carries more than one identity-type service. When empty the import filters on type alone. Minimum length 1, maximum 255, and no comma, mirroring K-ORC's OpenStackName pattern ^[^,]+$ which the value is cast to on the import filter.

All three interfaces are imported; only one is required. The public, internal and admin endpoints of the identity service are imported, not only the one endpointType selects. Catalog rows are listable through the identity API whether or not the endpoint they advertise is reachable from this cluster, so full visibility costs nothing. Entries for unreachable interfaces are informational.

Only the interface endpointType selects gates CatalogReady. The control plane already authenticates through that interface, so a catalog that does not publish it is not the catalog K-ORC was pointed at, and its import stalling is the silent-empty hazard the detector exists to surface. An external installation is free not to publish the other two — kolla-ansible stopped registering the identity admin endpoint after Zed, and a devstack bootstrapped with only a public URL publishes neither of the others. Those imports simply stay resolved: false in status.catalog.imports. Gating readiness on an interface the installation never published would hold the aggregate Ready at False forever for the two most common brownfield deployment tools.

Disambiguation is by name only. There is no import-by-id. K-ORC's ServiceImport.id carries a Format:=uuid marker (the RFC 4122 dashed form) while Keystone mints service ids as dashless uuid4().hex, so an id-based import is rejected by K-ORC's own CRD schema and cannot be offered. A catalog holding two identically named identity services therefore cannot be disambiguated from the spec at all: the control plane fails loud with CatalogReady=False/CatalogFailed and the external catalog must be repaired.

Multi-region catalogs. K-ORC's EndpointFilter carries no region field, so an identity service publishing one public endpoint per region makes the endpoint import match several rows. K-ORC reports that as a terminal error and the control plane relays it — loud, never silent — but no spec field can select among them today.


GatewaySpec ​

The shared commonv1.GatewaySpec (internal/common/types), the single source of truth for the Gateway API HTTPRoute knobs reused by both the ControlPlane and the Keystone CRD — see the Keystone CRD → GatewaySpec for the same shape on the projected child. The reconciler (L2) maps it onto the projected Keystone CR's spec.gateway. As with the other commonv1 shapes, reusing this type still keeps the L1 API package free of a dependency on the keystone module (the formerly hand-curated local copy was consolidated into commonv1; the L1 package imports only commonv1, never the keystone module).

FieldTypeRequiredDefaultDescription
parentRefGatewayParentRefSpecYes—The pre-existing Gateway the HTTPRoute attaches to. The Gateway (and GatewayClass) are platform-team infrastructure managed outside this CR.
hostnamestringYes—Externally reachable host (SNI / Host header) the HTTPRoute matches, e.g. keystone.example.com. Minimum length 1.
pathstringNo"/" (Keystone operator default)URL path prefix matched by the HTTPRoute.
annotationsmap[string]stringNonilPassed through to the generated HTTPRoute metadata verbatim (rate limits, CORS) without extending the CRD. The route timeout is operator-managed and reads nothing from these annotations — see the HTTPRoute resource mapping.

GatewayParentRefSpec ​

References a pre-existing Gateway that the projected Keystone's HTTPRoute attaches to. The shared commonv1.GatewayParentRefSpec (internal/common/types), nested under commonv1.GatewaySpec and reused by both the ControlPlane and the Keystone CRD.

FieldTypeRequiredDefaultDescription
namestringYes—Gateway resource name. Minimum length 1.
namespacestringNo""Namespace of the referenced Gateway. When empty, the projected Keystone CR's namespace is assumed.
sectionNamestringNo""Targets a specific listener on the Gateway (e.g. https) when it defines multiple listeners. When empty, the HTTPRoute attaches to all compatible listeners.

KORCSpec ​

Configures the K-ORC (OpenStack Resource Controller) integration of the control plane. It declares how the admin application credential is bootstrapped and rotated and which bootstrap resources are reconciled.

FieldTypeRequiredDefaultDescription
adminCredentialAdminCredentialSpecYes—The admin OpenStack credential K-ORC uses to reconcile resources, plus the application-credential rotation policy.
serviceRegistrations*ServiceRegistrationsSpecNonilWhich namespaces this control plane consents to standalone KeystoneService registrations from. Optional: its zero value admits only the namespaces the control plane already owns.

ServiceRegistrationsSpec ​

Carries the control plane's consent to standalone KeystoneService CRs registering against it.

A KeystoneService mints a Keystone service user with the roles its spec asks for, so a CR in a namespace the control plane does not consent to would turn namespace access into cloud admin. The reconciler gates every registration on this block and reports NamespaceNotAllowed on every declared block of a registration whose namespace it does not admit, so that CR's Ready reads False/NotAllReady and it projects nothing. See the KeystoneService Reconciler for where that gate sits in the registration's flow.

FieldTypeRequiredDefaultDescription
allowedNamespaces[]stringNo[]Namespaces outside the ones this control plane already owns that may register against it. listType=set, so the API server rejects a duplicate entry. At most 32 entries, each 1 to 63 characters matching ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, the RFC-1123 label shape of a Kubernetes namespace name.

The namespaces the control plane owns need no entry. Its own namespace and its dedicated service namespaces are admitted implicitly: it provisions a tenant store in each of them already, and their contents are its own. A nil block and an empty list are therefore identical — both admit exactly those, and nothing else.

The list is an admission gate, not a revocation tool. Removing a namespace freezes reconciliation of the KeystoneService CRs in it, which report NamespaceNotAllowed on their declared blocks and Ready=False/NotAllReady, while every Keystone user, catalog row and delivered Secret already minted stays in place and keeps authenticating. Teardown happens only through deletion of the KeystoneService CR itself, so an edit here can never destroy credentials a running service depends on. To revoke a registration, delete its CR.

A redundant entry is a no-op, not an error. Naming the control plane's own namespace or one of its dedicated service namespaces is accepted and changes nothing. validateServiceRegistrations deliberately adds no rule of its own here: rejecting such an entry would couple the allowlist to unrelated spec edits, so dropping a service's namespace block would invalidate an allowlist entry that never changed, on the very update that removed the block.

The per-tenant secret store a registration in an allowlisted namespace delivers its credentials through is provisioned by a sub-reconciler of its own and gated by RegistrationTenantStoresReady, not by ESOTenantStoreReady.

The Register a Service the ControlPlane Does Not Manage guide walks the flow on the ControlPlane devstack, including what removing a namespace from the allowlist does and does not do.


NamespaceAssignmentSpec ​

Assigns one namespace, on the management cluster or on a registered target cluster, to the owner of a service the control plane does not manage. An entry is the consent the order kinds check before they act on an order from that namespace, and it lists the Keystone roles such an order may request. The c5c3-operator reports every entry in status.namespaceAssignments (see NamespaceAssignmentStatus).

Seven order kinds read an entry. KeystoneUser orders an unscoped Keystone user and delivers its credentials as a Secret beside the order. KeystoneProject orders a project in the admin domain. KeystoneRoleAssignment orders a role for an ordered user on an ordered project, and is the one kind allowedRoles limits. KeystoneCatalogEntry orders a catalog entry, and needs allowCatalogEntries as well. KeystoneApplicationCredential orders an application credential for an ordered user on an ordered project, rotates it on a schedule, and delivers it as a Secret beside the order. MariaDBDatabase orders a schema on the shared MariaDB with a user OpenBao issues and replaces every day, and delivers its credential as a Secret beside the order. RabbitMQVhost orders a vhost and a user on the managed message bus, rotates the user on a schedule, and delivers the credentials as a Secret beside the order. The Order Resources from a ControlPlane guide walks all seven.

FieldTypeRequiredDefaultDescription
namespacestringYes—The assigned namespace, 1 to 63 characters matching ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$. The operator only reads it: it never creates, labels or deletes the namespace, and the entry may precede the namespace.
targetClusterRef*commonv1.TargetClusterRefSpecNonil (the management cluster)The registered target cluster the namespace lives on. Admission does not check that the cluster is registered. The ref may be edited: a changed ref is one entry removed and another added.
allowedRoles[]stringNo[]The Keystone role names an order from this namespace may request. listType=set, at most 16 entries, each 1 to 255 bytes without a comma, the bounds of a KeystoneService account role. An empty list allows no role, while the namespace may still order a database or a message-bus vhost.
allowCatalogEntriesboolNofalseAdmits KeystoneCatalogEntry orders from this namespace. Without it such an order reports CatalogNotAllowed and is frozen the way a withdrawn entry freezes it. It binds orders only.

The pair (namespace, targetClusterRef.name) is unique, so the same namespace name on two clusters is two entries. Only the validating webhook enforces this: a CEL rule would compare TargetClusterRefSpec.Name, which has no maximum length, so its cost cannot be bounded. A webhook-bypassed duplicate is reported twice in status, and the consent lookup uses the first entry.

Roles match by name. allowedRoles compares case-sensitively and expands nothing. Keystone's implied roles still apply to what an allowed role grants: member implies reader, and admin implies both. A platform operator may list admin.

An entry binds the KeystoneService CRs of its namespace. A management-cluster entry for a namespace that allowedNamespaces admits limits the account roles of every KeystoneService there. A role outside allowedRoles reports AccountReady=False/RoleNotAllowed, and the catalog block keeps reconciling. A CredentialRotation of that account refuses the same way. A namespace without an entry stays unlimited, and removing the entry lifts the limit again. The control plane's own namespace and its dedicated service namespaces are never bound, so the built-in registrations are unaffected. An entry never admits a KeystoneService and never provisions a tenant store.

Withdrawal freezes. Removing an entry makes every order from its namespace report refused, and the operator stops reconciling those orders: nothing is rotated or delivered again. Nothing they created is revoked or deleted, and the Secrets already delivered stay. Deleting an order still tears down what it created. Credentials that live on renewal, such as MariaDB dynamic leases, lapse at their TTL. A KeystoneService refused for a role freezes the same way: a role assignment that already exists in Keystone stays until the owner drops the role from spec.account.roles or deletes the CR.

Catalog entries need their own consent. A catalog row is visible to every cloud user, so the assignment alone does not admit a KeystoneCatalogEntry: the entry must set allowCatalogEntries. Clearing the flag later freezes the orders without removing their rows. The flag binds orders only; the catalog block of a KeystoneService stays admitted by allowedNamespaces.

An unknown cluster is refused in status. Admission never checks registration. An entry whose cluster does not resolve sets NamespaceAssignmentsReady=False/TargetClusterUnavailable, which turns the control plane's Ready False. An outage of a cluster that only tenants use does the same.

The Register a Service the ControlPlane Does Not Manage guide limits the roles of a registration on the ControlPlane devstack.


AdminCredentialSpec ​

Declares the admin OpenStack credential and the application-credential rotation policy for the control plane.

The figure shows where these fields act. K-ORC admin credential chain lists its steps.

The admin credential path in nine numbered steps across five lanes: OpenBao, ESO, c5c3-operator, K-ORC and Keystone. The admin password leaves OpenBao through an ExternalSecret. The c5c3-operator writes a password-based clouds.yaml and a Secret with a generated application-credential secret, a PushSecret stores that Secret in OpenBao, and an ExternalSecret returns it as k-orc-clouds-yaml. K-ORC imports the admin domain and user and creates the restricted application credential in Keystone. The operator then rewrites clouds.yaml with the application credential, the push and the read run a second time, and K-ORC registers the catalog with the application credential. A re-mint starts again at the generated secret when the admin password changes, a CredentialRotation resource asks for it, or the restriction settings change.

FieldTypeRequiredDefaultDescription
cloudCredentialsRefCloudCredentialsRefYes—References the clouds.yaml Secret and cloud entry K-ORC authenticates as.
passwordSecretRefcommonv1.SecretRefSpecNoname "keystone-admin", key "password"References the Secret holding the admin password used to (re-)mint the application credential. The defaulting webhook materializes a missing name to keystone-admin and a missing key to password, so the block may be omitted on a minimal CR. The validating webhook still enforces passwordSecretRef.name non-empty as defense-in-depth (see Validation Rules), but the defaulting webhook always satisfies it before validation runs, so a user may leave it unset. The reconciler's existing "password" key fallback also remains. Mode-dependent use: the keystone-admin default is the brownfield / spec-level default. In brownfield mode (database.clusterRef == nil) this field is used verbatim — the user supplies the admin-password Secret out-of-band and the operator projects no ExternalSecret, so this reference is projected onto the Keystone CR's bootstrap.adminPasswordSecretRef so Keystone and K-ORC agree on the admin password source. In managed mode (database.clusterRef set) the operator instead projects a per-ControlPlane admin ExternalSecret named {controlplane.Name}-keystone-admin-credentials (materialising the admin password from OpenBao) and overrides the projected Keystone CR's bootstrap.adminPasswordSecretRef to point at that operator-owned per-CP Secret's password key — the cp-level passwordSecretRef is not used as the child's ref in managed mode. See the managed-mode admin-password provisioning note below.
userNamestringNo"admin"OpenStack admin user name the control plane authenticates as. Defaulted to admin by both the +kubebuilder:default marker and the defaulting webhook. Valid in both keystone modes. Rendered as the clouds.yaml username and used as the K-ORC admin User import filter the application credential's UserRef resolves to — see the same-user constraint below.
projectNamestringNo"admin"OpenStack admin project name, rendered as the clouds.yaml project_name. Defaulted to admin by both the marker and the defaulting webhook. Valid in both modes.
domainNamestringNo"Default"OpenStack admin domain name. Defaulted to Default by both the marker and the defaulting webhook. Valid in both modes. It is the K-ORC admin Domain import filter. Phase-1 nuance: the single domainName feeds both user_domain_name and project_domain_name in the generated clouds.yaml, so the admin user and project must live in the same domain; a later userDomainName/projectDomainName split is a compatible extension.

Same-user constraint (hard, enforced by Keystone). Keystone's default policy allows creating an application credential only for the token's own user — even an admin token is refused (HTTP 403, identity:create_application_credential) when it targets another user. The clouds.yaml username and the imported admin User the credential's UserRef points at must therefore name the same OpenStack user. Both derive from this single userName field, and a unit test (TestPasswordCloudsYAMLIdentityMatchesUserImportFilter) pins the agreement.

Identity edits are not re-resolved. Changing userName, projectName or domainName on a live ControlPlane updates the K-ORC import filters in place, but K-ORC imports resolve once: the already-resolved OpenStack id is not looked up again. The mismatch surfaces as KORCReady=False/CredentialDrift rather than silently repointing the credential. The Kubernetes CR names of the imports ({controlplane.Name}-user-admin, {controlplane.Name}-domain-default) are stable handles and deliberately do not track the identity. | applicationCredential | ApplicationCredentialSpec | Yes | — | Policy for the K-ORC admin application credential (restriction, access rules, rotation mode). | | bootstrapResources | []BootstrapResourceSpec | No | nil | OpenStack resources K-ORC bootstraps alongside the admin credential (e.g. the projects/roles a fresh control plane needs). The element shape is intentionally minimal at L1; the reconciler interprets it. |

passwordSecretRef is operator-owned in managed mode. The keystone-admin default is the brownfield / spec-level default only. In managed mode (database.clusterRef set) the operator projects a per-ControlPlane admin ExternalSecret named {controlplane.Name}-keystone-admin-credentials in the ControlPlane namespace that materialises the admin password from OpenBao path bootstrap/{namespace}/{controlplane.Name}-keystone/admin (canonical: bootstrap/openstack/controlplane-keystone/admin, property password), and overrides the projected Keystone CR's bootstrap.adminPasswordSecretRef to point at that operator-owned per-CP Secret's password key. The cp-level passwordSecretRef is therefore not used as the child's ref in managed mode — the source cp.Spec is left untouched; only the projected child's ref is reassigned.

In brownfield mode (database.clusterRef == nil, a Host-based DB) the operator projects no admin ExternalSecret: the user supplies the admin-password Secret out-of-band and the cp-level passwordSecretRef (default keystone-admin) is projected onto the Keystone CR's bootstrap.adminPasswordSecretRef verbatim. See the ControlPlane Reconciler reference for the admin-credential flow.


CloudCredentialsRef ​

References the clouds.yaml Secret and the cloud entry within it that K-ORC authenticates as.

FieldTypeRequiredDefaultDescription
cloudNamestringNo"admin"The entry in clouds.yaml K-ORC authenticates as. Also used by the reconciler as the conventional K-ORC User reference name and projected onto the catalog Service/Endpoint CRs. Defaulted to admin by both the +kubebuilder:default marker (normal admission) and the defaulting webhook (callers that bypass the CRD default).
secretNamestringNo"k-orc-clouds-yaml"Name of the Secret holding the clouds.yaml document. Defaulted to k-orc-clouds-yaml by both the +kubebuilder:default marker and the defaulting webhook. The Secret is namespace-local to the ControlPlane's child namespace; because the operator enforces one ControlPlane per namespace, the shared default name does not collide across control planes. The operator (reconcileKORC → ensureKORCCloudsYAMLExternalSecret) creates and owns a per-ControlPlane ExternalSecret of this name in the child namespace that materialises the Secret, reading the per-CR OpenBao path — so the shared default name is safe and needs no per-CR manifest.

ApplicationCredentialSpec ​

Declares the K-ORC admin application-credential policy.

FieldTypeRequiredDefaultDescription
restricted*boolNotrueControls whether the application credential is restricted (least-privilege, unable to create further application credentials). Defaulted to true by both the +kubebuilder:default marker and the defaulting webhook. The pointer distinguishes "unset" (→ default true) from an explicit false, which is preserved. See the restricted → unrestricted inversion note.
accessRules[]AccessRuleNonilOptionally narrows the application credential to a specific set of service/method/path rules. When empty, the credential is not constrained by access rules.
rotationRotationSpecYes—How the application credential is rotated.

restricted → unrestricted inversion ​

The ControlPlane spec exposes a restricted flag (the safe, least-privilege posture). K-ORC's ApplicationCredentialResourceSpec exposes the inverse field, unrestricted. The reconciler performs the inversion when projecting the K-ORC ApplicationCredential CR:

restricted=true  → K-ORC spec.resource.unrestricted=false
restricted=false → K-ORC spec.resource.unrestricted=true

The same inversion is applied in reverse when reflecting the K-ORC-reported state back into status.adminApplicationCredential.restricted.


AccessRule ​

Narrows an application credential to a specific service endpoint and method, mirroring the Keystone application-credential access-rule shape.

FieldTypeRequiredDefaultDescription
servicestringYes—OpenStack service type the rule applies to (e.g. "compute"). The reconciler uses this verbatim as the name of the referenced K-ORC Service CR (serviceRef).
methodstringNo—HTTP method the rule allows (e.g. "GET", "POST"). Projected onto the K-ORC typed HTTPMethod enum; constrained by +kubebuilder:validation:Enum=CONNECT;DELETE;GET;HEAD;OPTIONS;PATCH;POST;PUT;TRACE (mirrors that enum). Optional — omitted from the projected rule when empty.
pathstringNo—Request path the rule allows (e.g. "/v2.1/servers"). When set it must be an absolute path (+kubebuilder:validation:Pattern=^/). Optional — omitted from the projected rule when empty.

RotationSpec ​

Declares the rotation policy for the admin application credential.

FieldTypeRequiredDefaultDescription
modeRotationModeNoPasswordDrivenSelects the rotation strategy. Defaulted to PasswordDriven by both the +kubebuilder:default marker and the defaulting webhook.

RotationMode ​

RotationMode is a string enum (+kubebuilder:validation:Enum=PasswordDriven;Scheduled;Manual).

ValueStatusMeaning
PasswordDrivenActive (default)Re-mints the application credential whenever the underlying admin password changes. The reconciler compares the SHA-256 of the admin password against an annotation stamped on the K-ORC ApplicationCredential CR; a mismatch drives a re-mint.
ScheduledReservedRotates the application credential on a schedule. Surfaced in the enum now so the CRD schema is stable, but the scheduled-rotation logic is deferred to a later level.
ManualReservedRotates only when a CredentialRotation CR requests it. The CredentialRotation flow is the mechanism; the Manual mode value itself is reserved at this level.

BootstrapResourceSpec ​

Declares an OpenStack resource K-ORC bootstraps with the control plane. The shape is intentionally minimal at L1 — the reconciler interprets the kind/name and applies it.

FieldTypeRequiredDefaultDescription
kindstringYes—The K-ORC resource kind to bootstrap. Constrained to the kinds the control plane bootstraps today by +kubebuilder:validation:Enum=Project;Role; widen the enum when the reconciler learns to interpret additional kinds.
namestringYes—Name of the bootstrapped resource.

RESERVED. No controller reads bootstrapResources today. For the service user of another OpenStack service, declare a KeystoneService CR instead — it owns the full user + project + password lifecycle, and its credentials are delivered into its own namespace.


ControlPlaneStatus ​

FieldTypeDescription
conditions[]metav1.ConditionLatest available observations of the control-plane state. Each condition carries an observedGeneration. See Status Conditions.
observedGenerationint64The .metadata.generation the controller last reconciled, so a stale status is distinguishable from a current one.
updatePhaseUpdatePhaseCurrent phase of a control-plane release update. Written on every status update; fixed at Idle in the current implementation because the release-update state machine is reserved (the other UpdatePhase values are not yet set).
services[]ServiceStatusPer-service readiness of the projected service CRs. A listType=map list keyed by name, so per-service entries merge under server-side apply and can grow per-service conditions cleanly. Written on every status update with one entry per managed service in a stable order — keystone, horizon, glance, placement, barbican, neutron, cinder, then nova — each present only when its spec.services.<svc> is set. Each entry's ready mirrors the matching KeystoneReady / HorizonReady / GlanceReady / PlacementReady / BarbicanReady / NeutronReady / CinderReady / NovaReady condition and its release is spec.openStackRelease; an unmanaged service is omitted rather than reported. See ServiceStatus.
catalog*CatalogStatusObserved state of the External-mode catalog imports. Nil in Managed mode, where the control plane creates the catalog entries rather than importing them. See CatalogStatus.
namespaceAssignments[]NamespaceAssignmentStatusWhat each spec.namespaceAssignments entry resolves to, one entry per spec entry and in spec order. A listType=atomic list, empty when the spec assigns no namespace. See NamespaceAssignmentsReady.

updatePhase vs the Keystone CRD's upgradePhase. These field names are intentionally distinct: ControlPlane.status.updatePhase is the control-plane release-update machine (Idle / Updating / …/ RollingBack), while Keystone.status.upgradePhase is the live per-service database expand-migrate- contract machine (Expanding / Migrating / RollingUpdate / Contracting). They carry different enum vocabularies for different concerns and are not the same field under two names. | adminApplicationCredential | *AdminApplicationCredentialStatus | Observed state of the K-ORC admin application credential. |

ServiceStatus ​

Reports the observed readiness of a single projected service CR.

FieldTypeRequiredDescription
namestringYesService name (e.g. keystone); keys the listType=map services list.
readyboolYesWhether the projected service CR is Ready.
releasestringNoThe OpenStack release the service currently reports installed.

AdminApplicationCredentialStatus ​

Reports the observed state of the K-ORC admin application credential.

Multi-instance — per-ControlPlane OpenBao path. The minted admin application credential is mirrored to OpenBao at the per-ControlPlane path openstack/keystone/{namespace}/{name}/admin/app-credential (for the default deployment identity openstack/controlplane, this is openstack/keystone/openstack/controlplane/admin/app-credential), replacing the earlier flat path shared across control planes. Because the validating webhook permits exactly one ControlPlane per namespace, these per-CR paths are disjoint across namespaces by construction. See the ControlPlane Reconciler reference for the full OpenBao layout and the migration from the legacy flat path.

FieldTypeRequiredDescription
idstringNoThe OpenStack application-credential ID currently in use, populated by K-ORC once the credential is minted.
restrictedboolNoWhether the active credential is restricted. Computed as the inverse of the K-ORC-reported unrestricted (falling back to the desired value while K-ORC status is empty).
lastRotation*metav1.TimeNoTimestamp of the last successful rotation. (Re-)stamped to "now" whenever the recorded credential id changes (initial mint or re-mint); preserved once the id is stable.

CatalogStatus ​

Reports how the External-mode identity catalog imports resolved. Nil in Managed mode. It is the operator-visible answer to "did the ControlPlane find the catalog it was pointed at?" — the aggregate CatalogReady condition says whether they all resolved, this list says which ones did.

The list is rebuilt on every pass before any failure return, so an unresolved import is reported as resolved: false rather than omitted.

FieldTypeRequiredDescription
imports[]CatalogImportStatusNoThe unmanaged K-ORC CRs importing the external identity service and its endpoint interfaces. A listType=map list keyed by name.

CatalogImportStatus ​

FieldTypeRequiredDescription
namestringYesThe K-ORC CR name; keys the listType=map imports list.
kindstring (Service | Endpoint)YesThe imported K-ORC kind.
interfacestring (public | internal | admin)NoThe catalog interface of an imported Endpoint; empty for the Service import.
resolvedboolYesWhether K-ORC matched this import against a live catalog entry (its Available condition is True for the CR's current generation).
idstringNoThe OpenStack id K-ORC resolved the import to. Empty while the import is unresolved.

NamespaceAssignmentStatus ​

Reports the observed state of one spec.namespaceAssignments entry. reconcileNamespaceAssignments rebuilds the list on every pass.

FieldTypeRequiredDescription
namespacestringYesThe assigned namespace, copied from the spec entry.
targetClusterRef*commonv1.TargetClusterRefSpecNoThe spec entry's cluster reference. Absent means the management cluster.
allowedRoles[]stringNoThe spec entry's role allowlist, echoed.
allowCatalogEntriesboolNoThe spec entry's catalog consent, echoed. Absent means false.
clusterReachableboolYesWhether the cluster resolved and answered the namespace GET.
namespaceExistsboolYesWhether the namespace exists on that cluster.
reasonstring (Assigned | NamespaceNotFound | NamespaceTerminating | TargetClusterUnavailable | ClusterUnreachable)YesThe entry's outcome. NamespaceNotFound and NamespaceTerminating keep NamespaceAssignmentsReady True. The last two fail it.
messagestringNoExplains a reason other than Assigned: the resolver's text for TargetClusterUnavailable, getting namespace "<ns>": <err> for ClusterUnreachable. A later entry on a cluster that did not answer an earlier GET in the same pass is not read and carries namespace "<ns>" not read: <cluster> did not answer an earlier read in this pass: <err>. At most 32768 bytes.

UpdatePhase ​

UpdatePhase is a string enum (+kubebuilder:validation:Enum=Idle;Updating;UpdatingServices;Verifying;RollingBack).

DECISION: the enum surfaces the future phases alongside the active ones so the CRD schema is stable across levels and does not need a breaking change when the update state machine is implemented. The reserved values below are never set by the current reconciler; they are documented so consumers (dashboards, kubectl) see the full vocabulary.

ValueStatusMeaning
IdleActiveNo update is in progress.
UpdatingActiveA release update has started.
UpdatingServicesReserved — not yet implementedPer-service CRs are being updated.
VerifyingReserved — not yet implementedThe control plane is verifying an update.
RollingBackReserved — not yet implementedA failed update is being rolled back.

CredentialRotationSpec ​

Defines the desired state of a CredentialRotation — a one-shot request to rotate a control-plane credential. The reconciler re-mints the target credential and reports progress via status conditions.

FieldTypeRequiredDefaultDescription
targetRotationTargetYes—Which credential to rotate.
keystoneServicestringConditional—Names the KeystoneService CR, in the CredentialRotation's own namespace, whose managed account's password is rotated. Required exactly when target is serviceAccountPassword, forbidden otherwise (two CEL rules; there is no CredentialRotation webhook, so CEL is the only gate). DNS-1123 subdomain, ≤ 253.
bootstrapboolNofalseWhen true, requests an initial mint of the credential rather than a rotation of an existing one. Idempotent: if the credential already exists it is a no-op.
reMintboolNofalseWhen true, forces the reconciler to discard the current credential and mint a fresh one even if the existing credential is still valid. The nudge is one-shot per spec generation (latched on status.lastTriggeredGeneration), so a reMint: true left in the spec does not re-rotate on every resync.
intervalDays*int32NonilDeferred — accepted by the schema but ignored by the L1 reconciler. Rotation cadence in days for scheduled rotation. Minimum: 1.
preRotationDays*int32NonilDeferred — accepted but ignored. Days before expiry a replacement credential is minted (the overlap window). Minimum: 0.
gracePeriodDays*int32NonilDeferred — accepted but ignored. Days the superseded credential remains valid after a rotation before it is revoked. Minimum: 0.

DECISION: the scheduled-rotation fields (intervalDays, preRotationDays, gracePeriodDays) surface in the CRD schema now so the contract is stable, but the L1 reconciler ignores them — scheduled rotation (and the two-credential pre-rotation/grace overlap) is implemented in a later level. They are kept here, rather than introduced via a future breaking schema change, so dashboards and GitOps manifests can be written against the final shape.

RotationTarget ​

RotationTarget is a string enum (+kubebuilder:validation:Enum=adminApplicationCredential;serviceAccountPassword).

ValueMeaning
adminApplicationCredentialRotates the K-ORC admin application credential.
serviceAccountPasswordRotates the password of the managed account of the KeystoneService named by spec.keystoneService. On demand only (no auto-detect: there is no external password source); fires on an explicit reMint, latched to the spec generation.

CredentialRotationStatus ​

FieldTypeDescription
conditions[]metav1.ConditionLatest available observations of the rotation state. Upserted via the shared conditions helper.
observedGenerationint64The .metadata.generation the controller last reconciled.
lastTriggeredGenerationint64The most recent .metadata.generation for which an explicit reMint nudge was performed. Latches reMint to a single spec generation so a reMint: true left in the spec fires once per edit, not on every resync or restart.

SecretAggregate ​

SecretAggregate aggregates the Secrets produced by a control plane into a single materialized Secret.

DECISION: this is types-only at this level — there is no controller. The reconciler is deferred to a later level, and the operator RBAC for this kind is read-only (get/list/watch) until that reconciler lands, so the operator can observe SecretAggregate CRs without being granted write access to a kind it does not yet manage. The Spec/Status below are intentionally minimal placeholders; a later level will flesh them out.

SecretAggregateSpec ​

FieldTypeRequiredDefaultDescription
targetSecretNamestringNo""Name of the materialized aggregate Secret the (deferred) reconciler will produce.

SecretAggregateStatus ​

FieldTypeDescription
conditions[]metav1.ConditionLatest available observations of the aggregate state. Upserted via the shared conditions helper.

SecretAggregate has no printer columns and no defaulting/validating webhook at this level.


Shared Types (from internal/common/types) ​

The ControlPlane reuses the canonical commonv1 shapes imported from github.com/c5c3/cobaltcore/internal/common/types. These are shared across all CobaltCore operator CRDs and are documented in full in the Keystone reference; this section links rather than re-documents them to keep a single source of truth.

TypeUsed byReference
ImageSpecservices.keystone.imageKeystone CRD → ImageSpec
DatabaseSpecinfrastructure.databaseKeystone CRD → DatabaseSpec
CacheSpecinfrastructure.cacheKeystone CRD → CacheSpec
MessagingSpecinfrastructure.messagingMessagingSpec (on this page: the ControlPlane is the first CRD to embed it)
SecretRefSpeckorc.adminCredential.passwordSecretRefKeystone CRD → SecretRefSpec
SecretStoreRefSpecsecretStoreRef (projected onto the Keystone, Horizon, Glance, Placement, and Barbican children)Keystone CRD → SecretStoreRefSpec
PolicySpecglobalPolicyOverrides, services.keystone.policyOverridesKeystone CRD → PolicySpec

Note on DatabaseSpec.tls / CacheSpec: the commonv1 shapes carry the full Keystone field set, including the optional database.tls block. Those fields are part of the reused struct and are validated by the API server, but the ControlPlane reconciler projects the DatabaseSpec/CacheSpec onto the Keystone CR verbatim — TLS behavior is therefore governed by the Keystone DatabaseTLSSpec on the projected child, not re-implemented in the aggregate.


Validation Rules ​

The c5c3 ControlPlane uses a two-layer validation strategy, mirroring the Keystone discipline:

  1. CRD schema markers (+kubebuilder:validation:*) enforced by the API server before webhooks run — patterns, enums, and minimums.
  2. The validating webhook (validate()), which re-checks the schema-level rules and adds the cross-field invariants that cannot be expressed as simple field markers.

CEL x-kubernetes-validations on this CRD. The ControlPlane CRD carries CEL XValidation rules inherited from the shared commonv1 types: the database clusterRef/host and cache clusterRef/servers mutual-exclusivity (from DatabaseSpec/CacheSpec, applied to spec.infrastructure.database and spec.infrastructure.cache), and the policy-rule name/value constraints (from PolicySpec, applied to spec.globalPolicyOverrides and spec.services.keystone.policyOverrides). The required passwordSecretRef.name remains enforced only by the validating webhook: a cluster that disables or bypasses the webhook (e.g. envtest without the webhook wired up, or a direct etcd write) will not reject a ControlPlane on that one rule, only on the CEL rules and the pattern/enum/minimum markers below. The markers and webhook together are defense-in-depth for the fields that can be expressed at both layers.

CRD schema markers (API-server enforced) ​

FieldRule
spec.openStackReleasePattern ^\d{4}\.[12]$
spec.imagePullPolicyEnum: Always, IfNotPresent, Never (no webhook twin: a child CRD or a ControlPlane CRD older than the operator prunes the field first)
spec.regionPattern ^[^,]+$; MaxLength 255 (K-ORC's bounds on OpenStackName, the type the adopted Region CR's spec.resource.name carries); schema default RegionOne
spec.regionDescriptionMaxLength 255 (K-ORC's bound on RegionResourceSpec.Description)
spec.services.keystone.publicEndpointPattern ^https?://; MaxLength 512 (the Horizon child's bound on websso.keystoneURL, which this value is projected onto)
spec.services.horizon.publicEndpointPattern ^https?://; MaxLength 499 (the Keystone child's 512-character bound on trustedDashboards[] minus /auth/websso/)
spec.services.keystone.modeEnum: Managed, External; schema default Managed
spec.services.keystone.external.authURLPattern ^https?://[^\s/]+; MaxLength 2048 (bounds the one unbounded input interpolated into status.conditions[].message)
spec.services.keystone.external.endpointTypeEnum: public, internal, admin; schema default public
spec.services.keystone.external.caBundleSecretRef.nameMinLength 1 (shared SecretRefSpec marker)
spec.services.keystone.caBundleSecretRef.nameMinLength 1 (shared SecretRefSpec marker)
spec.services.keystone.databaseCredentialsModeEnum: Static, Dynamic
spec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.targetClusterRef.nameMinLength 1; Pattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ — a DNS-1123 subdomain, from the shared commonv1.TargetClusterRefSpec markers, so a name no registration Secret could carry is refused before the resolver ever sees it
spec.korc.adminCredential.applicationCredential.accessRules[].methodEnum: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE
spec.korc.adminCredential.applicationCredential.accessRules[].pathPattern ^/
spec.korc.adminCredential.bootstrapResources[].kindEnum: Project, Role
spec.korc.adminCredential.applicationCredential.rotation.modeEnum: PasswordDriven, Scheduled, Manual
spec.infrastructure.database.replicasMinimum: 1, no schema default (the defaulting webhook fills it from the sizing). The webhook additionally rejects exactly 2 (Galera quorum — see below).
spec.infrastructure.database.storageSizePattern ^[0-9]+(Mi|Gi|Ti)$, no schema default (the defaulting webhook fills it from the sizing)
spec.infrastructure.cache.replicasMinimum: 1, no schema default (an unset value follows the sizing)
spec.infrastructure.messaging.replicasMinimum: 1, no schema default (an unset value follows the sizing)
spec.sizing (CEL)!(has(self.profile) && has(self.profileRef)) → "profile and profileRef are mutually exclusive"
spec.sizing.profile, SizingProfile spec.baseEnum: Minimal, Standard; spec.base has schema default Standard
spec.sizing.profileRef.nameMinLength 1; MaxLength 253
spec.sizing and SizingProfile spec: every replicas, processes, threads, workersMinimum: 1
spec.sizing.database.storageSize, SizingProfile spec.database.storageSizePattern ^[0-9]+(Mi|Gi|Ti)$
spec.sizing and SizingProfile spec: every spreadConstraints[]maxSkew Minimum 1; topologyKey MinLength 1; whenUnsatisfiable Enum DoNotSchedule, ScheduleAnyway
spec.sizing and SizingProfile spec: every api and horizon.api (CEL)!(has(self.autoscaling) && has(self.verticalAutoscaling)) → "autoscaling and verticalAutoscaling cannot both be set on one component"
spec.sizing and SizingProfile spec: every verticalAutoscalingupdateMode Enum Off, Initial, Recreate, InPlaceOrRecreate, Auto; minReplicas Minimum 1; minAllowed and maxAllowed at most two keys, CEL: only cpu and memory (see VerticalAutoscalingSpec)
CredentialRotation spec.targetEnum: adminApplicationCredential, serviceAccountPassword
CredentialRotation spec.keystoneServicePattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$; MinLength 1; MaxLength 253
CredentialRotation (CEL)target == 'serviceAccountPassword' ⇒ has(self.keystoneService) → "keystoneService is required when target is serviceAccountPassword"
CredentialRotation (CEL)has(self.keystoneService) ⇒ target == 'serviceAccountPassword' → "keystoneService may only be set when target is serviceAccountPassword"
CredentialRotation spec.intervalDaysMinimum: 1
CredentialRotation spec.preRotationDaysMinimum: 0
CredentialRotation spec.gracePeriodDaysMinimum: 0
status.updatePhaseEnum: Idle, Updating, UpdatingServices, Verifying, RollingBack
spec.infrastructure.database (CEL)has(self.clusterRef) != has(self.host) → "exactly one of clusterRef or host must be set"
spec.infrastructure.cache (CEL)has(self.clusterRef) != (has(self.servers) && size(self.servers) > 0) → "exactly one of clusterRef or servers must be set"
spec.infrastructure.messaging (CEL)has(self.clusterRef) != has(self.secretRef) → "exactly one of clusterRef or secretRef must be set"
spec.globalPolicyOverrides, spec.services.keystone.policyOverrides (CEL)!has(self.rules) || self.rules.all(k, size(k) > 0) → "policy rule name must not be empty"
spec.globalPolicyOverrides, spec.services.keystone.policyOverrides (CEL)!has(self.rules) || self.rules.all(k, size(self.rules[k]) > 0) → "policy rule value must not be empty"
spec.services.keystone (CEL)mode == 'External' ⇒ has(self.external) → "external is required when services.keystone.mode is External"
spec.services.keystone (CEL)has(self.external) ⇒ mode == 'External' → "external may only be set when services.keystone.mode is External"
spec.services.keystone (CEL)mode == 'External' ⇒ each managed-only field (image, policyOverrides, extraConfig, rotationInterval, gateway, publicEndpoint, federationProxyImage, databaseCredentialsMode, targetClusterRef, caBundleSecretRef) absent → "services.keystone.<field> is forbidden when services.keystone.mode is External" (one rule per field)
spec.services.glance.databaseCredentialsModeEnum: Static, Dynamic
spec.services.glance.gateway.hostnameMinLength 1 (shared GatewaySpec marker)
spec.services.glance.publicEndpointPattern ^https?://; MaxLength 512 (mirrors services.keystone.publicEndpoint; the value feeds the K-ORC image Endpoint URL)
spec.services.glance.backendslistType=map keyed by name; MinItems 1; MaxItems 32
spec.services.glance.backends (CEL)self.filter(b, has(b.isDefault) && b.isDefault).size() == 1 → "exactly one backends entry must set isDefault"
spec.services.glance.backends[].namePattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$; MinLength 1; MaxLength 63
spec.services.glance.backends[].typeEnum: S3
spec.services.glance.backends[] (CEL)(self.type == 'S3') == has(self.s3) → "the s3 block must be set exactly when type is S3"
spec.services.glance.backends[].s3.endpointPattern ^https?://; MinLength 1
spec.services.glance.backends[].s3.bucketMinLength 1
spec.services.glance.backends[].s3.bucketURLFormatEnum: path, virtual (no schema default, so an unset value serialises away)
spec.services.glance.backends[].s3.credentialsSecretRef.nameMinLength 1; MaxLength 253
spec.services.glance.importFiltering.allowedSchemes, .disallowedSchemesMaxItems 64; item Enum: http, https
spec.services.glance.importFiltering.allowedHosts, .disallowedHostsMaxItems 64; item MinLength 1; item MaxLength 253
spec.services.glance.importFiltering.allowedPorts, .disallowedPortsMaxItems 64; item Minimum 1; item Maximum 65535
spec.services.glance.importFiltering (CEL)!(has(self.allowedSchemes) && self.allowedSchemes.size() > 0 && has(self.disallowedSchemes) && self.disallowedSchemes.size() > 0) → "allowedSchemes and disallowedSchemes are mutually exclusive: glance ignores the deny-list when the allow-list is non-empty"
spec.services.glance.importFiltering (CEL)!(has(self.allowedHosts) && self.allowedHosts.size() > 0 && has(self.disallowedHosts) && self.disallowedHosts.size() > 0) → "allowedHosts and disallowedHosts are mutually exclusive: glance ignores the deny-list when the allow-list is non-empty"
spec.services.glance.importFiltering (CEL)!(has(self.allowedPorts) && self.allowedPorts.size() > 0 && has(self.disallowedPorts) && self.disallowedPorts.size() > 0) → "allowedPorts and disallowedPorts are mutually exclusive: glance ignores the deny-list when the allow-list is non-empty"
spec.services.glance.importPlugins.conversion.outputFormatEnum: qcow2, raw, vmdk
spec.services.glance.importPlugins.injectMetadata.propertiesRequired; MinProperties 1; MaxProperties 64; CEL: self.all(k, size(k) <= 255 && size(self[k]) <= 255) → "each injected property name and value must be at most 255 characters" (the CEL rule is the only marker that reaches the map's halves)
spec.services.glance.importPlugins.injectMetadata.ignoreUserRolesMaxItems 64; item MinLength 1; item MaxLength 255
spec.services.nova.publicEndpointPattern ^https?://; MaxLength 512 (mirrors services.keystone.publicEndpoint; the value feeds the K-ORC compute Endpoint URL)
spec.services.nova.databaseCredentialsModeEnum: Static, Dynamic
spec.services.nova.gateway.hostname, .metadataGateway.hostname, .consoleProxy.gateway.hostnameMinLength 1 (shared GatewaySpec marker)
spec.services.nova.consoleProxy (CEL)!has(self.enabled) || self.enabled || !has(self.gateway) → "gateway must not be set when consoleProxy.enabled is false"
spec.services.nova.dbArchive.maxRows, .retentionDaysMinimum: 1
spec.services.nova.dbArchive.sleepMinimum: 0
spec.services.nova.dedicatedBackingServices (CEL)has(self.database) || has(self.cache) → "dedicatedBackingServices must declare at least one backing-service class (database, cache)"
spec.korc.serviceRegistrations.allowedNamespaceslistType=set (the API server rejects duplicate entries); MaxItems 32; item MinLength 1; item MaxLength 63; item Pattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
spec.namespaceAssignmentslistType=atomic; MaxItems 32. No schema rule keeps the (namespace, targetClusterRef.name) pair unique; the webhook does
spec.namespaceAssignments[].namespaceRequired; MinLength 1; MaxLength 63; Pattern ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$
spec.namespaceAssignments[].targetClusterRef.nameRequired; MinLength 1; Pattern (DNS-1123 subdomain), the shared TargetClusterRefSpec markers
spec.namespaceAssignments[].allowedRoleslistType=set (the API server rejects duplicate entries); MaxItems 16; item MinLength 1; item MaxLength 255; item Pattern ^[^,]+$
spec.namespaceAssignments[].allowCatalogEntriesBoolean, default false; no further rule

Validating-webhook rules ​

The validate() method accumulates all errors in a field.ErrorList and returns a single apierrors.NewInvalid error keyed on GroupKind{Group: "c5c3.io", Kind: "ControlPlane"}. It does not short-circuit on the first error.

RuleField PathError TypeCondition
Release patternspec.openStackReleasefield.InvalidValue does not match ^\d{4}\.[12]$. Defense-in-depth alongside the CRD +kubebuilder:validation:Pattern marker.
Release floorspec.openStackReleasefield.InvalidValue parses as a release below 2026.1, the oldest this operator version supports, on create or when an update changes it: must be 2026.1 or later: this operator version no longer supports OpenStack releases below 2026.1. An update that keeps a below-floor value is admitted with the warning spec.openStackRelease "2025.2" is below 2026.1, the oldest OpenStack release this operator version supports; the unchanged value is admitted, but the operator renders the 2026.1 configuration for it. Set spec.openStackRelease to 2026.1 or later. A downgrade below the floor carries both the downgrade and the floor message. Webhook-only.
Image tag release floorspec.services.keystone.image.tag, spec.services.horizon.image.tagfield.InvalidThe tag the reconciler projects into the Keystone or Horizon child parses as a release below 2026.1, on create or when an update sets or changes it, with the release-floor message above. The projected tag is the override's tag, or spec.openStackRelease when the override is unset, so removing an override while spec.openStackRelease is below the floor is rejected. The child's webhook applies the same floor to spec.image.tag. An update that keeps a below-floor projected tag is admitted with the release-floor warning for that field. A service projected without an override before and after the update, or on create, is left to the spec.openStackRelease rule. Enabling a service without an override while spec.openStackRelease is below the floor is rejected, because the new child gets that tag. A digest-pinned override, or a tag that names no release, passes. Webhook-only.
Database mutual exclusivityspec.infrastructure.databasefield.InvalidBoth clusterRef and host set, or neither ((clusterRef != nil) == (host != "")). Defense-in-depth alongside the CEL XValidation rule on commonv1.DatabaseSpec.
Cache mutual exclusivityspec.infrastructure.cachefield.InvalidBoth clusterRef and servers set, or neither ((clusterRef != nil) == (len(servers) > 0)). Defense-in-depth alongside the CEL XValidation rule on commonv1.CacheSpec.
Messaging mutual exclusivityspec.infrastructure.messagingfield.InvalidThe block is present with both clusterRef and secretRef set, or with neither. Defense-in-depth alongside the CEL XValidation rule on commonv1.MessagingSpec; a nil block has nothing to validate, since messaging is opt-in.
Messaging brownfield Secret name requiredspec.infrastructure.messaging.secretRef.namefield.RequiredA brownfield secretRef with an empty name. Mirrors the shared SecretRefSpec MinLength marker.
Messaging CA bundle name requiredspec.infrastructure.messaging.tls.caBundleSecretRef.namefield.RequiredA tls block with an empty caBundleSecretRef.name. Mirrors the shared SecretRefSpec MinLength marker.
Messaging TLS is brownfield-onlyspec.infrastructure.messaging.tlsfield.InvalidA tls block beside a managed clusterRef. ensureRabbitMQ projects the replica count and the sizing but no listener, so the owned RabbitmqCluster comes up on the operator's default, plaintext listener and the requested client trust would never be honoured. Webhook-only: the shared commonv1.MessagingSpec must not carry a c5c3-specific CEL rule the keystone operator would inherit.
Database replicas quorumspec.infrastructure.database.replicasfield.InvalidValue is exactly 2. The managed-mode projection turns any replicas > 1 into a Galera cluster, and a two-node Galera cluster cannot hold a majority — a single pod disruption then loses quorum. Replicas must be 1 (standalone) or >=3. The CRD marker enforces only Minimum=1 (the shared commonv1.DatabaseSpec must not carry a c5c3-specific CEL rule the keystone operator, which ignores replicas, would inherit), so this check is webhook-only; a zero value (defaulting bypassed) is left to the reconciler's floor.
Sizing forbidden in External modespec.sizingfield.Forbiddenspec.sizing is set while services.keystone.mode is External. Message: forbidden when services.keystone.mode is External (no workload is deployed). Webhook-only.
Sizing requests within limitsspec.sizing[.<component>].resources.requests.<resource>field.InvalidA request exceeds the limit of the same resource. Checked on the values as written and, on create and on an update that changes spec.sizing, on the merged sizing, where a profile's request can meet the ControlPlane's limit. An error the written values already produced is not repeated for the merged ones. The merged checks (this row and the three below) are skipped while the SizingProfile spec.sizing.profileRef names does not exist, because the merge is unknown; restoring the profile runs them (see SizingProfile). Webhook-only.
Sizing autoscaling target against a zero requestspec.sizing.<service>.api.resources.{requests,limits}.<resource>, spec.sizing.keystone.federationProxy.resources.*field.InvalidOn the merged sizing, an autoscaling utilization target meets a zero or negative request (or, without a request, limit) of the same resource. The Keystone federation proxy runs in the API pods, so its resources meet Keystone's target too. Webhook-only.
Sizing autoscaling maxReplicas below replicasspec.sizing.<service>.api.autoscaling.maxReplicasfield.InvalidOn the merged sizing, an autoscaling block without minReplicas has a maxReplicas below the component's replica count (3 when the sizing names none). The HPA minimum defaults to that count, so the service's webhook would reject the projected child. Message: maxReplicas must be >= replicas (<n>) when minReplicas is not set, because minReplicas defaults to replicas. Webhook-only.
Sizing autoscaling target unreachablespec.sizing.<service>.api.autoscaling.{targetCPUUtilization,targetMemoryUtilization}field.InvalidOn the merged sizing, a target above 100 that no container of the API pod can reach, because every container's limit caps its usage below it. A block that names no memory renders the same figure as request and limit, so a memory target above 100 needs a memory limit above the request or a request without a limit; CPU is unbounded while a container names no CPU limit. The Keystone API is checked together with spec.sizing.keystone.federationProxy.resources while that block is set. Message: <field> <target> can never be reached: …. Webhook-only.
Sizing autoscaling behaviorspec.sizing.<service>.api.autoscaling.behavior.<scaleUp|scaleDown>.*, and spec.<service>.api.autoscaling.behavior.* on a SizingProfilefield.Invalid / field.NotSupportedOn the values as written, the autoscaling/v2 rules for each set direction: stabilizationWindowSeconds outside 0..3600, a selectPolicy other than Max, Min or Disabled, a policy type other than Pods or Percent, a policy value of zero or below, a policy periodSeconds outside 1..1800, and a negative tolerance. The embedded upstream type carries no markers, so without this check a bad value would surface as a reconcile error of the child operator. Webhook-only.
Sizing node selector and tolerationsspec.sizing[.<component>].nodeSelector, .tolerationsfield.Invalid / field.NotSupportedA node-selector key that is no qualified name or a value that is no label value, or a toleration the API server's own toleration validation refuses. Webhook-only.
Sizing database quorumspec.sizing.database.replicasfield.InvalidValue is exactly 2, with the message of the database quorum rule above. Webhook-only.
Sizing storage sizespec.sizing.database.storageSizefield.InvalidValue does not match ^[0-9]+(Mi|Gi|Ti)$. Twin of the CRD pattern.
Sizing cache memory floorspec.sizing.cache.resources.limits.memoryfield.InvalidA limit below 96Mi. Message: memory limit must be at least 96Mi: the Memcached operator requires maxMemoryMB (64) plus 32Mi. Webhook-only.
Sizing spread entriesspec.sizing.<component>.spreadConstraints[i].{maxSkew,topologyKey,whenUnsatisfiable}field.Invalid / field.Required / field.NotSupportedmaxSkew below 1, an empty topologyKey, or a whenUnsatisfiable outside DoNotSchedule, ScheduleAnyway. Twins of the CRD markers.
Sizing vertical autoscaling beside autoscalingspec.sizing.<service>.api.verticalAutoscaling, spec.sizing.horizon.api.verticalAutoscalingfield.ForbiddenThe component sets both autoscaling and verticalAutoscaling, as written and on the merged sizing, so a profile's VPA meets a ControlPlane's HPA on the same component, and the reverse. Message: autoscaling and verticalAutoscaling cannot both be set on one component. Twin of the CEL rule on the API sizing types.
Sizing vertical autoscaling valuesspec.sizing.<component>.verticalAutoscaling.*field.NotSupported / field.InvalidThe shared checks of validation.VerticalAutoscaling: an updateMode outside the enum, a minReplicas below 1, a minAllowed or maxAllowed key other than cpu and memory, a cpu or memory bound whose decimal exponent lies beyond ±30 (webhook-only), and a minAllowed above the maxAllowed of the same resource (webhook-only).
Console-proxy sizing while disabledspec.sizing.nova.consoleProxyfield.ForbiddenThe block is set while services.nova.consoleProxy.enabled is false. Message: must not be set when services.nova.consoleProxy.enabled is false. Webhook-only.
SizingProfile existsspec.sizing.profileRef.namefield.NotFound / field.InternalErrorNo SizingProfile of that name exists (NotFound), or reading it failed (InternalError). A missing profile is reported on create and on an update that changes the name, so a ControlPlane whose profile was deleted later can still be updated, finalizer removal included. A failed read is reported on create and on every update that changes spec.sizing. Webhook-only.
New dedicated database sizing resolvedspec.services.<service>.dedicatedBackingServices.database.{replicas,storageSize}field.RequiredOn update, a managed dedicated database the old revision did not declare still has replicas or storageSize unset, because the SizingProfile spec.sizing.profileRef names does not exist. Both values freeze at creation, so restore the profile or set them explicitly. Webhook-only.
Sizing priority classes existevery priorityClassName in spec.sizingfield.NotFound / field.InternalErrorA named PriorityClass does not exist. On update only the names the old spec.sizing did not carry are looked up, so a class deleted after admission does not block an unrelated edit. Webhook-only.
Admin password Secret requiredspec.korc.adminCredential.passwordSecretRef.namefield.Requiredname is empty — without it the reconciler cannot (re-)mint the admin application credential. Webhook-only.
Gateway hostname requiredspec.services.keystone.gateway.hostnamefield.RequiredA gateway is configured but its hostname is empty. Mirrors the +kubebuilder:validation:MinLength=1 marker on commonv1.GatewaySpec.Hostname; without it the reconciler derives an empty https:///v3 public endpoint.
Empty policy rule namespec.globalPolicyOverrides.rules[<key>], spec.services.keystone.policyOverrides.rules[<key>]field.RequiredA rule name (map key) is the empty string. Enforced via the shared policy.ValidatePolicyRules, mirrored by the CEL rule on commonv1.PolicySpec.
Empty policy rule valuespec.globalPolicyOverrides.rules[<key>], spec.services.keystone.policyOverrides.rules[<key>]field.RequiredA rule value is the empty string. Enforced via the shared policy.ValidatePolicyRules, mirrored by the CEL rule on commonv1.PolicySpec.
External block requiredspec.services.keystone.externalfield.Requiredmode: External but external unset. Defense-in-depth mirror of the CEL rule.
External authURL required/URLspec.services.keystone.external.authURLfield.Required / field.InvalidIn External mode, authURL empty (Required), or not matching ^https?://[^\s/]+ / failing a full net/url parse / exceeding 2048 characters (Invalid). Mirrors the CRD required/pattern/maxLength markers.
External caBundle name requiredspec.services.keystone.external.caBundleSecretRef.namefield.RequiredcaBundleSecretRef set with an empty name. Mirrors the shared SecretRefSpec MinLength marker.
Managed-only field forbidden in External modespec.services.keystone.{image,policyOverrides,extraConfig,rotationInterval,gateway,publicEndpoint,federationProxyImage}field.ForbiddenThe field is set while mode: External. Defense-in-depth mirror of the per-field CEL rules.
Keystone credentials-mode override forbidden in External modespec.services.keystone.databaseCredentialsModefield.ForbiddenThe per-service override is set while mode: External — no managed database is provisioned, so there is no credentials mode to override. Defense-in-depth mirror of the per-field CEL rule.
Dynamic credentials-mode override on a dedicated databasespec.services.{keystone,glance,placement,barbican,neutron,cinder,nova}.databaseCredentialsModefield.ForbiddenThe override is Dynamic while that service declares a dedicated database: the override retargets the shared database the service does not use, and a dedicated database is Static-only (set dedicatedBackingServices.database.credentialsMode instead). Static stays admitted. Cross-field, webhook-only.
Dynamic credentials-mode override on a brownfield shared databasespec.services.{keystone,glance,placement,barbican,neutron,cinder,nova}.databaseCredentialsModefield.ForbiddenThe override is Dynamic while the shared database is brownfield (clusterRef unset): the dynamic engine issues per-tenant DB users only against a cluster the operator provisions. Cross-field, webhook-only.
Federation proxy image resolvablespec.services.keystone.federationProxyImagefield.Required / field.InvalidEmpty repository, or neither/both of tag and digest. Surfaces on the ControlPlane the operator edits rather than as an opaque KeystoneProjectionRejected condition on the child.
Dashboard public endpoint is a URLspec.services.horizon.publicEndpointfield.InvalidNot an absolute HTTP(S) URL with a host. Keystone matches the derived WebSSO origin verbatim, so an unusable endpoint could never match any dashboard.
Dashboard public endpoint is a bare originspec.services.horizon.publicEndpointfield.InvalidCarries a path, query, or fragment (a single trailing / is trimmed and allowed). The ^https?:// pattern anchors only the prefix, so https://horizon.example.com?utm=1 is schema-legal and would render the trusted origin https://horizon.example.com?utm=1/auth/websso/ — accepted by Keystone, matched by nothing. Webhook-only.
Dashboard public endpoint agrees with the gatewayspec.services.horizon.publicEndpointfield.InvalidWith services.horizon.gateway set: the scheme is not https (the listener terminates TLS, and the browser posts the unscoped WebSSO token to this origin), or its host differs from gateway.hostname (Django derives the origin it sends from the request Host header). The port may differ. Cross-field, webhook-only.
Gateway hostname is a usable DNS namespec.services.{keystone,horizon}.gateway.hostnamefield.InvalidA wildcard, an embedded port, a path, a scheme, a control character, or over 253 characters. Each shape either breaks the browser-facing origins derived from the hostname or overruns the children's own MaxLength markers on those origins.
External block forbidden in non-External modespec.services.keystone.externalfield.Forbiddenexternal set while mode is not External. Defense-in-depth mirror of the CEL rule.
Infrastructure forbidden in External modespec.infrastructurefield.Forbiddenspec.infrastructure set while mode: External. Cross-field, webhook-only — CEL cannot span spec.infrastructure and spec.services.keystone (phase 2 relaxes this to optional).
Region description forbidden in External modespec.regionDescriptionfield.Forbiddenspec.regionDescription set while mode: External. No Region CR is adopted against a pre-existing installation, so the description would be silently inert. Cross-field, webhook-only.
Horizon forbidden in External modespec.services.horizonfield.Forbiddenservices.horizon set while mode: External (P2 — Horizon needs its own External-mode design). Cross-field, webhook-only.
Infrastructure required in non-External modespec.infrastructurefield.Requiredspec.infrastructure unset while the keystone mode is not External (Managed, unset mode, or services.keystone unset). Preserves today's contract now the Go field is an optional pointer. Webhook-only.
Glance gateway hostname requiredspec.services.glance.gateway.hostnamefield.RequiredA gateway is configured but its hostname is empty. Mirrors the +kubebuilder:validation:MinLength=1 marker on commonv1.GatewaySpec.Hostname; without it the derived public endpoint has an empty host. The same usable-DNS-name check that applies to the Keystone/Horizon gateway hostnames applies here too.
Glance public endpoint is a URLspec.services.glance.publicEndpointfield.InvalidNot an absolute HTTP(S) URL with a host. The value is advertised verbatim as the public image catalog Endpoint and is projected into no child CR, so https:// would register a hostless URL that no client can resolve and nothing downstream would catch.
Glance public endpoint is a bare originspec.services.glance.publicEndpointfield.InvalidCarries a path, query, or fragment (a single trailing / is allowed). The ^https?:// pattern anchors only the prefix, so https://glance.example.com?utm=1 is schema-legal; the Glance API is served at the root and clients append the API path to the catalog endpoint, yielding https://glance.example.com?utm=1/v2/images and a 404 on every image call. Webhook-only.
Glance public endpoint agrees with the gatewayspec.services.glance.publicEndpointfield.InvalidWith services.glance.gateway set: the scheme is not https (the listener terminates TLS, and every image call carries the caller's scoped Keystone token plus the image payload to this endpoint), or its host differs from gateway.hostname (the listener is what routes that hostname to the Glance API). The port may differ. Cross-field, webhook-only.
Glance image resolvablespec.services.glance.imagefield.InvalidThe image override sets neither or both of tag and digest (mirrors the commonv1.ImageSpec tag/digest XOR).
Glance backends non-emptyspec.services.glance.backendsfield.RequiredNo backend is declared. Mirrors the MinItems=1 marker.
Glance single default backendspec.services.glance.backendsfield.InvalidNot exactly one entry sets isDefault. Mirrors the single-isDefault CEL rule.
Glance backend type/s3 unionspec.services.glance.backends[]field.InvalidThe s3 block is not set exactly when type is S3. Mirrors the per-entry union CEL rule on GlanceBackendEntry.
Glance backend S3 endpoint is a URLspec.services.glance.backends[].s3.endpointfield.InvalidNot an HTTP(S) URL (^https?://, full net/url parse). Mirrors the CRD pattern marker.
Glance backend credentials Secret requiredspec.services.glance.backends[].s3.credentialsSecretRef.namefield.RequiredThe S3 credentials Secret name is empty.
Glance backend child-CR name lengthspec.services.glance.backends[].namefield.InvalidThe composed GlanceBackend child name {controlplane.Name}-glance-{name} would exceed the apiserver's 253-byte metadata.name limit. The bound is projectedChildNameBound, the per-entry check every projected satellite kind shares; Glance passes it the -glance- infix and the backend entry noun the remediation tells the operator to shorten. Webhook-only — no CRD marker can express it.
Glance import-filtering pairs are exclusivespec.services.glance.importFiltering.disallowedSchemes / .disallowedHosts / .disallowedPortsfield.InvalidBoth halves of one attribute's allow/deny pair are non-empty. Mirrors the three mutual-exclusivity CEL rules with byte-identical messages, keyed on the deny-list Glance would ignore.
Glance import-filtering item boundsspec.services.glance.importFiltering.*field.TooMany / field.NotSupported / field.InvalidA list exceeds 64 items, a scheme is neither http nor https, a host is empty or longer than 253 characters, or a port falls outside 1–65535. Mirrors the item markers on every list.
Glance import-filtering host INI-injection guardspec.services.glance.importFiltering.allowedHosts / .disallowedHostsfield.InvalidA host carries a newline or carriage return. The host lists are the only free-form strings in the block and are joined verbatim into [import_filtering_opts], so a newline would inject arbitrary config lines. Webhook-only — the item markers bound length, not content.
Glance import-filtering posturespec.services.glance.importFiltering.*admission warningTwo admissible-but-misleading shapes, warned about rather than rejected because each is a legal deployment choice: a disallowedSchemes/disallowedPorts list whose sibling allow-list is unset (and therefore resolves to a non-empty operator default) is never evaluated by Glance; and an allowedSchemes/allowedPorts list widened past the operator default drops the primary web-download control, leaving only literal host matching. Webhook-only; raised identically on the Glance CR.
Glance staging bound clears the floorspec.services.glance.staging.sizeLimitfield.InvalidThe size limit is below 1Mi, naming no usable bound for the two scratch emptyDirs while the CR reads as though they were capped. The floor is 1Mi rather than mere positivity because the schema pattern also admits the sub-byte milli suffix (100m, the common typo for 100Mi), which would evict the glance-api pod on its first staged byte. Enforced by calling the glance module's exported validator, so the ControlPlane admits what the projected Glance child admits. Webhook-only — a resource.Quantity renders as x-kubernetes-int-or-string, which carries no Minimum marker, in either CRD.
Glance staging bound contradicts the opt-outspec.services.glance.staging.unboundedfield.Invalidunbounded: true renders both scratch emptyDirs with no sizeLimit, so a sizeLimit set alongside it names a bound nothing applies. Enforced by a CEL rule on the shared StagingSpec and repeated by the glance module's exported validator, so the ControlPlane and the projected Glance child agree.
Glance image-cache bound clears the floorspec.services.glance.imageCache.sizeLimitfield.InvalidThe cache bound is below 1Mi, which names no usable cache budget: the derived image_cache_max_size would then be smaller than any image, so every cached download overruns the emptyDir and the kubelet evicts the pod that just served it. Like the staging floor it also catches the sub-byte milli suffix (100m for 100Mi). Enforced by calling the glance module's exported validator, so the ControlPlane admits what the projected Glance child admits. Webhook-only — a resource.Quantity renders as x-kubernetes-int-or-string, which carries no Minimum marker, in either CRD.
Glance image-cache interval clears the floorspec.services.glance.imageCache.maintenanceIntervalfield.InvalidThe maintenance cadence is below 1m. Every tick walks the cache directory and its sqlite index, so a sub-minute loop spends the pod's local-disk bandwidth on maintenance instead of on the downloads the cache exists to accelerate. Enforced by the same exported validator as the size floor. Webhook-only — a metav1.Duration renders as a plain string, which carries no Minimum marker, in either CRD.
Glance image-cache sidecar requests within limitsspec.services.glance.imageCache.maintenanceResources.requests.<resource>field.InvalidA request of the cache-maintenance sidecar exceeds its own limit. The same exported ValidateImageCache the Glance webhook runs, so the two CRDs admit the same block.
Glance import-plugin output formatspec.services.glance.importPlugins.conversion.outputFormatfield.NotSupportedThe format is none of qcow2, raw, vmdk. Mirrors the CRD enum marker by calling the glance module's exported validator, so the ControlPlane admits what the projected Glance child admits; a value outside the enum would make qemu-img fail on every import instead of at admission. An empty value is the request for the operator default (raw) and is admitted.
Glance injected properties presentspec.services.glance.importPlugins.injectMetadata.propertiesfield.Required / field.TooManyThe property map is absent or empty (the inject_image_metadata plugin would have nothing to inject, so enabling it says nothing), or it carries more than 64 entries. Mirrors the MinProperties/MaxProperties markers.
Glance injected property name is Dict-safespec.services.glance.importPlugins.injectMetadata.properties[<key>]field.InvalidA property name is empty, starts or ends with whitespace, exceeds 255 characters, or carries a colon, comma, newline, or carriage return. The rendered [inject_metadata_properties] inject value is an oslo Dict that splits pairs on commas and each pair on its first colon, so either character in a name silently produces a property nobody wrote, and a newline injects whole config lines the way an unfiltered import-filtering host would. Webhook-only apart from the length bound, which the CEL rule on properties repeats — no other marker reaches a map key.
Glance injected property value is Dict-safespec.services.glance.importPlugins.injectMetadata.properties[<key>]field.InvalidA property value carries a comma, a newline or carriage return, leading/trailing whitespace, or exceeds 255 characters. A colon is allowed: everything after the pair's first one belongs to the value, which is what lets a URL be injected. The length bound also has the CEL counterpart, because every pair renders verbatim into the one inject line and an unbounded value would push the child's rendered glance-api.conf past the 1 MiB ConfigMap ceiling. The rest is webhook-only.
Glance ignored-role boundsspec.services.glance.importPlugins.injectMetadata.ignoreUserRolesfield.TooMany / field.InvalidThe list exceeds 64 items, or an item is empty, longer than 255 characters, or carries a comma, newline, or carriage return. The rendered ignore_user_roles is a plain comma join, so a comma would split one role into two. The item markers bound length; the content checks are webhook-only.
Glance decompression needs a chosen staging boundspec.services.glance.staging.sizeLimitfield.Requiredservices.glance.importPlugins.decompression is set while services.glance.staging leaves both sizeLimit and unbounded unset. The plugin expands the staged image by a ratio the caller picks and nothing caps the result, which makes that bound the only one in the path — and the operator default was sized against the largest download, not the largest unpacked image. Both blocks are projected onto the Glance child untouched, so the same exported validator enforces the pairing there. Cross-field, webhook-only.
Glance forbidden in External modespec.services.glancefield.Forbiddenservices.glance set while mode: External (Glance needs its own External-mode design). Cross-field, webhook-only.
Neutron needs the shared busspec.infrastructure.messagingfield.Requiredservices.neutron is set while spec.infrastructure carries no messaging block: "is required when services.neutron is set: the Neutron CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus". Without it the ControlPlane would project a child its own admission rejects on every pass. A nil spec.infrastructure is left to the mode matrix, which already requires the block outside External mode and forbids services.neutron inside it. The rule lives in validateMessagingConsumers, which requires the bus once per declared service whose child CRD needs it; Neutron, Cinder and Nova are the three such services. Cross-field, webhook-only.
Neutron names its OVN control planespec.services.neutron.ovn.centralRef.namefield.RequiredThe name is empty: "must be set: it names the OVNCentral the projected Neutron programs". Defense-in-depth mirror of the MinLength=1 marker; the ML2/OVN mechanism driver writes every network, subnet, and port into that central's Northbound database.
OVN central namespace shapespec.services.neutron.ovn.centralRef.namespacefield.InvalidA non-empty namespace is not a lowercase alphanumeric RFC-1123 label; it names a Kubernetes namespace. Defense-in-depth mirror of the Pattern marker.
OVN central stays inside the planespec.services.neutron.ovn.centralRef.namespacefield.ForbiddenThe namespace is neither the ControlPlane's own nor one it claims through a services.<service>.namespace assignment, or it is such a claim with lifecycle: Managed. It is the one ControlPlane field that addresses another namespace: consuming a foreign central mirrors that central's client certificate — a full mTLS identity for its Northbound and Southbound databases — into this plane, and a Managed claim is deleted with the plane, taking the referenced central and its databases along. Runs on create and on the two updates that can newly violate it — the one that enables the network service and the one that moves the ref — so a grandfathered CR stays updatable and deletable; reconcileOVN re-runs the check as the controller-side backstop. Cross-field, webhook-only.
Neutron public endpoint is a URLspec.services.neutron.publicEndpointfield.InvalidNot an absolute HTTP(S) URL with a host. The value is advertised verbatim as the public network catalog Endpoint and is projected into no child CR, so https:// would register a hostless URL that no client can resolve and nothing downstream would catch.
Neutron public endpoint is a bare originspec.services.neutron.publicEndpointfield.InvalidCarries a path, query, or fragment (a single trailing / is allowed). The ^https?:// pattern anchors only the prefix, so https://neutron.example.com?utm=1 is schema-legal; the Neutron API is served at the root and clients append the API path to the catalog endpoint, yielding https://neutron.example.com?utm=1/v2.0/networks and a 404 on every network call. Webhook-only.
Neutron public endpoint agrees with the gatewayspec.services.neutron.publicEndpointfield.InvalidWith services.neutron.gateway set: the scheme is not https (the listener terminates TLS, and every network call sends the caller's scoped Keystone token to this endpoint), or its host differs from gateway.hostname (the listener is what routes that hostname to the Neutron API). The port may differ, since Gateway API hostnames carry none. Cross-field, webhook-only.
Neutron image resolvablespec.services.neutron.imagefield.InvalidThe image override sets neither or both of tag and digest (mirrors the commonv1.ImageSpec tag/digest XOR).
Projected Neutron name boundmetadata.namefield.InvalidThe projected child {controlplane.Name}-neutron would exceed the 40-character metadata.name cap the Neutron CRD enforces, so a 33-character ControlPlane name is rejected ("would be 41 characters") and a 32-character one is accepted. Runs on create and on the update that newly declares services.neutron: the ControlPlane name is immutable, so on a routine update the rule could only fire against a CR a pre-upgrade operator already admitted, including the finalizer-removal update that completes its deletion. Webhook-only.
Cinder needs the shared busspec.infrastructure.messagingfield.Requiredservices.cinder is set while spec.infrastructure carries no messaging block: "is required when services.cinder is set: the Cinder CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus". Reported by the same validateMessagingConsumers as the Neutron rule above, once per declared consumer. Cross-field, webhook-only.
Cinder declares a volume backendspec.services.cinder.backendsfield.RequiredNo backend is declared. Mirrors the MinItems=1 marker: a Cinder with no backend accepts a volume request and leaves it in error.
Cinder backend type/nfs and type/rbd unionsspec.services.cinder.backends[]field.InvalidThe nfs block is present without type: NFS or absent with it, or the rbd block is the same for type: RBD. Mirrors the two per-entry union CEL rules on CinderBackendEntry, with the same messages (the nfs block must be set exactly when type is NFS, the rbd block must be set exactly when type is RBD).
Cinder backup backend type/nfs and type/rbd unionsspec.services.cinder.backupBackendfield.InvalidThe same two unions on the backup entry. Mirrors the union CEL rules on CinderBackupBackendEntry.
Cinder backend type enumspec.services.cinder.backends[].type, spec.services.cinder.backupBackend.typefield.NotSupportedThe type is neither NFS nor RBD, the empty string included. Mirrors the Enum markers.
Cinder RBD fields within their markersspec.services.cinder.backends[].rbd.{pool,user,clusterName,monitors,networks,keySecretRef.name,secretUUID}, and the same under spec.services.cinder.backupBackend.rbd without secretUUIDfield.Required / field.TooLong / field.TooMany / field.Invalidpool, user or keySecretRef.name is empty ("must be set"), monitors or networks is empty ("at least one monitor must be listed", "at least one network must be listed"), a value exceeds its MaxLength or MaxItems, or a value other than keySecretRef.name is off its Pattern (monitors[i], networks[i] and a non-empty secretUUID included). Mirrors the markers on CinderBackendRBDSpec and CinderBackupBackendRBDSpec, one error per value. The four rows below run on an rbd block only when this row reports nothing for it.
Cinder RBD user without the client. prefixspec.services.cinder.backends[].rbd.user, spec.services.cinder.backupBackend.rbd.userfield.InvalidThe user starts with client.. Mirrors the CEL rule on the field with the same message, through cinderv1alpha1.ValidateRBDFields.
Cinder RBD monitor portspec.services.cinder.backends[].rbd.monitors[i], spec.services.cinder.backupBackend.rbd.monitors[i]field.InvalidA monitor's port is outside 1 to 65535: "port must be between 1 and 65535". The Pattern admits up to five digits. Delegated to cinderv1alpha1.ValidateRBDFields, the satellite webhooks' own rule. Webhook-only.
Cinder RBD network canonicalspec.services.cinder.backends[].rbd.networks[i], spec.services.cinder.backupBackend.rbd.networks[i]field.InvalidA network is not a canonical IPv4 CIDR: "must be a canonical IPv4 CIDR such as 10.128.0.0/16 (host bits zero)". The network becomes an ipBlock of the Cinder's NetworkPolicy, which the API server refuses in any other form. Delegated to cinderv1alpha1.ValidateRBDFields. Webhook-only.
Cinder RBD key Secret namespec.services.cinder.backends[].rbd.keySecretRef.name, spec.services.cinder.backupBackend.rbd.keySecretRef.namefield.InvalidThe name is no DNS-1123 subdomain. Mirrors the Pattern marker on CinderRBDKeySecretRef, through cinderv1alpha1.ValidateRBDFields.
Cinder NFS export addressablespec.services.cinder.backends[].nfs.server / .path, spec.services.cinder.backupBackend.nfs.server / .pathfield.RequiredA declared nfs block leaves server or path empty. Mirrors the MinLength markers: without both, the satellite renders a server:path line the cinder pod cannot mount.
Cinder NFS export renders one linespec.services.cinder.backends[].nfs.server / .path / .mountOptions, and the same three under spec.services.cinder.backupBackend.nfsfield.InvalidThe server is not ^[A-Za-z0-9.-]+$ (it reaches the mount command verbatim), the path is not ^/[!-~]*$ (a space cuts the export short and a newline appends a second one to the rendered shares file cinder reads a line at a time), or mountOptions carries a newline or carriage return (it would divide the rendered option line). Mirrors the three Pattern markers on NFSShareSpec, so a caller reaching a current operator through a stale CRD copy is rejected at admission with the field named rather than admitted and wedged on the satellite webhook's CinderReady=False / CinderBackendProjectionRejected.
Cinder backend name is not defaultspec.services.cinder.backends[].namefield.InvalidThe name is default, compared case-insensitively because cinder reads section names that way. It names cinder.conf's [DEFAULT] section, so the backend's options would be read as service-wide ones. Webhook-only.
Cinder backend child-name budgetspec.services.cinder.backends[].namefield.Invalidlen(controlplane.Name) + 7 + len(name) exceeds 47. Detaching a backend runs a <cinder>-<backend>-service-remove Job whose name is copied into a label value, so the projected Cinder name and the backend name share one budget; the message names the service-remove Job name and asks for a sum at or below 47. Without the guard the ControlPlane admits and the detach then fails on a Job the API server rejects. Cross-field, webhook-only.
Cinder backup chunk sizespec.services.cinder.backupBackend.fileSizefield.InvalidThe chunk size is below 1048576 bytes (1 MiB) or is not a multiple of 32768, the block size cinder hashes backup chunks in. Mirrors the Minimum and MultipleOf markers.
Cinder backup compressionspec.services.cinder.backupBackend.compressionfield.NotSupportedThe algorithm is none of none, zlib, bz2, zstd. Mirrors the CRD enum marker.
Cinder gateway hostname requiredspec.services.cinder.gateway.hostnamefield.RequiredA gateway is configured but its hostname is empty. Mirrors the +kubebuilder:validation:MinLength=1 marker on commonv1.GatewaySpec.Hostname; without it the derived public endpoint has an empty host. The same usable-DNS-name check that applies to the Keystone/Horizon gateway hostnames applies here too.
Cinder public endpoint is a URLspec.services.cinder.publicEndpointfield.InvalidNot an absolute HTTP(S) URL with a host. The value is advertised verbatim as the public block-storage catalog Endpoint and is projected into no child CR, so https:// would register a hostless URL that no client can resolve and nothing downstream would catch.
Cinder public endpoint is a bare originspec.services.cinder.publicEndpointfield.InvalidCarries a path, query, or fragment (a single trailing / is allowed, and cinderCatalogURL trims it before joining /v3). The ^https?:// pattern anchors only the prefix, so https://cinder.example.com?utm=1 is schema-legal; the ControlPlane appends /v3 when it registers the row, yielding https://cinder.example.com?utm=1/v3 and a 404 on every volume call. Webhook-only.
Cinder public endpoint agrees with the gatewayspec.services.cinder.publicEndpointfield.InvalidWith services.cinder.gateway set: the scheme is not https (the listener terminates TLS, and every volume call sends the caller's scoped Keystone token to this endpoint), or its host differs from gateway.hostname (the listener is what routes that hostname to the Cinder API). The port may differ, since Gateway API hostnames carry none. Cross-field, webhook-only.
Cinder image resolvablespec.services.cinder.imagefield.InvalidThe image override sets neither or both of tag and digest (mirrors the commonv1.ImageSpec tag/digest XOR).
Projected Cinder name boundmetadata.namefield.InvalidThe projected child {controlplane.Name}-cinder would exceed the 43-character metadata.name cap the Cinder CRD enforces, which is itself the 52-character CronJob bound minus the -db-purge suffix of the purge CronJob. The ControlPlane name may therefore be at most 36 characters while services.cinder is set. Runs on create and on the update that newly declares services.cinder, for the reason the Neutron bound above is also gated. Webhook-only.
Nova needs Placement, Neutron and Glancespec.services.placement, spec.services.neutron, spec.services.glancefield.Requiredservices.nova is set while one of the three siblings is not. Each message names what the compute service does with it: Placement, "Nova claims every instance's resources in Placement before it boots"; Neutron, "Nova creates and binds a port for every instance"; Glance, "Nova reads the image of every instance it boots". They are the first cross-service dependency rules on the ControlPlane, and errors rather than warnings because the child addresses its siblings by naming convention, so a missing one fails every boot with nothing on the plane naming the cause. Cross-field, webhook-only.
Nova needs the shared busspec.infrastructure.messagingfield.Requiredservices.nova is set while spec.infrastructure carries no messaging block: "is required when services.nova is set: the Nova CRD requires spec.messaging, and the ControlPlane derives the child's transport URL from the shared bus". Reported by the same validateMessagingConsumers as the Neutron and Cinder rules above, once per declared consumer. Cross-field, webhook-only.
Nova remote compute needs a brownfield busspec.services.nova.remoteComputefield.Forbiddenservices.nova.remoteCompute is set beside a managed bus (spec.infrastructure.messaging.clusterRef): "requires a brownfield bus with tls (spec.infrastructure.messaging.secretRef and spec.infrastructure.messaging.tls): a managed RabbitmqCluster is provisioned without a TLS listener, and a compute cluster reaches the bus across a cluster boundary". Cross-field, webhook-only.
Nova remote compute needs a verified busspec.infrastructure.messaging.tlsfield.Requiredservices.nova.remoteCompute is set beside a brownfield bus without tls: "is required when services.nova.remoteCompute is set: a compute cluster reaches the bus across a cluster boundary and verifies the broker against this CA bundle". A missing messaging block is left to the shared-bus rule above. Cross-field, webhook-only.
Nova remote compute needs a published Keystonespec.services.keystone.publicEndpointfield.Requiredservices.nova.remoteCompute is set while Keystone is absent or carries neither a publicEndpoint nor a gateway: "one of publicEndpoint or gateway is required when services.nova.remoteCompute is set: a compute cluster authenticates against the public Keystone URL". Cross-field, webhook-only.
Nova remote compute needs an https Keystonespec.services.keystone.publicEndpointfield.Invalidservices.nova.remoteCompute is set and the Keystone publicEndpoint uses http://: "must use scheme https when services.nova.remoteCompute is set: every compute cluster sends the nova service-user password to this URL across a cluster boundary". Cross-field, webhook-only.
Nova remote compute needs published siblingsspec.services.{glance,placement,neutron,cinder,barbican}.publicEndpointfield.Requiredservices.nova.remoteCompute is set while Glance, Placement or Neutron, or Cinder or Barbican when declared, carries neither a publicEndpoint nor a gateway: "one of publicEndpoint or gateway is required when services.nova.remoteCompute is set: a compute cluster resolves this service through its public catalog row, which otherwise names the in-cluster Service". An undeclared Glance, Placement or Neutron is left to the dependency rule above. Cross-field, webhook-only.
Nova gateway hostname requiredspec.services.nova.gateway.hostname, .metadataGateway.hostname, .consoleProxy.gateway.hostnamefield.RequiredA gateway block is configured but its hostname is empty. Mirrors the MinLength=1 marker on commonv1.GatewaySpec.Hostname. Nova is the one service with more than one gateway, so all three run through one helper and each carries the usable-DNS-name check the Keystone and Horizon hostnames take.
Nova console route is root-onlyspec.services.nova.consoleProxy.gateway.pathfield.InvalidThe path is neither empty nor /. The console page the API hands a browser sits at the root of the console hostname and the noVNC client opens its WebSocket there as well, so a prefix match would route neither while the HTTPRoute reports Accepted. Mirrors the rule the Nova CRD's own webhook applies to the block this one is projected onto.
Nova metadata route is root-onlyspec.services.nova.metadataGateway.pathfield.InvalidThe path is neither empty nor /. The Neutron metadata agent addresses nova-api-metadata by scheme, host and port alone (neutron has no path option) and the route rewrites nothing, so every request it proxies arrives on the root of the hostname and a prefix match would route none of them while the HTTPRoute reports Accepted. Webhook-only.
Nova listeners do not share a route attachmentspec.services.nova.metadataGateway.hostname, .consoleProxy.gateway.hostnamefield.InvalidTwo of the three Nova gateway blocks name the same hostname on the same Gateway (parentRef.name, and parentRef.namespace with an empty one read as the Nova namespace the routes are rendered in) with overlapping listeners: equal sectionNames, or either one empty, which attaches to every listener. Every Nova route matches the root of its hostname, so the two routes tie and the Gateway hands every request to the older one while both report Accepted. The later block of the pair is named. Cross-field, webhook-only.
Nova public endpoint is a URLspec.services.nova.publicEndpointfield.InvalidNot an absolute HTTP(S) URL with a host. The value is advertised verbatim as the public compute catalog Endpoint and is projected into no child CR, so https:// alone would register a hostless URL that no client can resolve and nothing downstream would catch.
Nova public endpoint is a bare originspec.services.nova.publicEndpointfield.InvalidCarries a path, query, or fragment (a single trailing / is allowed, and novaCatalogURL trims it before joining /v2.1). The ^https?:// pattern anchors only the prefix, so https://nova.example.com?utm=1 is schema-legal; the ControlPlane appends /v2.1 when it registers the row, yielding https://nova.example.com?utm=1/v2.1 and a 404 on every compute call. Webhook-only.
Nova public endpoint agrees with the gatewayspec.services.nova.publicEndpointfield.InvalidWith services.nova.gateway set: the scheme is not https (the listener terminates TLS, and the caller's scoped Keystone token rides every compute call to this endpoint), or its host differs from gateway.hostname (the listener is what routes that hostname to the Nova API). The port may differ, since Gateway API hostnames carry none. The metadata and console gateways are not read by this rule: neither publishes a catalog row. Cross-field, webhook-only.
Nova archive schedulespec.services.nova.dbArchive.schedulefield.InvalidA non-empty schedule is not a standard cron expression. Checked here rather than by a CRD Pattern marker, because the accepted grammar includes descriptors such as @daily that no regex expresses without also rejecting valid expressions. An empty schedule leaves the nova operator's own default in place. Webhook-only.
Nova image resolvablespec.services.nova.imagefield.InvalidThe image override sets neither or both of tag and digest (mirrors the commonv1.ImageSpec tag/digest XOR).
Projected Nova name boundmetadata.namefield.InvalidThe projected child {controlplane.Name}-nova would exceed the 41-character metadata.name cap the Nova CRD enforces, which is itself the 52-character CronJob bound minus the -db-archive suffix of the archive CronJob. The ControlPlane name may therefore be at most 36 characters while services.nova is set. Runs on create and on the update that newly declares services.nova, for the reason the Neutron and Cinder bounds above are also gated. Webhook-only.
Nova forbidden in External modespec.services.novafield.Forbiddenservices.nova set while mode: External (Nova needs its own External-mode design). Cross-field, webhook-only.
Target-cluster ref shapespec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.targetClusterRef.namefield.RequiredThe ref is set with an empty name. Defense-in-depth mirror of the MinLength=1 marker on the shared commonv1.TargetClusterRefSpec, applied through validation.TargetClusterRef for a caller that bypasses CRD schema admission.
Placed service needs a namespace of its ownspec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.namespacefield.RequiredtargetClusterRef is set while the service declares no namespace block. Every namespace maps to exactly one cluster and the ControlPlane's own stays on the local one, so the service's database, tenant store, and credential material would be provisioned in a namespace living on a different cluster than the ref names. Cross-field, webhook-only.
Placed catalog service needs a public addressspec.services.{keystone,glance,placement,barbican,neutron,cinder,nova}.publicEndpointfield.RequiredThe service is placed with neither a publicEndpoint nor a gateway. The catalog would then advertise the in-cluster Service DNS name, which resolves nowhere outside the cluster that service runs on, so every client reading the catalog from elsewhere gets an address it cannot connect to. Horizon is exempt: the dashboard is reached by a browser rather than looked up in the catalog. Cross-field, webhook-only.
Co-located services agree on the target clusterspec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.targetClusterReffield.InvalidTwo services declare the same namespace.name but do not name the same cluster — an unplaced service counts as naming the local one. The namespace exists on exactly one cluster, together with the backing services, the tenant store, and the credential material scoped to it. The co-location rule of the namespace assignment, one level out. Cross-item, webhook-only.
Target-cluster ref forbidden in External modespec.services.keystone.targetClusterReffield.ForbiddenThe ref is set while mode: External — no Keystone workload is deployed, so there is nothing to place. Defense-in-depth mirror of the per-field CEL rule.
Placed service needs a published Keystonespec.services.keystone.publicEndpointfield.RequiredAnother service is placed on a target cluster while Keystone advertises neither a publicEndpoint nor a gateway. That service validates its tokens against Keystone and cannot resolve Keystone's in-cluster Service DNS name from another cluster, so the operator would project an empty spec.keystoneEndpoint onto the placed child — which the child's own CRD refuses (MinLength=1, ^https?://) on every pass. The rule above only reaches a service carrying a ref of its own, so an unplaced Keystone falls outside it. Cross-field, webhook-only.
Keystone endpoint must use https across a cluster boundaryspec.services.keystone.publicEndpointfield.InvalidThe endpoint's scheme is http while Keystone carries a targetClusterRef or another service is 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 a cluster boundary to reach it — K-ORC dials it from the management cluster when Keystone moves, a placed service dials it from the target when the service moves. The ^https?:// pattern admits http:// for the all-local case, where the URL feeds only the bootstrap and the catalog. Cross-field, webhook-only.
Keystone CA bundle needs a placementspec.services.keystone.caBundleSecretReffield.ForbiddenThe bundle is set while targetClusterRef is not. A co-located Keystone is reached over its in-cluster Service URL, which performs no TLS handshake the bundle could verify, so accepting the pair would report trust that nothing enforces — the hazard the External-mode plaintext rule rejects from the other side. Cross-field, webhook-only.
Keystone CA bundle needs every service co-locatedspec.services.keystone.caBundleSecretReffield.ForbiddenThe bundle is set while a declared service does not share Keystone's target cluster. The 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 on another cluster gets that same https URL as its projected spec.keystoneEndpoint and renders it into [keystone_authtoken], which carries no option for a trust anchor. That service would fail every token validation with certificate signed by unknown authority and no field on any CR to repair it with, so the combination is refused rather than admitted as TLS-configured: publish a placed Keystone with a publicly trusted certificate, or co-locate every service with it. Cross-field, webhook-only.
Keystone CA bundle name requiredspec.services.keystone.caBundleSecretRef.namefield.RequiredcaBundleSecretRef set with an empty name. Mirrors the shared SecretRefSpec MinLength marker.
Keystone CA bundle forbidden in External modespec.services.keystone.caBundleSecretReffield.ForbiddenThe bundle is set while mode: External, where external.caBundleSecretRef is the field that verifies the external endpoint. Defense-in-depth mirror of the per-field CEL rule.
Registration allowlist capspec.korc.serviceRegistrations.allowedNamespacesfield.TooManyMore than 32 entries. Defense-in-depth alongside the MaxItems marker, for a caller that bypasses CRD schema admission.
Registration allowlist entry shapespec.korc.serviceRegistrations.allowedNamespaces[i]field.InvalidAn entry is not a lowercase alphanumeric RFC-1123 label; it names a Kubernetes namespace. Defense-in-depth alongside the item Pattern marker.
Registration allowlist duplicatesspec.korc.serviceRegistrations.allowedNamespaces[i]field.DuplicateAn entry repeats an earlier one. Defense-in-depth alongside the listType=set marker. validateServiceRegistrations adds no rule beyond these three: an entry naming a namespace the control plane already owns is a redundant no-op rather than an error, see ServiceRegistrationsSpec.
Namespace assignment capspec.namespaceAssignmentsfield.TooManyMore than 32 entries. Defense-in-depth alongside the MaxItems marker.
Namespace assignment namespace shapespec.namespaceAssignments[i].namespacefield.InvalidThe namespace is not a lowercase alphanumeric RFC-1123 label. Defense-in-depth alongside the Pattern marker.
Namespace assignment cluster namespec.namespaceAssignments[i].targetClusterRef.namefield.RequiredtargetClusterRef is set with an empty name (validation.TargetClusterRef). Defense-in-depth alongside the MinLength marker. Whether the cluster is registered is not checked.
Namespace assignment pair duplicatesspec.namespaceAssignments[i]field.DuplicateThe entry repeats the (namespace, cluster) pair of an earlier one; the value reads <namespace> on the management cluster or <namespace> on target cluster "<name>". The same namespace on two clusters is accepted. Webhook-only: a CEL twin would compare a cluster name without a maximum length, so its cost cannot be bounded.
Namespace assignment role capspec.namespaceAssignments[i].allowedRolesfield.TooManyMore than 16 roles. Defense-in-depth alongside the MaxItems marker.
Namespace assignment role shapespec.namespaceAssignments[i].allowedRoles[j]field.InvalidA role is empty, longer than 255 bytes, or contains a comma. Defense-in-depth alongside the item markers.
Namespace assignment role duplicatesspec.namespaceAssignments[i].allowedRoles[j]field.DuplicateA role repeats an earlier one. Defense-in-depth alongside the listType=set marker. validateNamespaceAssignments adds no rule for an entry naming a namespace the control plane occupies: such an entry binds nothing, see NamespaceAssignmentSpec.

Whether the named target cluster is registered is deliberately not checked. A registration is a runtime fact that can appear and disappear long after the edit, so an unresolvable name surfaces per CR as a TargetClusterUnavailable condition and converges once the cluster is engaged, instead of rejecting a ControlPlane the operator can still reconcile later. See When the name does not resolve.

ExtraConfig admission checks ​

spec.globalExtraConfig and the per-service services.<svc>.extraConfig blocks are checked at admission by the validating webhook only. There is no CEL or CRD-schema backstop (beyond the External-mode forbid rule on services.keystone.extraConfig above), so a cluster that bypasses the webhook accepts a misspelled option and surfaces it at render time. The checks run on the merged result — spec.globalExtraConfig unioned with each service's block, the per-service value winning per key — which is the same map the reconciler projects, so admission and projection never disagree. Findings are computed once on the merged map and attributed back to every block that carries the offending (section, key): an error anchored at spec.globalExtraConfig[<section>][<key>] and/or spec.services.<svc>.extraConfig[<section>][<key>]. A key present in both blocks yields one error per path. Because spec.globalExtraConfig reaches every declared INI service, a Keystone-only option placed there is rejected while services.glance is declared; the fix is to move it to services.keystone.extraConfig.

Two families run, with different gating:

  • Shape and ownership — un-gated, on every create and every update, since it depends on nothing a regenerated catalog can invalidate. It rejects empty section or key names in any INI block, and empty or non-Python-identifier ([A-Za-z_][A-Za-z0-9_]*) setting names in the Horizon block. It rejects the operator-owned keys the ControlPlane projects itself: Glance's [keystone_authtoken] password, its six [import_filtering_opts] keys (always — those are owned by services.glance.importFiltering, which is the only surface that runs the exclusivity rules, the host INI guard, and the loosened-filter warning), its three [DEFAULT] image_cache_* keys (always — owned by services.glance.imageCache, and each override ends in an evicted glance-api pod or in database rows nothing reclaims) and its four image-import plugin keys ([image_import_opts] image_import_plugins, [image_conversion] output_format, [inject_metadata_properties] inject and ignore_user_roles; always — owned by services.glance.importPlugins, the only surface that holds the decompression-before-conversion order, the output-format enum, and the oslo Dict rules on the injected property names), Horizon's SECRET_KEY and every WebSSO / multi-domain setting (always — the ControlPlane projects websso / multiDomain dynamically from the attached identity backends, so a collision is not decidable at admission), and Keystone's [federation] trusted_dashboardonly when the ControlPlane derives a dashboard endpoint from services.horizon. With no Horizon block that Keystone key is admitted with a warning, supporting an externally-run dashboard doing WebSSO against the managed Keystone. Every other operator-owned key is honored but produces an admission warning naming the key, its owner, its impact, and the contributing block path(s). One exception runs the other way: on update, a Rejected Neutron key the merged block already carried with the same value is admitted with a warning instead. [nova] password became Rejected when the neutron operator started owning the notifier account (spec.nova), and before that it was the documented way to configure the notifier, so refusing it on every update, the finalizer removal on delete included, would leave such a ControlPlane with no update to remove it through. A new or changed value is still rejected.
  • Option catalog — on every create, and on update only when a catalog input changed: either INI block, spec.openStackRelease, services.keystone.image, or a newly-declared service. So a stored CR whose extraConfig went stale-invalid against a regenerated catalog is not rejected by an unrelated edit such as a replicas bump. The merged block of each declared INI service is validated against that service's per-release option catalog embedded in the operator (Keystone's release resolved from services.keystone.image.tag when the image override is set, otherwise spec.openStackRelease; Glance's from spec.openStackRelease). An unknown section or option is rejected with the section and key named; a deprecated-but-accepted option is admitted with a warning naming its replacement. Plugin-registered sections are rejected as unknown, because the ControlPlane has no plugins field and never sets spec.plugins on a child; configure plugin sections on the service CR directly. The check fails open with exactly one warning per service (and no error) when no catalog resolves: a digest-pinned image, an unparseable tag, or a release the operator build ships no catalog for.

The network service's catalog is resolved from spec.openStackRelease alone and exempts no sections: it is the flat union of the neutron.conf, ml2_conf.ini, and neutron_ovn_metadata_agent.ini generator files, so it already enumerates every section the child configures and the exemptions stay keys-only. Neutron's always-rejected owned keys are the two [ovn] connection strings (ovn_nb_connection, ovn_sb_connection), the six [ovn] client-certificate and CA paths (ovn_nb_private_key, ovn_nb_certificate, ovn_nb_ca_cert and their ovn_sb_ twins), [DEFAULT] transport_url, [DEFAULT] auth_strategy, [DEFAULT] api_paste_config, [database] connection, [keystone_authtoken] password, and [securitygroup] enable_security_group. Each one either points the mechanism driver at a logical model the operator does not own, copies credential material into the config Secret every pod mounts, or takes the API off token validation and the instance ports off their OVN ACLs, and all of it lands the moment the pods load the rendered file.

The block-storage service's catalog is resolved from spec.openStackRelease and exempts no sections either: it is the flat union of the cinder.conf generator files, so the exemptions are keys-only. A section named after a declared backend is therefore reported as unknown, which is what the Cinder child's own catalog check does with it too. Cinder's always-rejected owned keys are [DEFAULT] auth_strategy and api_paste_config (the WSGI pipeline is what puts keystonemiddleware in front of an API services.cinder.gateway can publish outside the cluster), [DEFAULT] transport_url, [database] connection, [keystone_authtoken] password and [service_user] password (all four arrive through env overrides at runtime, so a file value is inert and only copies credential material into the config Secret every pod mounts), and the helper_command / capabilities pairs of [cinder_sys_admin] and [privsep_osbrick], which are the command the volume and backup services run as root and the capability set it keeps.

Three further keys are rejected only while services.barbican is declared: [key_manager] backend, [barbican] barbican_endpoint, and [barbican] auth_endpoint. The ControlPlane projects the Cinder child's keyManager from the Barbican child by naming convention (decision D9 of #979), so beside a declared key manager an override names one the plane does not own, and the volume-encryption keys are written to or read from a service this ControlPlane never provisioned. The message reads is projected by the ControlPlane from services.barbican (<owner>); remove the override or unset services.barbican. Without services.barbican the three keys stay Reported, which is what lets an external key manager be configured by hand.

A backend name that collides with a cinder.conf catalog section other than default is admitted here and refused downstream: that catalog lives in the cinder module, so the cinder webhook rejects the projected CinderBackend and the ControlPlane reports CinderReady=False with reason CinderBackendProjectionRejected.

The compute service's catalog is resolved from spec.openStackRelease and exempts no sections either, so its exemptions are keys-only. Nova's fourteen always-rejected owned keys fall into four groups. Eight carry credential material that arrives through an env override at runtime, so rendering one is inert and only copies the value into the config Secret every pod mounts: [DEFAULT] transport_url, the two connection strings [database] connection and [api_database] connection, the five client passwords under [keystone_authtoken], [service_user], [placement], [neutron] and [cinder], and [neutron] metadata_proxy_shared_secret. The [oslo_messaging_rabbit] pair ssl and ssl_ca_file decides whether the bus that carries every RPC call is encrypted and where the broker certificate is verified against. The remaining three address the console proxy: [DEFAULT] web names the directory the noVNC client is served from, and [vnc] novncproxy_host / novncproxy_port are the address the proxy Service routes to, so an override leaves the browser on a blank page or fails every console session while the Deployment stays Ready.

Three sections are rejected only beside the sibling block that owns them. [cinder] follows services.cinder, and [key_manager] and [barbican] follow services.barbican: the projection switches each on through the Nova child's endpoints.cinder.enabled and endpoints.barbican.enabled and computes every key behind that switch, so beside a declared sibling an override names a service the plane does own. The message reads is projected by the ControlPlane from services.cinder (<owner>); remove the override or unset services.cinder, and the same with services.barbican. Without the sibling those keys stay Reported, which is what lets an externally-run volume service or key manager be configured by hand. The nova registry classifies them that way because a Nova child on its own cannot tell a projected volume service or key manager from a hand-configured one; the ControlPlane can.

The catalogs consulted here are the ones embedded in the c5c3-operator build. A deployed service operator of a different build may embed a different catalog; the child service webhook remains the defense-in-depth check for that skew, and a skewed rejection surfaces as KeystoneProjectionRejected / GlanceProjectionRejected / NeutronProjectionRejected on the ControlPlane's conditions.

A newly-rejected key wedges a stored ControlPlane

The ownership family runs on update as well as create, so a key that becomes Rejected in a later operator build blocks every subsequent write to a ControlPlane that already carries it — including writes that touch nothing near it, such as an image bump or a replica change. The merged extraConfig is also projected onto the child unconditionally, so the child's own webhook refuses the projection and the ControlPlane reports GlanceReady / KeystoneReady False with reason GlanceProjectionRejected / KeystoneProjectionRejected until the key is removed.

The four Glance image-import plugin keys are the current instance: setting [image_import_opts] image_import_plugins through extraConfig was the only way to enable those plugins before services.glance.importPlugins existed. Move such settings onto the typed field before rolling the operator — see Migrate these keys out of extraConfig.

Update-only immutability rules ​

On update the validating webhook additionally rejects changes to the create-only fields below, accumulating them into the same field.ErrorList as the spec checks above. These fields are webhook-only (the affected leaves live in the shared commonv1.DatabaseSpec/CacheSpec, which the keystone operator reuses and which must not carry c5c3-specific CEL immutability markers). Flipping the database/cache mode or renaming a managed clusterRef would leave the previously-projected MariaDB/Memcached child (and its per-ControlPlane credential) orphaned and owned until the ControlPlane is deleted; renaming cloudCredentialsRef.secretName would leak the previously-projected K-ORC clouds.yaml ExternalSecret. The database name and the region are also immutable: both are projected verbatim into the Keystone child's now-immutable spec.database.database / spec.bootstrap.region, so a rename here would make the next reconcile attempt an update the Keystone CEL rule rejects, wedging the loop — rejecting it at the ControlPlane layer surfaces a clean error instead.

The webhook additionally rejects an openStackRelease downgrade: a monotonic upgrade check parses the YYYY.N release into (year, minor) and rejects a lower tuple while allowing upgrades and same-release no-ops. Keystone DB migrations are forward-only, so a downgrade would project an older image against an already-migrated schema — an unrecoverable state.

The webhook also gates the keystone-mode transition. Both directions are rejected with distinct messages — and deliberately as rejections, not immutable markers, because External → Managed must become a gated takeover in phase 3 (an immutable marker could never be relaxed to a gated transition):

  • Managed → External is rejected outright — adopting an existing installation must be a fresh External-mode ControlPlane, not an in-place flip of a live one.
  • External → Managed (or away from External by removing the keystone service) is rejected with a message naming the reserved phase-3 takeover.

An infrastructure presence flip (adding or removing the whole spec.infrastructure block on update while the mode is unchanged) is rejected independently as defense-in-depth for webhook-bypassed states.

The messaging block is a one-way add in both modes. Declaring spec.infrastructure.messaging on a live ControlPlane is always allowed; removing it is not.

The managed half is the direct one: the owned RabbitmqCluster holds the queues, so the removal would leave it running and unreferenced — delete the ControlPlane to tear that bus down. The brownfield half provisions nothing, so its removal strands no state on its own, but admitting it would turn the mode freeze below into a two-step operation: null the brownfield block, then re-add it with a clusterRef as an ordinary opt-in, and the ControlPlane has reached exactly the state the mode freeze exists to reject — a fresh, empty RabbitmqCluster every consumer renders a transport URL for while the queues stay on the external broker the secretRef named — without a single admission error. Neither spec nor status remembers the mode a previous revision declared, so the one-step rejection is only worth having while the two-step path is closed too. Re-pointing a brownfield bus at a different broker never needed the removal: secretRef stays mutable.

The mode and a managed clusterRef.name are frozen the way the cache ones are. replicas, secretRef and tls stay mutable: ensureRabbitMQ re-projects the replica count onto the owned CR on every pass — converging a scale-down by recreating the cluster, since the RabbitMQ Cluster Operator refuses an in-place shrink — and the brownfield Secret and the client trust are re-read on every reconcile. Freezing the scale-down instead would leave an oversized bus on a constrained cluster unrepairable: the broker never reaches its declared replica count, so InfrastructureReady never goes True, and the only remaining action would be deleting the whole ControlPlane. The destructive half of that mutability is gated in the reconciler rather than at admission: a shrink runs only when the ControlPlane carries c5c3.io/allow-messaging-recreate: "true", so the repair path stays open while an unintended decrement is refused with the broker intact.

The freeze is webhook-only, with no CEL transition rule, so a later teardown or migration feature can relax it.

The Cinder entry type freezes are the exception to the webhook-only rule: both are CEL transition rules on the entry types, and the webhook repeats them as a twin. A volume entry keeps its type for as long as an entry of its name exists, and the backup entry keeps its type while its name is unchanged; an added, removed or renamed entry passes.

RuleField PathCondition
Managed → External rejectedspec.services.keystone.modeOld not External, new External — "cannot be changed to External"
External → Managed rejectedspec.services.keystone.modeOld External, new not External — names the reserved phase-3 takeover
Infrastructure presence immutablespec.infrastructureThe block was added or removed (presence flip) while the mode is unchanged
Database mode immutablespec.infrastructure.databaseclusterRef nil-ness changed (managed ↔ brownfield)
Database clusterRef.name immutablespec.infrastructure.database.clusterRef.nameBoth managed, but the name changed
Database name immutablespec.infrastructure.database.databaseThe database name changed
Database replicas immutablespec.infrastructure.database.replicasThe value changed. replicas is projected into the managed MariaDB child's replica count and derived Galera topology, so a live edit would drive a destructive Update on the owned cluster (toggling Galera off or scaling a running Galera cluster down); the topology can only be changed safely by recreating the control plane. A stored 0, which only a ControlPlane an older operator admitted against a CRD without the 3 default carries, compares as 3, the count that operator provisioned, so it migrates once to an explicit 3.
Cache mode immutablespec.infrastructure.cacheclusterRef nil-ness changed (managed ↔ brownfield)
Cache clusterRef.name immutablespec.infrastructure.cache.clusterRef.nameBoth managed, but the name changed
Messaging removal rejectedspec.infrastructure.messagingA block was declared on the old revision and is absent on the new one, in either mode: "spec.infrastructure.messaging cannot be removed once declared: …". Adding the block is allowed; the brownfield removal is rejected because admitting it would launder the mode freeze below into a two-step flip.
Messaging mode immutablespec.infrastructure.messagingclusterRef nil-ness changed (managed ↔ brownfield): "messaging mode (managed clusterRef vs brownfield secretRef) is immutable"
Messaging clusterRef.name immutablespec.infrastructure.messaging.clusterRef.nameBoth managed, but the name changed: "managed messaging clusterRef.name is immutable"
Cloud secretName immutablespec.korc.adminCredential.cloudCredentialsRef.secretNameThe value changed
Region immutablespec.regionThe region changed
Service target cluster immutablespec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.targetClusterRefThe ref was added, removed, or renamed on a service the old revision already declared; the message contains targetClusterRef is immutable, the same string the workload CRDs' CEL transition rules pin. Re-pointing a live service leaves its workload, its database, its tenant store, and its credential material on the cluster they were created on, and nothing in the following reconcile moves or reaps them. Webhook-only, with no CEL transition rule, so a migration between clusters can be gated later rather than being blocked forever — the same rationale as the namespace freeze. A service the old revision did not declare may appear placed: that is the service's creation, not a move.
Cinder backend type immutablespec.services.cinder.backends[].typeAn entry whose name the old revision carries changed its type: "type is immutable: the volumes on a backend were created by its driver; declare the other driver under a new name". CEL-backed, webhook twin
Cinder backup backend type immutablespec.services.cinder.backupBackend.typeThe backup entry kept its name and changed its type: "type is immutable while name is unchanged: a restore reads a backup with the driver that wrote it; rename the entry to switch the driver". A rename is admitted. CEL-backed, webhook twin
Release downgrade rejectedspec.openStackReleaseNew release (year, minor) is lower than the old (upgrades and same-release updates allowed)

Webhooks ​

The ControlPlaneWebhook struct implements both defaulting and validating admission webhooks for the ControlPlane CRD via the typed-generic admission.Defaulter[*ControlPlane] and admission.Validator[*ControlPlane] interfaces from controller-runtime. CredentialRotation and SecretAggregate have no webhook at this level.

The struct carries a Client client.Reader, injected at startup with the manager's uncached API reader (mgr.GetAPIReader()). ValidateCreate uses it to enforce one ControlPlane per namespace; reading the API server directly ensures concurrent or cache-sync-window CREATEs cannot both pass the check against an empty informer cache. The spec-level validate() rules do not touch the client.

Registration ​

go
func (w *ControlPlaneWebhook) SetupWebhookWithManager(mgr ctrl.Manager) error

Registers both webhooks with the manager using builder.WebhookManagedBy[*ControlPlane]. The SizingProfile kind has webhooks of its own, SizingProfileWebhook (see SizingProfile). The generated webhook paths are /mutate-c5c3-io-v1alpha1-controlplane (mutating) and /validate-c5c3-io-v1alpha1-controlplane (validating); both use failurePolicy=fail, sideEffects=None, and admissionReviewVersions=v1. Both webhooks fire on create/update only. delete is deliberately not registered: the webhook is served in-process by the operator, so with failurePolicy=fail a delete rule would let a down operator block CR — and thereby namespace — deletion.

Defaulting Webhook ​

go
func (w *ControlPlaneWebhook) Default(_ context.Context, obj *ControlPlane) error

Fills only zero-valued fields with their documented defaults, leaving any explicit value untouched. It is idempotent: applying it twice produces the same result. The defaults split across two layers that do not uniformly overlap:

  • Dual-layer defaults — also expressed as a +kubebuilder:default marker on the corresponding spec field, so the marker covers the normal admission path and the webhook covers callers that bypass the CRD default. These are region, cloudCredentialsRef.secretName, applicationCredential.restricted, applicationCredential.rotation.mode, cloudCredentialsRef.cloudName, services.keystone.mode (→ Managed), services.keystone.external.endpointType (→ public, when the external block is present), and the admin identity fields adminCredential.userName / .projectName (→ admin) / .domainName (→ Default).

Mode-aware infrastructure defaulting. The webhook defaults services.keystone.mode to Managed first (when a keystone block is present with an empty mode), then branches on the mode:

  • In External mode it does not invent any managed database/cache clusterRef — spec.infrastructure is left unset (the validating webhook forbids it in External mode) — and only defaults the external block's own endpointType (→ public) and caBundleSecretRef.key (→ ca.crt, when the ref is set).
  • In Managed mode (or when the keystone service is unset) it materializes and defaults the shared backing services exactly as before, so a minimal managed CR still round-trips unchanged.
  • Webhook-only defaults — materialized by the webhook with no+kubebuilder:default marker. These are the shared-commonv1-leaf defaults (database.database, database.secretRef.name, cache.backend, passwordSecretRef.name, passwordSecretRef.key) and the two managed clusterRef names (openstack-db, openstack-memcached). They carry no marker because the commonv1 DatabaseSpec / CacheSpec / SecretRefSpec types are reused by the Keystone CRD, and a c5c3-specific +kubebuilder:default on those shared types would leak into Keystone's CRD. The two managed clusterRef names are brownfield-guarded: they are only invented when the brownfield discriminator (database.host / cache.servers) is unset, so the database/cache XOR validation still passes for a brownfield CR.

Database sizing is resolved at admission. Outside External mode the webhook resolves the ControlPlane's sizing, reading the SizingProfilespec.sizing.profileRef names through Client, and writes the resolved database.replicas and database.storageSize into a zero replicas and an empty storageSize of spec.infrastructure.database and of every declared dedicated database block. An explicit value is never overwritten. Both fields are immutable after creation, so storing the resolved values keeps the immutability check comparing stored values. When the referenced profile does not exist both fields stay unset and the validating webhook reports the reference; any other read error rejects the request with reading SizingProfile "<name>". Cache and messaging replicas are never written: the reconciler resolves them on every pass.

Messaging is opt-in and never materialized. The webhook does not construct spec.infrastructure.messaging, so a ControlPlane that omits it is admitted with no message bus at all. When the block is present, defaultMessagingLeaves fills its well-known leaves on the same brownfield discipline as the database: clusterRef.name (openstack-rabbitmq) is invented only when secretRef is unset, a brownfield secretRef.key becomes transport_url, and a tls.caBundleSecretRef.key becomes ca.crt.

The defaulting constants in controlplane_webhook.go (e.g. DefaultRegion"RegionOne", DefaultDatabaseName "keystone", DefaultCacheBackend"dogpile.cache.pymemcache") are the single source of truth shared with the markers' documented values where a marker also exists.

FieldConditionDefault ValueMechanism
spec.region== """RegionOne"Marker + webhook
spec.korc.adminCredential.cloudCredentialsRef.secretName== """k-orc-clouds-yaml"Marker + webhook
spec.korc.adminCredential.applicationCredential.restricted== niltrue (pointer set to true; an explicit false is preserved)Marker + webhook
spec.korc.adminCredential.applicationCredential.rotation.mode== ""PasswordDrivenMarker + webhook
spec.korc.adminCredential.cloudCredentialsRef.cloudName== """admin"Marker + webhook
spec.infrastructure.database.database== """keystone"Webhook-only
spec.infrastructure.database.secretRef.name== """keystone-db" †Webhook-only
spec.infrastructure.database.clusterRef.namehost == "" (managed mode)"openstack-db"Webhook-only, brownfield-guarded
spec.infrastructure.cache.backend== """dogpile.cache.pymemcache"Webhook-only
spec.infrastructure.cache.clusterRef.namelen(servers) == 0 (managed mode)"openstack-memcached"Webhook-only, brownfield-guarded
spec.infrastructure.database.replicas, and the same field of every dedicated database== 0, non-External modethe resolved sizing's database.replicas (3 under Standard)Webhook-only
spec.infrastructure.database.storageSize, and the same field of every dedicated database== "", non-External modethe resolved sizing's database.storageSize (100Gi under Standard)Webhook-only
spec.infrastructure.messaging.clusterRef.namemessaging block present, secretRef == nil (managed mode)"openstack-rabbitmq"Webhook-only, brownfield-guarded
spec.infrastructure.messaging.secretRef.keymessaging block present, secretRef set, key == """transport_url"Webhook-only
spec.infrastructure.messaging.tls.caBundleSecretRef.keymessaging block present, tls set, key == """ca.crt"Webhook-only
spec.korc.adminCredential.passwordSecretRef.name== """keystone-admin"Webhook-only
spec.korc.adminCredential.passwordSecretRef.key== """password"Webhook-only
spec.services.keystone.mode== "" (keystone block present)ManagedMarker + webhook
spec.services.keystone.external.endpointType== "" (External mode, external present)publicMarker + webhook
spec.services.keystone.external.caBundleSecretRef.key== "" (ref present)"ca.crt"Webhook-only
spec.services.keystone.caBundleSecretRef.key== "" (ref present)"ca.crt"Webhook-only
spec.korc.adminCredential.userName== """admin"Marker + webhook
spec.korc.adminCredential.projectName== """admin"Marker + webhook
spec.korc.adminCredential.domainName== """Default"Marker + webhook
spec.services.glance.dedicatedBackingServices.database.clusterRef.namemanaged dedicated database declared, == ""{controlplane}-glance-db (and credentialsMode → Static)Webhook-only, brownfield-guarded
spec.services.glance.dedicatedBackingServices.cache.clusterRef.namemanaged dedicated cache declared, len(servers) == 0{controlplane}-glance-cacheWebhook-only, brownfield-guarded
spec.services.neutron.ovn.centralRef.namespace== "" (a neutron block is declared)the ControlPlane's own namespaceWebhook-only
spec.services.neutron.dedicatedBackingServices.database.clusterRef.namemanaged dedicated database declared, == ""{controlplane}-neutron-db (and credentialsMode → Static)Webhook-only, brownfield-guarded
spec.services.neutron.dedicatedBackingServices.cache.clusterRef.namemanaged dedicated cache declared, len(servers) == 0{controlplane}-neutron-cacheWebhook-only, brownfield-guarded
spec.services.cinder.dedicatedBackingServices.database.clusterRef.namemanaged dedicated database declared, == ""{controlplane}-cinder-db (and credentialsMode → Static)Webhook-only, brownfield-guarded
spec.services.cinder.dedicatedBackingServices.cache.clusterRef.namemanaged dedicated cache declared, len(servers) == 0{controlplane}-cinder-cacheWebhook-only, brownfield-guarded
spec.services.nova.dedicatedBackingServices.database.clusterRef.namemanaged dedicated database declared, == ""{controlplane}-nova-db (and credentialsMode → Static)Webhook-only, brownfield-guarded
spec.services.nova.dedicatedBackingServices.cache.clusterRef.namemanaged dedicated cache declared, len(servers) == 0{controlplane}-nova-cacheWebhook-only, brownfield-guarded
spec.services.{keystone,horizon,glance,placement,barbican,neutron,cinder,nova}.namespace.lifecycle== "" (a namespace block is declared)ManagedMarker + webhook

The centralRef.namespace default is a convenience only. NeutronOVNCentralNamespace() reads an empty value as the ControlPlane's namespace too, so a CR that bypassed the webhook is reconciled against the same OVNCentral as one that went through it.

No leaf of services.cinder.backends or services.cinder.backupBackend is defaulted. mountOptions, fileSize, compression, and the RBD blocks' clusterName and secretUUID carry no schema default on purpose, so an unset value serializes away and the satellite CRD's own default (for secretUUID, the driver's cluster FSID) applies at one layer.

† database.secretRef.name default — managed-mode convenience name only. The webhook still defaults secretRef.name to the keystone-db value (unchanged), but that default is no longer the Secret Keystone consumes. In managed mode database.secretRef is operator-owned: reconcileDBCredentials materialises a per-ControlPlane ExternalSecret and reconcileKeystone overrides the projected Keystone CR's spec.database.secretRef to the operator-owned Secret {controlplane.Name}-keystone-db-credentials (key "password") — see managed-mode provisioning. In production the bare name keystone-db does not resolve to any cluster Secret (only the kind overlay ships a keystone-db ExternalSecret, pinned to the default identity's path for standalone Keystone instances); a managed ControlPlane never consumes it either way. A brownfield ControlPlane (database.host set, clusterRef == nil) MUST supply its own database.secretRef Secret out-of-band; the operator projects no ExternalSecret in brownfield mode.

Operational contract. The webhook only materializes the Secret names and references — it never invents credential material. In managed mode (database.clusterRef set) the operator itself projects the admin password: it creates a per-ControlPlane admin ExternalSecret ({controlplane.Name}-keystone-admin-credentials) materialising the password from OpenBao and overrides the projected Keystone CR's bootstrap.adminPasswordSecretRef onto it, so the cp-level passwordSecretRef (default keystone-admin) is not the Secret the child consumes — see the passwordSecretRef managed-mode note above. In brownfield mode (database.clusterRef == nil) the operator projects no admin ExternalSecret, so a ControlPlane that omits spec.korc (or spec.infrastructure.database.secretRef) relies on the cluster operator having pre-seeded the referenced Secrets in the ControlPlane's namespace before the credential sub-reconcilers can advance: the admin password Secret (keystone-admin, key password) and the K-ORC clouds.yaml ExternalSecret/Secret (k-orc-clouds-yaml). The infrastructure layer seeds these (see the quick-start). A missing admin password Secret degrades to KORCReady=False / WaitingForAdminPassword, and a not-yet-synced clouds.yaml to AdminCredentialReady=False / WaitingForCloudsYaml — never a silent authentication. A clouds.yaml that is synced but stale (a re-mint revoked the old credential while ESO has not yet re-materialised the Secret) degrades to AdminCredentialReady=False / WaitingForCloudsYamlSync: reconcileAdminCredential semantically compares the materialised Secret (by parsed application-credential id+secret) against the freshly assembled credential and forces an ESO re-sync, so the gate never passes against a revoked credential. TestIntegration_MinimalManagedToReady encodes this contract by pre-creating those Secrets at the defaulted names.

Validating Webhook ​

go
func (w *ControlPlaneWebhook) ValidateCreate(_ context.Context, obj *ControlPlane) (admission.Warnings, error)
func (w *ControlPlaneWebhook) ValidateUpdate(_ context.Context, _, newObj *ControlPlane) (admission.Warnings, error)
func (w *ControlPlaneWebhook) ValidateDelete(_ context.Context, _ *ControlPlane) (admission.Warnings, error)
  • ValidateCreate and ValidateUpdate both delegate to the internal validate() method (see Validating-webhook rules). ValidateCreate additionally enforces the one-ControlPlane-per-namespace contract: it lists existing ControlPlanes in the new object's namespace through the uncached API reader and rejects the CREATE with a Forbidden error naming the incumbent when one already exists. The check runs only on CREATE so an existing CR stays mutable; ValidateUpdate validates the new object only.
  • Both also run the sizing checks that read the cluster (see the SizingProfile exists, Sizing priority classes exist, and merged-sizing rows of Validating-webhook rules), and both return one warning per spec.sizing.<service> block whose spec.services.<service> is unset: spec.sizing.<service> is set but spec.services.<service> is not; the values are inert.
  • ValidateDelete always returns nil, nil. It exists only to satisfy the admission.Validator interface and is never invoked — the validating webhook does not register the delete verb, so deletion is unconditionally allowed even while the operator is down.

Status Conditions ​

The ControlPlane status is driven by twenty-one sub-reconcilers, one condition type each. An aggregate Ready condition comes on top. The condition-type constants in controlplane_controller.go (subConditionTypes) are the single source of truth; call sites reference the constants rather than inline literals.

The sub-reconcilers run in two phases. The first seven are a blocking prefix: a step that has not converged requeues and ends the pass. The other fourteen run as one group on every pass, and none of them stops another. Ten steps gate explicitly on an earlier condition being True (reconcileKeystone on InfrastructureReady, reconcileHorizon on KeystoneReady, reconcileGlance, reconcilePlacement and reconcileBarbican on KeystoneReady and on the AccountReady of the KeystoneService registration each of them projects for itself, reconcileNeutron on KeystoneReady and OVNReady plus its own registration, reconcileCinder on KeystoneReady plus its own registration, reconcileNova on KeystoneReady and PlacementReady plus its own registration, reconcileAdminCredential on KORCReady, reconcileCatalog on AdminCredentialReady).

The figure shows which condition waits for which. Reconciliation Flow lists every step with its gate and its requeue interval.

The conditions of a ControlPlane as a gate graph. A blocking prefix runs one step after another and ends the pass at the first step that is not done: SizingReady, NamespacesReady, InfrastructureReady, ESOTenantStoreReady, DBCredentialsReady, AdminPasswordReady, KeystoneReady. DBCredentialsReady waits for a step done by hand, the tenant onboarding with setup-database-tenant.sh. Once the prefix has passed, the fourteen members of the tail group all run on every pass and each gates itself. KORCReady gates AdminCredentialReady, which gates CatalogReady and the KeystoneService registrations. KeystoneReady gates HorizonReady and the six service legs GlanceReady, PlacementReady, BarbicanReady, NeutronReady, CinderReady and NovaReady, and each leg also waits for the AccountReady of its own registration. NeutronReady also waits for OVNReady, which mirrors an OVNCentral the ControlPlane references and does not own, and NovaReady for PlacementReady. ServiceAccountsReady folds the registrations. RegistrationTenantStoresReady and NamespaceAssignmentsReady have no gate.

In call order the condition types are SizingReady, NamespacesReady, InfrastructureReady, ESOTenantStoreReady, DBCredentialsReady, AdminPasswordReady, KeystoneReady, HorizonReady, KORCReady, AdminCredentialReady, CatalogReady, GlanceReady, PlacementReady, BarbicanReady, OVNReady, NeutronReady, CinderReady, NovaReady, ServiceAccountsReady, RegistrationTenantStoresReady and NamespaceAssignmentsReady.

SizingReady runs first because every later stage projects its children from the resolved sizing: a SizingProfile that cannot be read stops the pass before anything is projected.

ESOTenantStoreReady runs ahead of every store-consuming stage because it provisions the per-tenant SecretStore they route their ExternalSecrets and PushSecrets through. It is mode-independent: an External-mode ControlPlane provisions the same tenant store (it just never seeds a bootstrap path through it), so both ESOTenantStoreReady and HorizonReady appear on an External-mode CR's status — the latter as HorizonNotManaged, since the dashboard is forbidden in External mode.

ServiceAccountsReady, RegistrationTenantStoresReady and NamespaceAssignmentsReady run last and carry no gate of their own. The first only reads the KeystoneService registrations the Glance, Placement, Barbican, Neutron, Cinder and Nova legs wrote earlier in the same pass, so there is no projection it could defer. The second writes into namespaces the control plane does not own, which is why it sits at the end of the chain rather than beside ESOTenantStoreReady: a namespace someone else administers must never park this control plane's own credential material behind it. The third only reads: one namespace per spec.namespaceAssignments entry, on the cluster the entry names.

Ready is True (reason AllReady) only when all sub-conditions are True (via conditions.AllTrue); otherwise it is False (reason NotAllReady). One exception bypasses the aggregation: when a namespace holds more than one ControlPlane (possible only for CRs that predate the one-per-namespace webhook guard or bypassed it), every CR except the oldest is parked with Ready=False reason DuplicateControlPlane naming the incumbent, and none of its sub-reconcilers run. For the full flow, see the ControlPlane Reconciler reference.

SizingReady ​

Set by reconcileSizing, the first stage of the chain. A stage that fails returns its error, so no later stage runs and the children keep the sizing they were last projected with.

StatusReasonWhen
TrueSizingResolvedThe sizing resolved. Message: sizing resolved from built-in profile "<base>", or sizing resolved from SizingProfile "<name>" (base "<base>") for a profileRef.
FalseSizingProfileNotFoundThe SizingProfile spec.sizing.profileRef names does not exist, typically because it was deleted after admission. Message: SizingProfile "<name>" not found; the children keep their last projected sizing.
FalseSizingProfileErrorReading the SizingProfile failed with any other error; the message carries it.

InfrastructureReady ​

Set by reconcileInfrastructure.

StatusReasonWhen
TrueInfrastructureReadyAll managed backing services are ensured and report Ready (or the control plane uses only brownfield infra, so there is nothing to provision).
FalseWaitingForDatabaseManaged MariaDB is ensured but not yet Ready.
FalseWaitingForCacheManaged Memcached is ensured but not yet Ready.
FalseMariaDBErrorError create-or-updating the MariaDB child.
FalseMemcachedErrorError create-or-updating the Memcached child.
FalseWaitingForMessagingThe managed RabbitmqCluster is ensured but does not report AllReplicasReady yet. Message: RabbitmqCluster "<name>" in namespace "<ns>" (spec.infrastructure.messaging) is not ready.
FalseRabbitMQErrorError create-or-updating the RabbitmqCluster child. Message: ensuring RabbitmqCluster "<name>" in namespace "<ns>" (spec.infrastructure.messaging): <error>. A cluster that does not serve the rabbitmq.com CRD fails closed here, with a no matches for kind error from the Get. An unauthorised scale-down lands here too: the declared replicas is below the owned cluster's and c5c3.io/allow-messaging-recreate is not set, so the destructive recreate is refused and the error names the annotation.
FalseFinalizingMessagingOn deletion, the managed RabbitmqCluster has been deleted by the teardown (foreground propagation) and the ControlPlane finalizer waits for the RabbitMQ Cluster Operator to release its own finalizer on it before releasing; see Owner-ref / GC model. Message: waiting for the managed RabbitmqCluster "<name>" to be deleted before releasing the ControlPlane.
TrueExternallyManagedservices.keystone.mode is External: identity is managed against services.keystone.external.authURL, so no MariaDB/Memcached is provisioned.
FalseInfrastructureNotConfiguredspec.infrastructure is unset on a non-External ControlPlane. The validating webhook requires the block outside External mode, so this only fires for a webhook-bypassed CR; it fails closed rather than dereferencing the nil block.

ESOTenantStoreReady ​

Set by reconcileESOTenantStore. It provisions the per-tenant SecretStore (plus its ServiceAccount and mTLS certificate) that every store-consuming stage routes its ExternalSecrets and PushSecrets through, which is why it runs ahead of them. It is mode-independent — an External-mode ControlPlane provisions the same store.

StatusReasonWhen
TrueESOTenantStoreReadyThe operator-provisioned per-tenant SecretStore is Ready.
TrueStoreRefOverriddenAn explicit spec.secretStoreRef opts out of the operator-provisioned store: the ControlPlane owns the referenced store's lifecycle, so nothing is provisioned. The selected store's readiness is still gated by each store-consuming sub-reconciler.
FalseSecretStoreNotReadyThe per-tenant SecretStore is not Ready yet; waiting on cert issuance and the OpenBao backend.
FalseProvisioningErrorError ensuring the per-tenant secret-store objects (SecretStore, ServiceAccount, certificate).

DBCredentialsReady ​

Set by reconcileDBCredentials. In managed mode (database.clusterRef set) it create-or-updates the per-ControlPlane DB-credential ExternalSecret ({name}-keystone-db-credentials; in the default Dynamic mode fed by a VaultDynamicSecret generator with a 24h refresh, in Static mode reading OpenBao path openstack/keystone/{namespace}/{name}/db with an hourly refresh) and mirrors its Ready status. The ControlPlane's secret store is checked first so an ESO/OpenBao outage surfaces promptly instead of hiding behind the ExternalSecret's stale per-object Ready cache.

StatusReasonWhen
TrueDBCredentialsReadyThe DB-credential ExternalSecret is Ready; the materialised Secret exists.
TrueBrownfieldUserSuppliedCredentialBrownfield database (clusterRef unset): the user supplies the DB-credential Secret out-of-band, so no ExternalSecret is projected and the chain proceeds immediately.
TrueExternallyManagedservices.keystone.mode is External: the ControlPlane manages no database at all, so nothing is projected and neither OpenBao nor the ClusterSecretStore is consulted.
FalseSecretStoreNotReadyThe store selected by spec.secretStoreRef (a ClusterSecretStore or a namespaced SecretStore) is not Ready; the secret backend is unreachable.
FalseGeneratorErrorError ensuring the dynamic DB-credential objects (the MariaDB database engine-backed generator that issues short-lived credentials).
FalseExternalSecretErrorError ensuring or checking the DB-credential ExternalSecret.
FalseWaitingForDBCredentialSecretThe ExternalSecret is ensured but ESO has not yet synced it to Ready. In Static mode (the explicit opt-out, and every dedicated managed database) the message names the OpenBao KV path the credential has to be seeded at out-of-band — nothing seeds it, so until it exists this is where the ControlPlane stops.

AdminPasswordReady ​

Set by reconcileAdminPassword. It runs before the Keystone stage because the keystone-operator's SecretsReady gate needs the admin-password ExternalSecret to exist before the projected Keystone child references it. In managed mode it create-or-updates the per-ControlPlane admin-password ExternalSecret ({name}-keystone-admin-credentials, reading OpenBao path bootstrap/{namespace}/{name}-keystone/admin — keystone-name-scoped so it matches the seeder and the keystone-operator rotation PushSecret) and mirrors its Ready status.

StatusReasonWhen
TrueAdminPasswordReadyThe admin-password ExternalSecret is Ready; the materialised Secret exists.
TrueBrownfieldUserSuppliedCredentialBrownfield database (clusterRef unset): the user supplies the admin-password Secret out-of-band, so no ExternalSecret is projected and the chain proceeds immediately.
TrueExternallyManagedservices.keystone.mode is External: the admin password is read from the user-supplied korc.adminCredential.passwordSecretRef Secret; no ExternalSecret is projected and no OpenBao bootstrap path is seeded. Updating that Secret is what drives a hash-driven re-mint of the admin application credential.
FalseSecretStoreNotReadyThe store selected by spec.secretStoreRef (a ClusterSecretStore or a namespaced SecretStore) is not Ready; the secret backend is unreachable.
FalseExternalSecretErrorError ensuring or checking the admin-password ExternalSecret.
FalseWaitingForAdminPasswordSecretThe ExternalSecret is ensured but ESO has not yet synced it to Ready.

KeystoneReady ​

Set by reconcileKeystone (gated on InfrastructureReady).

StatusReasonWhen
TrueKeystoneReadyThe projected Keystone CR reports Ready.
TrueKeystoneNotManagedservices.keystone is unset: this ControlPlane manages no identity plane.
TrueExternallyManagedservices.keystone.mode is External: identity is managed against services.keystone.external.authURL; no Keystone child is projected and none is deleted.
FalseWaitingForInfrastructureInfrastructureReady is not True; Keystone projection deferred.
FalseWaitingForKeystoneThe Keystone CR is ensured but not yet Ready.
FalseInvalidRotationIntervalservices.keystone.rotationInterval could not be converted to a cron schedule.
FalseKeystoneProjectionRejectedThe Keystone API server rejected the projected spec (HTTP 422) — almost always a now-immutable db/bootstrap field that diverged from the frozen Keystone child. Reconcile the ControlPlane spec back to the child's values, or recreate the child, to recover. Distinct from KeystoneError so the wedge is diagnosable from the condition.
FalseKeystoneErrorError create-or-updating the Keystone CR.
FalseFinalizingKeystoneOn deletion, the Keystone child in the ControlPlane's own namespace has been deleted by the teardown, and the ControlPlane finalizer waits for it to leave etcd: its openbao-finalizer has ESO purge the fernet- and credential-keys backup paths through the tenant SecretStore, which the owner-reference cascade would delete at the same time. Past orcTeardownDeadline a Warning KeystoneTeardownStalled names the Keystone and both paths and the release proceeds; see Owner-ref / GC model. Message: waiting for the Keystone child "<name>" to finish its OpenBao cleanup before releasing the ControlPlane.

HorizonReady ​

Set by reconcileHorizon (gated on KeystoneReady — the dashboard authenticates against the Keystone child). The dashboard is forbidden in External mode, so an External-mode ControlPlane always reports HorizonNotManaged.

StatusReasonWhen
TrueHorizonReadyThe projected Horizon CR reports Ready.
TrueHorizonNotManagedservices.horizon is unset (or the ControlPlane is in External mode): no dashboard is managed, so the aggregate Ready is not blocked. Any previously-projected Horizon child is preserved unless the c5c3.io/allow-horizon-deletion: "true" annotation opts in to its deletion.
FalseWaitingForKeystoneKeystoneReady is not True; Horizon projection deferred.
FalseWaitingForHorizonThe Horizon CR is ensured but not yet Ready.
FalseIdentityBackendsUnavailableListing the Keystone child's identity backends for the Horizon projection failed. The chain stops rather than projecting an empty websso block, which would silently remove a working SSO button from the login page.
FalseHorizonProjectionRejectedThe Horizon API server rejected the projected spec (HTTP 422) — the projection violates a CRD/webhook rule. Reconcile the ControlPlane spec to a valid projection to recover.
FalseHorizonErrorError create-or-updating the Horizon CR.

GlanceReady ​

Set by reconcileGlance (gated on KeystoneReady — Glance validates every token against the Keystone child — and on the projected KeystoneService registration having provisioned the glance service account). Glance is forbidden in External mode, so it is only ever managed against a Managed-mode Keystone.

StatusReasonWhen
TrueGlanceReadyThe projected Glance CR reports Ready.
TrueGlanceNotManagedspec.services.glance is unset: no image service is managed, so the aggregate Ready is not blocked. Any previously-projected Glance child (and its GlanceBackend children and DB-credential ExternalSecret) is preserved unless the c5c3.io/allow-glance-deletion: "true" annotation opts in to its deletion. The dynamic DB-credential generator, its ServiceAccount, and its client Certificate are torn down either way — preserving a running service does not imply preserving the generator that keeps minting its credentials.
FalseWaitingForKeystoneKeystoneReady is not True; Glance projection deferred.
FalseWaitingForServiceRegistrationThe projected KeystoneService registration has not provisioned the glance account yet; projection deferred until its Keystone user and password exist. The message relays the registration's own failing sub-condition, so a collision on the glance user or its catalog row reads here verbatim.
FalseServiceRegistrationErrorKubernetes-level error writing or reading the KeystoneService registration child — a refused adoption of a same-named foreign CR among them.
FalseGlanceDBCredentialErrorError ensuring the Glance DB-credential ExternalSecret (managed database only).
FalseWaitingForGlanceDBCredentialcredentialsMode: Dynamic is in effect but no engine-issued credential has materialised yet — either the generator-backed DB-credential ExternalSecret has not synced, or (on a Static→Dynamic migration, where the ExternalSecret is updated in place and keeps reporting the previous Static sync's Ready) the Secret it targets still carries the retired static username. No Glance CR is projected, and an existing child keeps its current mode, until one does. The message names the database/mariadb/creds/glance-<namespace> path, which only exists once setup-database-tenant.sh has onboarded the tenant, or the stale username it found.
FalseGlanceBackendProjectionRejectedThe Glance API server rejected a projected GlanceBackend (HTTP 422). Reconcile the services.glance.backends entries to a valid projection to recover.
FalseGlanceBackendErrorError projecting or pruning a GlanceBackend child.
FalseWaitingForGlanceThe Glance CR is ensured but not yet Ready.
FalseGlanceProjectionRejectedThe Glance API server rejected the projected Glance spec (HTTP 422) — the projection violates a CRD/webhook rule. Reconcile the ControlPlane spec to a valid projection to recover.
FalseGlanceErrorError create-or-updating the Glance CR.

OVNReady ​

Set by reconcileOVN. It carries no gate and writes nothing. The OVNCentral named by spec.services.neutron.ovn.centralRef is deployed outside the plane, the way the infrastructure clusters in spec.infrastructure are, so this pass only reads it and records whether the two databases the ML2/OVN mechanism driver needs are usable. The read goes through the local client whatever cluster the network service is placed on: the OVNCentral CR lives on the management cluster, where the ovn-operator reconciles it. Every not-yet arm requeues after 15s instead of erroring, because nothing this ControlPlane does can converge a central it does not own.

StatusReasonWhen
TrueOVNCentralReadyThe central reports Ready and publishes both database addresses and its client Secret. The message carries the Northbound and Southbound addresses that were selected.
TrueOVNNotManagedspec.services.neutron is unset: no OVN control plane is consumed, so the aggregate Ready is not blocked.
FalseOVNCentralNamespaceForbiddenThe resolved centralRef namespace is outside the plane's reach — the same rule the validating webhook enforces, re-run here because a CR can reach etcd without passing through admission. The central is not read, so none of its addresses or status reaches this plane. The message is the webhook's own, naming the field and the direction of the refusal. Requeue 15s.
FalseOVNCentralNotFoundNo OVNCentral of that name exists in the resolved namespace. The message carries the full <namespace>/<name> the ref resolved to. Requeue 15s.
FalseOVNCentralReadErrorThe OVNCentral CRD is not served on this cluster, in which case the message says to install the ovn-operator and the pass requeues after 15s; any other read failure is returned as an error.
FalseOVNCentralNotExternallyReachableThe network service runs on another cluster than the central, and the central does not set both spec.northbound.externallyReachable and spec.southbound.externallyReachable. The Neutron pods would reach the databases over the node network, which the in-cluster addresses do not serve. Requeue 15s.
FalseWaitingForOVNCentralThe central has not reported Ready yet, or reports it not True. The central's own reason and message are relayed verbatim, since "not ready" alone names nothing to act on. Requeue 15s.
FalseOVNEndpointsPendingThe central reports Ready but has not published one of the two database addresses or status.clientSecretName yet; the ovn-operator fills them in as its children converge. The message names the missing fields. Requeue 15s.

NeutronReady ​

Set by reconcileNeutron. It is gated on KeystoneReady (Neutron validates every token against the Keystone child), on OVNReady, and on the projected KeystoneService registration having provisioned the neutron service account. While spec.services.nova is set it takes a second registration, {controlplane.Name}-neutron-nova, and gates on that account too: it is the user the network service posts its port-status notifications to the compute service as, and the child must not be pointed at a password that does not resolve yet. Once gated through, the pass delivers the shared message bus into the namespace the network service runs in and ensures the DB credential before it projects the child, so the Secrets the child references exist by the time the neutron operator resolves them. Neutron is forbidden in External mode, so it is only ever managed against a Managed-mode Keystone.

StatusReasonWhen
TrueNeutronReadyThe projected Neutron CR reports Ready and its registration reports Ready.
TrueNeutronNotManagedspec.services.neutron is unset: no network service is managed, so the aggregate Ready is not blocked. Any previously-projected Neutron child (with its DB-credential ExternalSecret, the two messaging Secrets, and the registration) is preserved unless the c5c3.io/allow-neutron-deletion: "true" annotation opts in to its deletion. The dynamic DB-credential generator, its ServiceAccount, and its client Certificate are torn down either way. The referenced OVNCentral is never touched.
FalseWaitingForKeystoneKeystoneReady is not True; Neutron projection deferred. Requeue 5s.
FalseWaitingForOVNOVNReady is not True; Neutron projection deferred. A Neutron pointed at a central whose databases do not serve cannot program a single network. Requeue 5s.
FalseWaitingForMessagingCredentialsThe shared bus has not delivered its transport URL yet: the RabbitmqCluster, its default-user Secret, or the brownfield Secret is missing. Nothing is written, so the child never sees a partial URL. Requeue 15s.
FalseWaitingForMessagingCABundlespec.infrastructure.messaging.tls names a CA bundle Secret that does not exist, or one that carries no data under the referenced key. Requeue 15s.
FalseNeutronMessagingErrorError resolving the shared transport URL, writing either messaging Secret into the Neutron namespace, or removing the stale CA mirror after the tls block was dropped.
FalseTargetClusterUnavailableThe cluster the Neutron namespace lives on did not resolve, so the messaging Secrets cannot be written there. The resolver's own message is relayed. Requeue 15s.
FalseWaitingForServiceRegistrationThe projected KeystoneService registration has not provisioned the neutron account yet; projection deferred until its Keystone user and password exist. The message relays the registration's own failing sub-condition, so a collision on the neutron user or its catalog row reads here verbatim. While services.nova is set the same reason also covers the account-only {controlplane.Name}-neutron-nova registration, which carries no catalog entry and whose CatalogReady therefore reports True with reason CatalogNotDeclared.
FalseServiceRegistrationErrorKubernetes-level error writing or reading the KeystoneService registration child; a refused adoption of a same-named foreign CR is among them.
FalseServiceRegistrationFieldsReclaimedThe pass reset a spec field another field manager had written on the registration child (an adopt consent, a rotation block, or an extra catalog endpoint). The condition names the same fields as the Warning event and stands until a pass reads an untampered child.
FalseNeutronDBCredentialErrorError ensuring or reading the Neutron DB-credential objects (managed database only).
FalseWaitingForNeutronDBCredentialcredentialsMode: Dynamic is in effect but no engine-issued credential has materialised yet: either the generator-backed DB-credential ExternalSecret has not synced, or (on a Static→Dynamic migration, where the ExternalSecret is updated in place and keeps reporting the previous Static sync's Ready) the Secret it targets still carries the retired static username. No Neutron CR is projected, and an existing child keeps its current mode, until one does. The message names the database/mariadb/creds/neutron-<namespace> path, which only exists once setup-database-tenant.sh has onboarded the tenant, or the stale username it found.
FalseWaitingForNeutronThe Neutron CR is ensured but not yet Ready. Requeue 15s.
FalseNeutronProjectionRejectedThe Neutron API server rejected the projected Neutron spec (HTTP 422): the projection violates a CRD/webhook rule. Reconcile the ControlPlane spec to a valid projection to recover.
FalseNeutronErrorError create-or-updating the Neutron CR.

A registration that is provisioned but not yet fully Ready relays its own first failing sub-condition's reason onto NeutronReady, so a catalog-level collision surfaces here under the registration's own vocabulary.

CinderReady ​

Set by reconcileCinder. It is gated on KeystoneReady (Cinder validates every token against the Keystone child) and on the projected KeystoneService registration having provisioned the cinder service account. There is no OVN gate, and Glance and Barbican are siblings rather than gates: a Cinder with an empty glanceEndpoint and no keyManager runs, and the pass that follows a sibling block being declared re-renders the child with it. Once gated through, the pass delivers the shared message bus into the namespace the block-storage service runs in, ensures the DB credential, and projects the CinderBackend and CinderBackupBackend satellites before the child, so everything the child references exists by the time the cinder operator resolves it. Cinder is forbidden in External mode, so it is only ever managed against a Managed-mode Keystone.

StatusReasonWhen
TrueCinderReadyThe projected Cinder CR reports Ready and its registration reports Ready.
TrueCinderNotManagedspec.services.cinder is unset: no block-storage service is managed, so the aggregate Ready is not blocked. Any previously-projected Cinder child (with its two satellite kinds, its DB-credential ExternalSecret, the two messaging Secrets, and the registration) is preserved unless the c5c3.io/allow-cinder-deletion: "true" annotation opts in to its deletion. The dynamic DB-credential generator, its ServiceAccount, and its client Certificate are torn down either way.
FalseWaitingForKeystoneKeystoneReady is not True; Cinder projection deferred. Requeue 5s.
FalseWaitingForMessagingCredentialsThe shared bus has not delivered its transport URL yet: the RabbitmqCluster, its default-user Secret, or the brownfield Secret is missing. Nothing is written, so the child never sees a partial URL. Requeue 15s.
FalseWaitingForMessagingCABundlespec.infrastructure.messaging.tls names a CA bundle Secret that does not exist, or one that carries no data under the referenced key. Requeue 15s.
FalseCinderMessagingErrorError resolving the shared transport URL, writing either messaging Secret into the Cinder namespace, or removing the stale CA mirror after the tls block was dropped.
FalseTargetClusterUnavailableThe cluster the Cinder namespace lives on did not resolve, so the messaging Secrets, the registration's credential mirror, or the DB-credential objects cannot be written there. The resolver's own message is relayed. Requeue 15s from the bus delivery, 10s from the registration mirror and the DB credential.
FalseWaitingForServiceRegistrationThe projected KeystoneService registration has not provisioned the cinder account yet; projection deferred until its Keystone user and password exist. The message relays the registration's own failing sub-condition, so a collision on the cinder user or its catalog row reads here verbatim.
FalseServiceRegistrationErrorKubernetes-level error writing, reading, or mirroring the KeystoneService registration child; a refused adoption of a same-named foreign CR is among them.
FalseServiceRegistrationFieldsReclaimedThe pass reset a spec field another field manager had written on the registration child (an adopt consent, a rotation block, or an extra catalog endpoint). The condition names the same fields as the Warning event and stands until a pass reads an untampered child.
FalseSecretStoreNotReadyThe block-storage service is placed on a target cluster whose secret store is not ready, so the registration's consumer credentials cannot be materialised there. Requeue 10s.
FalseCinderDBCredentialErrorError ensuring or reading the Cinder DB-credential objects (managed database only).
FalseWaitingForCinderDBCredentialcredentialsMode: Dynamic is in effect but no engine-issued credential has materialised yet: either the generator-backed DB-credential ExternalSecret has not synced, or (on a Static→Dynamic migration, where the ExternalSecret is updated in place and keeps reporting the previous Static sync's Ready) the Secret it targets still carries the retired static username. No Cinder CR is projected, and an existing child keeps its current mode, until one does. The message names the database/mariadb/creds/cinder-<namespace> path, which only exists once setup-database-tenant.sh has onboarded the tenant, or the stale username it found. Requeue 10s.
FalseCinderBackendErrorError projecting or pruning a CinderBackend or the CinderBackupBackend satellite. The pass halts here rather than applying the child behind it, which would run with a backend set the ControlPlane never projected.
FalseCinderBackendProjectionRejectedThe Cinder API server rejected a projected satellite (HTTP 422). A backend name colliding with a cinder.conf catalog section other than default is the case the ControlPlane webhook deliberately leaves to the cinder module. Chart skew is another: a cinder CRD older than the RBD satellites rejects a projected RBD satellite (see the version-skew paragraph under CinderBackendEntry). Reconcile the services.cinder.backends / services.cinder.backupBackend entries to a valid projection to recover.
FalseWaitingForCinderThe Cinder CR is ensured but not yet Ready. Requeue 15s.
FalseCinderProjectionRejectedThe Cinder API server rejected the projected Cinder spec (HTTP 422): the projection violates a CRD/webhook rule. Reconcile the ControlPlane spec to a valid projection to recover.
FalseCinderErrorError create-or-updating the Cinder CR.

A registration that is provisioned but not yet fully Ready relays its own first failing sub-condition's reason onto CinderReady, the same way the network service's does.

NovaReady ​

Set by reconcileNova. It is gated on KeystoneReady (Nova validates every token against the Keystone child), on PlacementReady (the conductor claims every instance's resources in Placement before it boots, so a compute service brought up ahead of its placement service accepts requests it cannot serve), and on the projected KeystoneService registration having provisioned the nova service account. Neutron and Glance are required beside the block at admission but gate nothing here; Cinder and Barbican are optional siblings that switch the child's endpoints.cinder and endpoints.barbican on. Once gated through, the pass delivers the shared message bus into the namespace the compute service runs in (and, while services.nova.remoteCompute is set, the handed external bus URL as {controlplane.Name}-nova-remote-messaging), ensures the two DB credentials the nova_api and cell schemas take, and generates the metadata shared secret before it projects the child, so everything the child references exists by the time the nova operator resolves it. Past the child's readiness it delivers the compute contract to every mirror target and, while hypervisorOperator is set, provisions the hypervisor operator's account and delivers its auth Secret to the same targets. It then copies the metadata shared secret to every cluster a NeutronMetadataAgent of the plane asks for it on (see The generated metadata shared secret). Nova is forbidden in External mode, so it is only ever managed against a Managed-mode Keystone.

StatusReasonWhen
TrueNovaReadyThe projected Nova CR reports Ready, every compute-config mirror target is served, the hypervisor operator's auth Secret is delivered to every target while hypervisorOperator is set, every metadata agent that asks for the shared-secret copy has it on its cluster, and its registration reports Ready.
TrueNovaNotManagedspec.services.nova is unset: no compute service is managed, so the aggregate Ready is not blocked. Any previously-projected Nova child (with its two DB-credential chains, the generated metadata shared secret, the three messaging Secrets, the registration, and the hypervisor operator's registration and auth Secret) is preserved unless the c5c3.io/allow-nova-deletion: "true" annotation opts in to its deletion. Both dynamic DB-credential generators, their ServiceAccounts, and their client Certificates are torn down either way.
FalseWaitingForKeystoneKeystoneReady is not True; Nova projection deferred. Requeue 5s.
FalseWaitingForPlacementPlacementReady is not True; Nova projection deferred. A ControlPlane that manages no placement service reports that condition True under its own not-managed reason, so this gate reads the condition rather than the block. Requeue 5s.
FalseWaitingForMessagingCredentialsThe shared bus has not delivered its transport URL yet: the RabbitmqCluster, its default-user Secret, or the brownfield Secret is missing. Nothing is written, so the child never sees a partial URL. Requeue 15s.
FalseWaitingForMessagingCABundlespec.infrastructure.messaging.tls names a CA bundle Secret that does not exist, or one that carries no data under the referenced key. Requeue 15s.
FalseNovaMessagingErrorError resolving the shared transport URL, writing either messaging Secret into the Nova namespace, or removing the stale CA mirror after the tls block was dropped.
FalseWaitingForRemoteMessagingservices.nova.remoteCompute is set but the handed Secret, or its key, is missing or empty. The message is prefixed services.nova.remoteCompute.transportURLSecretRef:. No child is written this pass, so the child never names a Secret that is not there. Requeue 15s.
FalseNovaRemoteMessagingErrorThe handed URL is not a rabbit:// URL (the message names the scheme, never the URL), or writing {controlplane.Name}-nova-remote-messaging into the Nova namespace failed, or deleting it after services.nova.remoteCompute was removed failed.
FalseTargetClusterUnavailableThe cluster the Nova namespace lives on did not resolve, so the messaging Secrets, the registration's credential mirror, the DB-credential objects, the metadata generator pair, a compute-config mirror, or the hypervisor operator's auth Secret or one of its copies cannot be written there. The same reason covers the cluster of a metadata agent that asks for the shared-secret copy; the message then names the namespace and the cluster of every such cluster that did not resolve. The resolver's own message is relayed. Requeue 15s from the bus delivery, the metadata secret and the compute-config mirror, 10s from the registration mirror and the DB credentials.
FalseWaitingForServiceRegistrationThe projected KeystoneService registration has not provisioned the nova account yet; projection deferred until its Keystone user and password exist. The message relays the registration's own failing sub-condition, so a collision on the nova user or its catalog row reads here verbatim. The hypervisor operator's registration relays the same way once the child is Ready, naming {controlplane.Name}-nova-hypervisor-operator.
FalseServiceRegistrationErrorKubernetes-level error writing, reading, or mirroring the KeystoneService registration child; a refused adoption of a same-named foreign CR is among them.
FalseServiceRegistrationFieldsReclaimedThe pass reset a spec field another field manager had written on the registration child (an adopt consent, a rotation block, or an extra catalog endpoint). The condition names the same fields as the Warning event and stands until a pass reads an untampered child.
FalseSecretStoreNotReadyThe compute service is placed on a target cluster whose secret store is not ready, so the registration's consumer credentials cannot be materialised there. Requeue 10s.
FalseNovaAPIDBCredentialErrorError ensuring or reading the nova_api DB-credential objects (managed database only).
FalseWaitingForNovaAPIDBCredentialcredentialsMode: Dynamic is in effect but the nova_api chain has produced no engine-issued credential yet: either the generator-backed ExternalSecret has not synced, or the Secret it targets still carries a retired static username. The message names the database/mariadb/creds/nova-api-<namespace> path, which only exists once setup-database-tenant.sh has onboarded the tenant. The API chain is checked first, because its schema is the one the cell schema's mappings are registered in. Requeue 10s.
FalseNovaCellDBCredentialErrorError ensuring or reading the cell DB-credential objects (managed database only).
FalseWaitingForNovaCellDBCredentialThe cell chain's twin of the wait above, over database/mariadb/creds/nova-cell-<namespace>. Requeue 10s.
FalseNovaMetadataSecretErrorError ensuring the Password generator and the ExternalSecret that materialise the metadata shared secret, or taking a generated pair down once the child has converged on the Secret metadataSharedSecretRef names. The pass does not wait for the value: the Nova child gates on it itself.
FalseWaitingForNovaThe Nova CR is ensured but not yet Ready. Requeue 15s.
FalseNovaProjectionRejectedThe Nova API server rejected the projected Nova spec (HTTP 422): the projection violates a CRD/webhook rule. Reconcile the ControlPlane spec to a valid projection to recover.
FalseNovaErrorError create-or-updating the Nova CR.
FalseWaitingForComputeConfigThe child is Ready but the compute contract Secret the nova operator publishes has not appeared yet, so a mirror target cannot be served. The message names the Secret waited for: {controlplane.Name}-nova-compute-config, or {controlplane.Name}-nova-remote-compute-config while services.nova.remoteCompute is set, which the nova operator writes only once the delivered remote URL resolves. The metadata-agent leg reaches the same reason while {controlplane.Name}-nova-compute-config is absent (the message names the central's namespace whose agents sign with its shared secret) or carries no metadata_proxy_shared_secret yet. Requeue 15s.
FalseNovaComputeConfigErrorError listing the NovaComputes, reading the published compute contract, or writing its mirror into a target namespace.
FalseWaitingForHypervisorOperatorCredentialshypervisorOperator is set and the account is provisioned, but its consumer Secret {controlplane.Name}-nova-hypervisor-operator-credentials is absent from the Nova namespace or carries no password yet. Requeue 15s.
FalseNovaMetadataAgentSecretErrorError listing the NeutronMetadataAgents in the OVN central's namespace, reading the compute contract for the shared secret, or writing the copy {controlplane.Name}-nova-metadata-agent-secret onto an agent's cluster; a same-named Secret the ControlPlane did not write is among them. The message names the namespace and the cluster of every copy that failed, and of every agent cluster that did not resolve.
FalseHypervisorOperatorErrorError reading that consumer Secret, writing the auth Secret {controlplane.Name}-nova-hypervisor-operator-auth or one of its copies (the message names the namespace and the cluster), or deleting the registration, the Secret or a copy after the block was cleared.

The compute-config reasons only appear once a NovaCompute of the plane's Nova runs on a cluster other than the Nova's own. The mirror targets are one per such cluster, in the Nova namespace: pools sharing a cluster share a target, a pool being deleted is left out, and a cluster that does not serve the NovaCompute kind has no pools and so no target. The mirror carries nova.openstack.c5c3.io/compute-config-mirror: "true", and the last pool of the Nova on a cluster reaps it when it is torn down. The hypervisor operator's auth Secret goes to the same targets under the same label and is reaped with it.

A registration that is provisioned but not yet fully Ready relays its own first failing sub-condition's reason onto NovaReady, the same way its peers do.

KORCReady ​

Set by reconcileKORC.

StatusReasonWhen
TrueApplicationCredentialMintedThe K-ORC admin ApplicationCredential is minted and reports Available=True.
FalseWaitingForAdminPasswordThe admin password Secret/key is not yet available; minting deferred.
FalseWaitingForCABundleThe Secret referenced by the Keystone endpoint's CA bundle — external.caBundleSecretRef in External mode, caBundleSecretRef on a placed Keystone — does not exist yet, or exists with a missing/empty key; the mint is deferred rather than attempted against an endpoint whose certificate cannot be verified. The present-but-empty shape is the normal transient of a two-step "create the Secret, then populate it" flow.
FalseCABundleErrorNon-missing error reading that CA bundle.
FalseTransportErrorRetryFailedClearing a latched transport error from an admin Domain/User import or from the ApplicationCredential failed, for example a Forbidden status patch when the chart's RBAC predates the patch grant on the K-ORC status subresources. The error is returned. See Latched transport errors for which errors are cleared.
FalseApplicationCredentialFailedThe ApplicationCredential reports a terminal K-ORC error (GetTerminalError — an unrecoverable/invalid-config reason such as K-ORC being unable to authenticate); the message folds in any stuck admin Domain/User import. A latched transport error is cleared first (at most once every 30s per child), so K-ORC retries and the pass reports WaitingForApplicationCredential.
FalseWaitingForApplicationCredentialThe ApplicationCredential CR is ensured but not yet Available; the message folds in any stuck admin Domain/User import.
FalseReMintingThe admin application credential is being re-minted (delete + recreate) after an admin-password change or a CredentialRotation request; awaiting K-ORC's revoke of the previous credential. The old credential is already revoked at the Keystone level — see the consumption contract.
FalseReMintStalledThe ApplicationCredential has been Terminating longer than remintStallTimeout; K-ORC may be unable to reach Keystone to revoke the previous credential. Escalated from ReMinting so a stuck finalizer is alertable instead of looping silently.
FalseAdminPasswordErrorNon-missing error reading the admin password.
FalsePasswordCloudErrorError ensuring the operator-owned password-based clouds.yaml Secret the mint authenticates with.
FalseAdminImportErrorError create-or-updating the K-ORC admin User/Domain imports.
FalseSecretErrorError ensuring the application-credential Secret, or regenerating its value for a re-mint.
FalseSeedCloudsYamlErrorError seeding the bootstrap clouds.yaml.
FalsePushSecretErrorError ensuring the admin app-credential PushSecret, or forcing its CA-bundle re-push.
FalseExternalSecretErrorError ensuring the K-ORC clouds.yaml ExternalSecret.
FalseApplicationCredentialErrorError create-or-updating (or deleting, for a re-mint) the ApplicationCredential CR.
FalseFinalizingServiceRegistrationsThe ControlPlane is being deleted and the operator is waiting for the KeystoneService registrations it projects for its built-in services to finish their own teardown. They go first: their K-ORC CRs belong to the registration, and its controller tears them down through the admin credential the next step revokes. Set by reconcileDelete; see the reconciler reference.
FalseFinalizingORCThe ControlPlane is being deleted and the operator is waiting for the owned K-ORC CRs and PushSecrets to finish their teardown (K-ORC's finalizer revokes the credential against Keystone) before releasing the c5c3.io/orc-teardown finalizer. Set by reconcileDelete; see the reconciler reference.
FalseAuthenticationFailedExternal mode only. The external Keystone rejected the admin credential (HTTP 401) — typically the password was rotated out-of-band and passwordSecretRef is stale. K-ORC's message is relayed verbatim.
FalseEndpointUnreachableExternal mode only. services.keystone.external.authURL could not be dialled (DNS failure, connection refused, timeout).
FalseTLSVerificationFailedExternal mode only. The external endpoint's certificate did not verify; supply the private CA via external.caBundleSecretRef.
FalseCatalogEndpointMismatchExternal mode only. Authentication succeeded but the requested interface/region is absent from the external service catalog — a wrong external.endpointType or spec.region.
FalseCredentialDriftExternal mode only. An application-credential create against a stale resolve-once import id yielded a Keystone 403 (identity:create_application_credential). Drift is surfaced, never remediated.
FalseImportStalledExternal mode only. An admin Domain/User import has been waiting to be "created externally" for longer than externalImportStallGrace (2m). In External mode the import target already exists, so this is a misconfiguration.

AdminCredentialReady ​

Set by reconcileAdminCredential (gated on KORCReady, the ControlPlane's secret store being Ready (by default the SecretStore openbao-tenant-store), the K-ORC clouds.yaml ExternalSecret being Ready, the admin app-credential PushSecret having actually synced to OpenBao, and the materialised clouds.yaml Secret semantically matching (parsed application-credential id+secret) the freshly assembled credential).

StatusReasonWhen
TrueAdminCredentialReadyThe admin application credential is committed to the owned Secret, mirrored to OpenBao, and the materialised clouds.yaml Secret matches the assembled credential.
FalseWaitingForKORCKORCReady is not True; credential push deferred.
FalseCredentialDriftExternal mode only. KORCReady reports drift in the external installation (AuthenticationFailed or CredentialDrift). The operator never remediates the external Keystone; update the passwordSecretRef Secret to drive a re-mint.
FalseSecretStoreNotReadyThe store selected by spec.secretStoreRef (a ClusterSecretStore or a namespaced SecretStore) is not Ready; the secret backend is unreachable.
FalseSecretStoreErrorError checking whether the store selected by spec.secretStoreRef is Ready (as opposed to it reporting not-Ready). The condition is stamped before the error is returned so reconcileCatalog and reconcileServiceAccounts — which gate on AdminCredentialReady and run in the same reconcile pass — cannot act on a stale True.
FalseWaitingForCloudsYamlThe operator-created per-ControlPlane k-orc-clouds-yaml ExternalSecret in the control-plane namespace (co-located with the K-ORC CRs per C1; created and owned by reconcileKORC) is not yet Ready.
FalseWaitingForCredentialIDK-ORC has not yet reported the minted application credential's id; the assembly is deferred until it does.
FalseWaitingForAppCredentialSecretThe operator-owned app-credential Secret does not exist yet, or carries no credential key — the mint is not complete.
FalseWaitingForPushSecretThe admin app-credential PushSecret has not synced the assembled clouds.yaml to OpenBao yet. AdminCredentialReady is gated on the PushSecret's Ready condition — not merely on the CR existing — so a backend permission failure (e.g. the ESO role missing the push policy) cannot yield a false-positive Ready while OpenBao still serves the password-based bootstrap clouds.yaml.
FalseWaitingForCloudsYamlSyncThe materialised k-orc-clouds-yaml Secret is absent or still holds a stale credential (a re-mint revoked the old one but ESO has not re-synced yet). reconcileAdminCredential stamps the external-secrets.io/force-sync annotation to force an immediate re-sync and compares the materialised Secret semantically (parsed application-credential id+secret) against the assembled credential before reporting Ready, so the condition never reads True against a revoked credential.
FalseCloudsYamlSyncStuckThe materialised k-orc-clouds-yaml Secret has failed to match the assembled credential for longer than cloudsYamlSyncStuckTimeout (measured from the credential's LastRotation); the ESO ExternalSecret or OpenBao backend may be unable to sync. Escalated from WaitingForCloudsYamlSync so a never-converging sync is alertable and distinguishable from a transient miss.
FalseCloudsYamlErrorError checking the clouds.yaml ExternalSecret, forcing its re-sync, or reading the materialised Secret.
FalseSecretErrorError ensuring the operator-owned application-credential Secret.
FalsePushSecretErrorError ensuring the OpenBao PushSecret, stamping its content-hash re-push annotation, or reading it back for the sync gate.

CatalogReady ​

Set by reconcileCatalog (gated on AdminCredentialReady, and on every catalog child reporting Available for its current generation — korcAvailableUpToDate, which refuses a stale Available condition whose ObservedGeneration lags the object, so an endpoint/region edit cannot flip CatalogReady True before K-ORC re-reconciles).

What "every catalog child" means depends on the Keystone mode. In Managed mode the control plane owns the catalog, registers the identity Service and its public Endpoint, and adopts the bootstrap region as a managed Region. In External mode it is import-first: the identity Service and the Endpoint of the interface endpointType selects are the gating unmanaged imports, and nothing else — the other two interfaces are imported for visibility only (see ExternalCatalogSpec). Catalog rows a KeystoneService registers are gated by that CR's own CatalogReady, not by this condition.

StatusReasonWhen
TrueCatalogRegisteredManaged mode only. The bootstrap region is adopted as a managed Region CR, and every managed catalog entry is registered as K-ORC CRs and reports Available. The catalog is a per-service table whose only entry today is the identity (Keystone) Service and its public Endpoint; the message names the adopted region and counts the registered entries, so a future second service is one more entry rather than a reworded condition.
TrueCatalogImportedExternal mode only. The external identity Service and the endpoint interface endpointType selects resolved as unmanaged imports. The message reports how many of the three endpoint interfaces resolved. Deliberately distinct from CatalogRegistered: nothing was registered, and conflating the two would make "did this ControlPlane write to my catalog?" unanswerable from status.
FalseWaitingForAdminCredentialAdminCredentialReady is not True; catalog reconciliation deferred.
FalseWaitingForCatalogA catalog child is reconciled but not yet Available for the current generation (a stale Available condition whose ObservedGeneration lags the object does not count). In Managed mode the adopted Region CR is one of those children, including on the pass where the description apply has bumped its generation; the message then names the Region CR and the region it adopts. In External mode this names the gating import that has not resolved.
FalseTransportErrorRetryFailedClearing a latched transport error from a catalog child failed, in Managed or in External mode, for example a Forbidden status patch when the chart's RBAC predates the patch grant on the K-ORC status subresources. The error is returned.
FalseCatalogFailedA catalog child reports a terminal K-ORC error (GetTerminalError). A latched transport error is cleared first (at most once every 30s per child), so K-ORC retries and the pass reports WaitingForCatalog. In Managed mode the adopted Region is one of those children, and the message names Region. In External mode this is where the >1-match half of the ambiguity contract lands: K-ORC refuses to guess and stops retrying, and the message relays it verbatim plus a hint at external.catalog.identityServiceName (or, for an endpoint import, at the region limitation no spec field can fix). Terminal errors are surfaced for every import, gating or not — with one exception: a >1-match on a non-gating interface has no remediation and nothing depends on it, so it is tolerated exactly like a non-gating ImportStalled and reported as resolved: false.
FalseImportStalledExternal mode only. A gating catalog import has been waiting to be "created externally" for longer than externalImportStallGrace (2m). This is the 0-match half of the ambiguity contract: a gating import's target pre-exists by definition, so the wait never ends on its own. The message names external.endpointType and spec.region as the likely causes, and for an endpoint import the third possibility — the external catalog publishes no such interface. A non-gating interface import stalls on the same marker without failing the condition.
FalseAuthenticationFailed | EndpointUnreachable | TLSVerificationFailed | CatalogEndpointMismatch | CredentialDriftExternal mode only. An unresolved import carries a K-ORC message identifying one of these failure classes; it is relayed verbatim (see KORCReady for each class). CatalogEndpointMismatch additionally names the effective endpointType and spec.region.
FalseServiceErrorManaged mode only. Error create-or-updating the identity Service CR.
FalseEndpointErrorManaged mode only. Error create-or-updating the identity Endpoint CR.
FalseRegionErrorManaged mode only. Error reading or applying the managed Region CR that adopts the bootstrap region (the error is returned, so the pass is retried with backoff).
FalseImportErrorExternal mode only. Kubernetes-level error create-or-updating one of the unmanaged import CRs.

ServiceAccountsReady ​

Set by reconcileServiceAccounts. It aggregates the readiness of the KeystoneService registrations the built-in service legs project into the one condition operators alert on: one per declared services.glance, .placement, .barbican, .neutron, .cinder and .nova, plus the account-only {controlplane.Name}-neutron-nova while both neutron and nova are declared, and {controlplane.Name}-nova-hypervisor-operator while services.nova.hypervisorOperator is set. It projects nothing itself and needs no gate of its own: it only reads the children those legs wrote earlier in the same pass. The Nova leg applies the hypervisor operator's registration only once the Nova child is Ready, so with that block set the condition waits through the compute service's rollout.

The relayed reason is the failing registration's own first failing sub-condition, not an aggregate placeholder, so any reason a KeystoneService reports can appear here — including catalog-side ones such as CatalogFailed, because CatalogReady is folded in before AccountReady. The message always names the registration child it came from.

StatusReasonWhen
TrueNoServiceRegistrationsProjectedNo built-in service block is declared, which is every External-mode ControlPlane. Still True so the condition schema does not depend on the spec.
TrueServiceAccountsProvisionedEvery projected registration reports Ready. The message counts them.
FalseWaitingForServiceRegistrationA registration child has not been projected yet (its service leg is gated on something upstream of its own apply), or it has not reported Ready yet.
FalseServiceRegistrationErrorKubernetes-level error reading a registration child. Returned as an error too, so the reconcile group joins it.
False(the child's reason)A registration reports a False sub-condition — ServiceAccountCollision, ProbingForCollision, WaitingForServiceAccounts, ServiceAccountsFailed, SecretStoreNotReady, CatalogFailed, … — relayed verbatim with the child's own message.

RegistrationTenantStoresReady ​

Set by reconcileRegistrationTenantStores. It provisions the per-tenant openbao-tenant-store trio (a ServiceAccount, an mTLS Certificate, and the SecretStore itself) in every allowlisted namespace hosting at least one KeystoneService, and collects the trio again once the last registration there is gone. Credential delivery is namespace-local, so without it an allowlisted foreign registration would mint its Keystone account and then wait forever on a store nobody creates.

It is deliberately separate from ESOTenantStoreReady, which covers only the namespaces the control plane occupies. That one runs in the chain's blocking prefix, where a non-zero result parks DBCredentialsReady, AdminPasswordReady and KeystoneReady behind it. These namespaces are not the operator's — a foreign object can occupy the store's name there, a certificate can fail to issue — and none of that may reach the plane's core reconciliation. A failure here surfaces in this condition, and through it in the aggregate Ready, and stops there.

An allowlisted namespace that hosts no registration is not provisioned. A namespace removed from the allowlist while it still holds registrations keeps its store rather than losing it: de-listing is an admission gate, and revoking the store under a running service would destroy credentials it depends on (see ServiceRegistrationsSpec).

StatusReasonWhen
TrueStoreRefOverriddenAn explicit spec.secretStoreRef opts the whole control plane out of operator-provisioned stores. Nothing is provisioned, and nothing this sub-reconciler created is standing to collect; a registration resolves that store in its own namespace itself.
TrueNoRegistrationNamespacesNo allowlisted namespace hosts a service registration. Covers both arms that reach it: an empty or absent allowlist with nothing standing, and an allowlist whose namespaces hold no KeystoneService.
TrueRegistrationTenantStoresReadyThe openbao-tenant-store SecretStore is Ready in every allowlisted registration namespace. The message counts them.
FalseProvisioningErrorWriting or collecting a trio failed. Every namespace is attempted before the failures are reported together, so one broken namespace cannot starve its peers, and the message names the failing namespaces and the joined error. It requeues (10s) instead of returning an error: the cause is usually a tenant-side one no backoff resolves, such as a foreign object holding the store's name, and returning an error would put the whole ControlPlane reconcile into exponential backoff for it. Listing the provisioned namespaces or counting the registrations is the one arm that does return the error as well.
FalseSecretStoreNotReadyA store is written but not yet Ready, naming the namespace. A registration's delivery leg gates on exactly this, so reporting True while a store is still issuing its client certificate would claim a delivery path that does not carry yet. Requeue 10s. A failed readiness read returns the error with no condition written, leaving the previous value standing until the retry.

NamespaceAssignmentsReady ​

Set by reconcileNamespaceAssignments. It reports every spec.namespaceAssignments entry in status.namespaceAssignments (see NamespaceAssignmentStatus) and decides the condition over all of them. The step only reads: one namespace GET per entry, on the cluster the entry names. It never creates, labels or deletes an assigned namespace.

Admission accepts an entry whose cluster does not resolve, and this condition refuses it. The False condition turns the aggregate Ready False, also when the cluster only serves tenants. A missing namespace keeps the condition True, because an assignment may precede its namespace. While any entry is not Assigned the step requeues after one minute, because no watch reaches an assigned namespace.

StatusReasonWhen
TrueNoNamespaceAssignmentsThe spec assigns no namespace. status.namespaceAssignments is cleared.
TrueNamespaceAssignmentsReadyEvery entry resolves to a cluster that answers. The message counts the entries and, when one or more namespaces do not exist yet, how many.
FalseTargetClusterUnavailableAt least one entry names a cluster that does not resolve. The message lists every unresolved and unreachable entry as <namespace> on <cluster>: orders from these namespaces are refused until the cluster resolves or the entry is removed. Requeue 1m.
FalseClusterUnreachableNo entry is unresolved, but at least one cluster answered the namespace GET with an error other than NotFound, for example Forbidden. The same listing. Requeue 1m.

Ready (aggregate) ​

Set by setReadyCondition.

StatusReasonWhen
TrueAllReadyAll twenty-one sub-conditions are True.
FalseNotAllReadyOne or more sub-conditions are not True.

Service Namespaces ​

By default every service a ControlPlane projects lands in the ControlPlane's own namespace: namespace and ControlPlane are the same boundary, so no network-policy, RBAC, or quota line can be drawn between the services of one control plane. A namespace assignment on any of the eight service blocks (services.keystone, services.horizon, services.glance, services.placement, services.barbican, services.neutron, services.cinder, services.nova) makes the target namespace a per-service choice — a service can be placed in a namespace of its own, and the backing services, secret store, and credential material that belong to it follow it there. A service without an assignment stays in the ControlPlane's namespace exactly as before.

ServiceNamespaceSpec ​

FieldTypeRequiredDefaultDescription
namestringYes—The namespace the service is placed in. Must be an RFC-1123 label (^[a-z0-9]([-a-z0-9]*[a-z0-9])?$, ≤ 63 chars) and must differ from the ControlPlane's own namespace — omit the whole block to keep the service there.
lifecyclestring (Managed | External)NoManagedWho owns the namespace's lifecycle. Defaulted to Managed by both the +kubebuilder:default marker and the defaulting webhook.

Lifecycles ​

The two lifecycles are deliberately asymmetric — they differ on who owns the namespace, both when the ControlPlane is created and when it is deleted:

LifecycleOn reconcileOn ControlPlane deletion
ManagedThe operator creates the namespace and stamps it with the ownership labels, app.kubernetes.io/managed-by: c5c3-operator and the annotation c5c3.io/controlplane-uid. A namespace that already exists without those labels is never adopted — the operator fails loud with NamespacesReady=False/NamespaceNotOwned rather than taking over a namespace it did not create.The operator deletes the namespace (only if it carries the ownership labels), which cascades everything left in it.
ExternalThe operator only verifies the namespace exists; it never creates, labels, or mutates it. A missing one parks on NamespacesReady=False/NamespaceNotFound and requeues.The namespace survives. The residue the ControlPlane placed in it — backing services, credential material, tenant store — is swept by name, but the namespace itself is left standing.

Use External for namespaces whose quotas, RBAC, and policies are provisioned out-of-band, and Managed for a namespace the operator should own end to end.

Backing services follow the service ​

Each namespace that hosts at least one service of the ControlPlane gets its own set of backing-service instances (database, cache) materialized from the shared spec.infrastructure block. Services co-located in one namespace share that namespace's instances (with the same per-service logical databases, users, and cache isolation used within a single namespace); services placed apart each get their own. Within a namespace a service can still opt into dedicated instances via dedicatedBackingServices — that opt-in simply follows its service into the assigned namespace.

Ownership and garbage collection ​

Kubernetes garbage collection only cascades within a namespace, so a controller owner reference cannot cross one — the API server rejects it. A child the ControlPlane places in a service namespace therefore carries no owner reference; it is stamped with two ownership labels instead — c5c3.io/controlplane-name and c5c3.io/controlplane-namespace, which together name the owning ControlPlane — and the ORC-teardown finalizer deletes it explicitly, because nothing else collects it. The finalizer deletes the service children first and waits for them (their own operators run a sequenced ESO cleanup through the tenant store in the same namespace), then takes the namespace down per its lifecycle.

The figure shows the ControlPlane namespace, a service namespace and a target cluster with the ownership model of each. ControlPlane placement covers the third.

The three places a child of a ControlPlane lives. In the ControlPlane namespace on the management cluster, the service CR, its database and cache, its secret store, its Secrets, its ConfigMaps and its workloads carry an owner reference, and the garbage collector reaps them. In a dedicated service namespace on the management cluster the children of the ControlPlane carry the labels c5c3.io/controlplane-name and c5c3.io/controlplane-namespace instead, and the finalizer c5c3.io/orc-teardown deletes them. For a service placed on a target cluster, the service CR stays in its namespace on the management cluster, while database, cache, secret store, Secrets, ConfigMaps and workloads land in a namespace of the same name on the target, marked with those two labels plus openstack.c5c3.io/owner-kind, owner-name and owner-namespace, and the finalizer openstack.c5c3.io/remote-children sweeps them. A namespace the operator creates carries the annotation c5c3.io/controlplane-uid. The K-ORC resources stay in the ControlPlane namespace for every service.

Secret distribution ​

An ESO SecretStore and the Secrets it materializes are namespace-local, so a store in the ControlPlane's namespace cannot deliver anything into a service namespace. Every namespace the ControlPlane occupies therefore gets its own per-tenant openbao-tenant-store (its own ServiceAccount, client certificate, and store object), and ESOTenantStoreReady gates on all of them. This needs no OpenBao-side change: the eso-tenant role binds the ServiceAccount name in any namespace and its templated policy scopes every path to the caller's own namespace.

The credential material follows the Keystone service, and its OpenBao paths are re-keyed on the Keystone service namespace:

  • the admin-password seed path is bootstrap/<keystone-namespace>/<controlplane>-keystone/admin — the same path the keystone-operator's rotation PushSecret writes to (both follow the Keystone child), and the path write-bootstrap-secrets.sh seeds (its KORC_CONTROLPLANES entries accept an optional <namespace>/<controlplane>/<keystone-namespace> third segment);
  • the database-engine role is keystone-<keystone-namespace>, which setup-database-tenant.sh provisions by resolving the service namespace from the live ControlPlane spec.

A brownfield or External admin-password Secret is the user's, supplied against the ControlPlane they wrote, so it stays in the ControlPlane's own namespace where they put it.

A namespace the control plane does not occupy gets a trio too when it is allowlisted for service registrations and hosts at least one KeystoneService, because that CR's consumer Secret is materialized through the store in its own namespace. Those trios are provisioned by a sub-reconciler of their own and gated by RegistrationTenantStoresReady, so a namespace somebody else administers cannot park the control plane's own credential material. OpenBao again needs no change: the eso-tenant role binds the ServiceAccount name in any namespace, and its templated policy confines each token to its own paths.

Cross-namespace service discovery ​

A service placed in one namespace still reaches the identity service in another through the namespace-qualified Service DNS: http://<controlplane>-keystone.<keystone-namespace>.svc:5000/v3. ClusterIP Service DNS resolves across namespaces unchanged, so K-ORC's clouds.yamlauth_url and the dashboard's spec.keystoneEndpoint reach a Keystone placed apart with no extra wiring. What does not come for free is reachability — namespaces are where NetworkPolicy is attached, so a default-deny namespace must explicitly allow this flow (see below).

Network policies ​

The operator creates no NetworkPolicies — it never has, in either the single-namespace or the split-namespace case. Splitting a control plane across namespaces is precisely what makes drawing them possible, so a platform operator who wants a default-deny posture writes them per namespace. The cross-namespace flows one ControlPlane needs are:

FromToPortPurpose
the Horizon namespacethe Keystone namespace5000the dashboard authenticates every login against Keystone
the ControlPlane's namespace (K-ORC)the Keystone namespace5000K-ORC reconciles the identity catalog and the admin credential
each service's namespaceits own database3306the service's DB connection
each service's namespaceits own cache11211the service's cache connection
a gateway namespacethe exposed service's namespacethe service portexternal ingress via Gateway API
each compute cluster's nodesthe platform's external bus listenerthe port of the services.nova.remoteCompute transport URLnova-compute RPC over TLS, only while services.nova.remoteCompute is set
each compute cluster's nodesthe Gateway that publishes Keystone, Placement, Neutron and Glance (and Cinder and Barbican when declared)its listener portnova-compute authenticates against Keystone and calls each service through its public catalog row, only while services.nova.remoteCompute is set

The two compute-cluster flows are filtered elsewhere. nova-compute runs with host networking, so on the compute side the node's firewall filters its traffic; a namespace NetworkPolicy does not. The broker side belongs to the platform's exposure of the bus: loadBalancerSourceRanges on its Service, or a policy of its own.

Uniqueness and immutability ​

A namespace is the tenant key the whole secret stack is scoped by (the OpenBao KV paths, the keystone-<namespace> database-engine role, the templated eso-tenant policy), so it belongs to at most one ControlPlane. Admission rejects an assignment that names a namespace another ControlPlane already occupies (its own or one of its service namespaces), and vice versa — the same rule validateUniqueInNamespace already enforces for a ControlPlane's own namespace, one level out. An External-lifecycle namespace that also hosts unrelated third-party workloads shares that namespace's OpenBao path scope by design, so pick a dedicated namespace when that scope matters.

The assignment is create-only: the block's presence, its name, and its lifecycle are all frozen after creation (webhook-only, no CEL transition rule, so a later gated migration can relax it). Moving a live service across namespaces would leave its backing services, its secret store, and every OpenBao path scoped to the old namespace behind with no migration path — remove and recreate the ControlPlane to change it. Two services co-located in one namespace must also agree on its lifecycle: they share that namespace's backing services and tenant store, so one must not have the teardown delete what the other declared untouchable.

Chart mode: the c5c3-operator chart refuses the namespace-scoped RBAC mode (rbac.namespaceScoped: true) as a whole, so dedicated service namespaces always run under the default ClusterRole mode. The operator needs cluster-scoped namespaces (create, delete) and cross-namespace child access, which only that mode grants.


Child Namespace ​

DECISION: the ControlPlane-scoped children the reconciler projects — the ones that belong to the ControlPlane as a whole rather than to one service: the K-ORC ApplicationCredential / Service / Endpoint / User / Domain CRs, the clouds.yaml Secret, the OpenBao PushSecret, and the service-account material — are created in the ControlPlane's own namespace (childNamespace = cp.Namespace), owned by it through a controller owner reference so the GC cascade reaps them.

A service and the things that follow it — its MariaDB, Memcached, and Keystone/Horizon/Glance (and GlanceBackend) CRs, its tenant store, its credential material — are placed in cp.KeystoneNamespace() / cp.HorizonNamespace() / cp.GlanceNamespace(), the service's own namespace when services.<svc>.namespace assigns one and the ControlPlane's namespace otherwise. Only the latter can carry an owner reference; a child in a different namespace carries the ownership labels instead (see Ownership and garbage collection).

The rationale is garbage collection: controllerutil.SetControllerReference rejects cross-namespace owner references because Kubernetes GC only cascades within a single namespace. A child in openstack owned by a ControlPlane in default would fail admission and, even if forced, would never be GC'd. Co-locating a same-namespace child with its owner keeps the owner reference valid and the GC cascade intact; a cross-namespace child is instead cleaned up by the finalizer. In production a ControlPlane without any namespace assignments is deployed into the openstack control-plane namespace, so its projected children land in openstack exactly as expected — the namespace is derived from the owner (or the assignment) rather than assumed. Projected child names are deterministic and derived from the ControlPlane name (e.g. {name}-keystone, {name}-glance, {name}-glance-{backend} (a projected GlanceBackend), {name}-admin-app-credential, {name}-identity-service, {name}-identity-endpoint, {name}-service-account-{account}-role-{slug} (an unmanaged Role import) and {name}-service-account-{account}-assign-{slug} (a managed RoleAssignment)) so a single namespace can host the children of multiple ControlPlanes without clashing.


  • ControlPlane Reconciler — the reconciliation flow, sub-reconciler ordering, and gating semantics.
  • Keystone CRD — the shared commonv1 types and the reference operator the c5c3 aggregate projects into.
  • Keystone Reconciler — the per-service reconciler that owns the projected Keystone CR.
  • Target Clusters — registering a target cluster, what a placed service takes with it, and how its children are owned and torn down.
  • Kubernetes Packages — operator image and Helm-chart packaging.
  • Infrastructure Manifests — the GitOps stack that deploys the c5c3-operator, K-ORC, and the backing services.