Skip to content

Keystone CRD API Reference

Reference documentation for the Keystone Custom Resource Definition. The Keystone CRD is the reference implementation for all CobaltCore service operators — the patterns established here (types, webhooks, generation, scheme registration) will be replicated for Nova, Neutron, Glance, and other OpenStack service operators.

API Group and Version

FieldValue
Groupkeystone.openstack.c5c3.io
Versionv1alpha1
KindKeystone
List KindKeystoneList
ScopeNamespaced

Import path:

go
import keystonev1alpha1 "github.com/c5c3/forge/operators/keystone/api/v1alpha1"

Scheme registration:

The init() function in keystone_types.go registers Keystone and KeystoneList with the SchemeBuilder. Operator main.go calls AddToScheme to register the types with the manager's scheme.


Sub-Resource Naming Convention

All operator-managed sub-resources for a Keystone CR are named after the CR itself with no -api suffix. For a Keystone CR named keystone in namespace openstack, the operator creates:

Sub-resourceNameCluster-internal DNS
Deploymentkeystone
Service (ClusterIP)keystonekeystone.openstack.svc.cluster.local
HorizontalPodAutoscalerkeystone
PodDisruptionBudgetkeystone
NetworkPolicykeystone
HTTPRoutekeystone
Container & named portkeystoneport 5000

This convention replaces the historical <name>-api form (where the same CR would have produced keystone-api Service, Deployment, etc.). The change aligns the internal Service DNS with the public Gateway hostname posture and removes the redundant suffix that no longer reflected a meaningful split — the Keystone CR has only ever owned the API role.

For migration semantics (catalog refresh, ownerReference cascade GC of legacy sub-resources, and operator workflows for upgrading a pre-rename cluster), see the Keystone Upgrade Flow reference.


Resource Shape

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  database:
    clusterRef:
      name: mariadb
    database: keystone
    secretRef:
      name: keystone-db-credentials
      key: password
  cache:
    backend: dogpile.cache.pymemcache
    clusterRef:
      name: memcached
  fernet:
    rotationSchedule: "0 0 * * 0"
    maxActiveKeys: 3
  credentialKeys:
    rotationSchedule: "0 0 * * 0"
    maxActiveKeys: 3
  trustFlush:
    schedule: "0 * * * *"
  autoscaling:
    minReplicas: 2
    maxReplicas: 10
    targetCPUUtilization: 80
  networkPolicy:
    ingress:
      - namespaceSelector:
          kubernetes.io/metadata.name: openstack
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: ScheduleAnyway
      labelSelector:
        matchLabels:
          app.kubernetes.io/name: keystone
          app.kubernetes.io/instance: keystone
  priorityClassName: system-cluster-critical
  resources:
    requests:
      memory: 256Mi
      cpu: 100m
    limits:
      memory: 512Mi
      cpu: 500m
  uwsgi:
    processes: 4
    threads: 4
    httpKeepAlive: true
  bootstrap:
    adminUser: admin
    adminPasswordSecretRef:
      name: keystone-admin
      key: password
    region: RegionOne
    publicEndpoint: https://keystone.example.com/v3
status:
  conditions:
    - type: Ready
      status: "True"
      reason: AllReady
      message: All sub-resources are ready
      lastTransitionTime: "2026-03-09T00:00:00Z"
    - type: KeystoneAPIReady
      status: "True"
      reason: APIHealthy
      message: "Keystone API is responding at http://keystone.openstack.svc.cluster.local:5000/v3"
      lastTransitionTime: "2026-03-09T00:00:00Z"
  endpoint: http://keystone.openstack.svc.cluster.local:5000/v3
  installedRelease: "2025.2"

Printer Columns

kubectl get keystones displays these columns:

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

KeystoneSpec

FieldTypeRequiredDefaultDescription
replicasint32No3Number of Keystone API replicas. Minimum: 1. The webhook provides a secondary default of 3 when zero.
imageImageSpecYesKeystone container image reference.
databaseDatabaseSpecYesMariaDB connection configuration.
cacheCacheSpecYesMemcached cache configuration.
fernetFernetSpecNoSee belowFernet key rotation configuration.
credentialKeysCredentialKeysSpecNoSee belowCredential-key rotation configuration. Drives the per-CR CronJob that rotates and credential_migrates the credential keys used for encrypting application credentials.
trustFlush*TrustFlushSpecNo{schedule: "0 * * * *", suspend: false} (materialized by the defaulting webhook)Trust flush CronJob configuration. Default-on: when the field is omitted, the defaulting webhook populates an hourly schedule so keystone-manage trust_flush runs by default; there is no nil-back path on a webhook-enabled cluster (a kubectl patch ... 'spec/trustFlush'='null' round-trips through admission and is re-materialized). To pause without deleting the CronJob, set suspend: true — the resource and TrustFlushReady=True condition are preserved.
federation*FederationSpecNonilFederation configuration (optional).
bootstrapBootstrapSpecYesInitial Keystone bootstrap parameters.
middleware[]MiddlewareSpecNonilWSGI middleware filters for api-paste.ini.
plugins[]PluginSpecNonilService plugins/drivers to configure.
policyOverrides*PolicySpecNonilCustom oslo.policy rules.
autoscaling*AutoscalingSpecNonilHorizontal pod autoscaling configuration. When set, an HPA is created targeting the {name} Deployment. When removed, the HPA is deleted.
networkPolicy*NetworkPolicySpecNonilNetwork isolation for Keystone API pods. When set, a NetworkPolicy restricting ingress to TCP 5000 and auto-deriving egress rules for DNS, MariaDB, and Memcached is created. When nil, no NetworkPolicy is managed and traffic is unrestricted.
gateway*GatewaySpecNonilGateway API HTTPRoute configuration. When set, an HTTPRoute is created targeting the {name} Service on port 5000 and attached to the referenced pre-existing Gateway; status.endpoint is updated to https://{hostname}/v3. When removed, the HTTPRoute is deleted and status.endpoint reverts to the cluster-local Service URL.
resources*corev1.ResourceRequirementsNoSee belowCPU and memory requests and limits for the Keystone API container. When unset, the defaulting webhook injects sensible defaults to ensure Burstable QoS class and enable HPA utilization calculations.
uwsgi*UWSGISpecNoniluWSGI application server parameters. When set, the operator uses these values for the Deployment container command. When nil, hardcoded defaults (processes=2, threads=1, httpKeepAlive=true) are used in the reconciler.
logging*LoggingSpecNoSee belowoslo.log configuration for the Keystone API container. When nil, the defaulting webhook materializes a baseline (format=text, level=INFO, debug=false, no per-logger overrides) so downstream reconciler code never sees a nil pointer. When set, zero-valued sub-fields are partially filled with the same baseline.
topologySpreadConstraints[]corev1.TopologySpreadConstraintNoSee belowScheduler hints for spreading pods across zones and nodes. nil injects two defaults (zone + hostname, MaxSkew=1, ScheduleAnyway); a non-nil value (including []) is used verbatim.
priorityClassName*stringNonilPriorityClass attached to the Keystone API pod spec. When set, the webhook verifies the class exists; when unset, no priority class is configured.
terminationGracePeriodSeconds*int64NonilGrace period (seconds) granted to Keystone API pods between SIGTERM and SIGKILL during rolling updates. When nil, the reconciler applies 30 (the CRD schema emits no default: so pre-existing CRs are not mutated on operator upgrade). Minimum: 10. Must be strictly greater than preStopSleepSeconds. Drives the PodSpec terminationGracePeriodSeconds. See Graceful-termination fields and the HA rollout sequence in architecture/docs/04-architecture/04-high-availability.md.
preStopSleepSeconds*int64NonilSleep duration (seconds) of the preStop lifecycle hook, covering the window between EndpointSlice removal and kube-proxy/ingress propagation. When nil, the reconciler applies 5 (the CRD schema emits no default: so pre-existing CRs are not mutated on operator upgrade). Minimum: 0. Must be strictly less than terminationGracePeriodSeconds. See Graceful-termination fields.
strategy*appsv1.DeploymentStrategyNoRollingUpdate(maxSurge=1, maxUnavailable=0)Overrides the Deployment rollout strategy. When nil, the reconciler injects RollingUpdate with maxUnavailable=0 and maxSurge=1 so available capacity never drops below spec.replicas during an image-tag patch. Set to customize surge/unavailable counts or switch to Recreate.
extraConfigmap[string]map[string]stringNonilFree-form INI sections for additional configuration.

CEL Validation Rules

The CRD includes structural validation rules enforced by the API server before webhooks are invoked:

