XTenantDatabase
Tenant-facing PostgreSQL database with dynamic tier resolution, Vault-backed
credentials, and ESO sync. Supports two deployment models: shared (auto-assign
to a least-loaded pool cluster) and dedicated (compose an isolated CNPG cluster
as a child resource).
| Group | platform.wxops.cloud |
| Kind | XTenantDatabase |
| Plural | xtenantdatabases |
| Scope | Cluster |
| API version | v1alpha1 (served, storage) |
| Composition functions | function-extra-resources (shared cluster discovery) + function-kcl |
Tier modelβ
Shared β function-extra-resources discovers all XPlatformDatabaseCluster
resources labeled wxops.cloud/managed-by: platform-database-clusters and
shared: true, counts existing tenant databases per cluster, and assigns to the
least-loaded one matching the claim's environment. The assignment is sticky β
once resolved, subsequent reconciles reuse the same cluster.
Dedicated β composes a child XPlatformDatabaseCluster for full isolation.
Sized via dedicatedCluster.* fields.
spec.parametersβ
Tier resolutionβ
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
cluster | string | "default" | provider-kubernetes ProviderConfig to compose resources through. default (the hub cluster) is the only one wired up today β leaving this unset is behavior-neutral. Multi-cluster targeting is not yet functional; no spoke ProviderConfig exists yet. | |
tier | string | "shared" | shared or dedicated. | |
environment | string | "dev" | One of dev, staging, prod. For shared, filters the pool to clusters matching this environment. For dedicated, passed to the child cluster. | |
dedicatedCluster.instances | integer (1β9) | 1 | PostgreSQL instances. 1 = standalone, 3 = HA. Only used when tier: dedicated. | |
dedicatedCluster.storageSize | string | "8Gi" | PVC storage per instance. Only used when tier: dedicated. | |
dedicatedCluster.postgresVersion | integer | 16 | PostgreSQL major version. Only used when tier: dedicated. | |
dedicatedCluster.enablePooler | boolean | true | Deploy a PgBouncer RW pooler. Only used when tier: dedicated. | |
dedicatedCluster.namespace | string | "cnpg-system" | Namespace for the dedicated cluster. Only used when tier: dedicated. | |
clusterRef | string | Name of an XPlatformDatabaseCluster to target directly. When set (with clusterNamespace), bypasses all tier logic. | ||
clusterNamespace | string | Namespace of the target CNPG cluster. Required when clusterRef is set. |
Databaseβ
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
dbName | string | yes | PostgreSQL database name. Must be unique per cluster. | |
owner | string | yes | Team or project identifier (e.g. "rocket-team"). Scopes the Vault path and PostgreSQL role name. | |
extensions | array<string> | [] | PostgreSQL extensions to enable (e.g. [uuid-ossp, pgcrypto, postgis]). | |
databaseReclaimPolicy | string | "retain" | retain or delete. Controls what happens to the database, credentials Secret, and Vault entry when the XR is deleted. See below. | |
vaultSecretStoreName | string | "vault-cluster-store" | Name of the ESO ClusterSecretStore used by the PushSecret that writes credentials to Vault. |
Reclaim policyβ
| Policy | On XR deletion |
|---|---|
retain (default) | Database, role, and credentials Secret survive XR deletion β manual cleanup required if the tenant is decommissioned. Stale Vault entry must also be manually deleted. |
delete | CNPG drops the database and role; credentials Secret and Vault entry are removed. Two ClusterUsage ordering constraints ensure DROP DATABASE completes before DROP ROLE, and DROP ROLE completes before credentials cleanup β preventing the 2BP01 parallel-deletion race. |
Switching from retain to delete and then deleting the XR cleans up Kubernetes resources but does not remove the Vault entry β the PushSecret's deletionPolicy update is not retroactive for entries written under None. Manual cleanup required:
vault kv metadata delete tenants/{owner}/databases/{dbName}/connection-creds
statusβ
Read these fields directly rather than polling Crossplane's own composite Ready
condition, which is unreliable on this platform's Crossplane v2.3 + function-kcl v0.12.1
combination.
| Field | Type | Description |
|---|---|---|
status.clusterRef | string | Resolved CNPG cluster name this database is provisioned on. Written on first assignment and reused on subsequent reconciles (sticky) β other XTenantDatabase XRs read this via function-extra-resources to count databases per cluster for load-balanced pool assignment. |
status.clusterNamespace | string | Namespace of the resolved cluster. |
status.tier | string | Resolved tier β shared, dedicated, or explicit (clusterRef set directly). |
status.dbName | string | PostgreSQL database name, echoed for cross-XR collision checks. |
status.created | boolean | True once every composed resource has been observed at least once. Indicates provisioning has started, not that it succeeded. |
status.ready | boolean | True when the database is actually usable: it exists, its owning role exists, and the connection Secret has materialized (plus, on tier: dedicated, the child cluster is up). The Vault credential push (PushSecret) is deliberately excluded β if it lags, applications can still connect with the Kubernetes Secret. |
Credential flowβ
XTenantDatabase
ββ PushSecret (provider-kubernetes)
ββ Vault KV: tenants/{owner}/databases/{dbName}/connection-creds
ββ ExternalSecret (ESO)
ββ K8s Secret: {appName}-db-creds (in the tenant namespace)
Wire to XTenantApp via secretsFrom.database.enabled: true β XTenantApp
adds envFrom.secretRef: {appName}-db-creds without managing the Secret itself.
XTenantDatabase must be applied and reach Ready before the XTenantApp
Deployment starts β otherwise Pods fail with CreateContainerConfigError: secret not found.
GitOps sync wave ordering or an ArgoCD sync-wave annotation on the XTenantApp
Application ensures this.
Required discovery labelsβ
function-extra-resources requires these labels in the XR manifests β they cannot be set by the Composition:
| Resource | Required label | Purpose |
|---|---|---|
XPlatformDatabaseCluster | wxops.cloud/managed-by: platform-database-clusters | Discovered as a candidate for tier: shared pool |
XTenantDatabase | wxops.cloud/tenant-database: "true" | Counted for per-cluster load balancing and dbName collision detection |
Without the XTenantDatabase label, single-database scenarios still work but collision detection and load balancing across clusters are disabled.
Examplesβ
Shared tier (default)β
apiVersion: platform.wxops.cloud/v1alpha1
kind: XTenantDatabase
metadata:
name: payment-api-db
labels:
wxops.cloud/tenant-database: "true"
spec:
parameters:
tier: shared
environment: dev
dbName: payment_api
owner: rocket-team
extensions:
- uuid-ossp
- pgcrypto
Dedicated tierβ
apiVersion: platform.wxops.cloud/v1alpha1
kind: XTenantDatabase
metadata:
name: analytics-db
labels:
wxops.cloud/tenant-database: "true"
spec:
parameters:
tier: dedicated
environment: staging
dbName: analytics
owner: data-team
databaseReclaimPolicy: retain
dedicatedCluster:
instances: 3
storageSize: 50Gi
postgresVersion: 16
enablePooler: true
See alsoβ
- XTenantApp β wire the resulting Secret via
secretsFrom.database - XPlatformDatabaseCluster β the platform clusters tenants land on, and the vision of PostgreSQL as a multi-modal data platform
- Crossplane APIs β package catalog and tier diagram
- Platform Features β scaffold wizard database configuration