Skip to content

Keystone Reconciler ​

The Keystone Reconciler implements the core control loop that drives the Keystone Identity Service from a desired state (CRD spec) to an observed state (running pods, synced database, rotated keys). This page documents the reconciler architecture, sub-reconciler pattern, error handling, and controller setup.

For the CRD type definitions and webhooks, see CRD Implementation.

Reconciler Architecture ​

text
┌─────────────────────────────────────────────────────────────────────────────┐
│                       KEYSTONE RECONCILIATION FLOW                          │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  Keystone CR changed (or requeue timer fires)                               │
│  (finalizers handled first: MariaDB + OpenBao backup cleanup on delete)     │
│         │                                                                   │
│         ▼                                                                   │
│  reconcileSecrets          Check ESO K8s Secrets exist  → SecretsReady      │
│         ▼                                                                   │
│  reconcileDatabaseTLS      Issue DB client cert (cert-manager) → DatabaseTLSReady │
│         ▼                                                                   │
│  reconcileDBConnectionSecret  Build <name>-db-connection Secret (pymysql URL)│
│         ▼                       (reuses SecretsReady; creds NOT in conf)     │
│  reconcileConfig           Render keystone.conf.d/ → immutable ConfigMap     │
│         │                  (returns configMapName; sets LoggingHealthy)      │
│         ▼                                                                   │
│  ┌──────── parallel group (no cross-dependency) ────────┐                   │
│  │ reconcileFernetKeys      → FernetKeysReady            │                   │
│  │ reconcileCredentialKeys  → CredentialKeysReady        │                   │
│  │ reconcileNetworkPolicy   → NetworkPolicyReady         │                   │
│  └───────────────────────────┬───────────────────────────┘                  │
│         ▼                                                                   │
│  reconcileDatabase         MariaDB CRs + db_sync Job → DatabaseReady         │
│         ▼                                                                   │
│  reconcilePolicyValidation oslopolicy-validator Job → PolicyValidReady       │
│         ▼                                                                   │
│  reconcileDeployment       Deployment + Service (5000) → DeploymentReady     │
│         ▼   (then pruneStaleConfigMaps)                                      │
│  reconcileHTTPRoute        Gateway API HTTPRoute → HTTPRouteReady            │
│         ▼                                                                   │
│  reconcileHealthCheck      Active HTTP probe of endpoint → KeystoneAPIReady  │
│         ▼                                                                   │
│  reconcileHPA              HPA create/update/delete → HPAReady               │
│         ▼                                                                   │
│  reconcileBootstrap        keystone-manage bootstrap → BootstrapReady        │
│         ▼                                                                   │
│  reconcileTrustFlush       trust_flush CronJob → TrustFlushReady             │
│         ▼                                                                   │
│  reconcilePasswordRotation admin-password rotate CronJob → PasswordRotationReady │
│         ▼   (Model B, opt-in; runs after Bootstrap seeds the admin cred)     │
│  setReadyCondition → Ready=True (all aggregated sub-conditions met)          │
│                                                                             │
│  Every sub-reconciler is wrapped by instrumentSubReconciler (CC-0089),      │
│  emitting per-step duration + error metrics under a `sub_reconciler` label. │
└─────────────────────────────────────────────────────────────────────────────┘

Controller Setup ​

The controller is registered through the shared bootstrap manager, whose SetupFunc wires the reconciler and (when enabled) the webhook:

go
func main() {
    if err := bootstrap.Run(bootstrap.ManagerConfig{
        Scheme:           scheme,
        LeaderElectionID: "keystone.openstack.c5c3.io",
        SetupFunc: func(mgr ctrl.Manager, enableWebhooks bool) error {
            if err := (&controller.KeystoneReconciler{
                Client:   mgr.GetClient(),
                Scheme:   mgr.GetScheme(),
                Recorder: mgr.GetEventRecorderFor("keystone-controller"),
            }).SetupWithManager(mgr); err != nil {
                return err
            }
            if enableWebhooks {
                return (&keystonev1alpha1.KeystoneWebhook{
                    Client: mgr.GetClient(),
                }).SetupWebhookWithManager(mgr)
            }
            return nil
        },
    }); err != nil {
        ctrl.Log.WithName("setup").Error(err, "unable to run manager")
        os.Exit(1)
    }
}

