Horizon CRD
horizons.horizon.openstack.c5c3.io/v1alpha1, kind Horizon. The CRD is generated from operators/horizon/api/v1alpha1/horizon_types.go; the Helm chart ships a synced copy (make sync-crds / make verify-crd-sync).
Spec
| Field | Type | Required | Description |
|---|---|---|---|
deployment | DeploymentSpec | no | Shared pod-level knobs: replicas (default 3), resources (resolved per resource when the pod is rendered: 70m CPU request, no CPU limit, and 720Mi memory request and limit for the fixed two uWSGI processes of one thread, see the resource defaults), terminationGracePeriodSeconds, preStopSleepSeconds, strategy, topologySpreadConstraints, priorityClassName, and the node placement nodeSelector, tolerations and affinity (see NodePlacementSpec). Horizon runs no Job, so it has no spec.jobs block |
deployment.verticalAutoscaling | *VerticalAutoscalingSpec | no | Opts the Horizon dashboard Deployment into a VerticalPodAutoscaler that controls the requests of its containers; see VerticalAutoscalingSpec. Rejected beside spec.autoscaling, which scales the same Deployment. On a cluster without the VPA, VPAReady turns False with reason VPANotInstalled. |
image | ImageSpec | yes | Container image; exactly one of tag or digest (shared CEL rule) |
cache | CacheSpec | yes | Memcached backing the Django cache. Exactly one of clusterRef (managed) or servers (brownfield); backend is a Django cache backend path, defaulted to django.core.cache.backends.memcached.PyMemcacheCache. The operator renders CACHES["default"]["KEY_PREFIX"] as horizon- plus the first 12 hex characters of the SHA-256 of the image reference (repository:tag or repository@digest), so Horizon releases that share one Memcached never read each other's template-fragment cache and an image change starts with a cold cache. The prefix follows the spec and the operator never resolves the running digest: two builds pushed behind one tag share a prefix |
keystoneEndpoint | string | yes | The Keystone endpoint URL (OPENSTACK_KEYSTONE_URL); must match ^https?:// and parse with a host. Consumed server-side by the dashboard pods, so it must be reachable from inside the cluster — for a colocated control plane use the cluster-local Service URL, not an externally routable address |
secretKeyRef | SecretRefSpec | yes | Secret holding the Django SECRET_KEY; key defaults to secret-key. Injected as the HORIZON_SECRET_KEY env var, never into the ConfigMap |
gateway | *GatewaySpec | no | External exposure via a Gateway API HTTPRoute on port 8080; requires hostname and parentRef.name |
networkPolicy | *NetworkPolicySpec | no | Ingress restricted to TCP 8080 from the listed sources; egress auto-derived (DNS, the Keystone endpoint port, cache ports). At least one ingress source is required (fail-closed) |
autoscaling | *AutoscalingSpec | no | HPA bounds, CPU/memory utilization targets and scaling behavior |
logging | *LoggingSpec | no | Django LOGGING dictConfig derivation: root level, debug, perLoggerLevels. Defaulted to text/INFO/debug: false |
extraConfig | map[string]JSON | no | Free-form Django settings rendered after the operator defaults (user values win). SECRET_KEY is rejected by the webhook. Overrides of operator-owned settings (for example STATIC_ROOT, COMPRESS_OFFLINE, or CACHES, whose override drops the image-scoped KEY_PREFIX) are honored but reported via the ExtraConfigHealthy condition and an ExtraConfigOwnedKeyOverride Warning event |
websso | *WebSSOSpec | no | Federated single-sign-on choices on the login page. When nil the operator renders no WEBSSO_* settings and the dashboard offers local credentials only. The c5c3 ControlPlane projects this from the federation backends attached to its Keystone child |
multiDomain | *MultiDomainSpec | no | Multi-domain login: the domain field (or dropdown) on the login form, needed when users live in domains other than the default one — typically LDAP-backed. When nil the operator renders no OPENSTACK_KEYSTONE_MULTIDOMAIN_* settings |
secretStoreRef | *SecretStoreRefSpec | no | Selects the External Secrets store the dashboard resolves its SecretsReady gate against — kind (ClusterSecretStore | SecretStore, default ClusterSecretStore) and a required non-empty name. When omitted the shared cluster-scoped ClusterSecretStore openbao-cluster-store is used, so existing deployments are unchanged; set to a namespaced SecretStore in this Horizon's own namespace to reach OpenBao as a per-tenant identity. It only gates SecretsReady on the selected store — Horizon creates no PushSecrets. Normally projected from the owning ControlPlane |
targetClusterRef | *TargetClusterRefSpec | no | Names the registered target cluster that receives this Horizon's children: the Deployment, the ConfigMaps, and the Secrets. The CR itself does not move, and neither do its status or the webhooks that admit it. When omitted the children are created on the local cluster the operator runs on, so an existing CR keeps its behavior without an edit. name must be a non-empty DNS-1123 subdomain matching the registration Secret. Immutable (CEL transition rule): adding, removing, or renaming the ref strands the children already created on the previously selected cluster; delete and recreate instead. See Target Clusters |
Defaulting and validation
The mutating webhook applies the shared DeploymentSpec/LoggingSpec defaults, materializes the PyMemcacheCache backend, and defaults secretKeyRef.key. The validating webhook accumulates every violation: replicas floor, image tag/digest XOR, cache mutual exclusivity, the keystoneEndpoint URL shape, gateway hostname/parentRef, network-policy ingress, autoscaling bounds (including the implicit minReplicas default from deployment.replicas, and a non-zero spec.deployment.resources request for each utilization target), logging enums, graceful-termination cross-field arithmetic, topology-spread selectors, PriorityClass existence, and the spec.deployment.nodeSelector label grammar and tolerations (the API server's toleration rules).
A CEL rule over extraConfig keys is not expressible (the API server cannot build CEL type information for preserve-unknown-fields map values), so the empty-key and SECRET_KEY guards are webhook-only.
The validating webhook also holds spec.image.tag to a release floor when the tag parses as an OpenStack release; a digest-only image or a tag that names no release, such as latest, is skipped. A tag below 2026.1, the oldest release this operator version supports, is rejected on create and whenever an update changes it, with must be 2026.1 or later: this operator version no longer supports OpenStack releases below 2026.1. An update that keeps a stored tag below the floor is admitted with a warning, so an unrelated edit never blocks the resource: spec.image.tag "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.image.tag to 2026.1 or later.
ExtraConfig ownership guard
Beyond the rejected settings, the operator ships a registry of the local_settings.py settings it computes. Overriding one of them through spec.extraConfig is report-only — the value is rendered — but it flips the informational ExtraConfigHealthy condition to False (Reason=OwnedKeysOverridden, with the overridden settings named in the message; with no overrides the condition reads True, Reason=NoOwnedKeysOverridden) and emits a one-shot ExtraConfigOwnedKeyOverride Warning event gated on that message transition — it fires once on entering False and once more when the overridden-setting set changes, never on the steady reconcile poll. The condition is intentionally not aggregated into Ready, so an explicit operator override is honored rather than blocking the rollout. OPENSTACK_ENDPOINT_TYPE and SECURE_PROXY_SSL_HEADER are deliberately left unregistered as supported escape hatches, so overriding either stays silent. Horizon has no separate events reference page by design; this is the documentation home for the ExtraConfigOwnedKeyOverride event.
WebSSOSpec
Maps onto Horizon's WEBSSO_* Django settings. Rendered by the settings renderer, so operators never hand-write Python literals into extraConfig.
The figure shows where these settings act. keystoneURL is the address of hop 2, and hop 8 uses spec.keystoneEndpoint. The hops of a login lists all eight.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | no | false | Turns the SSO selector on the login page on (WEBSSO_ENABLED). When false the remaining fields are inert |
choices | []WebSSOChoice | no | — | Ordered entries of the "Authenticate using" dropdown (WEBSSO_CHOICES); order is preserved verbatim. Required (non-empty) when enabled is true. The defaulting webhook prepends the local-credentials fallback when no choice carries the id credentials, which is what stops enabling SSO from locking out non-federated accounts. Max 17 counts the list after that prepend — the 16 federated entries idpMapping bounds, plus the fallback — so a list that already declares credentials may hold 17 entries and one that does not is bounded at 16. Submitting a 17th without the fallback is rejected by the defaulting webhook, because mutating admission runs before schema validation and a prepend would otherwise be rejected on a count you never wrote |
idpMapping | map[string]WebSSOIDPTarget | no | — | Maps a choice id onto the Keystone identity provider and federation protocol (WEBSSO_IDP_MAPPING). A choice with no mapping entry is a local login. Every key must name a declared choice. Max 16 |
initialChoice | string | no | credentials | Preselects one of choices by id (WEBSSO_INITIAL_CHOICE). Must name a declared choice |
keystoneURL | string | no | "" | The browser-facing Keystone base URL the SSO redirect is built from (WEBSSO_KEYSTONE_URL); matches ^https?://. It exists because spec.keystoneEndpoint is by contract the cluster-local Service URL, consumed server-side — redirecting a browser there would fail to resolve. When empty the setting is omitted and Horizon falls back to spec.keystoneEndpoint |
WebSSOChoice
| Field | Type | Required | Description |
|---|---|---|---|
id | string | yes | Submitted as the form's auth_type value and referenced by idpMapping / initialChoice. Matches ^[A-Za-z0-9_.-]+$ (it round-trips through a URL query string) |
label | string | yes | Human-readable text rendered for this choice |
WebSSOIDPTarget
| Field | Type | Required | Description |
|---|---|---|---|
identityProvider | string | yes | The Keystone identity-provider id (the backend's effective identityProviderName) |
protocol | string | yes | The Keystone federation protocol id (e.g. openid) |
Operator-pinned WebSSO settings
Two settings are rendered by the operator and are not configurable through spec.websso:
WEBSSO_USE_HTTP_REFERER = False. It defaults toTrueupstream, which makesopenstack_authvalidate the returned token against the Keystone URL derived from the browser'sReferer— i.e. the external gateway URL, resolved server-side from inside the pod, where it does not reach Keystone. WithFalseit validates againstOPENSTACK_KEYSTONE_URLinstead.SECURE_PROXY_SSL_HEADER = ["HTTP_X_FORWARDED_PROTO", "https"], rendered whenspec.gatewayis set. A Gateway terminates TLS and forwards plain HTTP, so without it Django reportshttp://and the origin it sends Keystone never matches anhttps://trusted_dashboard. Gating on the Gateway is what makes trusting the header safe: Envoy overwritesX-Forwarded-Protoon ingress.
MultiDomainSpec
Configures the login form's domain handling. Horizon only renders the domain dropdown when domainDropdown is true and domainChoices is non-empty; with enabled alone the form shows a free-text domain field.
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
enabled | bool | no | false | Turns on multi-domain login (OPENSTACK_KEYSTONE_MULTIDOMAIN_SUPPORT) |
defaultDomain | string | no | Default | The domain assumed for users who supply none (OPENSTACK_KEYSTONE_DEFAULT_DOMAIN). Materialized by the defaulting webhook |
domainDropdown | bool | no | false | Replaces the free-text domain field with a select populated from domainChoices (OPENSTACK_KEYSTONE_DOMAIN_DROPDOWN). Requires enabled and a non-empty domainChoices |
domainChoices | []DomainChoice | no | — | Ordered entries of the domain dropdown (OPENSTACK_KEYSTONE_DOMAIN_CHOICES). Rendered only when domainDropdown is true. Max 32 |
DomainChoice
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The Keystone domain name submitted with the login form |
label | string | yes | Human-readable text rendered for this domain |
Status
| Field | Description |
|---|---|
conditions | List-map keyed by type; see the reconciler reference for the vocabulary |
observedGeneration | The .metadata.generation last reconciled |
endpoint | The dashboard URL: https://{gateway.hostname}/ when a gateway is set, otherwise the cluster-local Service URL |
Sub-Resource Naming Convention
Operator-managed sub-resources (Deployment, Service, PodDisruptionBudget, HorizontalPodAutoscaler, NetworkPolicy, HTTPRoute) use the bare CR name with no suffix, matching the keystone convention. A Horizon CR named horizon in the openstack namespace is therefore reachable in-cluster at horizon.openstack.svc.cluster.local:8080 — the Service DNS name is the CR name. The immutable settings ConfigMap is the one derived name: {name}-config-<content-hash>.
Example
apiVersion: horizon.openstack.c5c3.io/v1alpha1
kind: Horizon
metadata:
name: horizon
namespace: openstack
spec:
deployment:
replicas: 3
image:
repository: ghcr.io/c5c3/horizon
tag: "2026.1"
cache:
clusterRef:
name: openstack-memcached
keystoneEndpoint: http://keystone.openstack.svc.cluster.local:5000/v3
secretKeyRef:
name: horizon-secret-key
gateway:
parentRef:
name: openstack-gw
namespace: envoy-gateway-system
hostname: horizon.127-0-0-1.nip.io