Phase 29 — ASK PostgreSQL Clusters (CNPG)¶
Deploy three production-grade PostgreSQL clusters via CNPG as an App of Apps under the ask
ArgoCD group. Each cluster has its own superuser and per-service roles backed by Vault-managed
credentials distributed via ESO ExternalSecret.
Prerequisites: CNPG operator (Phase 8), Vault HA + VSO (Phase 16–17), ESO (Phase 19), and Harbor pull-secret injection all running and healthy.
ArgoCD Hierarchy¶
mod-auh1-dev-ask ← root
└── mod-auh1-dev-ask.ask ← ask group (watches ask/ directory)
└── mod-auh1-dev-ask.ask.postgresql ← App of Apps (watches ask-db/apps/)
├── mod-auh1-dev-ask.ask.postgresql.core
├── mod-auh1-dev-ask.ask.postgresql.hatchet
└── mod-auh1-dev-ask.ask.postgresql.knowledge
Cluster Summary¶
| ArgoCD App | CNPG Cluster | Managed Roles (DB users) | Instances | Data PVC | WAL PVC | CPU req/lim | Mem req/lim |
|---|---|---|---|---|---|---|---|
...postgresql.core |
core-pg |
core-service, zitadel, assistant-service | 3 | 50 Gi × 3 | 5 Gi × 3 | 500m / 2 | 1Gi / 2Gi |
...postgresql.hatchet |
hatchet-pg |
hatchet | 3 | 50 Gi × 3 | 4 Gi × 3 | 250m / 1 | 512Mi / 1Gi |
...postgresql.knowledge |
knowledge-pg |
knowledge-management-service | 3 | 150 Gi × 3 | 5 Gi × 3 | 500m / 2 | 1Gi / 2Gi |
Total storage: 750 Gi data + 42 Gi WAL ≈ 792 Gi on csi-rbd-sc
| Component | Value |
|---|---|
| PostgreSQL image | harbor.cl1.sq4.aegis.internal/ghcr/cloudnative-pg/postgresql:16.8-bookworm |
| CNPG operator | 1.28.1 (chart 0.27.1) |
| Namespace | ask |
| StorageClass | csi-rbd-sc (Ceph RBD, reclaimPolicy: Delete) |
Each cluster exposes:
| Service | Purpose |
|---|---|
<cluster>-rw |
Primary — port 5432, all writes |
<cluster>-ro |
Replicas — read-only queries |
Why Separate ArgoCD Application¶
| Concern | Reason |
|---|---|
| Independent lifecycle | DB upgraded/patched without touching the app |
| Survive app redeploy | Deleting ASK app does not delete the database |
| Separate RBAC | DBA access to DB ops without app deploy permissions |
| Sync order clarity | ask-postgresql must be Healthy before ask-core-service syncs |
File Structure in mod-ask-devops¶
applications/ask-db/
├── apps/ ← App of Apps watches this
│ ├── core-pg-app.yaml
│ ├── hatchet-pg-app.yaml
│ └── knowledge-pg-app.yaml
├── core/
│ ├── core-pg-cluster.yaml 50 Gi data + 5 Gi WAL, 3 managed roles
│ └── core-pg-credentials.yaml 4 × ExternalSecret (superuser + 3 app roles)
├── hatchet/
│ ├── hatchet-pg-cluster.yaml 50 Gi data + 4 Gi WAL, 1 managed role
│ └── hatchet-pg-credentials.yaml 2 × ExternalSecret
└── knowledge/
├── knowledge-pg-cluster.yaml 150 Gi data + 5 Gi WAL, 1 managed role + vector
└── knowledge-pg-credentials.yaml 2 × ExternalSecret
applications/cluster-addons/
├── external-secrets/manifests/
│ ├── clustergenerator-password-alphanumeric-24.yaml (kept; unused until ESO bug fixed)
│ ├── clustergenerator-password-alphanumeric-32.yaml
│ └── clustergenerator-password-complex-safe-16.yaml
└── vault/manifests/vault-bootstrap-config.yaml adds eso-postgres-push policy
environment/auh1/dev/02-argocd-application/ask/
└── argocd-application-postgresql.yaml App of Apps parent
Key Manifests¶
CNPG Cluster CR — core-pg (full spec)¶
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: core-pg
namespace: ask
annotations:
argocd.argoproj.io/sync-wave: "2"
spec:
instances: 3
imageName: harbor.cl1.sq4.aegis.internal/ghcr/cloudnative-pg/postgresql:16.8-bookworm
imagePullSecrets:
- name: harbor-pull-secret
enableSuperuserAccess: true
superuserSecret:
name: core-pg-superuser # ← ExternalSecret creates this K8s Secret
managed:
roles:
- name: core-service # ← PostgreSQL ROLE, not a database
ensure: present
login: true
passwordSecret:
name: core-service-db-password
- name: zitadel
ensure: present
login: true
passwordSecret:
name: zitadel-db-password
- name: assistant-service
ensure: present
login: true
passwordSecret:
name: assistant-service-db-password
bootstrap:
initdb:
postInitTemplateSQL:
# Created in template1 → inherited by every database on this cluster.
# Service init jobs do NOT need to run CREATE EXTENSION.
- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
- CREATE EXTENSION IF NOT EXISTS "pgcrypto";
- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
- CREATE EXTENSION IF NOT EXISTS "unaccent";
storage:
size: 50Gi
storageClass: csi-rbd-sc
walStorage:
size: 5Gi
storageClass: csi-rbd-sc
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
primaryUpdateStrategy: unsupervised
primaryUpdateMethod: switchover
PostgreSQL extensions per cluster¶
Extensions are created in template1 so every database on the cluster inherits them.
| Cluster | Extensions |
|---|---|
core-pg |
uuid-ossp, pgcrypto, pg_trgm, unaccent |
hatchet-pg |
uuid-ossp, pgcrypto |
knowledge-pg |
uuid-ossp, pgcrypto, pg_trgm, unaccent, vector (pgvector — built into 16.8-bookworm) |
CNPG Cluster CR — knowledge-pg (adds vector extension)¶
# Only the differences from core-pg are shown
spec:
instances: 3
managed:
roles:
- name: knowledge-management-service
ensure: present
login: true
passwordSecret:
name: knowledge-management-service-db-password
bootstrap:
initdb:
postInitTemplateSQL:
- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
- CREATE EXTENSION IF NOT EXISTS "pgcrypto";
- CREATE EXTENSION IF NOT EXISTS "pg_trgm";
- CREATE EXTENSION IF NOT EXISTS "unaccent";
- CREATE EXTENSION IF NOT EXISTS "vector"; # pgvector — built into 16.8-bookworm
storage:
size: 150Gi
storageClass: csi-rbd-sc
walStorage:
size: 5Gi
storageClass: csi-rbd-sc
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "2"
memory: 2Gi
ExternalSecret pattern (superuser)¶
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: core-pg-superuser
namespace: ask
annotations:
argocd.argoproj.io/sync-wave: "0" # must be ready before Cluster at wave 2
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: core-pg-superuser
creationPolicy: Owner
template:
data:
username: "postgres"
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: ask/postgres/core-pg--SUPERUSER--PASSWORD
property: password
ExternalSecret pattern (managed role password)¶
CNPG 1.28.x requires username key in managed role secrets
spec.managed.roles[].passwordSecret must contain both username (the role name)
and password. Omitting username causes a continuous reconcile error:
"username key doesn't exist inside the secret" — roles are never created.
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: core-service-db-password
namespace: ask
annotations:
argocd.argoproj.io/sync-wave: "0"
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: core-service-db-password
creationPolicy: Owner
template:
data:
username: "core-service" # ← required by CNPG 1.28.x — must match spec.managed.roles[].name
password: "{{ .password }}"
data:
- secretKey: password
remoteRef:
key: ask/postgres/core-service--DATABASE--PASSWORD
property: password
Vault-Backed Credentials¶
Each service gets its own PostgreSQL role and password. Passwords are generated once and stored in
Vault KV v2 at secret/ask/postgres/. ESO ExternalSecret (refreshInterval 1h) reads them back
into K8s Secrets that CNPG consumes via spec.managed.roles and spec.superuserSecret.
PushSecret + generatorRef disabled (ESO 0.14.x bug)
The original design used ESO PushSecret with selector.generatorRef to auto-generate and
push passwords to Vault. This hits a bug in the ESO 0.14.x Vault provider: the generated
password bytes are accidentally used as the Vault HTTP response body, causing infinite
"error unmarshalling vault secret: invalid character 'X'" loops. PushSecrets are removed
from the repo. Passwords are seeded manually (see Seed Vault step below) and rotation
is manual. See troubleshooting → ESO PushSecret generatorRef Vault Unmarshal Loop.
Flow¶
Admin (one-time, using vault-0 exec)
│ vault kv put -mount=secret ask/postgres/<cluster>--SUPERUSER--PASSWORD password=<24-char>
│ vault kv put -mount=secret ask/postgres/<service>--DATABASE--PASSWORD password=<24-char>
▼
Vault KV v2 (secret/ask/postgres/*)
│
│ ESO ExternalSecret (sync-wave: 0, refreshInterval: 1h)
▼
K8s Secrets in namespace: ask
├── core-pg-superuser { username: postgres, password: ... }
├── core-service-db-password { password: ... }
├── zitadel-db-password { password: ... }
├── assistant-service-db-password { password: ... }
├── hatchet-pg-superuser { username: postgres, password: ... }
├── hatchet-db-password { password: ... }
├── knowledge-pg-superuser { username: postgres, password: ... }
└── knowledge-management-service-db-password { password: ... }
│
│ CNPG Cluster CR (sync-wave: 2)
▼
PostgreSQL roles: core-service, zitadel, assistant-service, hatchet, knowledge-management-service
│
│ Service Helm chart's job-postgres-init (runs as superuser)
▼
Databases: core_service_db, zitadel_db, etc. — with GRANT to each role
Why per-service roles instead of a single app user? Principle of least privilege. Each
service can only access its own database. A compromise of core-service credentials does not
expose the hatchet or zitadel database.
Why managed.roles? CNPG continuously reconciles each role. When the K8s secret changes,
CNPG issues ALTER ROLE <name> PASSWORD '...' automatically — no cluster restart.
Who creates the databases? The service Helm chart's job-postgres-init runs as superuser
and issues CREATE DATABASE + GRANT. CNPG manages only users.
Vault paths¶
| Vault KV path | Field | K8s Secret | Consumer |
|---|---|---|---|
secret/ask/postgres/core-pg--SUPERUSER--PASSWORD |
password |
core-pg-superuser |
spec.superuserSecret |
secret/ask/postgres/core-service--DATABASE--PASSWORD |
password |
core-service-db-password |
spec.managed.roles |
secret/ask/postgres/zitadel--DATABASE--PASSWORD |
password |
zitadel-db-password |
spec.managed.roles |
secret/ask/postgres/assistant-service--DATABASE--PASSWORD |
password |
assistant-service-db-password |
spec.managed.roles |
secret/ask/postgres/hatchet-pg--SUPERUSER--PASSWORD |
password |
hatchet-pg-superuser |
spec.superuserSecret |
secret/ask/postgres/hatchet--DATABASE--PASSWORD |
password |
hatchet-db-password |
spec.managed.roles |
secret/ask/postgres/knowledge-pg--SUPERUSER--PASSWORD |
password |
knowledge-pg-superuser |
spec.superuserSecret |
secret/ask/postgres/knowledge-management-service--DATABASE--PASSWORD |
password |
knowledge-management-service-db-password |
spec.managed.roles |
All 8 paths must be seeded manually before the first ArgoCD sync.
Vault policy — eso-postgres-push¶
The eso Vault Kubernetes auth role has two policies:
| Policy | Capability | Path |
|---|---|---|
eso-read |
read, list | secret/data/*, secret/metadata/* |
eso-postgres-push |
create, update, read | secret/data/ask/postgres/* |
eso-postgres-push is applied by the vault-bootstrap-config.yaml PostSync Job:
vault policy write eso-postgres-push - << 'POLICY'
path "secret/data/ask/postgres/*" { capabilities = ["create", "update", "read"] }
path "secret/metadata/ask/postgres/*" { capabilities = ["read", "list"] }
POLICY
vault write auth/kubernetes/role/eso \
bound_service_account_names=external-secrets \
bound_service_account_namespaces=external-secrets \
policies=eso-read,eso-postgres-push \
ttl=1h
Deploy Runbook¶
Step 1 — Seed Vault (once per environment)¶
Run before any ArgoCD sync. Uses vault-bootstrap-token (the root-equivalent Vault token
stored as a K8s Secret during initial cluster bootstrap):
BOOT_TOKEN=$(kubectl get secret -n vault vault-bootstrap-token \
-o jsonpath='{.data.token}' | base64 -d)
kubectl exec -n vault vault-0 -- sh -c "
export VAULT_ADDR=https://127.0.0.1:8200
export VAULT_SKIP_VERIFY=true
export VAULT_TOKEN='$BOOT_TOKEN'
gen() { cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 24; }
vault kv put -mount=secret ask/postgres/core-pg--SUPERUSER--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/core-service--DATABASE--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/zitadel--DATABASE--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/assistant-service--DATABASE--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/hatchet-pg--SUPERUSER--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/hatchet--DATABASE--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/knowledge-pg--SUPERUSER--PASSWORD password=\$(gen) && echo OK
vault kv put -mount=secret ask/postgres/knowledge-management-service--DATABASE--PASSWORD password=\$(gen) && echo OK
vault kv list -mount=secret ask/postgres/
"
Expected: 8 OK lines followed by 8 key names in the list.
Step 2 — Clean up existing clusters (re-deploy only)¶
Skip on a fresh cluster. Required when re-initialising CNPG clusters that have existing data PVCs:
kubectl delete cluster core-pg hatchet-pg knowledge-pg -n ask
kubectl get pods -n ask -w # wait for all cluster pods to terminate
# csi-rbd-sc has reclaimPolicy=Delete — PVCs auto-delete when cluster is deleted.
# If they did not auto-delete:
kubectl delete pvc -n ask -l cnpg.io/cluster=core-pg
kubectl delete pvc -n ask -l cnpg.io/cluster=hatchet-pg
kubectl delete pvc -n ask -l cnpg.io/cluster=knowledge-pg
kubectl get pvc -n ask # expected: No resources found
Step 3 — Commit and push¶
cd /path/to/mod-ask-devops
git add applications/ask-db/ \
applications/cluster-addons/external-secrets/manifests/ \
applications/cluster-addons/vault/manifests/vault-bootstrap-config.yaml
git commit -m "feat: CNPG clusters — per-service credentials, managed.roles, postInitTemplateSQL extensions"
git push
Step 4 — Sync supporting infra¶
# Apply eso-postgres-push Vault policy via PostSync bootstrap hook
argocd app sync mod-auh1-dev-ask.cluster-addons.vault --grpc-web --assumeYes
# Deploy ClusterGenerators (harmless; kept for future PushSecret fix upgrade)
argocd app sync mod-auh1-dev-ask.cluster-addons.external-secrets --grpc-web --assumeYes
Step 5 — Sync PostgreSQL App of Apps¶
# Create/update the App of Apps and all three child apps
argocd app sync mod-auh1-dev-ask.ask --grpc-web --assumeYes
argocd app sync mod-auh1-dev-ask.ask.postgresql --grpc-web --assumeYes
# Child apps sync in wave order:
# wave 0 — ExternalSecrets → read from Vault → create K8s Secrets
# wave 2 — CNPG Clusters → init with superuserSecret + managed.roles + extensions
argocd app sync mod-auh1-dev-ask.ask.postgresql.core \
mod-auh1-dev-ask.ask.postgresql.hatchet \
mod-auh1-dev-ask.ask.postgresql.knowledge \
--grpc-web --assumeYes
Step 6 — Verify¶
# ExternalSecrets — all READY=True
kubectl get externalsecret -n ask
# Expected (8 rows, all READY=True, SYNCED with recent timestamp):
# core-pg-superuser vault-backend 1h True InSync
# core-service-db-password vault-backend 1h True InSync
# ...
# CNPG Clusters — all Healthy
kubectl get cluster -n ask
# Expected:
# NAME AGE INSTANCES READY STATUS PRIMARY
# core-pg ... 3 3 Cluster in healthy state core-pg-1
# hatchet-pg ... 3 3 Cluster in healthy state hatchet-pg-1
# knowledge-pg ... 3 3 Cluster in healthy state knowledge-pg-1
# Verify managed roles exist on core-pg
PRIMARY=$(kubectl get pod -n ask -l cnpg.io/cluster=core-pg,role=primary \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ask ${PRIMARY} -- psql -U postgres -c "\du"
# Expected: rows for core-service, zitadel, assistant-service
# Verify extensions in template1
kubectl exec -n ask ${PRIMARY} -- psql -U postgres -d template1 -c "\dx"
# Expected: uuid-ossp, pgcrypto, pg_trgm, unaccent present
# knowledge-pg — verify vector extension
PRIMARY_KP=$(kubectl get pod -n ask -l cnpg.io/cluster=knowledge-pg,role=primary \
-o jsonpath='{.items[0].metadata.name}')
kubectl exec -n ask ${PRIMARY_KP} -- psql -U postgres -d template1 -c "\dx" | grep vector
Password Rotation¶
Write a new value to Vault; ESO picks it up within refreshInterval (1h). Optionally force
an immediate refresh:
BOOT_TOKEN=$(kubectl get secret -n vault vault-bootstrap-token \
-o jsonpath='{.data.token}' | base64 -d)
kubectl exec -n vault vault-0 -- sh -c "
export VAULT_ADDR=https://127.0.0.1:8200
export VAULT_SKIP_VERIFY=true
export VAULT_TOKEN='$BOOT_TOKEN'
vault kv put -mount=secret ask/postgres/core-service--DATABASE--PASSWORD \
password=\$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | head -c 24)
"
# Force immediate ESO refresh (optional)
kubectl annotate externalsecret core-service-db-password -n ask \
force-sync=$(date +%s) --overwrite
# CNPG detects the K8s secret change and issues ALTER ROLE automatically
kubectl get cluster core-pg -n ask -o jsonpath='{.status.conditions}' | jq .
Storage Resize Runbook¶
CNPG does not auto-grow storage. Alert at 80% and resize manually — zero downtime via online
PVC resize (requires csi-rbd-sc to support allowVolumeExpansion: true).
# Example: grow core-pg data PVCs from 50Gi to 100Gi
kubectl patch cluster core-pg -n ask \
--type merge -p '{"spec":{"storage":{"size":"100Gi"}}}'
# Watch PVCs expand
kubectl get pvc -n ask -l cnpg.io/cluster=core-pg -w
Alert metric: cnpg_pg_database_size_bytes / cnpg_pg_volume_size_bytes > 0.8
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
ExternalSecret SecretSyncedError |
Vault path not seeded yet | Run Step 1 — Seed Vault |
ExternalSecret No External Secrets |
ESO token lacks eso-read policy |
Re-sync vault app; restart ESO deployment |
ImagePullBackOff on cluster pod |
harbor-pull-secret not in ask namespace |
Check ESO ClusterExternalSecret; verify harbor-pull-secret: "true" label on namespace |
Cluster stuck Setting up primary |
ExternalSecret not synced yet (wave race) | Wait for all ExternalSecrets READY=True, then re-sync cluster app |
| CNPG role password not updating | ESO refresh not fired yet | Annotate ExternalSecret with force-sync=$(date +%s) |
app path does not exist on sync |
Files not pushed to GitLab | git push first, then sync |
PermissionDenied on argocd sync |
Session expired | argocd login; sync parent mod-auh1-dev-ask.ask first |
App stuck Deleting |
resources-finalizer.argocd.io waiting |
kubectl patch application <name> -n argocd --type json -p '[{"op":"remove","path":"/metadata/finalizers"}]' |
postgresInit job could not connect |
Cluster not ready when ASK app syncs | Ensure all 3 clusters Healthy before syncing ask-core-service |
PushSecret error unmarshalling vault secret |
ESO 0.14.x bug | See troubleshooting → ESO PushSecret generatorRef Vault Unmarshal Loop |