Skip to content

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

XRDWhat it provisionsTypical lifetime
PLANAClientA complete tenant — DB, filestore, HTTPRoute, backup CronJob, Matrix support room, account-portal webhook, event emitYears
TenantEnvironmentA single Odoo environment (most tenants have one, multi-env Pro tenants may have more)Years
TenantUpgradeRun an upgrade against a tenant — same-major (-u all) or cross-major (OpenUpgrade two-pass)Minutes
TemplateSnapshotBuild a template database for a given edition and versionHours, 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.

yaml
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 name acme.planapulse.app, owner plana Postgres role
  • HTTPRoute in plana-odoo-{odooVersion} namespace, hostname acme.planapulse.appworker-odoo:8069 (and /websocketworker-odoo:8072)
  • NFS subdirectory at /var/lib/odoo/filestore/acme.planapulse.app
  • Tenant backup CronJob in backup namespace
  • Matrix support room #support-acme-023 (idempotent — Job is a no-op on re-run)
  • client.provisioned event XADDed to PLANA:events Redis Stream
  • Account-portal webhook so the customer's PLANA account UI shows the new tenant
  • ERP-side subscription record via the plana_saas callback

The naming convention is fixed:

ConceptPatternExample
Namespace name (for the Odoo workers)plana-odoo-{odooVersion}plana-odoo-18
Database name{subdomain}.planapulse.appacme.planapulse.app
Hostname{subdomain}.planapulse.appacme.planapulse.app
Filestore path/.../filestore/{db_name}/.../filestore/acme.planapulse.app
Matrix room slugsupport-{subdomain}-{projectId:03d}support-acme-023

TenantUpgrade

TenantUpgrade runs an upgrade against an existing tenant. Two strategies:

StrategyWhen to useWhat it does
same-majorModule set changed, code update, -u all neededSingle odoo --update=all Job
snapshot-then-upgradeCross-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.

yaml
apiVersion: planapulse.com/v1alpha1
kind: TenantUpgrade
metadata:
  name: acme-to-18
spec:
  slug: acme
  fromVersion: "17"
  toVersion: "18"
  strategy: snapshot-then-upgrade

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

yaml
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 apply a 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 selection17 → 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 TemplateSnapshot Composition runs a Job in plana-odoo-18, it needs provider-sql-postgres-creds in that namespace — a copy of the secret in crossplane-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.odooVersion was 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

© PLANA Digital Ltd.