FieldRuleError Message
spec.databasehas(self.clusterRef) != has(self.host)"exactly one of clusterRef or host must be set"
spec.cachehas(self.clusterRef) != (has(self.servers) && size(self.servers) > 0)"exactly one of clusterRef or servers must be set"
spec.policyOverrides(has(self.rules) && size(self.rules) > 0) || self.configMapRef != null"at least one of rules or configMapRef must be set"
spec.policyOverrides.rules!has(self.rules) || self.rules.all(k, k != '')"policy rule name must not be empty"
spec.autoscalinghas(self.targetCPUUtilization) || has(self.targetMemoryUtilization)"at least one of targetCPUUtilization or targetMemoryUtilization must be set"
spec.networkPolicysize(self.ingress) > 0"at least one ingress source must be specified"
spec.replicasMinimum: 1
spec.fernet.maxActiveKeysMinimum: 3
spec.credentialKeys.maxActiveKeysMinimum: 3
spec.autoscaling.maxReplicasMinimum: 1
spec.autoscaling.minReplicasMinimum: 1
spec.autoscaling.targetCPUUtilizationRange: 1–100
spec.autoscaling.targetMemoryUtilizationRange: 1–100
spec.uwsgi.processesMinimum: 1
spec.uwsgi.threadsMinimum: 1
spec.uwsgi.harakiriMinimum: 1
spec.uwsgi.httpKeepAliveTimeoutMinimum: 1
spec.terminationGracePeriodSecondsMinimum: 10
spec.preStopSleepSecondsMinimum: 0
spec.gateway.hostnameMinLength: 1(empty string rejected by API server)
spec.gateway.parentRef.nameMinLength: 1(empty string rejected by API server)

Known limitation: spec.uwsgi.processes and spec.uwsgi.threads have no upper-bound validation. A user could set an extremely high value (e.g., processes: 10000), causing the Deployment to request more workers than the node can sustain. A +kubebuilder:validation:Maximum marker should be added once the team agrees on a safe ceiling. Track this as a follow-up product decision.


AutoscalingSpec

Configures horizontal pod autoscaling for the Keystone API Deployment. This is a pointer field (*AutoscalingSpec) on KeystoneSpec — when nil, no HPA is created and the HPAReady condition is set to True with reason HPANotRequired. When set, a HorizontalPodAutoscaler (autoscaling/v2) is created targeting the {name} Deployment. Removing the field deletes the existing HPA.

FieldTypeRequiredDefaultDescription
minReplicas*int32Nospec.replicasLower bound for the number of replicas. Minimum: 1. Defaults to spec.replicas when unset, allowing the HPA to scale down to the static replica count.
maxReplicasint32YesUpper bound for the number of replicas. Minimum: 1.
targetCPUUtilization*int32No*Target average CPU utilization as a percentage. Range: 1–100. At least one of targetCPUUtilization or targetMemoryUtilization must be set.
targetMemoryUtilization*int32No*Target average memory utilization as a percentage. Range: 1–100. At least one of targetCPUUtilization or targetMemoryUtilization must be set.

* At least one of targetCPUUtilization or targetMemoryUtilization is required (enforced by CEL XValidation).

HPA Resource Mapping

The HPA created from this spec has the following shape:

HPA FieldValue
metadata.name{name}
metadata.labelscommonLabels (same as Deployment)
spec.scaleTargetRef.apiVersionapps/v1
spec.scaleTargetRef.kindDeployment
spec.scaleTargetRef.name{name}
spec.minReplicasautoscaling.minReplicas (or spec.replicas if unset)
spec.maxReplicasautoscaling.maxReplicas
spec.metricsCPU and/or memory Resource metrics based on which targets are set
ownerReferencesPoints to the Keystone CR (controller: true)

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  autoscaling:
    minReplicas: 2
    maxReplicas: 10
    targetCPUUtilization: 80
    targetMemoryUtilization: 70

UWSGISpec

Configures the uWSGI application server parameters for the Keystone API container. This is a pointer field (*UWSGISpec) on KeystoneSpec — when nil, the reconciler uses hardcoded defaults (processes=2, threads=1, httpKeepAlive=true) and the webhook does not inject a default UWSGISpec. When set (even as uwsgi: {}), the webhook defaults zero-valued sub-fields and the reconciler reads from the spec.

FieldTypeRequiredDefaultDescription
processesint32No2Number of uWSGI worker processes. Minimum: 1. Maps to --processes in the container command.
threadsint32No1Number of threads per uWSGI worker process. Minimum: 1. Maps to --threads in the container command.
httpKeepAliveboolNotrueEnables the --http-keepalive flag on the uWSGI process. When false, the flag is omitted. See HTTPKeepAlive defaulting for the zero-value caveat.
harakiri*int32Nonil (flag omitted)Caps the per-request worker lifetime (seconds) via --harakiri. Minimum: 1. The webhook additionally enforces harakiri < terminationGracePeriodSeconds − preStopSleepSeconds so the worst-case per-request kill fits inside the shutdown drain window. See the HA rollout sequence in architecture/docs/04-architecture/04-high-availability.md.
httpKeepAliveTimeout*int32Nonil (flag omitted)Idle timeout (seconds) for keep-alive connections via --http-keepalive-timeout. Minimum: 1. Emitted only when httpKeepAlive=true (the webhook rejects a non-nil timeout combined with httpKeepAlive=false). Recommended to set ≤ preStopSleepSeconds so idle sockets close before SIGTERM reaches uWSGI. See the HA rollout sequence in architecture/docs/04-architecture/04-high-availability.md.

Deployment Command Mapping

The reconciler's uwsgiCommand() helper constructs the container command from spec.uwsgi (or defaults when nil). Fixed flags are always present regardless of configuration:

Command FlagSource
uwsgiBinary name (always first)
--http :5000Fixed — Keystone API listen port
--http-keepaliveIncluded when httpKeepAlive is true (or default); omitted when false
--wsgi-file /var/lib/openstack/bin/keystone-wsgi-publicFixed — Keystone WSGI entry point
--masterFixed — enables uWSGI master process
--lazy-appsFixed — loads apps in each worker after fork
--need-appFixed — exits if no WSGI app is found
--processes <N>spec.uwsgi.processes (default: 2)
--threads <N>spec.uwsgi.threads (default: 1)
--pyargv=--config-dir=/etc/keystone/keystone.conf.d/Fixed — passes config directory to Keystone

HTTPKeepAlive Defaulting Caveat

Go's bool zero value is false, making it impossible for the webhook to distinguish "not set" from "explicitly set to false". Therefore, the defaulting webhook does not touch httpKeepAlive at all — it only defaults processes and threads. The CRD schema default (+kubebuilder:default=true) handles httpKeepAlive in the normal admission path (API server applies the schema default before the webhook runs). This means:

  • uwsgi: {} → processes=2 (webhook), threads=1 (webhook), httpKeepAlive=true (CRD schema default via normal admission)
  • uwsgi: {processes: 4} → processes=4, threads=1 (webhook), httpKeepAlive=true (CRD schema default)
  • uwsgi: {httpKeepAlive: false} → httpKeepAlive stays false (explicit value is preserved by the API server)

Bypass paths (e.g., kubectl patch, upgrades, or when admission webhooks are temporarily unavailable) may not apply the CRD schema default. In those cases, httpKeepAlive remains at its Go zero value (false). The uwsgiCommand function in the controller applies a defense-in-depth clamp but does not override httpKeepAlive, so the --http-keepalive flag will be omitted from the uWSGI invocation in bypass scenarios.

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  uwsgi:
    processes: 4
    threads: 4
    httpKeepAlive: false

Graceful-termination fields

Five CR fields control the shutdown envelope applied during Keystone rolling updates — spec.terminationGracePeriodSeconds, spec.preStopSleepSeconds, spec.strategy, spec.uwsgi.harakiri, and spec.uwsgi.httpKeepAliveTimeout. Each field is listed in its owning section (top-level KeystoneSpec or UWSGISpec); this section consolidates their semantics, interaction rules, and defaulting behavior.

For the rollout sequence diagram and tunable-selection guidance, see architecture/docs/04-architecture/04-high-availability.md (section "Keystone Rolling Update").

Field Summary

FieldTypeDefaultMinimumEffect
spec.terminationGracePeriodSeconds*int643010PodSpec terminationGracePeriodSeconds — total envelope between SIGTERM and SIGKILL.
spec.preStopSleepSeconds*int6450Sleep duration of the preStop hook (/bin/sh -c 'sleep <n>'). Covers the EndpointSlice / kube-proxy propagation window.
spec.strategy*appsv1.DeploymentStrategyRollingUpdate(maxSurge=1, maxUnavailable=0)Deployment rollout strategy. Default guarantees surge-before-remove so capacity never dips below spec.replicas.
spec.uwsgi.harakiri*int32unset (flag omitted)1Per-request worker kill bound (--harakiri <n>). Prevents a single stuck request from holding a worker past the shutdown envelope.
spec.uwsgi.httpKeepAliveTimeout*int32unset (flag omitted)1Idle keep-alive socket timeout (--http-keepalive-timeout <n>). Only emitted when httpKeepAlive=true.

Interaction Rules Enforced by the Webhook

The validating webhook enforces the following cross-field invariants so that the shutdown envelope is always internally consistent. Violations are returned as field.Invalid errors.

