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: {{- with .Values.imageMirror.pullSecret }} # Air-gap support (B.10): when imageMirror.pullSecret is set, # K8s authenticates against the mirror with this Secret to pull # the upstream Odoo image. Default empty = anonymous (Docker # Hub library images need no auth). imagePullSecrets: - name: {{ . }} {{- end }} # fsGroup=101 makes the kubelet recursively chgrp the filestore # PVC's root inode to gid=101 on attach. The Odoo user is in # group 101 (`odoo`) so it inherits group access to the volume. # # NOTE: fsGroup ONLY changes group, never owner UID. If a helper # job (Tower's checkpoint, spawn-seed, import) ever wrote files # as a different UID, fsGroup wouldn't fix that. The # filestore-chown initContainer below is what closes the gap — # it's the single source of truth for "files in /var/lib/odoo # are owned by 100:101 with mode g+rwX before Odoo runs." securityContext: fsGroup: 101 fsGroupChangePolicy: OnRootMismatch # Bootstrap initContainers always run; addon initContainers only # run when there are addons. We always need filestore-chown + # db-init to ensure the filestore is writable + the tenant DB # exists + base module is initialized before Odoo's main process # starts serving — without these the operator would either hit # Permission denied on first attachment write (filestore-chown # fixes that) or have to click through Odoo's setup wizard # manually for every new instance (db-init fixes that). initContainers: # filestore-chown: load-bearing self-heal. Runs as root, chowns # /var/lib/odoo to 100:101 (the Odoo runtime user inside the # official Odoo image: uid 100, gid 101) and chmods g+rwX. # Idempotent — runs on every pod boot. # # Why this exists: Tower's clone helpers (checkpoint Job, # spawn-seed Job, import Job) used to chown to 101:101 (uid # 101 doesn't even exist as a user — that's the gid). Files # ended up owned by a non-existent UID, group=odoo. mode 0755 # gave the running Odoo (uid 100) only group r-x — install # operations failed with "Permission denied: mkdir # /var/lib/odoo/filestore//" inside Odoo's # mail.data load. This initContainer fixes ownership-drift # regardless of what wrote the files. Defence in depth. - name: filestore-chown image: {{ include "instance.odooImage" . | quote }} imagePullPolicy: IfNotPresent command: ["/bin/sh", "-c"] args: - | set -eu # uid 100 = odoo, gid 101 = odoo group (per the Odoo # base image's /etc/passwd + /etc/group). Hardcoded # because the chart targets that specific image. chown -R 100:101 /var/lib/odoo chmod -R u+rwX,g+rwX /var/lib/odoo echo "filestore ownership: $(stat -c '%u:%g %a' /var/lib/odoo)" securityContext: runAsUser: 0 runAsGroup: 0 volumeMounts: - name: filestore mountPath: /var/lib/odoo # 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: {{ include "instance.odooImage" . | quote }} 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 }}" CLONE_FROM="{{ .Values.instance.cloneFromCode | default "" }}" 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 # VolumeClone fast-path rename (ADR 0003 phase 4): when # spawn-env clones a source PG PVC, the data dir carries # source's database name. Rename it to target's instance # code BEFORE the existence check below, so the rest of # the script sees the correct DB name. Idempotent — if # CLONE_FROM == DBNAME or target already exists, skip. if [ -n "$CLONE_FROM" ] && [ "$CLONE_FROM" != "$DBNAME" ]; then if PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d postgres -tAc \ "SELECT 1 FROM pg_database WHERE datname = '$CLONE_FROM'" | grep -q 1; then 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 "── renaming cloned database $CLONE_FROM → $DBNAME ──" PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d postgres \ -c "ALTER DATABASE \"$CLONE_FROM\" RENAME TO \"$DBNAME\"" else echo "(both $CLONE_FROM and $DBNAME exist — leaving rename to operator)" fi fi fi # 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: {{ include "instance.odooImage" . | quote }} 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. # # 5s period catches a true pod restart fast (initialDelay # gates the boot path; failureThreshold only matters once # the pod has been ready). failureThreshold: 15 (= 75s of # consecutive probe failures) gives Odoo headroom to install # heavy modules (e.g. ks_dashboard_ninja) without K8s pulling # the Pod from Endpoints mid-install — which produced 503s # at the operator surface during addon-apply. Paired with # the Traefik retry middleware for short gaps. readinessProbe: httpGet: path: /web/login port: 8069 initialDelaySeconds: 30 periodSeconds: 5 timeoutSeconds: 5 failureThreshold: 15 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 }}