Upgrading a tenant
Audience
PLANA staff. Both same-major upgrades (v18 → v18 with new code) and cross-major (v17 → v18, v18 → v19).
Tenant upgrades are run by applying a TenantUpgrade CR. The Composition selects the strategy from spec.strategy and emits a Kubernetes Job that performs the actual work.
Two strategies
| Strategy | Use for | Mechanism |
|---|---|---|
same-major | Module set changed, code update, -u all needed | One Job: odoo --update=all |
snapshot-then-upgrade | Cross-major (17→18, 18→19) | Two-pass OpenUpgrade Job (init container + main) |
Pick same-major when the tenant stays on its current Odoo major and you only want to apply code changes. Pick snapshot-then-upgrade when crossing a major boundary; the framework handles the schema migration.
Same-major upgrade
Prerequisites
- Tenant is on the major you are upgrading code for (
PLANAClient.spec.odooVersionmatches the current production major) - New base image has been built — image tag
base-{N}:<sha> - ResourceQuota has headroom for one extra Job pod (2 CPU / 4 GiB)
The CR
apiVersion: planapulse.com/v1alpha1
kind: TenantUpgrade
metadata:
name: acme-2026-05-29
spec:
slug: acme
fromVersion: "18"
toVersion: "18" # same as fromVersion → same-major
strategy: same-major
modules: all # or comma-separated listThe procedure
kubectl apply -f acme-upgrade.yaml
kubectl get tenantupgrade acme-2026-05-29 -wWatch the Job that the Composition emits:
kubectl -n plana-odoo-18 get job -l tenantupgrade=acme-2026-05-29
kubectl -n plana-odoo-18 logs -l tenantupgrade=acme-2026-05-29 --tail=200Same-major typically completes in 2–5 minutes for an average tenant (depends on the module set being updated).
After the Job completes, rolling-restart the worker so the new code is loaded:
kubectl -n plana-odoo-18 rollout restart deploy worker-odoo
kubectl -n plana-odoo-18 rollout status deploy worker-odoo --timeout=180sXML-only changes
For pure-XML changes (view files, no Python), odoo --update=all writes the new views to the DB and a rolling restart of the worker is not required. The new views are visible immediately. Only restart the pod for Python code changes.
Verifying
curl -sI "https://acme.planapulse.app/web/health" # 200 expected
# Smoke a real page:
curl -s "https://acme.planapulse.app/web/login" | grep -q 'Log in' && echo OKIf the worker pod crashloops after the update Job, roll back by deploying the previous image:
kubectl -n plana-odoo-18 set image deploy/worker-odoo \
worker-odoo=git.planapulse.com/plana-pulse/odoo-modules/base-18:<previous-sha>Cross-major upgrade (snapshot-then-upgrade)
This is the production-validated path used for v17 → v18 migrations in May 2026 and for future v18 → v19 migrations once OCA module gaps close.
Prerequisites — MUST be done before applying the CR
Audit the module set. Every installed module on the source DB must exist on the target version.
bashpsql -h pg01.planapulse.com -U plana -d acme.planapulse.app \ -c "SELECT name FROM ir_module_module WHERE state='installed'"Compare against the available module list on the target version's image. If any module is not available — STOP. Stay on the current major until OCA ports the missing one (or schedule a custom port, which is rare and explicitly authorised).
Take an insurance backup.
bashkubectl -n backup create job --from=cronjob/acme-backup \ acme-insurance-$(date +%Y%m%d%H%M%S)Notify the customer. Cross-major has 15–30 minutes of downtime. Use the workspace's Matrix room to confirm a maintenance window.
The CR
apiVersion: planapulse.com/v1alpha1
kind: TenantUpgrade
metadata:
name: acme-17-to-18
spec:
slug: acme
fromVersion: "17"
toVersion: "18"
strategy: snapshot-then-upgradeWhat the Composition does
The Job has an init container under the source-version binary and a main container under the target-version binary. UIDs differ between majors (v17→101, v18→100), and the Composition sets per-container securityContext.runAsUser from the version map.
Pass 1 (init): base-17-upgrade runs odoo -i openupgrade_framework --stop-after-init against the live tenant DB. This installs the OpenUpgrade framework module so its module-loader patches are active in the DB.
Pass 2 (main): base-18-upgrade first runs the agreement_legal SQL pre-fix (DELETE the two stale ir_model_data rows that would otherwise cause a unique-constraint violation), then runs odoo --update=all --load=base,web,openupgrade_framework against the same DB.
The procedure
# 1. Freeze the worker
kubectl -n plana-odoo scale deploy worker-odoo --replicas=0
# 2. Apply the CR
kubectl apply -f acme-upgrade.yaml
# 3. Watch the Job
kubectl -n plana-odoo get job -l tenantupgrade=acme-17-to-18 -w
kubectl -n plana-odoo logs -l tenantupgrade=acme-17-to-18 -f --all-containers
# 4. After the Job exits 0, tar-pipe the filestore between holders
HOLDER_FROM=$(kubectl -n plana-odoo get pod -l role=filestore-holder -o jsonpath='{.items[0].metadata.name}')
HOLDER_TO=$(kubectl -n plana-odoo-18 get pod -l role=filestore-holder -o jsonpath='{.items[0].metadata.name}')
kubectl -n plana-odoo exec "$HOLDER_FROM" -- \
tar -cC /var/lib/odoo/filestore acme.planapulse.app | \
kubectl -n plana-odoo-18 exec -i "$HOLDER_TO" -- \
tar -xC /var/lib/odoo/filestore --no-same-owner --no-same-permissions
# 5. chown filestore to v18 UID (workaround for tar-pipe preserving v17 UID 101)
kubectl -n plana-odoo-18 apply -f infra/k8s/migrations/filestore-chown-job.yaml
# 6. Flip the PLANAClient version to repoint the HTTPRoute
kubectl patch planaclient acme --type=merge -p '{"spec":{"odooVersion":"18"}}'
# 7. Scale up the target worker
kubectl -n plana-odoo-18 scale deploy worker-odoo --replicas=3
kubectl -n plana-odoo-18 rollout status deploy worker-odoo
# 8. Smoke
curl -sI "https://acme.planapulse.app/web/health"
curl -s "https://acme.planapulse.app/web/login" | grep -q 'Log in' && echo OKVerifying
# Module versions
psql -h pg01 -U plana -d acme.planapulse.app \
-c "SELECT name, latest_version FROM ir_module_module WHERE state='installed' ORDER BY name" | head -40
# Server version
psql -h pg01 -U plana -d acme.planapulse.app \
-c "SELECT value FROM ir_config_parameter WHERE key='database.version'"
# Critical data preserved
psql -h pg01 -U plana -d acme.planapulse.app \
-c "SELECT COUNT(*) AS users FROM res_users; \
SELECT COUNT(*) AS partners FROM res_partner; \
SELECT COUNT(*) AS invoices FROM account_move WHERE state='posted'"Soak period
After a cross-major cutover, soak for 48–72 hours before:
- Deleting the old version's filestore subdirectory
- Bumping the production branch (
PROD_ERP_BRANCH) to the new version
If anything breaks during soak, the rollback procedure is:
# Restore the insurance backup to a fresh DB and swap the HTTPRoute
kubectl patch planaclient acme --type=merge -p '{"spec":{"odooVersion":"17"}}'
# Apply EnvironmentRestore XR pointing at the insurance backupCommon pitfalls
1. kubectl apply on a Composition-managed resource
A Composition owns the HTTPRoute, the filestore PVC, and other emitted resources. Editing them directly will be reverted by Crossplane within seconds. Always patch at the XR level (kubectl patch planaclient acme ...) and let the Composition map the change down.
2. Resource quota in the target namespace
If plana-odoo-18 is tight, the upgrade Job + HPA worker replicas + any parallel snapshot Job will collide. Check kubectl -n plana-odoo-18 describe resourcequota plana-quota before starting. Bump if needed.
3. Filestore UID mismatch
v17 base ran as UID 101; v18+ runs as UID 100. A tar-pipe with --same-owner preserves the source UID and the target worker can't write to its own filestore. Always pass --no-same-owner --no-same-permissions, or chown after with a root-uid Job.
4. SSO breaks after a cross-major upgrade
PLANA's plana_user_roles._login signature changed between v17 and v18. If you see oauth_error=2 ("Access Denied") on first login post-upgrade, the module's _login method is using the old signature. Ensure the target-version code is in the image and the worker has been restarted.
Where to read more
- Multi-version Odoo — branch-per-version, namespace-per-version, the framework
- Annual Odoo port — the upstream cycle
- Provisioning a tenant — the upstream operation
- Crossplane — the CR mechanism