RBAC markers on the reconciler define the required ClusterRole permissions:

go
// +kubebuilder:rbac:groups=keystone.openstack.c5c3.io,resources=keystones,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=keystone.openstack.c5c3.io,resources=keystones/status,verbs=get;update;patch
// +kubebuilder:rbac:groups=keystone.openstack.c5c3.io,resources=keystones/finalizers,verbs=update
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=services;configmaps;secrets;serviceaccounts,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch
// +kubebuilder:rbac:groups=core,resources=pods,verbs=get;list
// +kubebuilder:rbac:groups=batch,resources=jobs;cronjobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=k8s.mariadb.com,resources=databases;users;grants,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=k8s.mariadb.com,resources=mariadbs,verbs=get;list;watch
// +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=external-secrets.io,resources=externalsecrets,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=external-secrets.io,resources=pushsecrets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=external-secrets.io,resources=clustersecretstores,verbs=get;list;watch
// +kubebuilder:rbac:groups=policy,resources=poddisruptionbudgets,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes/status,verbs=get
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=scheduling.k8s.io,resources=priorityclasses,verbs=get;list;watch

Note: Service operators that depend on RabbitMQ (Nova, Neutron, Cinder) require additional RBAC for the Messaging Topology Operator CRDs:

go
// +kubebuilder:rbac:groups=rabbitmq.com,resources=rabbitmqclusters,verbs=get;list;watch
// +kubebuilder:rbac:groups=rabbitmq.com,resources=vhosts;users;permissions,verbs=get;list;watch;create;update;patch;delete

Watches — the controller watches the Keystone CR, all owned resources, and several externally-owned dependencies. Owns(HTTPRoute) is registered conditionally — only when the Gateway API CRD is present (probed via the RESTMapper at startup), so the operator still runs on clusters without Gateway API. A field indexer (KeystoneSecretNameIndexKey, CC-0087) lets the Secret mapper do an O(1) reverse lookup rather than listing every CR:

go
func (r *KeystoneReconciler) SetupWithManager(mgr ctrl.Manager) error {
    // spec.gateway is optional; only watch HTTPRoute when the CRD exists.
    r.gatewayAPIAvailable = isGatewayAPIAvailable(mgr.GetRESTMapper())

    // Register the Secret-name field indexer before Watches (CC-0087).
    if err := registerSecretNameIndex(context.Background(), mgr.GetFieldIndexer()); err != nil {
        return err
    }

    b := ctrl.NewControllerManagedBy(mgr).
        For(&keystonev1alpha1.Keystone{}).
        Owns(&appsv1.Deployment{}).
        Owns(&corev1.Service{}).
        Owns(&corev1.ConfigMap{}).
        Owns(&batchv1.Job{}).
        Owns(&policyv1.PodDisruptionBudget{}).
        Owns(&autoscalingv2.HorizontalPodAutoscaler{}).
        Owns(&networkingv1.NetworkPolicy{}).
        Owns(&batchv1.CronJob{})

    if r.gatewayAPIAvailable {
        b = b.Owns(&gatewayv1.HTTPRoute{})
    }

    return b.
        // ESO-managed Secrets are owned by the ExternalSecret controller, not
        // the Keystone CR — map them via the field indexer instead (CC-0013).
        Watches(&corev1.Secret{}, handler.EnqueueRequestsFromMapFunc(
            secretToKeystoneMapper(mgr.GetClient()),
        )).
        // Reflect upstream MariaDB outages in DatabaseReady without waiting for
        // the next periodic requeue (CC-0047).
        Watches(&mariadbv1alpha1.MariaDB{}, handler.EnqueueRequestsFromMapFunc(
            mariaDBToKeystoneMapper(mgr.GetClient()),
        )).
        // Reflect OpenBao-backend outages in SecretsReady as soon as ESO flips
        // the ClusterSecretStore Ready condition (CC-0047).
        Watches(&esov1.ClusterSecretStore{}, handler.EnqueueRequestsFromMapFunc(
            clusterSecretStoreToKeystoneMapper(mgr.GetClient()),
        )).
        // Watch backup PushSecrets via a name mapper + predicate (not Owns) so
        // the OpenBao-finalizer adoption-wait reacts to finalizer/deletion
        // transitions while suppressing ESO's status-only ticks (CC-0092).
        Watches(&esov1alpha1.PushSecret{},
            handler.EnqueueRequestsFromMapFunc(pushSecretToKeystoneMapper(mgr.GetClient())),
            builder.WithPredicates(pushSecretRelevantChangePredicate),
        ).
        Complete(r)
}

