224 lines
10 KiB
YAML
224 lines
10 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.
|
|
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 }}
|