Rule
preStopSleepSeconds < terminationGracePeriodSeconds (with nil pointers resolved to defaults 5 / 30)
harakiri < terminationGracePeriodSeconds − preStopSleepSeconds (only when harakiri is set)
httpKeepAliveTimeout requires httpKeepAlive=true
strategy.type=Recreate must not carry a strategy.rollingUpdate block

Operator Guidance (not webhook-enforced)

  • httpKeepAliveTimeout ≤ preStopSleepSeconds — when the keep-alive timeout exceeds the preStop sleep, a client may still hold a warm keep-alive socket to the Pod when SIGTERM fires, returning a connection reset on the client's next request. Tune httpKeepAliveTimeout at or below preStopSleepSeconds to close idle sockets before the kubelet signals uWSGI and preserve the zero-reset SLO. The webhook does not enforce this because slow clients may legitimately need a longer keep-alive window at the cost of occasional resets on rollout.

Reconciler Fallbacks

The reconciler applies internal defaults when the CR field is nil so older CRs continue to reconcile without the fields set:

FieldFallback when nil
spec.terminationGracePeriodSecondsPodSpec receives 30
spec.preStopSleepSecondspreStop command is sleep 5
spec.strategyRollingUpdate with maxUnavailable=0, maxSurge=1
spec.uwsgi.harakiri--harakiri flag is omitted
spec.uwsgi.httpKeepAliveTimeout--http-keepalive-timeout flag is omitted

These fallbacks live in internal/controller/reconcile_deployment.go (terminationGracePeriodSeconds, preStopSleepCommand, deploymentStrategy, uwsgiCommand) and are the single source of truth for the no-op upgrade path.

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  terminationGracePeriodSeconds: 60
  preStopSleepSeconds: 10
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  uwsgi:
    processes: 4
    threads: 4
    httpKeepAlive: true
    httpKeepAliveTimeout: 10
    harakiri: 45

LoggingSpec

Configures oslo.log output for the Keystone API container. This is a pointer field (*LoggingSpec) on KeystoneSpec. When nil, the defaulting webhook materializes a baseline LoggingSpec{Format: "text", Level: "INFO", Debug: false} (no per-logger overrides) so downstream reconciler code never sees a nil pointer — matching the documented production baseline (stdout/stderr, oslo.log line format, no debug noise). When set (even as logging: {}), the webhook partially fills zero-valued sub-fields with the same baseline values and the validating webhook enforces the enum constraints described below.

The reconciler always emits [DEFAULT] use_stderr=true and [DEFAULT] debug=<spec.logging.debug> into keystone.conf. When spec.logging.format == "json", an additional logging.conf ConfigMap entry is rendered (oslo.log JSON formatter wired to a stderr StreamHandler) and [DEFAULT] log_config_append=/etc/keystone/keystone.conf.d/logging.conf is appended; toggling format back to text drops the logging.conf key. A Warning event with reason LoggingStderrDisabled is emitted when spec.extraConfig overrides [DEFAULT].use_stderr to a non-true value, because doing so silently breaks the cluster log-aggregation pipeline. The reconciler also surfaces this misconfiguration via an informational LoggingHealthy status condition (Reason=StderrDisabled when overridden, Reason=StderrEnabled otherwise). The condition is intentionally not aggregated into the top-level Ready condition so an explicit operator override is honoured rather than blocking the rollout; the gated event fires only on transition into StderrDisabled. See keystone-events.md, Logging for the full event/condition contract.

FieldTypeRequiredDefaultDescription
formatstringNotextOn-wire layout of oslo.log records. text emits the standard oslo.log line format; json emits one JSON object per record for direct ingest by Loki/OpenSearch. Enforced as +kubebuilder:validation:Enum=text;json.
levelstringNoINFORoot logger level applied to oslo.log. One of DEBUG, INFO, WARNING, ERROR, CRITICAL. Enforced as +kubebuilder:validation:Enum=DEBUG;INFO;WARNING;ERROR;CRITICAL.
debugboolNofalseToggles oslo.log [DEFAULT] debug=true. Independent of level because oslo.log gates several extra-verbose code paths on the debug flag specifically (SQL echo, auth-backend tracing).
perLoggerLevelsmap[string]stringNonilOverrides the level of named loggers, mirroring oslo.log's default_log_levels. Each value must be one of DEBUG/INFO/WARNING/ERROR/CRITICAL — enforced by the validating webhook (CRD v1 additionalProperties does not support enum constraints). Empty-string keys are rejected. Rendered into [DEFAULT].default_log_levels in deterministic alphabetical order to keep ConfigMap content-hashes stable across reconciles.

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  logging:
    format: json
    level: INFO
    debug: false
    perLoggerLevels:
      sqlalchemy.engine: WARNING
      keystone.middleware: DEBUG

FernetSpec

Configures Fernet token key rotation.

FieldTypeRequiredDefaultDescription
rotationSchedulestringNo"0 0 * * 0"Cron expression (5-field standard format) for key rotation. Validated by robfig/cron/v3 ParseStandard.
maxActiveKeysint32No3Maximum number of active Fernet keys. Minimum: 3.

CredentialKeysSpec

Configures credential-key rotation. Credential keys encrypt the application-credential passwords stored in the database. Rotation uses the same 32-byte base64url format as Fernet but runs keystone-manage credential_migrate after generating a new primary key so that existing rows stay readable after the old key is purged. Rotation is driven by a CronJob that pushes the regenerated key set back to the {name}-credential-keys Secret via a minimally-scoped ServiceAccount. The Secret is also mirrored to OpenBao through a PushSecret.

FieldTypeRequiredDefaultDescription
rotationSchedulestringNo"0 0 * * 0"Cron expression (5-field standard format). Validated by robfig/cron/v3 ParseStandard in the webhook.
maxActiveKeysint32No3Maximum number of active credential keys. Minimum: 3. Exposed to keystone-manage via the OS_credential__max_active_keys environment variable on the rotation CronJob.

TrustFlushSpec

Configures periodic purging of expired trust delegations. This is a pointer field (*TrustFlushSpec) on KeystoneSpec, but on a webhook-enabled cluster it is default-on: the defaulting webhook materializes {schedule: "0 * * * *", suspend: false} whenever the field is omitted (or patched to null), so the operator always creates a CronJob named {name}-trust-flush running keystone-manage trust_flush and the TrustFlushReady condition is set to True with reason TrustFlushReady.

There is no nil-back path on a webhook-enabled cluster — a kubectl patch ... 'spec/trustFlush'='null' round-trips through admission and is re-materialized, preserving the existing CronJob (no delete/recreate). To pause the schedule without deleting the CronJob, set suspend: true — the resource and TrustFlushReady=True condition are preserved while suspended.

The pointer shape is retained for envtest fixtures and other webhook-less clusters where the defaulting webhook is not wired up. In that legacy bypass posture the reconciler logs a warning, deletes any existing CronJob, and sets TrustFlushReady=True with reason TrustFlushNotRequired and a message identifying the bypass — see reconcileTrustFlush.

For brownfield CRs that omit spec.trustFlush at the time of an operator upgrade and the recommended pre-upgrade actions on clusters with very large trust tables, see Default-on Trust Flush at Upgrade Time in the upgrade-flow reference.

FieldTypeRequiredDefaultDescription
schedulestringNo"0 * * * *"Cron expression (5-field standard format) for trust flush. Validated by robfig/cron/v3 ParseStandard. Default is hourly.
suspendboolNofalseSuspends the CronJob without deleting it. Maps to the CronJob spec.suspend field. The CronJob resource and TrustFlushReady=True condition are preserved while suspended.
args[]stringNonilAdditional CLI flags appended after keystone-manage trust_flush. Flags such as --keystone-user, --keystone-group, --date are passed through verbatim.

CronJob Resource Mapping

The CronJob created from this spec has the following shape. Field values sourced from trustFlush.* are populated either by the user or — when the field was omitted on submission — by the defaulting webhook, which materializes {schedule: "0 * * * *", suspend: false} before the reconciler ever sees the object.

CronJob FieldValue
metadata.name{name}-trust-flush
metadata.labelscommonLabels (same as Deployment)
spec.scheduletrustFlush.schedule (webhook-defaulted to "0 * * * *" when omitted)
spec.suspend&trustFlush.suspend (pointer to bool; webhook-defaulted to false when omitted)
spec.jobTemplate.spec.template.spec.restartPolicyOnFailure
Container nametrust-flush
Container image{spec.image.repository}:{spec.image.tag}
Container command["keystone-manage", "--config-dir=/etc/keystone/keystone.conf.d/", "trust_flush"] + args
Container securityContextrestrictedSecurityContext() (PSS Restricted)
ownerReferencesPoints to the Keystone CR (controller: true)

Volume Mounts

The trust-flush container mounts the same configuration and key volumes as the Deployment, all read-only:

Volume NameMount PathSourceReadOnly
config/etc/keystone/keystone.conf.d/ConfigMap {configMapName}Yes
fernet-keys/etc/keystone/fernet-keysSecret {name}-fernet-keysYes
credential-keys/etc/keystone/credential-keysSecret {name}-credential-keysYes

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  trustFlush:
    schedule: "30 2 * * 0"
    args: ["--date", "2024-01-01"]