Keystone Container Image ​

The reconciler uses the Keystone service image (ghcr.io/c5c3/keystone:<tag>) specified in spec.image for all workloads. The same image serves different purposes based on the command:

text
┌─────────────────────────────────────────────────────────────────────────────┐
│                       KEYSTONE IMAGE USAGE                                  │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ghcr.io/c5c3/keystone:28.0.0                                               │
│  ├─────────────────────────────────────────────────────────────────────┐    │
│  │                                                                     │    │
│  │  Jobs (one-shot):                                                   │    │
│  │  ├── keystone-manage db_sync          → Database migration          │    │
│  │  ├── keystone-manage bootstrap        → Initial admin setup         │    │
│  │  └── keystone-manage fernet_setup     → Initial key generation      │    │
│  │                                                                     │    │
│  │  CronJob (recurring):                                               │    │
│  │  └── keystone-manage fernet_rotate    → Periodic key rotation       │    │
│  │                                                                     │    │
│  │  Deployment (long-running):                                         │    │
│  │  └── uwsgi / gunicorn                → WSGI API server              │    │
│  │      (serves Keystone API on port 5000)                             │    │
│  │                                                                     │    │
│  └─────────────────────────────────────────────────────────────────────┘    │
│                                                                             │
│  Volume Mounts:                                                             │
│  ├── /etc/keystone/keystone.conf.d/     ← ConfigMap (--config-dir)          │
│  ├── /etc/keystone/fernet-keys/         ← Secret (Fernet keys, mode 0400)   │
│  ├── /etc/keystone/credential-keys/     ← Secret (credential keys)          │
│  ├── /etc/keystone/db-tls/              ← Secret (DB client cert, when TLS)  │
│  └── /etc/keystone/domains/             ← ConfigMap (domain-specific conf)  │
│                                                                             │
│  DB password is NOT in the ConfigMap — it is injected via the              │
│  OS_DATABASE__CONNECTION env var from the derived <name>-db-connection      │
│  Secret (CC-0080).                                                          │
└─────────────────────────────────────────────────────────────────────────────┘

For image build details and tag schema, see Build Pipeline and Versioning.

Sub-Reconciler Pattern ​

The main Reconcile function calls sub-reconcilers in order. Each sub-reconciler handles one responsibility, sets its own status condition, and returns early (requeue) if its precondition is not met. Every call is wrapped by instrumentSubReconciler (CC-0089), which records per-step duration (keystone_operator_reconcile_duration_seconds) and error (..._reconcile_errors_total) metrics under a sub_reconciler label. The operator's internal/metrics package registers three further metrics: ..._key_rotation_age_seconds (per CR and key type), ..._db_sync_total (terminal-state counter) and ..._db_sync_duration_seconds. Three sub-reconcilers with no cross-dependency — reconcileFernetKeys, reconcileCredentialKeys, reconcileNetworkPolicy — run concurrently in a reconcileParallelGroup.

Note: reconcileConfig() runs early (after secrets and the DB-connection Secret) because the rendered keystone.conf.d/ ConfigMap is required by the parallel key group, the db_sync Job, and the Deployment.

Note: Keystone does not use RabbitMQ. For services that depend on messaging (Nova, Neutron, Cinder), the sub-reconciler chain will be extended with a reconcileMessaging() step (planned, via the shared messaging/ library) that creates RabbitMQ Topology Operator CRs (Vhost, User, Permission) and sets a MessagingReady condition.

reconcileSecrets() ​

Verifies that ESO-provided Kubernetes Secrets exist before proceeding. These Secrets are created by ESO from OpenBao paths (see Secret Management):

