Skip to main content
Version: 0.4.x

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).

Groupplatform.wxops.cloud
KindXTenantDatabase
Pluralxtenantdatabases
ScopeCluster
API versionv1alpha1 (served, storage)
Composition functionsfunction-extra-resources (shared cluster discovery) + function-kcl

Tier model

Sharedfunction-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

FieldTypeRequiredDefaultDescription
tierstring"shared"shared or dedicated.
environmentstring"dev"One of dev, staging, prod. For shared, filters the pool to clusters matching this environment. For dedicated, passed to the child cluster.
dedicatedCluster.instancesinteger (1–9)1PostgreSQL instances. 1 = standalone, 3 = HA. Only used when tier: dedicated.
dedicatedCluster.storageSizestring"8Gi"PVC storage per instance. Only used when tier: dedicated.
dedicatedCluster.postgresVersioninteger16PostgreSQL major version. Only used when tier: dedicated.
dedicatedCluster.enablePoolerbooleantrueDeploy a PgBouncer RW pooler. Only used when tier: dedicated.
dedicatedCluster.namespacestring"cnpg-system"Namespace for the dedicated cluster. Only used when tier: dedicated.
clusterRefstringName of an XPlatformDatabaseCluster to target directly. When set (with clusterNamespace), bypasses all tier logic.
clusterNamespacestringNamespace of the target CNPG cluster. Required when clusterRef is set.

Database

FieldTypeRequiredDefaultDescription
dbNamestringyesPostgreSQL database name. Must be unique per cluster.
ownerstringyesTeam or project identifier (e.g. "rocket-team"). Scopes the Vault path and PostgreSQL role name.
extensionsarray<string>[]PostgreSQL extensions to enable (e.g. [uuid-ossp, pgcrypto, postgis]).
databaseReclaimPolicystring"retain"retain or delete. Controls what happens to the database, credentials Secret, and Vault entry when the XR is deleted. See below.
vaultSecretStoreNamestring"vault-cluster-store"Name of the ESO ClusterSecretStore used by the PushSecret that writes credentials to Vault.

Reclaim policy

PolicyOn 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.
deleteCNPG 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.
Vault entry on retain→delete transition

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

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: trueXTenantApp adds envFrom.secretRef: {appName}-db-creds without managing the Secret itself.

Go-live ordering

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:

ResourceRequired labelPurpose
XPlatformDatabaseClusterwxops.cloud/managed-by: platform-database-clustersDiscovered as a candidate for tier: shared pool
XTenantDatabasewxops.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