NetworkPolicySpec

Configures network isolation for the Keystone API pods. This is a pointer field (*NetworkPolicySpec) on KeystoneSpec — when nil, no NetworkPolicy is managed and the NetworkPolicyReady condition is set to True with reason NetworkPolicyNotRequired. When set, the operator creates a NetworkPolicy that restricts ingress on TCP 5000 to the declared sources and auto-derives egress rules for DNS, MariaDB (when database.clusterRef is set), and Memcached (when cache.clusterRef is set). Removing the field deletes the NetworkPolicy on the next reconcile.

FieldTypeRequiredDefaultDescription
ingress[]NetworkPolicyIngressSourceYesSources allowed to reach Keystone API on TCP 5000. At least one entry required (enforced by CEL and webhook).
additionalEgress[]networkingv1.NetworkPolicyEgressRuleNonilExtra egress rules appended after the auto-derived rules. Use for brownfield backends or external integrations not covered by ClusterRef auto-derivation.

NetworkPolicyIngressSource

FieldTypeRequiredDescription
namespaceSelectormap[string]stringYesLabel selector for source namespaces. All pods in matching namespaces may reach Keystone on TCP 5000 unless podSelector narrows the set.
podSelectormap[string]stringNoOptional label selector restricting allowed pods within the selected namespaces (AND logic within a single peer).

Auto-derived Egress

The operator appends the following egress rules before additionalEgress:

RuleTriggerNotes
DNS UDP+TCP 53AlwaysDestination is unrestricted because CoreDNS may run in any namespace (e.g. NodeLocal DNSCache).
MariaDB TCP 3306database.clusterRef setPort-only; destination unrestricted.
Memcached TCP 11211cache.clusterRef setPort-only; destination unrestricted.

A defensive guard in the reconciler refuses to create a NetworkPolicy with an empty ingress list, even if CEL validation was bypassed (stored objects, disabled webhooks, direct etcd writes) — the operator fails closed rather than open.

Example

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  # ... required fields ...
  networkPolicy:
    ingress:
      - namespaceSelector:
          kubernetes.io/metadata.name: openstack
      - namespaceSelector:
          kubernetes.io/metadata.name: ingress-gateway
        podSelector:
          app.kubernetes.io/name: envoy
    additionalEgress:
      - to:
          - ipBlock:
              cidr: 10.0.0.0/24
        ports:
          - protocol: TCP
            port: 443

GatewaySpec

Configures external exposure of the Keystone API via a Gateway API HTTPRoute. This is a pointer field (*GatewaySpec) on KeystoneSpec — when nil, no HTTPRoute is created and the HTTPRouteReady condition is set to True with reason HTTPRouteNotRequired. When set, an HTTPRoute (from gateway.networking.k8s.io/v1) is created in the Keystone CR's namespace, attached to the referenced pre-existing Gateway, and pointing to the {name} Service on port 5000. Removing the field deletes the existing HTTPRoute.

The operator plays the application-developer role in the Gateway API model: it manages only the HTTPRoute. The referenced Gateway (and its GatewayClass) are platform-team concerns and must be pre-provisioned — this operator does not create or reconcile them. Cross-namespace parentRef references additionally require a ReferenceGrant in the target namespace, which is out of scope for this operator.

Gateway API CRD prerequisite: the gateway.networking.k8s.io/v1 HTTPRoute CRD must be installed in the cluster before the Keystone operator starts. The operator probes for the CRD at startup (via the manager RESTMapper); when the CRD is missing it disables the HTTPRoute watch so Keystone CRs without spec.gateway still reconcile, and reports HTTPRouteReady=False with reason GatewayAPINotInstalled for any CR that sets spec.gateway. Installing the CRD after the operator has started requires restarting the operator for the watch to become active. The quickstart stack (make deploy-infra) installs the upstream Gateway API standard CRDs for this reason; the pinned version is set via GATEWAY_API_VERSION in hack/deploy-infra.sh and tracks sigs.k8s.io/gateway-api in operators/keystone/go.mod.