Secret NameOpenBao PathContents
keystone-db-credentialskv-v2/openstack/keystone/dbMariaDB username and password
keystone-admin-credentialskv-v2/bootstrap/keystone-adminAdmin password for bootstrap
go
func (r *KeystoneReconciler) reconcileSecrets(ctx context.Context,
    keystone *keystonev1alpha1.Keystone) (ctrl.Result, error) {

    // Check DB credentials
    ready, err := secrets.WaitForExternalSecret(ctx, r.Client,
        client.ObjectKey{Namespace: keystone.Namespace, Name: keystone.Spec.Database.SecretRef.Name})
    if err != nil {
        return ctrl.Result{}, err
    }
    if !ready {
        conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
            Type:    "SecretsReady",
            Status:  metav1.ConditionFalse,
            Reason:  "WaitingForDBCredentials",
            Message: "Waiting for ESO to sync database credentials from OpenBao",
        })
        return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
    }

    // Check admin credentials
    ready, err = secrets.WaitForExternalSecret(ctx, r.Client,
        client.ObjectKey{Namespace: keystone.Namespace, Name: keystone.Spec.Bootstrap.AdminPasswordSecretRef.Name})
    if err != nil {
        return ctrl.Result{}, err
    }
    if !ready {
        conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
            Type:    "SecretsReady",
            Status:  metav1.ConditionFalse,
            Reason:  "WaitingForAdminCredentials",
            Message: "Waiting for ESO to sync admin credentials from OpenBao",
        })
        return ctrl.Result{RequeueAfter: 15 * time.Second}, nil
    }

    conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
        Type:   "SecretsReady",
        Status: metav1.ConditionTrue,
        Reason: "SecretsAvailable",
    })
    return ctrl.Result{}, nil
}

reconcileDatabaseTLS() ​

Provisions the client certificate Keystone presents to MariaDB/MaxScale for mutual TLS (CC-0106). Driven by spec.database.tls, it resolves to one of three modes (dbtls_mode.go):

  • NotRequired — spec.database.tls is nil or disabled. No certificate is issued; the connection is plaintext TCP. DatabaseTLSReady=True (trivially satisfied).
  • ExternallyManaged — the user supplies their own caBundleSecretRef/clientCertSecretRef (brownfield). The operator validates the referenced Secrets exist but issues no Certificate.
  • Managed — the operator issues a <name>-db-client cert-manager Certificate from the shared openstack-db-ca-issuer ClusterIssuer, waits for it to become ready, then sets DatabaseTLSReady=True.

The verification strength (prefer / require / verify-ca / verify-full) maps to pymysql ssl_* DSN parameters that the next sub-reconciler folds into the connection URL.

reconcileDBConnectionSecret() ​

Materializes the database connection URL into a derived Secret rather than the config file (CC-0080). The previous design embedded the DB password into the keystone.conf ConfigMap, exposing credentials at rest in an object that is not treated as a Secret. Instead, this sub-reconciler:

  1. Reads the synced DB credentials Secret (and, in managed mode, derives the username from the Keystone CR name — only brownfield mode reads username from the Secret).
  2. Assembles the full mysql+pymysql://… URL, appending the ssl_* parameters from reconcileDatabaseTLS when TLS is enabled.
  3. Writes it to a <name>-db-connection Secret.

Every workload (API Deployment, db_sync, bootstrap, key-rotation, trust-flush) consumes it at runtime via the OS_DATABASE__CONNECTION environment variable. This step reuses the SecretsReady condition (no separate condition type).

reconcileConfig() ​

Implements the config generation pipeline from Config Generation. This runs after the DB-connection Secret because the rendered ConfigMap is required by subsequent sub-reconcilers (parallel key group, db_sync Job, Deployment).

  1. Read CRD spec — Extract database, cache, fernet, bootstrap, middleware, plugins, logging, and extraConfig fields.
  2. Apply defaults — Merge CRD values with Keystone-specific defaults for the target OpenStack release. The DB password is not resolved here — it is injected at runtime from the <name>-db-connection Secret via OS_DATABASE__CONNECTION.
  3. Render INI — Generate the keystone.conf.d/ files from the merged config map. Plugin config sections from spec.plugins and spec.extraConfig are merged into the output. (The container runs keystone-manage --config-dir=/etc/keystone/keystone.conf.d/.)
  4. Render api-paste.ini — Generate the WSGI pipeline configuration, extended with middleware filters from spec.middleware[].
  5. Create immutable ConfigMap — Hash the rendered config content and create a ConfigMap with the hash in its name (e.g., keystone-config-a3f8b2c1); returns the generated name.
  6. LoggingHealthy — set as an informational (non-aggregating) condition based on stderr-disabled detection.

