Crossplane
Audience
PLANA staff working on tenant lifecycle, infrastructure, or anyone surprised that there is no longer an HTTP "orchestrator" API.
PLANA provisions tenants declaratively. There is no HTTP API to "create a tenant" — instead, you apply a Kubernetes custom resource (a CR) and Crossplane's reconciliation loop turns it into the actual cluster objects: the database, the filestore PVC, the HTTPRoute, the backup CronJob, the Matrix support room, and so on.
We adopted this pattern in May 2026, replacing the previous Node.js saas-orchestrator that exposed a BullMQ-backed REST API. The orchestrator namespace was deleted on 2026-05-13.
What is Crossplane
Crossplane is a Kubernetes operator that turns "composite resources" (XRs) into a graph of real cluster objects. You define:
- a Composite Resource Definition (
XRD) — the shape of the API - a Composition — the YAML transformations that produce the actual resources
When a user applies an XR (an instance of the XRD), Crossplane reconciles the Composition: it creates / updates / deletes the resulting cluster objects on every loop. If you delete the XR, the cascade reverses and cleans everything up.
This gives us the benefits of an API (typed, declarative, with status) and the benefits of GitOps (everything in YAML, reconciliation, drift detection) without writing a custom controller.
PLANA's four XRDs
| XRD | What it provisions | Typical lifetime |
|---|---|---|
PLANAClient | A complete tenant — DB, filestore, HTTPRoute, backup CronJob, Matrix support room, account-portal webhook, event emit | Years |
TenantEnvironment | A single Odoo environment (most tenants have one, multi-env Pro tenants may have more) | Years |
TenantUpgrade | Run an upgrade against a tenant — same-major (-u all) or cross-major (OpenUpgrade two-pass) | Minutes |
TemplateSnapshot | Build a template database for a given edition and version | Hours, rebuilt on push |
All four live in the crossplane-system namespace.
PLANAClient — the main one
A PLANAClient is what most operators apply. It captures everything about a tenant in a single resource.
apiVersion: planapulse.com/v1alpha1
kind: PLANAClient
metadata:
name: acme
spec:
subdomain: acme
projectId: 23
tier: pro # starter | pro | enterprise
odooVersion: "18" # defaults to "18"
ownerEmail: ceo@acme.bg
ownerName: "Acme Corp"The Composition emits:
- DB on pg01 via
provider-sql— database nameacme.planapulse.app, ownerplanaPostgres role - HTTPRoute in
plana-odoo-{odooVersion}namespace, hostnameacme.planapulse.app→worker-odoo:8069(and/websocket→worker-odoo:8072) - NFS subdirectory at
/var/lib/odoo/filestore/acme.planapulse.app - Tenant backup CronJob in
backupnamespace - Matrix support room
#support-acme-023(idempotent — Job is a no-op on re-run) client.provisionedevent XADDed toPLANA:eventsRedis Stream- Account-portal webhook so the customer's PLANA account UI shows the new tenant
- ERP-side subscription record via the
plana_saascallback
The naming convention is fixed:
| Concept | Pattern | Example |
|---|---|---|
| Namespace name (for the Odoo workers) | plana-odoo-{odooVersion} | plana-odoo-18 |
| Database name | {subdomain}.planapulse.app | acme.planapulse.app |
| Hostname | {subdomain}.planapulse.app | acme.planapulse.app |
| Filestore path | /.../filestore/{db_name} | /.../filestore/acme.planapulse.app |
| Matrix room slug | support-{subdomain}-{projectId:03d} | support-acme-023 |
TenantUpgrade
TenantUpgrade runs an upgrade against an existing tenant. Two strategies:
| Strategy | When to use | What it does |
|---|---|---|
same-major | Module set changed, code update, -u all needed | Single odoo --update=all Job |
snapshot-then-upgrade | Cross-major (e.g. 17→18) | OpenUpgrade two-pass: install openupgrade_framework on the source-version binary, then run --update=all on the target-version binary, with the agreement_legal SQL pre-fix in between |
The cross-major path was proven in May 2026 on the PLANA ERP tenant and is the same path used for customer tenants today.
apiVersion: planapulse.com/v1alpha1
kind: TenantUpgrade
metadata:
name: acme-to-18
spec:
slug: acme
fromVersion: "17"
toVersion: "18"
strategy: snapshot-then-upgradeFilestore is not migrated between major versions automatically. The filestore PVCs are per-namespace; a cross-major upgrade includes a tar-pipe between the source-namespace holder Pod and the target-namespace holder Pod, with --no-same-owner --no-same-permissions to handle the v17→v18 UID change (101 → 100).
TemplateSnapshot
A TemplateSnapshot produces a clean template database used for fast provisioning. New tenants are created by PostgreSQL TEMPLATE clone, which takes ~45 seconds compared to ~15 minutes for a full module install.
apiVersion: planapulse.com/v1alpha1
kind: TemplateSnapshot
metadata:
name: basic-18
spec:
templateCode: basic # basic | pro | enterprise
odooVersion: "18"The Composition runs a Job that creates the template DB, installs the tier's module list with --without-demo=all, and marks the resulting DB as a PostgreSQL TEMPLATE (datistemplate=true). Templates are rebuilt automatically on every push to a version branch by the .forgejo/workflows/pipeline.yml workflow.
Reconciliation behaviour you need to know
Crossplane runs a tight reconciliation loop. Several gotchas have caught us out:
- Never
kubectl applya Composition-managed resource directly. The reconciler will revert your edit within seconds. We learned this when someone applied an HTTPRoute manually after a v18 cutover and watched it flip back to the v17 namespace target ten seconds later. The fix was to patch the XR (PLANAClient.spec.odooVersion), which the Composition then mapped to the right namespace. - The Composition uses
transforms: map:for namespace selection —17 → plana-odoo,18 → plana-odoo-18,19 → plana-odoo-19. The XR is the authoritative source. - Provider secrets must be copied per-namespace. When the
TemplateSnapshotComposition runs a Job inplana-odoo-18, it needsprovider-sql-postgres-credsin that namespace — a copy of the secret incrossplane-system. We learned this on the first v18 template rebuild; the secret-copy step is now in the runbook. - XRD defaults matter. When
PLANAClient.spec.odooVersionwas bumped from"17"to"18"as the default, every new manifest applied without an explicit version now targets v18. That was the deliberate cutover.
Crossplane's place in the stack
User / CI / Account portal
│
▼
kubectl apply / kubectl edit
│
▼
Kubernetes API
│
▼
Crossplane reconciler ─── reads XR
│
▼
Composition ─── renders concrete resources
│
├──▶ Database (provider-sql → pg01)
├──▶ Filestore PVC
├──▶ HTTPRoute (cross-ns + ReferenceGrant)
├──▶ CronJob (backup)
├──▶ Job (Matrix room, account webhook, ERP callback)
└──▶ Redis XADD (event bus)Where to read more
- Multi-version Odoo — how 17/18/19 namespaces work in concert with Crossplane
- Operations → Provisioning a tenant
- Operations → Upgrading a tenant
- Tenant isolation — the security view
- Source:
infra/crossplane/in theplana-pulse/infrarepo