FieldTypeRequiredDefaultDescription
parentRefGatewayParentRefSpecYesGateway the HTTPRoute attaches to.
hostnamestringYesExternally reachable hostname (SNI / Host header) matched by the HTTPRoute. Used for both route hostname matching and deriving status.endpoint (https://{hostname}/v3). Minimum length: 1.
pathstringNo"/"URL path prefix matched by the HTTPRoute. The reconciler applies the default when the field is empty. Uses PathPrefix match type.
annotationsmap[string]stringNonilAnnotations passed through verbatim to the HTTPRoute metadata.annotations, allowing implementation-specific configuration (rate limits, timeouts, CORS). Operator-managed labels are preserved — user annotations do not shadow them.

GatewayParentRefSpec

References the pre-existing Gateway that the operator attaches the HTTPRoute to.

FieldTypeRequiredDefaultDescription
namestringYesGateway resource name. Minimum length: 1.
namespacestringNoCR namespaceNamespace of the referenced Gateway. When empty, the Gateway is assumed to live in the Keystone CR's namespace. Cross-namespace references require a ReferenceGrant.
sectionNamestringNo""Targets a specific listener on the Gateway (e.g., "https") when the Gateway defines multiple listeners. When empty, the HTTPRoute attaches to all compatible listeners.

HTTPRoute Resource Mapping

The HTTPRoute created from this spec has the following shape (gateway.networking.k8s.io/v1, Kind: HTTPRoute):

HTTPRoute FieldValue
metadata.name{name} (matches the backend Service, Deployment, HPA, NetworkPolicy naming)
metadata.namespaceKeystone CR namespace
metadata.labelscommonLabels (same as Deployment)
metadata.annotationsMerged from spec.gateway.annotations
spec.parentRefs[0].namespec.gateway.parentRef.name
spec.parentRefs[0].namespacespec.gateway.parentRef.namespace when non-empty; omitted otherwise
spec.parentRefs[0].sectionNamespec.gateway.parentRef.sectionName when non-empty; omitted otherwise
spec.hostnames[0]spec.gateway.hostname
spec.rules[0].matches[0].path.typePathPrefix
spec.rules[0].matches[0].path.valuespec.gateway.path (or "/" when empty)
spec.rules[0].backendRefs[0].kindService
spec.rules[0].backendRefs[0].name{name}
spec.rules[0].backendRefs[0].port5000
ownerReferencesPoints to the Keystone CR (controller: true) — enables garbage collection

status.endpoint Derivation

status.endpoint reflects the externally reachable Keystone API URL and is recomputed on every reconcile:

spec.gatewaystatus.endpoint Value
nilhttp://{name}.{namespace}.svc.cluster.local:5000/v3 (cluster-local fallback)
Sethttps://{hostname}/v3 — HTTPS is fixed because Gateways are the public-ingress hop and terminate TLS

status.endpoint does not include spec.gateway.path. The /v3 suffix is appended unconditionally because Keystone API v3 is served at that fixed path; the PathPrefix match on the HTTPRoute routes any prefix under spec.gateway.path to the backend. spec.publicEndpoint (if set) still takes precedence over the gateway-derived URL for the --bootstrap-public-url argument passed to keystone-manage bootstrap; the precedence is unchanged from earlier behavior.

Interaction with NetworkPolicy

When both spec.gateway and spec.networkPolicy are configured, the operator automatically appends an extra ingress peer to the managed NetworkPolicy so that the Gateway's data-plane pods can reach Keystone on TCP 5000:

  • Peer selector: namespaceSelector matching kubernetes.io/metadata.name={gatewayNamespace}. The gateway data plane's pod labels are implementation-specific (Kong/Envoy/NGINX/…) and not known to this operator, so selection is by entire gateway namespace rather than by pod labels.
  • Namespace source: spec.gateway.parentRef.namespace when set; otherwise the Keystone CR's own namespace (mirroring the ParentRef lookup semantics).
  • Removal: Clearing spec.gateway removes the extra peer on the next reconcile.
  • networkPolicy nil: When spec.networkPolicy is nil, no NetworkPolicy is managed at all and no extra peer is added (gateway-only deployments rely on the namespace's default network policy or absence thereof).

Example — Basic Gateway Exposure

kind Quick Start note: a ready-made Gateway/openstack-gw ships in the kind overlay (deploy/kind/base/openstack-gateway.yaml) and is reachable on the host at https://keystone.127-0-0-1.nip.io/v3 — see the Quick Start (Extended) / Access Keystone section. On a Quick Start cluster, setting spec.gateway.parentRef.name: openstack-gw plus hostname: keystone.127-0-0-1.nip.io makes status.endpoint = https://keystone.127-0-0-1.nip.io/v3 actually resolve from your workstation — no /etc/hosts edit, no kubectl port-forward. Production overlays do not ship openstack-gw; operators pick their own Gateway implementation and parent reference there.

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  replicas: 3
  image:
    repository: c5c3/keystone
    tag: "2025.1"
  # ... other required fields ...
  gateway:
    parentRef:
      name: public-gateway
      namespace: istio-ingress
      sectionName: https
    hostname: keystone.example.com
    path: /identity
    annotations:
      konghq.com/plugins: rate-limit-sha

Resulting status.endpoint: https://keystone.example.com/v3.

Example — Gateway with NetworkPolicy

yaml
apiVersion: keystone.openstack.c5c3.io/v1alpha1
kind: Keystone
metadata:
  name: keystone
  namespace: openstack
spec:
  # ... required fields ...
  gateway:
    parentRef:
      name: public-gateway
      namespace: istio-ingress
    hostname: keystone.example.com
  networkPolicy:
    ingress:
      - namespaceSelector:
          kubernetes.io/metadata.name: openstack

The operator-managed NetworkPolicy allows ingress from:

  1. The openstack namespace (user-declared).
  2. The istio-ingress namespace (auto-added because spec.gateway is set).

TopologySpreadConstraints

spec.topologySpreadConstraints attaches scheduler spread hints to the Keystone API Deployment's pod template. Uses the upstream corev1.TopologySpreadConstraint type verbatim, except that the webhook restricts labelSelector to exact matchLabels matching the Deployment selector (see below).

spec.topologySpreadConstraintsEffect
nil (unset)Operator injects two defaults: topology.kubernetes.io/zone and kubernetes.io/hostname, both MaxSkew=1 with ScheduleAnyway, selecting pods via app.kubernetes.io/name=keystone + app.kubernetes.io/instance={name}.
[] (empty slice)Defaults disabled; no spread constraints configured. Explicit opt-out.
Non-empty sliceUser value is applied verbatim; no defaults merged.

Webhook Constraint

Each entry must set labelSelector.matchLabels equal to the Deployment selector (app.kubernetes.io/name=keystone, app.kubernetes.io/instance={CR name}). matchExpressions is rejected. This prevents constraints that widen or narrow beyond the Deployment's intent, which would otherwise silently produce wrong spread behavior.

Example

yaml
spec:
  # ... required fields ...
  topologySpreadConstraints:
    - maxSkew: 1
      topologyKey: topology.kubernetes.io/zone
      whenUnsatisfiable: DoNotSchedule
      labelSelector:
        matchLabels:
          app.kubernetes.io/name: keystone
          app.kubernetes.io/instance: keystone

PriorityClassName

spec.priorityClassName (pointer) passes through to pod.spec.priorityClassName on the Keystone API pods. Uses the standard scheduling.k8s.io/v1 PriorityClass resource model.

ValueEffect
nilNo priority class is configured; the cluster default applies.
"" (empty string)No priority class — explicit opt-out, useful when clearing a previously set value via kubectl patch.
Non-empty stringValue is written to the Deployment PodSpec. The webhook performs a cluster-scoped Get of the PriorityClass at admission time and rejects unknown names with field.NotFound.

The rotation CronJobs (Fernet, credential) reuse the same priorityClassName to stay co-scheduled with the API pods.


FederationSpec

Configures Keystone federation support. This is a pointer field (*FederationSpec) on KeystoneSpec — when nil, federation is disabled.

FieldTypeRequiredDefaultDescription
enabledboolYesActivates federation support.

BootstrapSpec

Configures the initial Keystone bootstrap.

FieldTypeRequiredDefaultDescription
adminUserstringNo"admin"Admin username for the bootstrap.
adminPasswordSecretRefSecretRefSpecYesSecret containing the admin password.
regionstringNo"RegionOne"Keystone region name.
publicEndpointstringNoCluster-local service DNSExternally routable Keystone endpoint URL. Used for the --bootstrap-public-url argument passed to keystone-manage bootstrap. Required by external clients (CLI users, Horizon, federation partners) that cannot resolve the cluster-local service DNS.

KeystoneStatus

FieldTypeDescription
conditions[]metav1.ConditionLatest available observations of the Keystone state.
endpointstringKeystone API endpoint URL (set by the controller when ready). Defaults to http://{name}.{namespace}.svc.cluster.local:5000/v3.
installedReleasestringOpenStack release version currently deployed. Set by the controller after a successful db_sync; reflects the value extracted from spec.image.tag.
targetReleasestringUpgrade target release during an active upgrade. Set while upgradePhase is one of Expanding/Migrating/RollingUpdate/Contracting; cleared after Contracting completes.
upgradePhaseUpgradePhaseCurrent phase of an active database upgrade. Empty outside upgrades.

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

UpgradePhase

UpgradePhase is a string enum (+kubebuilder:validation:Enum=Expanding;Migrating;RollingUpdate;Contracting) representing the current phase of a sequential release upgrade driven by reconcileDatabase. Phase transitions follow the expand-migrate-contract pattern:

ValueMeaning
ExpandingAdditive schema migrations running (new columns/tables). Old pods keep serving.
MigratingBackfill/data-migration jobs running against the expanded schema.
RollingUpdateNew image is rolling out; old and new pods read the expanded schema side-by-side.
ContractingDestructive schema migrations running (drop old columns/tables) after the rollout completes.

spec.image.tag must be parseable by ParseRelease (YYYY.N or YYYY.N-patch). Sequential upgrades are limited to one minor step (2025.1 → 2025.2) or a year-boundary crossing (2025.2 → 2026.1); downgrades and skip-level upgrades are rejected by the reconciler.


Shared Types (from internal/common/types)

The following types are imported as commonv1 from github.com/c5c3/forge/internal/common/types. They are shared across all CobaltCore operator CRDs.

ImageSpec

FieldTypeRequiredDescription
repositorystringYesContainer image repository (e.g., c5c3/keystone).
tagstringYesImage tag (e.g., 2025.1).

DatabaseSpec

FieldTypeRequiredDescription
clusterRef*corev1.LocalObjectReferenceNoReference to a MariaDB CR (managed mode).
hoststringNoDatabase hostname (brownfield mode).
portint32NoDatabase port (brownfield mode, default 3306).
databasestringYesDatabase name.
secretRefSecretRefSpecYesSecret with database credentials.

Exactly one of clusterRef or host must be set (enforced by CEL validation).

CacheSpec

FieldTypeRequiredDescription
clusterRef*corev1.LocalObjectReferenceNoReference to a Memcached CR (managed mode).
backendstringYesCache backend (e.g., dogpile.cache.pymemcache).
servers[]stringNoCache server endpoints (brownfield mode).

SecretRefSpec

FieldTypeRequiredDescription
namestringYesName of the Kubernetes Secret.
keystringNoKey within the Secret's data.

PolicySpec

FieldTypeRequiredDescription
rulesmap[string]stringNoInline policy rule overrides. Keys are oslo.policy rule names; values are rule definitions. Inline rules take precedence over ConfigMap rules.
configMapRef*corev1.LocalObjectReferenceNoReference to a ConfigMap containing a policy.yaml key with rule overrides.

When policyOverrides is set on KeystoneSpec, at least one of rules or configMapRef must be provided (enforced by both CEL validation and the webhook).

PluginSpec

FieldTypeRequiredDescription
namestringYesPlugin name (e.g., keystone-keycloak-backend).
configSectionstringYesINI section name (e.g., keycloak). Must be unique across all plugins.
configmap[string]stringNoKey-value pairs for the plugin's INI section.

MiddlewareSpec

FieldTypeRequiredDescription
namestringYesFilter name (e.g., audit).
filterFactorystringYesPython entry point (e.g., audit_middleware:filter_factory).
positionPipelinePositionYesPipeline insertion point: "before" or "after".
configmap[string]stringNoKey-value pairs for the filter section.

Webhooks

The KeystoneWebhook struct implements both defaulting and validating admission webhooks via the admission.Defaulter[*Keystone] and admission.Validator[*Keystone] interfaces from controller-runtime.

Registration

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

Registers both webhooks with the manager using builder.WebhookManagedBy[*Keystone].

Defaulting Webhook

go
func (w *KeystoneWebhook) Default(_ context.Context, obj *Keystone) error

Sets spec fields to their documented defaults when they carry zero values. Explicit (non-zero) values are never overridden.

FieldConditionDefault Value
spec.replicas== 03
spec.fernet.maxActiveKeys== 03
spec.credentialKeys.maxActiveKeys== 03
spec.cache.backend== """dogpile.cache.pymemcache"
spec.bootstrap.adminUser== """admin"
spec.bootstrap.region== """RegionOne"
spec.uwsgi.processes== 0 (when spec.uwsgi is non-nil)2 — webhook only; when spec.uwsgi is nil, the reconciler applies this default internally.
spec.uwsgi.threads== 0 (when spec.uwsgi is non-nil)1 — same nil-pointer caveat as processes.
spec.uwsgi.httpKeepAliveField absent from JSON payloadtrue — defaulted by the CRD schema (+kubebuilder:default=true), not by the webhook. The webhook cannot distinguish "not set" from "explicitly false" for a bool field. See HTTPKeepAlive defaulting.
spec.resources== nil or empty (requests and limits both unset){requests: {memory: 256Mi, cpu: 100m}, limits: {memory: 512Mi, cpu: 500m}} — ensures Burstable QoS class and enables HPA utilization calculations.

Not defaulted by the webhook:

  • spec.fernet.rotationSchedule, spec.credentialKeys.rotationSchedule, spec.trustFlush.schedule, spec.autoscaling.minReplicas, spec.topologySpreadConstraints, spec.priorityClassName — these rely on CRD schema defaults or reconciler-level fallbacks. For topologySpreadConstraints the reconciler distinguishes nil (inject zone+hostname defaults) from [] (opt out), so the webhook must not materialise a struct.

Design note: spec.fernet.rotationSchedule is NOT defaulted by the webhook — it relies solely on the Kubebuilder +kubebuilder:default="0 0 * * 0" marker. The webhook uses conditional checks (== 0 / == "") rather than always-set to cooperate with the remaining Kubebuilder +default markers, which also provide schema-level defaults. Both layers are intentional — schema defaults apply at deserialization time, while webhook defaults catch zero values that bypass schema defaults (e.g., explicit replicas: 0).

Validating Webhook

go
func (w *KeystoneWebhook) ValidateCreate(_ context.Context, obj *Keystone) (admission.Warnings, error)
func (w *KeystoneWebhook) ValidateUpdate(_ context.Context, _, newObj *Keystone) (admission.Warnings, error)
func (w *KeystoneWebhook) ValidateDelete(_ context.Context, _ *Keystone) (admission.Warnings, error)
  • ValidateCreate and ValidateUpdate both delegate to the internal validate() method. There are no create-specific or update-specific rules.
  • ValidateDelete always returns nil — deletion is unconditionally allowed.

Validation Rules

The validate() method accumulates all errors in a field.ErrorList and returns a single apierrors.NewInvalid error. It does not short-circuit on the first error.

RuleField PathError TypeCondition
Replicas minimumspec.replicasfield.Invalidreplicas < 1. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
Cache mutual exclusivityspec.cachefield.InvalidBoth clusterRef and servers set, or neither. Defense-in-depth alongside the CEL XValidation rule.
Database mutual exclusivityspec.databasefield.InvalidBoth clusterRef and host set, or neither. Defense-in-depth alongside the CEL XValidation rule.
Fernet maxActiveKeys minimumspec.fernet.maxActiveKeysfield.InvalidmaxActiveKeys < 3. Defense-in-depth alongside the +kubebuilder:validation:Minimum=3 marker.
Fernet schedule requiredspec.fernet.rotationSchedulefield.RequiredEmpty after admission (bypass paths).
Fernet cron expressionspec.fernet.rotationSchedulefield.Invalidcron.ParseStandard() fails. Error message includes the parse failure details.
CredentialKeys maxActiveKeys minimumspec.credentialKeys.maxActiveKeysfield.InvalidmaxActiveKeys < 3. Defense-in-depth alongside the +kubebuilder:validation:Minimum=3 marker.
CredentialKeys schedule requiredspec.credentialKeys.rotationSchedulefield.RequiredEmpty after admission (bypass paths).
CredentialKeys cron expressionspec.credentialKeys.rotationSchedulefield.Invalidcron.ParseStandard() fails.
Duplicate plugin sectionsspec.plugins[i].configSectionfield.DuplicateTwo or more plugins share the same configSection value.
Policy source requiredspec.policyOverridesfield.RequiredpolicyOverrides is set but both rules and configMapRef are nil/empty.
Empty policy rule namespec.policyOverrides.rulesfield.InvalidA key in rules map is the empty string.
Autoscaling maxReplicas minimumspec.autoscaling.maxReplicasfield.InvalidmaxReplicas < 1. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
Autoscaling minReplicas minimumspec.autoscaling.minReplicasfield.InvalidminReplicas < 1 when set. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
Autoscaling min exceeds maxspec.autoscaling.minReplicasfield.InvalidminReplicas > maxReplicas when set.
Autoscaling maxReplicas vs replicasspec.autoscaling.maxReplicasfield.InvalidminReplicas is unset and spec.replicas > autoscaling.maxReplicas. Would otherwise produce an HPA the API server rejects, because minReplicas defaults to spec.replicas.
Autoscaling CPU utilization rangespec.autoscaling.targetCPUUtilizationfield.InvalidValue outside 1..100 when set.
Autoscaling memory utilization rangespec.autoscaling.targetMemoryUtilizationfield.InvalidValue outside 1..100 when set.
Autoscaling no metric targetsspec.autoscalingfield.RequiredNeither targetCPUUtilization nor targetMemoryUtilization is set. Defense-in-depth alongside the CEL XValidation rule.
NetworkPolicy ingress requiredspec.networkPolicy.ingressfield.RequirednetworkPolicy is set but ingress is empty. Defense-in-depth alongside the CEL XValidation rule.
uWSGI processes minimumspec.uwsgi.processesfield.Invalidprocesses < 1 when spec.uwsgi is non-nil. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
uWSGI threads minimumspec.uwsgi.threadsfield.Invalidthreads < 1 when spec.uwsgi is non-nil. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
uWSGI harakiri minimumspec.uwsgi.harakirifield.Invalidharakiri < 1 when set. Defense-in-depth alongside the +kubebuilder:validation:Minimum=1 marker.
uWSGI keep-alive timeout minimumspec.uwsgi.httpKeepAliveTimeoutfield.InvalidhttpKeepAliveTimeout < 1 when set. A zero value is rejected because uWSGI interprets it as unbounded, defeating the graceful-termination contract.
uWSGI keep-alive timeout without keep-alivespec.uwsgi.httpKeepAliveTimeoutfield.InvalidhttpKeepAliveTimeout is set while httpKeepAlive=false. The --http-keepalive-timeout flag is only emitted when keep-alive is enabled, so the combination is rejected to avoid silently dropping user intent.
TerminationGracePeriodSeconds minimumspec.terminationGracePeriodSecondsfield.InvalidterminationGracePeriodSeconds < 10 when set. Defense-in-depth alongside the +kubebuilder:validation:Minimum=10 marker.
PreStopSleepSeconds minimumspec.preStopSleepSecondsfield.InvalidpreStopSleepSeconds < 0 when set. Defense-in-depth alongside the +kubebuilder:validation:Minimum=0 marker.
PreStopSleep ≥ grace periodspec.preStopSleepSecondsfield.InvalidResolved preStopSleepSeconds >= terminationGracePeriodSeconds (nil pointers resolve to defaults 5/30). Guarantees a non-zero drain window between the end of the preStop sleep and SIGKILL.
Harakiri ≥ drain windowspec.uwsgi.harakirifield.Invalidharakiri >= terminationGracePeriodSeconds − preStopSleepSeconds (nil pointers resolve to defaults). Guarantees the per-request kill fits inside the shutdown envelope.
Recreate strategy with RollingUpdatespec.strategy.rollingUpdatefield.Invalidstrategy.type = Recreate combined with a non-nil strategy.rollingUpdate block. The Deployment controller would reject the object at apply time; the webhook catches the misconfiguration up-front.
Resource request exceeds limitspec.resources.requests.<resource>field.InvalidA resource request exceeds its corresponding limit (e.g., CPU request 1000m > limit 500m). Checked per resource type when both requests and limits are set.
Trust flush schedule requiredspec.trustFlush.schedulefield.RequiredtrustFlush is set but schedule is empty. Defense-in-depth — the +kubebuilder:default marker normally prevents this, but bypass paths (e.g., kubectl patch) may produce an empty string.
Trust flush cron expressionspec.trustFlush.schedulefield.Invalidcron.ParseStandard() fails on trustFlush.schedule. Error message includes the parse failure details.
PriorityClass existencespec.priorityClassNamefield.NotFound / field.InternalErrorThe webhook performs a cluster-scoped Get of the referenced scheduling.k8s.io/v1 PriorityClass when the field is non-empty. Missing classes produce NotFound; transient API errors produce InternalError.
TopologySpread labelSelector requiredspec.topologySpreadConstraints[i].labelSelectorfield.RequiredEntry has no labelSelector.
TopologySpread matchLabels mismatchspec.topologySpreadConstraints[i].labelSelectorfield.InvalidmatchLabels does not exactly equal {app.kubernetes.io/name: keystone, app.kubernetes.io/instance: {CR name}}.
TopologySpread matchExpressions forbiddenspec.topologySpreadConstraints[i].labelSelector.matchExpressionsfield.InvalidmatchExpressions is non-empty. Only exact matchLabels are allowed.

Error format: All validation errors are returned as a structured apierrors.StatusError with GroupKind{Group: "keystone.openstack.c5c3.io", Kind: "Keystone"}, providing clear, field-specific error messages to the operator.


Testing

The Keystone CRD has a three-layer test strategy:

  1. Unit tests — fast, in-process tests for webhook logic.
  2. Integration tests — envtest-based tests that run a real API server + etcd to validate CRD schema, CEL rules, and webhooks through the full admission pipeline.
  3. E2E tests — Chainsaw tests that deploy the operator to a real cluster and verify webhook rejection in a production-like environment.

Running the Tests

LayerCommandPrerequisites
Unitgo test ./operators/keystone/api/v1alpha1/None
Integrationgo test -tags=integration ./operators/keystone/api/v1alpha1/KUBEBUILDER_ASSETS set to envtest binaries
E2Echainsaw test --test-dir tests/e2e/keystone/invalid-cr/Operator deployed to a cluster with webhooks active

envtest Integration Helper

The operators/keystone/internal/testutil package provides a Keystone-specific envtest setup helper that configures CRD installation and webhook serving for integration tests.

go
func SetupKeystoneEnvTest(
    t testing.TB,
    addToScheme func(*runtime.Scheme) error,
    registerWebhooks func(ctrl.Manager) error,
) (client.Client, context.Context, context.CancelFunc)

Design decisions:

  • Uses a local schemeSharedScheme() from internal/common is not modified. Only Keystone tests need Keystone types registered.
  • Resolves CRD and webhook manifest paths via runtime.Caller(0) relative navigation, matching the pattern in internal/common/testutil/envtest/setup.go.
  • Starts a controller-runtime manager with a webhook server bound to the envtest-allocated host, port, and certificate directory.
  • Waits for the webhook server TLS endpoint to accept connections before returning.
  • Tears down the environment automatically via t.Cleanup().

Parameters:

NameTypeDescription
addToSchemefunc(*runtime.Scheme) errorRegisters Keystone API types (breaks import cycle between testutil and v1alpha1).
registerWebhooksfunc(ctrl.Manager) errorSets up webhook handlers with the manager.

The SkipIfEnvTestUnavailable guard is re-exported from internal/common/testutil/envtest for convenience.

Integration Test Coverage

All integration tests use the //go:build integration tag and call testutil.SkipIfEnvTestUnavailable(t) as the first statement.

CRD Installation and Valid CR Acceptance

TestRequirementBehavior
TestIntegration_CRDInstalledCRD discoverableLists CRDs via apiextensions API; verifies keystones.keystone.openstack.c5c3.io is present.
TestIntegration_ValidCRAcceptedHappy-path admissionCreates a valid Keystone CR (brownfield database mode), verifies HTTP 201 and successful Get.
TestIntegration_ValidCRWithClusterRefAcceptedClusterRef modeCreates a valid CR using database.clusterRef and cache.clusterRef, verifies acceptance and readback.

CEL Validation Rejection

TestRequirementTriggerExpected Error
TestIntegration_CELRejectsDBBothClusterRefAndHostMutual exclusivityBoth database.clusterRef and database.host setInvalid/Forbidden containing "database"
TestIntegration_CELRejectsCacheBothClusterRefAndServersMutual exclusivityBoth cache.clusterRef and cache.servers setInvalid/Forbidden containing "cache"
TestIntegration_CELRejectsReplicasBelowMinimumMinimum constraintreplicas = -1 (note: 0 is converted to 3 by the defaulting webhook, so -1 is used)Invalid/Forbidden
TestIntegration_CELRejectsMaxActiveKeysBelowMinimumMinimum constraintfernet.maxActiveKeys = 1 (below minimum of 3; 0 is defaulted to 3 by webhook)Invalid/Forbidden
TestIntegration_CELRejectsPolicyOverridesEmptyPolicy source requiredpolicyOverrides set with neither rules nor configMapRefInvalid/Forbidden containing "policyOverrides"

Admission pipeline note: In Kubernetes, the admission order is: mutating webhooks then schema validation (CEL) then validating webhooks. The defaulting webhook converts replicas: 0 to 3 and maxActiveKeys: 0 to 3 before CEL validation runs, so these tests use values that bypass defaulting (negative or non-zero-but-below-minimum) to exercise the CRD schema constraints.

Webhook Defaulting

TestRequirementBehavior
TestIntegration_WebhookDefaultsSetsZeroValuesDefaults appliedCreates a CR with zero-valued defaultable fields; verifies replicas=3, cache.backend="dogpile.cache.pymemcache", bootstrap.adminUser="admin", bootstrap.region="RegionOne", fernet.maxActiveKeys=3 after admission.
TestIntegration_WebhookDefaultsPreservesExplicitExplicit values preservedCreates a CR with replicas=5 and region="EU-West"; verifies these values are not overwritten by the defaulting webhook.
TestIntegration_ResourcesDefaultedWhenNilResources defaultedCreates a CR with spec.resources unset (nil); verifies the defaulting webhook injects {requests: {memory: 256Mi, cpu: 100m}, limits: {memory: 512Mi, cpu: 500m}}.
TestIntegration_ResourcesPreservedWhenExplicitExplicit resources preservedCreates a CR with explicit spec.resources (1Gi/2Gi memory, 200m/1 CPU); verifies the defaulting webhook does not overwrite them.
TestIntegration_UWSGIDefaultsAppliedWhenEmptyuWSGI defaults appliedCreates a CR with spec.uwsgi: {} (all zero values); verifies processes=2, threads=1, httpKeepAlive=true after admission.
TestIntegration_UWSGIExplicitValuesPreservedExplicit uWSGI preservedCreates a CR with spec.uwsgi.processes=4, threads=4; verifies these values are not overwritten by the defaulting webhook.
TestIntegration_UWSGIPartialDefaultingPartial uWSGI defaultsCreates a CR with only spec.uwsgi.processes=4; verifies threads=1 is defaulted while processes=4 is preserved.
TestIntegration_UWSGINilPreserveduWSGI nil preservedCreates a CR without spec.uwsgi; verifies the field remains nil after admission — webhook does not inject a default struct.

Webhook Validation Rejection

TestRequirementTriggerExpected Error
TestIntegration_ResourcesRequestExceedsLimitRejectedRequest must not exceed limitspec.resources with CPU request 1000m > limit 500mInvalid/Forbidden containing "resources".
TestIntegration_UWSGIProcessesBelowMinimumRejectedProcesses minimumspec.uwsgi.processes below minimum (bypassing defaulting)Invalid/Forbidden containing "uwsgi".
TestIntegration_UWSGIThreadsBelowMinimumRejectedThreads minimumspec.uwsgi.threads below minimum (bypassing defaulting)Invalid/Forbidden containing "uwsgi".

Chainsaw E2E Tests

E2E tests live in tests/e2e/keystone/ and use the Chainsaw framework (chainsaw.kyverno.io/v1alpha2). The invalid-cr suite below verifies webhook rejection in a real cluster with the operator deployed. For the full reconciler E2E test suite inventory (basic-deployment, scale, fernet-rotation, credential-rotation, network-policy, topology-spread, priority-class, release-upgrade, schema-drift-detection, events, healthcheck, graceful-shutdown, policy-validation, config-pruning, …), see Keystone E2E Test Suites.

invalid-cr Suite

The full webhook + CEL rejection matrix extends the original two-step suite so that every implemented XValidation rule and every webhook.validate() branch in operators/keystone/api/v1alpha1/ is pinned by a Chainsaw step.

StepManifestRequirementExpected Error
invalid-cron-expression-rejected00-invalid-cron.yamlInvalid cronError containing "rotationSchedule" and "invalid cron expression"
duplicate-plugin-config-section-rejected01-duplicate-plugins.yamlDuplicate configSectionError containing "configSection" and "Duplicate value"
database-both-modes-rejected02-database-both-modes.yamlDatabaseSpec mutual exclusivityError containing "spec.database" and "exactly one of clusterRef or host must be set"
cache-both-modes-rejected03-cache-both-modes.yamlCacheSpec mutual exclusivityError containing "spec.cache" and "exactly one of clusterRef or servers must be set"
autoscaling-no-target-rejected04-autoscaling-no-target.yamlAutoscalingSpec target requiredError containing "spec.autoscaling" and "at least one of targetCPUUtilization or targetMemoryUtilization"
policy-overrides-no-source-rejected05-policy-overrides-no-source.yamlPolicyOverrides source requiredError containing "spec.policyOverrides" and "at least one of rules or configMapRef must be set"
policy-overrides-empty-rule-key-rejected06-policy-overrides-empty-rule-key.yamlNon-empty rule namesError containing "spec.policyOverrides" and "policy rule name must not be empty"
networkpolicy-empty-ingress-rejected07-networkpolicy-empty-ingress.yamlNetworkPolicy ingress requiredError containing "spec.networkPolicy" and "at least one ingress source"
replicas-negative-rejected09-replicas-negative.yamlReplicas Minimum=1 (subsumes the dropped 08-replicas-zero.yaml case — see layer-ordering aside)Error containing "replicas"
hpa-min-greater-than-max-rejected10-hpa-min-greater-than-max.yamlminReplicas ≤ maxReplicasError containing "spec.autoscaling.minReplicas" and "must not exceed maxReplicas"
fernet-maxactivekeys-below-minimum-rejected11-fernet-maxactivekeys-below-minimum.yamlFernet maxActiveKeys Minimum=3Error containing "maxActiveKeys"
credentialkeys-maxactivekeys-below-minimum-rejected12-credentialkeys-maxactivekeys-below-minimum.yamlCredentialKeys maxActiveKeys Minimum=3Error containing "maxActiveKeys"

Each step uses apply with expect to assert that the $error variable is non-null and contains the expected field-level error message. Kubernetes admission evaluates validation in a fixed pipeline — mutating webhook (defaulting) → CRD structural schema (incl. CEL XValidation rules) → validating webhook — and the first layer that rejects an object is the one whose message Chainsaw sees. The mutating step is listed first because it can silently rewrite a value out from under a downstream rule: keystone_webhook.go:80-82 coerces spec.replicas == 0 to 3 BEFORE the +kubebuilder:validation:Minimum=1 marker is evaluated, so a manifest using spec.replicas: 0 would be silently accepted. This is the precise reason the 08-replicas-zero.yaml case was dropped from the suite: the 09-replicas-negative.yaml fixture (spec.replicas: -1) uses a value the defaulter does not touch (the defaulter only fires on == 0) and exercises the same Minimum=1 and webhook-defense-in-depth path. The same trap applies to maxActiveKeys: 0, which is why the maxActiveKeys fixtures use 2 rather than 0.

For most rules the producing layer is unambiguous (CEL emits the exact "exactly one of …", "at least one of …", "must not exceed maxReplicas" wording), so the assertions match the full webhook-equivalent message. The 06-policy-overrides-empty-rule-key.yaml and 07-networkpolicy-empty-ingress.yaml fixtures are the dual-layer exceptions where the fieldPath emitted by CEL is the parent path (spec.policyOverrides / spec.networkPolicy) — the path where the XValidation rule is declared — and NOT the deeper path the validating webhook would emit (…rules / …ingress). Because CEL fails first and short-circuits the admission pipeline, the validating webhook's deeper-path message never reaches Chainsaw, so the assertions match only the parent path. The 11-fernet-maxactivekeys-below-minimum.yaml and 12-credentialkeys-maxactivekeys-below-minimum.yaml fixtures are the field-substring exceptions: they trip the CRD structural schema's Minimum=N first, whose generated wording ("must be greater than or equal to N") differs from the webhook's defense-in-depth wording ("maxActiveKeys must be at least 3"). Both layers carry the field name, so the loose-substring assertion (maxActiveKeys) keeps the tests stable regardless of which layer fires first and across upstream Kubernetes admission-pipeline changes.

The 10 generated fixtures (02-… through 12-…, with the 08-replicas-zero.yaml gap explained above) share an otherwise-identical minimal valid Keystone scaffold and differ only by the field under test. To prevent that scaffold from drifting across files, the fixtures are generated from a single canonical source in tests/e2e/keystone/invalid-cr/_generate.py. After editing the scaffold or any per-fixture override, regenerate via python3 tests/e2e/keystone/invalid-cr/_generate.py. The verify-invalid-cr-fixtures CI job (and the matching make verify-invalid-cr-fixtures Makefile target) runs _generate.py --check in drift mode and the test_generate.py unit suite (len(FIXTURES) == 10 plus a cross-reference assertion that every Fixture.filename appears as a file: step in chainsaw-test.yaml), so a hand-edit to any generated fixture — or a rename/removal that desynchs FIXTURES from chainsaw-test.yaml — fails the build before the cluster-bound e2e-operator job runs. The 00-invalid-cron.yaml and 01-duplicate-plugins.yaml fixtures predate the generator and are intentionally NOT regenerated.

The following follow-up gaps are intentionally not covered by this suite — they require new validation rules that do not exist yet, and each one is tracked as its own feature ticket:

  • Empty / malformed spec.image.tag (no MinLength or pattern on ImageSpec.Tag).
  • topologySpreadConstraints[*].maxSkew: 0 (no CRD-level minimum on the upstream type, no defense-in-depth in the Keystone webhook).
  • Mutation of immutable fields (spec.database.clusterRef, spec.cache.clusterRef) on ValidateUpdate — old-vs-new comparison is not yet implemented.

uwsgi Suite

The uwsgi suite (tests/e2e/keystone/uwsgi/) validates that spec.uwsgi values propagate to the Deployment container command in a real cluster with the operator deployed and reconciling.

StepDescriptionAssertion
Step 1Apply Keystone CR without explicit spec.uwsgiCR created
Step 2 (step-2-assert-default-uwsgi-args)Assert Deployment command contains default uWSGI argsContainer command includes --processes 2 --threads 1 --http-keepalive
Step 3Patch CR with spec.uwsgi: {processes: 3, threads: 3, httpKeepAlive: false}Patch applied
Step 4 (step-4-assert-custom-uwsgi-args)Assert Deployment command updated with custom valuesContainer command includes --processes 3 --threads 3; --http-keepalive is absent

CRD Generation

The CRD manifest and DeepCopy methods are generated by controller-gen:

TargetCommandOutput
DeepCopymake generateoperators/keystone/api/v1alpha1/zz_generated.deepcopy.go
CRD YAMLmake manifestsoperators/keystone/config/crd/bases/keystone.openstack.c5c3.io_keystones.yaml

Both targets are parameterized by operator directory in the Makefile. Generated zz_generated.*.go files are excluded from linting via .golangci.yml.

Generated DeepCopy Types

zz_generated.deepcopy.go provides DeepCopyObject() and DeepCopyInto() for:

  • Keystone
  • KeystoneList
  • KeystoneSpec
  • KeystoneStatus
  • AutoscalingSpec
  • NetworkPolicySpec
  • NetworkPolicyIngressSource
  • UWSGISpec
  • TrustFlushSpec
  • FernetSpec
  • CredentialKeysSpec
  • FederationSpec
  • BootstrapSpec

File Layout

text
operators/keystone/
├── api/v1alpha1/
│   ├── groupversion_info.go          GroupVersion, SchemeBuilder, AddToScheme
│   ├── keystone_types.go             CRD types + init() scheme registration
│   ├── keystone_webhook.go           Defaulting + validating webhooks
│   ├── keystone_types_test.go        Type and scheme registration tests
│   ├── keystone_webhook_test.go      Webhook unit tests (table-driven)
│   ├── integration_test.go           envtest integration tests
│   └── zz_generated.deepcopy.go     Generated DeepCopy methods
├── config/crd/bases/
│   └── keystone.openstack.c5c3.io_keystones.yaml  Generated CRD manifest
├── config/webhook/
│   ├── manifests.yaml                Generated webhook configurations
│   └── ...
├── internal/testutil/
│   └── envtest_setup.go              Keystone-specific envtest helper
└── main.go                           Scheme registration + bootstrap + webhook wiring

tests/e2e/keystone/
├── basic-deployment/                 Happy-path reconciliation E2E
├── missing-secret/                   Secret dependency recovery E2E
├── fernet-rotation/                  Fernet key rotation E2E
├── scale/                            Replica scaling E2E
├── deletion-cleanup/                 Garbage collection E2E
├── policy-overrides/                 oslo.policy integration E2E
├── middleware-config/                Middleware pipeline E2E
├── brownfield-database/              External database mode E2E
├── image-upgrade/                    Rolling image upgrade E2E
├── uwsgi/                            uWSGI field propagation E2E
│   ├── chainsaw-test.yaml            Chainsaw E2E test definition
│   ├── 00-keystone-cr.yaml           Keystone CR without explicit uWSGI
│   └── 01-patch-custom-uwsgi.yaml    Patch with custom uWSGI values
└── invalid-cr/
    ├── chainsaw-test.yaml                                  Chainsaw E2E test definition
    ├── 00-invalid-cron.yaml                                Invalid cron expression CR manifest
    ├── 01-duplicate-plugins.yaml                           Duplicate plugin configSection CR manifest
    ├── 02-database-both-modes.yaml                         Database clusterRef + host both set
    ├── 03-cache-both-modes.yaml                            Cache clusterRef + servers both set
    ├── 04-autoscaling-no-target.yaml                       Autoscaling without utilization target
    ├── 05-policy-overrides-no-source.yaml                  PolicyOverrides without rules or configMapRef
    ├── 06-policy-overrides-empty-rule-key.yaml             PolicyOverrides rule with empty key
    ├── 07-networkpolicy-empty-ingress.yaml                 NetworkPolicy with empty ingress array
    ├── 09-replicas-negative.yaml                           spec.replicas: -1 (subsumes the dropped 08-replicas-zero case)
    ├── 10-hpa-min-greater-than-max.yaml                    HPA minReplicas > maxReplicas
    ├── 11-fernet-maxactivekeys-below-minimum.yaml          Fernet maxActiveKeys < 3
    └── 12-credentialkeys-maxactivekeys-below-minimum.yaml  CredentialKeys maxActiveKeys < 3

This layout is the canonical pattern for all CobaltCore operators. New operators should replicate this directory structure.