If the config content changes, a new ConfigMap is created and the Deployment is updated to reference it, triggering a rolling restart; pruneStaleConfigMaps later garbage-collects superseded ConfigMaps. See Validation for how validation operates across the pipeline.

reconcileFernetKeys() ​

Generates the initial Fernet key set and configures periodic rotation using a split-compute-write design (CC-0081) that keeps token-forgery primitives out of the CronJob's RBAC:

  1. Initial generation — Generates maxActiveKeys keys (indices 0..n-1) and stores them in the <name>-fernet-keys Secret.
  2. Rotation CronJob — Creates a CronJob that runs keystone-manage fernet_rotate on the configured schedule. The CronJob does not write the production Secret; it PATCHes a dedicated <name>-fernet-keys-rotation staging Secret with narrow get+patch RBAC.
  3. Validate + apply — The operator (rotation_staging.go / rotation_validation.go) validates the staged material (44-byte base64url → 32 bytes, no duplicates, key count within range) and, using its own privileged ServiceAccount, applies it to the production Secret.
  4. OpenBao backup — Creates a PushSecret CR backing up the keys to openstack/keystone/{name}/fernet-keys in OpenBao. See Credential Lifecycle.

Keys are projected into running pods and rotate in place — no pod-template hash change and no rolling restart on rotation (CC-0074).

reconcileCredentialKeys() ​

Mirrors the Fernet flow for Keystone's credential encryption keys (CC-0036), with the same staging-Secret rotation boundary:

  1. Initial generation — Generates the <name>-credential-keys Secret.
  2. Rotation CronJob — Runs keystone-manage credential_rotate on spec.credentialKeys.rotationSchedule, writing to the <name>-credential-keys-rotation staging Secret; the operator validates and applies. The rotate script additionally runs keystone-manage credential_migrate to re-encrypt stored credentials under the new primary key.
  3. Backup — PushSecret to openstack/keystone/{name}/credential-keys; retention controlled by spec.credentialKeys.maxActiveKeys (minimum 3).

reconcileDatabase() ​

Creates MariaDB Database and User CRs (watched by the MariaDB Operator) and runs the db_sync Job using the Keystone image. Supports both managed (ClusterRef) and brownfield (explicit host/port) modes:

  • Managed mode (spec.database.clusterRef set): The reconciler creates MariaDB Database and User CRs within the referenced MariaDB cluster. Endpoints are resolved dynamically from the MariaDB CR status.
  • Brownfield mode (spec.database.host set): The reconciler uses the explicit host/port directly. No MariaDB Database or User CRs are created — the external database must be provisioned separately.
go
func (r *KeystoneReconciler) reconcileDatabase(ctx context.Context,
    keystone *keystonev1alpha1.Keystone,
    configMapName string) (ctrl.Result, error) {

    dbSpec := keystone.Spec.Database

    if dbSpec.ClusterRef != nil {
        // Managed mode: create MariaDB Database + User CRs, resolve endpoint from CR status
        dbReady, err := database.EnsureDatabase(ctx, r.Client, r.Scheme, keystone, dbSpec)
        if err != nil {
            return ctrl.Result{}, err
        }
        if !dbReady {
            conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
                Type:   "DatabaseReady",
                Status: metav1.ConditionFalse,
                Reason: "WaitingForDatabase",
            })
            return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
        }
    }
    // Brownfield mode: skip MariaDB CR creation, use host/port directly

    // Run db_sync job using the Keystone service image
    synced, err := database.RunDBSyncJob(ctx, r.Client, keystone,
        keystone.Spec.Image,
        []string{"keystone-manage", "db_sync"},
        r.buildDBSyncEnv(keystone))
    if err != nil {
        return ctrl.Result{}, err
    }
    if !synced {
        conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
            Type:   "DatabaseReady",
            Status: metav1.ConditionFalse,
            Reason: "DBSyncInProgress",
        })
        return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
    }

    conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
        Type:   "DatabaseReady",
        Status: metav1.ConditionTrue,
        Reason: "DatabaseSynced",
    })
    return ctrl.Result{}, nil
}

For database migration patterns during upgrades, see Upgrades.

reconcilePolicyValidation() ​

Validates the rendered oslo.policy overrides before the Deployment is updated (CC-0058). When spec.policyOverrides is set, it runs an oslopolicy-validator Job against the generated policy.yaml; PolicyValidReady gates the Deployment so an invalid policy never reaches running pods. When no overrides are configured, the condition is trivially satisfied.

