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

233 lines
11 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:
# 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/<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 }}
- name: addon-{{ $a.code | replace "_" "-" | lower }}
image: {{ $a.image }}:{{ $a.version }}
imagePullPolicy: Always
volumeMounts:
- name: addons
mountPath: /target
{{- 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 }}