Skip to content

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

StrategyUse forMechanism
same-majorModule set changed, code update, -u all neededOne Job: odoo --update=all
snapshot-then-upgradeCross-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.odooVersion matches 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

yaml
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 list

The procedure

bash
kubectl apply -f acme-upgrade.yaml
kubectl get tenantupgrade acme-2026-05-29 -w

Watch the Job that the Composition emits:

bash
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=200

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

bash
kubectl -n plana-odoo-18 rollout restart deploy worker-odoo
kubectl -n plana-odoo-18 rollout status deploy worker-odoo --timeout=180s

XML-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

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

If the worker pod crashloops after the update Job, roll back by deploying the previous image:

bash
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

  1. Audit the module set. Every installed module on the source DB must exist on the target version.

    bash
    psql -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).

  2. Take an insurance backup.

    bash
    kubectl -n backup create job --from=cronjob/acme-backup \
      acme-insurance-$(date +%Y%m%d%H%M%S)
  3. Notify the customer. Cross-major has 15–30 minutes of downtime. Use the workspace's Matrix room to confirm a maintenance window.

The CR

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

What 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

bash
# 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 OK

Verifying

bash
# 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:

bash
# 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 backup

Common 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

© PLANA Digital Ltd.