reconcileNetworkPolicy() ​

Creates or updates a Kubernetes NetworkPolicy restricting ingress and egress traffic for Keystone API pods (CC-0039). It runs in the parallel group alongside the key reconcilers (it has no data dependency on the Deployment):

  • When spec.networkPolicy is set: creates a NetworkPolicy allowing ingress on TCP 5000 from specified sources and auto-deriving egress rules for DNS (always), plus MariaDB (TCP 3306) and Memcached (TCP 11211) only in managed mode — i.e. when spec.database.clusterRef / spec.cache.clusterRef are set. In brownfield mode no MariaDB/Memcached egress rule is auto-derived (the external host is outside the cluster).
  • When spec.networkPolicy is nil: deletes any existing NetworkPolicy, allowing unrestricted traffic.

reconcileDeployment() ​

Creates the Keystone Deployment and Service:

go
func (r *KeystoneReconciler) reconcileDeployment(ctx context.Context,
    keystone *keystonev1alpha1.Keystone,
    configMapName string) (ctrl.Result, error) {

    dep := &appsv1.Deployment{
        ObjectMeta: metav1.ObjectMeta{
            Name:      "keystone-api",
            Namespace: keystone.Namespace,
        },
        Spec: appsv1.DeploymentSpec{
            Replicas: &keystone.Spec.Replicas,
            Template: corev1.PodTemplateSpec{
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{{
                        Name:  "keystone-api",
                        Image: fmt.Sprintf("%s:%s",
                            keystone.Spec.Image.Repository,
                            keystone.Spec.Image.Tag),
                        Ports: []corev1.ContainerPort{{
                            ContainerPort: 5000,
                        }},
                        ReadinessProbe: &corev1.Probe{
                            ProbeHandler: corev1.ProbeHandler{
                                HTTPGet: &corev1.HTTPGetAction{
                                    Path: "/v3",
                                    Port: intstr.FromInt(5000),
                                },
                            },
                        },
                        VolumeMounts: []corev1.VolumeMount{
                            {Name: "config",
                                MountPath: "/etc/keystone/keystone.conf.d/",
                                ReadOnly: true},
                            {Name: "fernet-keys",
                                MountPath: "/etc/keystone/fernet-keys",
                                ReadOnly: true},
                            {Name: "credential-keys",
                                MountPath: "/etc/keystone/credential-keys",
                                ReadOnly: true},
                        },
                    }},
                    Volumes: []corev1.Volume{
                        {Name: "config", VolumeSource: corev1.VolumeSource{
                            ConfigMap: &corev1.ConfigMapVolumeSource{
                                LocalObjectReference: corev1.LocalObjectReference{
                                    Name: configMapName,
                                }}}},
                        {Name: "fernet-keys", VolumeSource: corev1.VolumeSource{
                            Secret: &corev1.SecretVolumeSource{
                                SecretName: "keystone-fernet-keys",
                            }}},
                        {Name: "credential-keys", VolumeSource: corev1.VolumeSource{
                            Secret: &corev1.SecretVolumeSource{
                                SecretName: "keystone-credential-keys",
                            }}},
                    },
                },
            },
        },
    }

    // Set owner reference for garbage collection
    ctrl.SetControllerReference(keystone, dep, r.Scheme)

    // The DB password is injected via OS_DATABASE__CONNECTION from the derived
    // <name>-db-connection Secret (CC-0080), not rendered into the ConfigMap.
    // reconcileDeployment also builds the PodDisruptionBudget (EnsurePDB) and,
    // when database.tls is enabled, mounts the client cert at /etc/keystone/db-tls.
    ready, err := deployment.EnsureDeployment(ctx, r.Client, r.Scheme, keystone, dep)
    if !ready {
        conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
            Type:   "DeploymentReady",
            Status: metav1.ConditionFalse,
            Reason: "DeploymentProgressing",
        })
        return ctrl.Result{RequeueAfter: 10 * time.Second}, nil
    }

    // Set endpoint in status (cluster-local Service DNS by default;
    // overridden to https://{spec.gateway.hostname}/v3 when a gateway is set).
    keystone.Status.Endpoint = fmt.Sprintf(
        "http://%s.%s.svc.cluster.local:5000/v3", "keystone", keystone.Namespace)

    conditions.SetCondition(&keystone.Status.Conditions, metav1.Condition{
        Type:   "DeploymentReady",
        Status: metav1.ConditionTrue,
        Reason: "DeploymentAvailable",
    })
    return ctrl.Result{}, nil
}

