Skip to content

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

  • kubectl access to the SKS cluster (~/.kube/config)
  • kubectl auth can-i create planaclient returns yes (you need the planaclient-creator ClusterRole)
  • 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

yaml
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

bash
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

bash
kubectl apply -f acme-planaclient.yaml

Crossplane immediately starts reconciling. You should see the resource become READY=True within ~2 minutes for a template-cloned provision.

3. Watch the reconciliation

bash
kubectl get planaclient acme -o yaml | yq .status

Status conditions you want to see:

ConditionFinal state
SyncedTrue
ReadyTrue
Composition resourcesAll Synced

If Ready doesn't transition to True within 5 minutes, jump to Troubleshooting below.

4. Verify the side-effects

bash
# 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.provisioned

5. First HTTP smoke

bash
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 HTML

6. 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:

bash
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-class if isolation requirements are higher
  • Manual TenantEnvironment setup 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=true and back to force a re-reconcile.
  • provider-sql-postgres-creds missing in target namespace — copy the secret from crossplane-system:
    bash
    kubectl -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. Check kubectl -n plana-odoo-18 describe resourcequota plana-quota. Bump the quota in infra/k8s/plana-odoo-18/quota.yaml if 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:

bash
kubectl -n plana-odoo-18 get pods -l app=worker-odoo
kubectl -n plana-odoo-18 logs deploy/worker-odoo --tail=200 | grep ERROR

Most 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:

bash
kubectl -n crossplane-system delete job matrix-room-create-acme-23 --ignore-not-found
# Crossplane will re-emit the Job on next reconcile

Cleanup

To deprovision (after a customer cancels and the soak period elapses):

bash
kubectl delete planaclient acme

The 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

© PLANA Digital Ltd.