Files
instance-template-v3/templates/odoo-deployment.yaml

322 lines
16 KiB
YAML

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/<db>/<hash-prefix>" 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/<code>,
# 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 so K8s catches an Odoo restart (addon install
# via the Apps menu can trigger one) within a single probe
# cycle and pulls the Pod from Endpoints — paired with the
# Traefik retry middleware that swallows the brief gap.
readinessProbe:
httpGet:
path: /web/login
port: 8069
initialDelaySeconds: 30
periodSeconds: 5
timeoutSeconds: 5
failureThreshold: 2
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 }}