reconcileHTTPRoute() ​

When spec.gateway is set and the Gateway API CRD is installed, reconciles a gateway.networking.k8s.io/HTTPRoute attaching the Keystone API Service to the referenced Gateway (CC-0065), and derives status.endpoint as https://{hostname}/v3. When spec.gateway is nil, the condition is trivially satisfied; when spec.gateway is set but Gateway API is absent (isGatewayAPIAvailable returned false at startup), HTTPRouteReady is set False with an explanatory message rather than crashing the controller.

reconcileHealthCheck() ​

Performs an active HTTP health check against status.endpoint (CC-0067) via an injectable HTTPClient (an HTTPDoer interface, so tests can stub it). A passing probe sets KeystoneAPIReady=True; a failing probe requeues at RequeueHealthCheck (10s). This complements the pod-level ReadinessProbe by verifying the API is reachable end-to-end through the Service.

reconcileHPA() ​

Creates, updates, or deletes a HorizontalPodAutoscaler for the Keystone API deployment (CC-0038):

  • When spec.autoscaling is set: creates an HPA targeting the Keystone deployment with the specified min/max replicas and CPU/memory utilization targets. At least one of targetCPUUtilization or targetMemoryUtilization must be set (enforced by CEL validation).
  • When spec.autoscaling is nil: deletes any existing HPA, restoring static replica count from spec.replicas.
  • spec.resources must be set for HPA utilization calculations to work (the defaulting webhook injects sensible defaults when unset).

reconcileBootstrap() ​

Runs the Keystone bootstrap Job using the same service image:

go
// Bootstrap job: a small Python pre-seed script inserts the admin region row,
// then exec's keystone-manage bootstrap:
// keystone-manage --config-dir=/etc/keystone/keystone.conf.d/ bootstrap \
//   --bootstrap-password <from ESO Secret> \
//   --bootstrap-admin-url    http://keystone.openstack.svc.cluster.local:5000/v3 \
//   --bootstrap-internal-url http://keystone.openstack.svc.cluster.local:5000/v3 \
//   --bootstrap-public-url   <spec.bootstrap.publicEndpoint, else cluster-local>/v3 \
//   --bootstrap-region-id RegionOne

The admin password is injected from the keystone-admin-credentials Secret (provisioned by ESO from kv-v2/bootstrap/keystone-admin). URLs use http (TLS is terminated upstream by the Gateway) and include the /v3 path. The public URL is taken from spec.bootstrap.publicEndpoint when set. The bootstrap Job is idempotent — it can be run multiple times without side effects.

reconcileTrustFlush() ​

Reconciles a <name>-trust-flush CronJob that runs keystone-manage trust_flush to purge expired trust delegations (CC-0057, CC-0096). The schedule defaults to hourly (0 * * * *, materialized by the defaulting webhook); spec.trustFlush.suspend pauses it without deleting the CronJob. Sets TrustFlushReady.

reconcilePasswordRotation() ​

Reconciles scheduled rotation of the admin password ("Model B", CC-0109). It is the final sub-reconciler and runs after reconcileBootstrap has seeded the initial admin credential, so the rotation CronJob and PushSecret never race the bootstrap seed. The feature is opt-in via spec.bootstrap.passwordRotation:

  • Disabled / nil (the default): tears down every Model B resource and sets PasswordRotationReady=True with reason RotationDisabled — a clean no-op.
  • Enabled: ensures the full split-compute-write rotation chain, mirroring the Fernet/credential-key boundary (CC-0081):
    1. A <name>-admin-password-rotate CronJob (on spec.bootstrap.passwordRotation.schedule, suspended when .suspend is true) runs scripts/admin_password_rotate.sh. The script mints a strong password and PATCHes it onto a narrow-RBAC staging Secret (<name>-admin-password-rotation) using the pod's ServiceAccount token — it never touches OpenBao or the production credential.
    2. The operator validates the staged password (length floor 24, default 32) and copies it into an operator-owned push-source Secret (<name>-admin-password-next).
    3. A clobber-safe PushSecret (DeletionPolicy=None, created only once the push-source holds a valid password) mirrors it to OpenBao at bootstrap/keystone-admin — the same key the keystone-admin ExternalSecret reads.
    4. ESO syncs the new password down; the apply side (reconcileBootstrap, CC-0108) re-runs keystone-manage bootstrap to write it into Keystone.

