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 # platform-shim: workaround for upstream Odoo manifest bug. # Materializes a tiny addon (`odoosky_hoot_dom_shim`) into the # shared addons volume; the addon injects # `web/static/lib/hoot-dom/**/*` into the `web.assets_backend` # bundle. # # Why: Odoo 18.0 nightlies (verified through 2026-05-08) # include production source files # (`barcodes/static/src/barcode_handlers.js`, # `web_tour/.../tour_step_automatic.js`, # `sale/.../tour_utils.js`) that `import '@odoo/hoot-dom'`, # but the upstream `web/__manifest__.py` only places the # hoot-dom library in `web.assets_unit_tests_setup` (the test # bundle). Production bundle ends up with dangling references # and the browser fails to bootstrap. erp18 incident # 2026-05-08 — surfaced after a heavy addon install pulled # barcode_handlers.js into the active backend bundle. # # Remove this initContainer (and the shim install step in # db-init) once upstream patches the manifest. - name: platform-shim image: {{ include "instance.odooImage" . | quote }} imagePullPolicy: IfNotPresent command: ["/bin/sh", "-c"] args: - | set -eu mkdir -p /target/odoosky_hoot_dom_shim/static/src : > /target/odoosky_hoot_dom_shim/__init__.py # Investigated three approaches before settling on this one: # # 1. Drop the hoot-dom lib files into web.assets_backend # (`web/static/lib/hoot-dom/**/*`). They got bundled # but the asset compiler registered them under their # path-based names (`@web/../lib/hoot-dom/hoot-dom`) # and ignored the `@odoo-module alias=@odoo/hoot-dom` # directive — that directive only fires for files # under `static/src/`, not `static/lib/`. Bundle grew, # `@odoo/hoot-dom` still undefined. # 2. Add `web/static/tests/_framework/hoot_module_loader.js` # on top of #1 (mirror of upstream assets_unit_tests_setup). # Same outcome — the loader patches `odoo.define` for # follow-on hoot test files but doesn't register the # canonical alias. # 3. (this) Inject a tiny alias file under our addon's # `static/src/` that bridges the two names with a # plain `odoo.define` call. The lib files are already # in the prod bundle (the broken manifest puts them # only in unit_tests_setup, but Odoo's barcode/tour # code in static/src/ references them so they get # pulled in transitively); the alias file just # re-exports them under the canonical name production # code asks for. cat > /target/odoosky_hoot_dom_shim/static/src/hoot_dom_alias.js <<'JSEOF' /** @odoo-module ignore */ // OdooSky platform shim: register `@odoo/hoot-dom` as an // alias for the path-based module the asset compiler // produces from web/static/lib/hoot-dom/hoot-dom.js. // Without this, prod source files in barcodes / sale / // web_tour that `import '@odoo/hoot-dom'` fail to bootstrap // because no module is registered under that name in the // production bundle. odoo.define( "@odoo/hoot-dom", ["@web/../lib/hoot-dom/hoot-dom"], function (require) { return require("@web/../lib/hoot-dom/hoot-dom"); } ); JSEOF # Manifest version MUST start with ".0." or # Odoo refuses to install with: "incompatible version, # setting installable=False" (v19 ran into this — shim # was hardcoded as 18.0.1.0.2 and silently became # uninstallable, leaving the bundle without hoot-dom). # Render the major from .Values.odoo.tag so the shim is # compatible across every Odoo major in `pinnedTags`. cat > /target/odoosky_hoot_dom_shim/__manifest__.py </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 # Install the platform-shim addon if not already # installed. The shim's files were materialized into # the addons volume by the platform-shim initContainer # above; here we just register them in this DB. Once # registered, Odoo's asset machinery emits an ir.asset # row that adds web/static/lib/hoot-dom/**/* to the # web.assets_backend bundle on next regeneration. See # platform-shim initContainer for the why. SHIM_INSTALLED_VER="" IS_INIT_AFTER=$(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_AFTER" = "1" ]; then SHIM_INSTALLED_VER=$(PGPASSWORD="$PASSWORD" psql -h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME" -tAc \ "SELECT latest_version FROM ir_module_module WHERE name='odoosky_hoot_dom_shim' AND state='installed'" 2>/dev/null || true) fi # Manifest version is the source of truth — on bump, run -u # so Odoo re-reads assets and regenerates affected bundles. # H2: anchor on the literal `'version':` key (with optional # leading whitespace) so we don't accidentally match other # lines containing the substring "version" (e.g. a future # 'description' or 'summary' that mentions "versioning"). # Falls back to the python parser if awk produces empty — # that path also catches the case where the manifest has # been switched to double-quoted values. SHIM_MANIFEST_VER=$(awk -F"'" '/^[[:space:]]*.version.:/{print $4; exit}' /mnt/extra-addons/odoosky_hoot_dom_shim/__manifest__.py) if [ -z "$SHIM_MANIFEST_VER" ]; then SHIM_MANIFEST_VER=$(python3 -c "import ast,sys; print(ast.literal_eval(open(sys.argv[1]).read()).get('version',''))" /mnt/extra-addons/odoosky_hoot_dom_shim/__manifest__.py 2>/dev/null || echo "") fi if [ -z "$SHIM_INSTALLED_VER" ]; then echo "── installing odoosky_hoot_dom_shim ($SHIM_MANIFEST_VER) — hoot-dom workaround ──" odoo -i odoosky_hoot_dom_shim -d "$DBNAME" --stop-after-init --no-http \ --db_host="$HOST" --db_port="$PORT" --db_user="$USER" --db_password="$PASSWORD" \ --workers=0 \ --addons-path="/usr/lib/python3/dist-packages/odoo/addons,{{ .Values.addonsMountPath }}" elif [ "$SHIM_INSTALLED_VER" != "$SHIM_MANIFEST_VER" ]; then echo "── upgrading odoosky_hoot_dom_shim ($SHIM_INSTALLED_VER → $SHIM_MANIFEST_VER) ──" odoo -u odoosky_hoot_dom_shim -d "$DBNAME" --stop-after-init --no-http \ --db_host="$HOST" --db_port="$PORT" --db_user="$USER" --db_password="$PASSWORD" \ --workers=0 \ --addons-path="/usr/lib/python3/dist-packages/odoo/addons,{{ .Values.addonsMountPath }}" else echo "── odoosky_hoot_dom_shim $SHIM_INSTALLED_VER up-to-date — skipping ──" 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 volumeMounts: # db-init reads the platform-shim addon from this mount # to install it via `odoo -i odoosky_hoot_dom_shim`. The # mount is unconditional because the shim is unconditional; # any tenant addon images write here too if .Values.addons # is non-empty. - name: addons mountPath: {{ .Values.addonsMountPath }} {{- 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 }}$" # addons-path always includes the shared addons mount — # the platform-shim addon lives there even when the # tenant has no extra addons. See platform-shim # initContainer. - "--addons-path=/usr/lib/python3/dist-packages/odoo/addons,{{ .Values.addonsMountPath }}" 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 # addons mount is always present — at minimum it carries # the platform-shim addon (see platform-shim initContainer). - name: addons mountPath: {{ .Values.addonsMountPath }} readOnly: true 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 # Shared scratch volume that init containers populate. Always # created — at minimum the platform-shim initContainer drops # `odoosky_hoot_dom_shim` here. emptyDir is fine — source of # truth is the registry's images / hardcoded shim; if the # volume is wiped (pod recreate) initContainers re-materialize. - name: addons emptyDir: {}