Provisioning a tenant
Audience
PLANA staff. Customers asking how their tenant gets created don't need to read this — see PLANA Business Cloud → First login for the end-user view.
This is the procedure for creating a new tenant from scratch. It uses Crossplane composite resources (no HTTP API). Typical wall-clock time: ~2 minutes when cloning from a template, ~15 minutes if installing the module set from scratch.
When to use this runbook
- New customer signs up
- Internal staging or demo tenant
- Pilot tenant for an upcoming version
Prerequisites
kubectlaccess to the SKS cluster (~/.kube/config)kubectl auth can-i create planaclientreturnsyes(you need theplanaclient-creatorClusterRole)- Tenant subdomain decided — must be
[a-z0-9-]+, ≤ 30 characters, unique across all environments - Edition decided —
starter/pro/enterprise - Odoo version — usually
"18"(the default); rarely"17"for legacy customers,"19"for limited release
The CR
apiVersion: planapulse.com/v1alpha1
kind: PLANAClient
metadata:
name: acme
spec:
subdomain: acme
projectId: 23
tier: pro
odooVersion: "18"
ownerEmail: ceo@acme.bg
ownerName: "Acme Corp"Save as acme-planaclient.yaml.
The procedure
1. Verify the subdomain is free
kubectl get planaclient | grep acme || echo "subdomain available"
psql -h pg01.planapulse.com -U plana -lqt | grep acme.planapulse.app || echo "DB name available"If either finds a match, pick another subdomain.
2. Apply the CR
kubectl apply -f acme-planaclient.yamlCrossplane immediately starts reconciling. You should see the resource become READY=True within ~2 minutes for a template-cloned provision.
3. Watch the reconciliation
kubectl get planaclient acme -o yaml | yq .statusStatus conditions you want to see:
| Condition | Final state |
|---|---|
Synced | True |
Ready | True |
| Composition resources | All Synced |
If Ready doesn't transition to True within 5 minutes, jump to Troubleshooting below.
4. Verify the side-effects
# DB on pg01
psql -h pg01.planapulse.com -U plana -lqt | grep acme.planapulse.app
# HTTPRoute (in plana-odoo-{odooVersion} namespace)
kubectl -n plana-odoo-18 get httproute acme
# Filestore subdirectory
kubectl -n plana-odoo-18 exec deploy/worker-odoo -- ls /var/lib/odoo/filestore/ | grep acme
# Backup CronJob
kubectl -n backup get cronjob | grep acme
# Matrix room (admin token call to Synapse)
curl -s "https://matrix.planapulse.com/_synapse/admin/v1/rooms" \
-H "Authorization: Bearer ${MATRIX_ADMIN_TOKEN}" | jq '.rooms[] | select(.name=="support-acme-023")'
# Event in PLANA:events
redis-cli -h redis.planapulse.com -a "${REDIS_PASSWORD}" \
XREVRANGE PLANA:events + - COUNT 5 | grep client.provisioned5. First HTTP smoke
curl -sI "https://acme.planapulse.app/web/health"
# Expect: HTTP/2 200, body: {"status":"pass"}
curl -sI "https://acme.planapulse.app/web/login"
# Expect: HTTP/2 200, Odoo login page HTML6. Confirm in the account portal
Open https://my.planapulse.ai/{ownerEmail} — the new workspace should appear in the customer's account portal once account-webhook Job completes.
7. Send the welcome email
The client.provisioned event triggers the welcome notification, but double-check it landed (Pulse Notifications log). If the email did not send (e.g. due to a transient Mailu issue), trigger it manually:
kubectl -n pulse-events run welcome-resend --rm -i --image=alpine/curl \
-- curl -X POST https://pulse-notifications.pulse-notifications.svc:8080/v1/welcome \
-H "X-API-Key: ${PULSE_NOTIFICATIONS_KEY}" \
-d "{\"email\":\"ceo@acme.bg\",\"workspace\":\"acme\"}"Edition-specific notes
Starter
Default path. Worker is in the shared pool (plana-odoo-18). Backups retained 30 days. No special configuration needed.
Pro
Same namespace as Starter today; the per-namespace isolation upgrade is planned. Backups retained 90 days. Subscription billing engine (subscription_oca) installed automatically via the tier module list.
Enterprise
Requires:
- A dedicated Kubernetes node (label-and-taint, requested via Exoscale console)
- Custom domain CNAMEd to the LB
- Per-tenant
runtime-classif isolation requirements are higher - Manual
TenantEnvironmentsetup if more than one environment is needed
The Enterprise provision path is not fully automated. After the PLANAClient CR is applied, follow the supplemental "Enterprise tenant setup" steps in infra/docs/runbooks/enterprise-provision.md.
Troubleshooting
Ready=False, condition ReconcileError
Read the message: kubectl describe planaclient acme | grep -A 5 Message
Common causes:
- Database name collision — the DB already exists on pg01. Drop or rename the existing DB, then
kubectl annotate planaclient acme crossplane.io/paused- ; kubectl annotate planaclient acme crossplane.io/paused=trueand back to force a re-reconcile. provider-sql-postgres-credsmissing in target namespace — copy the secret fromcrossplane-system:bashkubectl -n crossplane-system get secret provider-sql-postgres-creds -o yaml \ | yq 'del(.metadata.namespace, .metadata.resourceVersion, .metadata.uid)' \ | kubectl -n plana-odoo-18 apply -f -- Resource quota exceeded — the target namespace has hit
plana-quota. Checkkubectl -n plana-odoo-18 describe resourcequota plana-quota. Bump the quota ininfra/k8s/plana-odoo-18/quota.yamlif real demand justifies it, or wait for the snapshot Jobs to drain.
Synced=True but /web/health returns 503
The HTTPRoute is up but the worker is not. Check:
kubectl -n plana-odoo-18 get pods -l app=worker-odoo
kubectl -n plana-odoo-18 logs deploy/worker-odoo --tail=200 | grep ERRORMost common: the new DB has not been initialised because the TemplateSnapshot was missing for the requested (edition, version). Verify a template DB exists on pg01: basic-template-18.planapulse.app, pro-template-18.planapulse.app.
Matrix room creation failed
The matrix-room-create Job is non-fatal — provisioning completes even if the Matrix room call fails. Retry manually:
kubectl -n crossplane-system delete job matrix-room-create-acme-23 --ignore-not-found
# Crossplane will re-emit the Job on next reconcileCleanup
To deprovision (after a customer cancels and the soak period elapses):
kubectl delete planaclient acmeThe Composition reverses every side-effect: HTTPRoute removed, DB dropped, filestore subdirectory wiped, backup CronJob removed, Matrix room marked inactive. Backups in SOS are retained per the customer's tier — they are NOT deleted by the CR delete.
Where to read more
- Crossplane — the underlying mechanism
- Upgrading a tenant — same-major and cross-major
- Restoring from backup — recovery procedure