Keeping the privileged write (OpenBao + the production Secret) in the operator — not the CronJob — keeps token-forgery primitives out of the narrow-RBAC CronJob. Sets PasswordRotationReady. See Keystone Dependencies → Admin Credential Rotation for the full secret flow.

Error Handling ​

ScenarioActionRequeue DelayCondition
ESO Secret not yet syncedRequeue, wait for ESO15sSecretsReady=False
DB client cert not readyRequeue, wait for cert-manager15sDatabaseTLSReady=False
MariaDB not readyRequeue, wait for MariaDB Operator30sDatabaseReady=False
db_sync Job in progressRequeue, poll until complete30s (RequeueDatabaseWait)DatabaseReady=False
Fernet/credential key generation failedRequeue with backoff30sFernetKeysReady / CredentialKeysReady=False
Policy validation Job in progressRequeue, do not roll out Deployment15s (RequeueValidationWait)PolicyValidReady=False
NetworkPolicy creation failedReturn error, controller-runtime retriesExponentialNetworkPolicyReady=False
Deployment not availableRequeue, wait for rollout10sDeploymentReady=False
HTTPRoute reconcile failed / Gateway API absentRequeue or surface in conditionvariesHTTPRouteReady=False
API health check failedRequeue, re-probe endpoint10sKeystoneAPIReady=False
HPA creation/update failedReturn error, controller-runtime retriesExponentialHPAReady=False
Bootstrap Job in progressRequeue, poll until complete60s (RequeueBootstrapWait)BootstrapReady=False
Trust-flush CronJob reconcile failedReturn error, controller-runtime retriesExponentialTrustFlushReady=False
Admin-password rotation reconcile failed / staged password invalidReturn error or requeue; production credential left untouchedExponentialPasswordRotationReady=False
db_sync / policy-validation / bootstrap Job failedReturn error, controller-runtime retriesExponentialrespective *Ready=False
Unrecoverable API errorReturn error (controller-runtime handles backoff)Exponential—

The fixed RequeueAfter delays above apply to the in-progress (!done) path — the operator polls a Job that is still running. A failed Job is different: the sub-reconciler returns an error and controller-runtime applies exponential backoff (it does not re-run the Job on a fixed timer). Requeue intervals are centralized in requeue_intervals.go (e.g. RequeueSecretPolling=15s, RequeueDatabaseWait=30s, RequeueBootstrapWait=60s, RequeueDeploymentPolling=10s, RequeueHealthCheck=10s, RequeueValidationWait=15s, plus RequeueUpgradeWait=30s).

All transient errors result in a requeue with appropriate delay. Permanent errors (e.g., invalid CRD spec) are surfaced via conditions and events.

Owner References ​

All resources created by the reconciler have an owner reference pointing to the Keystone CR:

go
ctrl.SetControllerReference(keystone, resource, r.Scheme)

This enables:

  • Automatic garbage collection — When the Keystone CR is deleted, all owned resources (Deployments, Services, ConfigMaps, Jobs, CronJobs, PodDisruptionBudgets, HorizontalPodAutoscalers, NetworkPolicies) are automatically cleaned up by the Kubernetes garbage collector.
  • Watch triggers — Changes to owned resources trigger reconciliation of the owning Keystone CR.

Finalizers — the reconciler registers two finalizers, both handled before the main reconcile flow on deletion:

  • keystone.openstack.c5c3.io/finalizer — cleans up MariaDB Database/User/Grant CRs that are not garbage-collected via owner references.
  • keystoneOpenBaoFinalizer (CC-0079) — blocks deletion until the fernet-keys-backup and credential-keys-backup PushSecrets are purged from OpenBao. reconcileDeleteOpenBao drives a multi-pass ESO adoption-wait (CC-0091/CC-0092) so ESO deletes the remote KV-v2 paths (DeletionPolicy=Delete) before the CR is released; without it, OpenBao paths would orphan. Events are emitted exactly once per transition.