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 echo "── initializing base module (no-op if already installed) ──" 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 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 }} - 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 }}