apiVersion: apps/v1 kind: Deployment metadata: name: {{ include "instance.fullname" . }}-odoo labels: {{- include "instance.labels" . | nindent 4 }} spec: replicas: 1 # ReadWriteOnce filestore volume + Odoo's session locks rule out # rolling deploys with two pods overlapping. Recreate is safe and # only causes brief downtime. strategy: type: Recreate selector: matchLabels: app.kubernetes.io/instance: {{ .Values.instance.code | quote }} odoosky.io/role: odoo template: metadata: labels: {{- include "instance.labels" . | nindent 8 }} odoosky.io/role: odoo annotations: # Bumping this hash whenever the addons list changes forces # Helm/ArgoCD to roll the Deployment, which re-runs the init # containers and re-materializes the shared volume from the # current image set. Without this, changing only `addons:` in # values.yaml would leave the existing pod alone. odoosky.io/addons-hash: {{ .Values.addons | toJson | sha256sum | trunc 16 }} spec: # fsGroup=101 makes the kubelet recursively chown the filestore # PVC's root inode to gid=101 on attach. Odoo runs as uid 101 # and writes /var/lib/odoo/sessions on first request; without # this it crashes "Permission denied: '/var/lib/odoo/sessions'" # on the first hit because Longhorn-formatted PVCs come up # owned by root:root. (k3s local-path masked this — its hostPath # provisioner left the dir world-writable. Real CSI drivers # don't.) supplementalGroups + runAsUser kept default so the # rest of the pod (db-init, addon init containers) keeps the # behavior they already had. securityContext: fsGroup: 101 # OnRootMismatch: only recursive-chown when the volume root # ISN'T already gid=101. Saves O(filestore-size) chmod cost # on every pod restart for instances with millions of files. # Defaults work for fresh PVCs (root != 101 → chown once). fsGroupChangePolicy: OnRootMismatch # Bootstrap initContainers always run; addon initContainers only # run when there are addons. We always need db-init to ensure the # tenant DB exists + base module is initialized before Odoo's # main process starts serving — without this the operator would # have to click through Odoo's setup wizard manually for every # new instance, which is exactly the SaaS UX v3 sets out to # eliminate. initContainers: # db-init: idempotent on every pod boot. # 1. createdb if missing (PG-level) # 2. odoo -i base --stop-after-init to install base module # and create Odoo's tables. After base is installed, # `-i base` is a no-op so subsequent boots add ~5s. - name: db-init image: "{{ .Values.odoo.image }}:{{ .Values.odoo.tag }}" imagePullPolicy: IfNotPresent # Override the official Odoo entrypoint so we can run psql # before odoo. The image ships with postgresql-client, so # createdb is on PATH. command: ["/bin/sh", "-c"] args: - | set -eu DBNAME="{{ .Values.instance.code }}" echo "── ensuring database $DBNAME exists ──" # Wait for PG to accept connections (max 60s) for i in $(seq 1 30); do if PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d postgres -c '\q' 2>/dev/null; then break; fi echo "(waiting for postgres, $i/30)" sleep 2 done # createdb is idempotent if we wrap with an existence check. if PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname = '$DBNAME'" | grep -q 1; then echo "(database $DBNAME already exists)" else echo "── creating database $DBNAME ──" PGPASSWORD="$PASSWORD" createdb -h "$HOST" -p "$PORT" -U "$USER" "$DBNAME" fi # Initialise base ONLY if the DB has never been initialised. # The earlier comment claimed `-i base` was a no-op on # already-initialised databases — that's wrong on Odoo # 16+. `-i base` always runs `_process_end` which tries # to unlink stale `ir.model.data` records flagged # noupdate=False; if any of those records are # referenced by REAL user data (e.g. a sale_order # pointing at the partner Odoo wants to clean up), # ForeignKeyViolation crashes the init and the pod # crash-loops. # # The migrate-from-bundle flow restores a DB that has # `base` installed AND real user FKs. Re-running # `-i base` against it deterministically breaks. The # check below skips the odoo step when base is already # installed — fresh deploys still bootstrap correctly # because base isn't installed in a brand-new database. # Init both vars to "" so `set -u` doesn't crash when # the schema query returns nothing (fresh DB case — # ir_module_module doesn't exist yet, so the inner # query block is skipped, leaving BASE_INSTALLED # unset). Without this, db-init crashlooped on every # fresh deploy with: "BASE_INSTALLED: parameter not # set". IS_INIT="" BASE_INSTALLED="" IS_INIT=$(PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME" -tAc \ "SELECT 1 FROM information_schema.tables WHERE table_schema='public' AND table_name='ir_module_module'" 2>/dev/null || true) if [ "$IS_INIT" = "1" ]; then BASE_INSTALLED=$(PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME" -tAc \ "SELECT 1 FROM ir_module_module WHERE name='base' AND state='installed'" 2>/dev/null || true) fi if [ "$BASE_INSTALLED" = "1" ]; then echo "── base already installed — skipping --init (preserves restored data) ──" else echo "── initializing base module ──" odoo -i base -d "$DBNAME" --stop-after-init --without-demo=all --no-http \ --db_host="$HOST" --db_port="$PORT" --db_user="$USER" --db_password="$PASSWORD" \ --workers=0 fi echo "── db-init done ──" env: - name: HOST value: {{ include "instance.fullname" . }}-pg - name: PORT value: "5432" - name: USER valueFrom: secretKeyRef: name: {{ include "instance.fullname" . }}-pg key: POSTGRES_USER - name: PASSWORD valueFrom: secretKeyRef: name: {{ include "instance.fullname" . }}-pg key: POSTGRES_PASSWORD {{- if .Values.addons }} # One initContainer per selected addon. Each addon image's # ENTRYPOINT/CMD copies its bundled content into /target/, # which is the shared volume the Odoo container reads from. # Init containers run in order, but each writes to its own # subdir, so order doesn't matter. # # `imagePullPolicy: Always` is intentional: addon images can be # rebuilt under the same {code}:{version} tag (e.g. when Tower's # build pipeline changes — adding pip-install layer for python # external_dependencies). With IfNotPresent, kubelet would # reuse the cached old digest and the pod would never see the # new content. Always forces a manifest fetch on each pod # start; layers themselves are still cached so the pull is # cheap (~50 ms when content is unchanged, fresh otherwise). {{- range $i, $a := .Values.addons }} {{- /* Skip odoo-builtin rows — they ship inside the odoo image, have no addon image to materialize, and have empty $a.image / $a.version which would render `image: :` and break YAML parsing. They get tracked in the overlay (Phase 2 Promote work, #344) for reconcile + audit but produce no init container. */ -}} {{- if and $a.code $a.image $a.version (ne $a.source "odoo-builtin") }} - name: addon-{{ $a.code | replace "_" "-" | lower }} image: {{ $a.image }}:{{ $a.version }} imagePullPolicy: Always volumeMounts: - name: addons mountPath: /target {{- end }} {{- end }} {{- end }} containers: - name: odoo image: "{{ .Values.odoo.image }}:{{ .Values.odoo.tag }}" imagePullPolicy: IfNotPresent # Pin the active database to our tenant code. Without this # Odoo runs in multi-DB mode and exposes /web/database/manager; # for the SaaS UX we want one instance == one DB and never # show the manager. db-init has already created and bootstrapped # this DB, so Odoo opens it cleanly. args: - "-d" - "{{ .Values.instance.code }}" - "--db-filter=^{{ .Values.instance.code }}$" {{- if .Values.addons }} - "--addons-path=/usr/lib/python3/dist-packages/odoo/addons,{{ .Values.addonsMountPath }}" {{- end }} ports: - name: http containerPort: 8069 env: - name: HOST value: {{ include "instance.fullname" . }}-pg - name: PORT value: "5432" - name: USER valueFrom: secretKeyRef: name: {{ include "instance.fullname" . }}-pg key: POSTGRES_USER - name: PASSWORD valueFrom: secretKeyRef: name: {{ include "instance.fullname" . }}-pg key: POSTGRES_PASSWORD {{- if .Values.addons }} # Python packages declared by addons (paramiko, lxml, # cryptography, …) are baked into each addon's image at # build time, then materialized into # /mnt/extra-addons/.python-deps by the init containers. # PYTHONPATH points Odoo's interpreter at that dir so # `import paramiko` from inside an addon Just Works without # the operator ever pip-installing anything at runtime. - name: PYTHONPATH value: "{{ .Values.addonsMountPath }}/.python-deps" {{- end }} volumeMounts: - name: filestore mountPath: /var/lib/odoo {{- if .Values.addons }} - name: addons mountPath: {{ .Values.addonsMountPath }} readOnly: true {{- end }} resources: {{- include "instance.resources" (dict "Values" .Values "role" "odoo") | nindent 12 }} # /web/login is the most stable health endpoint across Odoo # 16/17/18/19 — /web/health is 17+. Use login HTTP 200 as # readiness signal. readinessProbe: httpGet: path: /web/login port: 8069 initialDelaySeconds: 30 periodSeconds: 10 timeoutSeconds: 5 livenessProbe: tcpSocket: port: 8069 initialDelaySeconds: 60 periodSeconds: 30 volumes: - name: filestore persistentVolumeClaim: claimName: {{ include "instance.fullname" . }}-odoo {{- if .Values.addons }} # Shared scratch volume that init containers populate with # addon content. emptyDir is fine — the source of truth is # the registry's images; if the volume is wiped (pod # recreate) the init containers re-materialize from the # cached images. - name: addons emptyDir: {} {{- end }}