apiVersion: apps/v1 kind: Deployment metadata: name: {{ taskserv.name }} namespace: {{ taskserv.namespace }} labels: app.kubernetes.io/name: {{ taskserv.name }} app.kubernetes.io/instance: {{ taskserv.tenant }} app.kubernetes.io/managed-by: provisioning spec: replicas: 1 strategy: type: Recreate selector: matchLabels: app.kubernetes.io/name: {{ taskserv.name }} app.kubernetes.io/instance: {{ taskserv.tenant }} template: metadata: labels: app.kubernetes.io/name: {{ taskserv.name }} app.kubernetes.io/instance: {{ taskserv.tenant }} app.kubernetes.io/managed-by: provisioning spec: {% if taskserv.placement is defined and taskserv.placement.node_class %} affinity: nodeAffinity: {% if taskserv.placement.mode == "require" %} requiredDuringSchedulingIgnoredDuringExecution: nodeSelectorTerms: - matchExpressions: - key: node-class.{{ taskserv.placement.node_class.0 }} operator: In values: ["true"] {% else %} preferredDuringSchedulingIgnoredDuringExecution: {% for c in taskserv.placement.node_class %} - weight: {{ 100 - loop.index0 * 10 }} preference: matchExpressions: - key: node-class.{{ c }} operator: In values: ["true"] {% endfor %} {% endif %} {% endif %} securityContext: fsGroup: 101 initContainers: - name: wait-for-db image: postgres:18-bookworm command: ["/bin/bash", "-c"] args: - | for i in $(seq 1 30); do pg_isready -h {{ taskserv.db_host }} -p {{ taskserv.db_port }} -U {{ taskserv.db_user }} && exit 0 echo "waiting for postgresql (attempt $i/30)..." sleep 2 done echo "ERROR: postgresql not ready after 30 attempts" >&2 exit 1 - name: clone-oca-addons image: alpine/git:latest command: ["/bin/sh", "-c"] args: - | set -e CLONEDIR=$(mktemp -d) : > /mnt/extra-addons/.oca-lock {% for a in odoo_oca_addons_resolved %} echo "Fetching OCA/{{ a.repo }} @ {{ a.ref }}" # fetch by ref works for branch, tag, or commit SHA (GitHub allows # reachable-SHA fetch), so a pinned commit and a rolling branch # share one code path. if git init -q "$CLONEDIR/{{ a.repo }}" \ && git -C "$CLONEDIR/{{ a.repo }}" remote add origin https://github.com/OCA/{{ a.repo }}.git \ && git -C "$CLONEDIR/{{ a.repo }}" fetch -q --depth 1 origin "{{ a.ref }}" \ && git -C "$CLONEDIR/{{ a.repo }}" checkout -q --detach FETCH_HEAD; then sha=$(git -C "$CLONEDIR/{{ a.repo }}" rev-parse HEAD) echo "{{ a.repo }} {{ a.ref }} $sha" >> /mnt/extra-addons/.oca-lock find "$CLONEDIR/{{ a.repo }}" -maxdepth 1 -mindepth 1 -type d \ -exec sh -c '[ -f "$1/__manifest__.py" ] && mv "$1" /mnt/extra-addons/ || true' _ {} \; rm -rf "$CLONEDIR/{{ a.repo }}" else echo "WARN: OCA/{{ a.repo }} fetch failed (ref {{ a.ref }}) — skipping" fi {% endfor %} # fingerprint over the resolved SHAs; changes only when code changes sort /mnt/extra-addons/.oca-lock | sha256sum | cut -d' ' -f1 \ > /mnt/extra-addons/.oca-fingerprint echo "OCA fingerprint: $(cat /mnt/extra-addons/.oca-fingerprint)" chmod -R 755 /mnt/extra-addons 2>/dev/null || true volumeMounts: - name: extra-addons mountPath: /mnt/extra-addons - name: install-python-deps image: {{ taskserv.image }} command: ["/bin/bash", "-c"] args: - | set -e TARGET=/mnt/extra-addons/python-packages mkdir -p $TARGET pkgs=$(python3 - <<'PYEOF' import os, ast seen = set() for entry in os.scandir("/mnt/extra-addons"): if not entry.is_dir(): continue manifest = os.path.join(entry.path, "__manifest__.py") if not os.path.isfile(manifest): continue try: with open(manifest) as f: data = ast.literal_eval(f.read()) for pkg in data.get("external_dependencies", {}).get("python", []): seen.add(pkg) except Exception: pass print(" ".join(sorted(seen))) PYEOF ) reqs=$(find /mnt/extra-addons -name "requirements.txt" -exec cat {} \; \ | grep -v '^\s*#' | sort -u | tr '\n' ' ') # `packaging` is needed by Odoo to parse external-dependency # version specifiers (e.g. schwifty==2024.4.0) during `-u`; the base # image only vendors it inside pip, not as an importable top-level. all="$pkgs $reqs packaging{% if odoo_python_packages %} {{ odoo_python_packages }}{% endif %}" all=$(echo "$all" | tr -s ' ' | sed 's/^ //;s/ $//') # Pin the system crypto stack to the image's versions. The main # container puts $TARGET on PYTHONPATH, so an addon-pulled newer # cryptography (via xmlsig) would shadow and ABI-break the system # pyOpenSSL ("module 'lib' has no attribute 'GEN_EMAIL'"). Pinning # to the installed versions keeps the shadow copy ABI-compatible. CONSTRAINTS=$(mktemp) python3 - > "$CONSTRAINTS" <<'PYEOF' import importlib.metadata as m for pkg in ("cryptography", "cffi", "pyOpenSSL"): try: print(f"{pkg}=={m.version(pkg)}") except Exception: pass PYEOF echo "[deps] system crypto constraints:"; cat "$CONSTRAINTS" if [ -n "$all" ]; then pip install --target $TARGET -c "$CONSTRAINTS" $all 2>/dev/null || true fi chmod -R 755 $TARGET securityContext: runAsUser: 0 volumeMounts: - name: extra-addons mountPath: /mnt/extra-addons - name: init-odoo-db image: {{ taskserv.image }} command: ["/bin/bash", "-c"] args: - | set -e initialized=$(PGPASSWORD="$ODOO_DB_PASSWORD" python3 -c " import psycopg2, os, sys try: conn = psycopg2.connect( host='{{ taskserv.db_host }}', port={{ taskserv.db_port }}, user='{{ taskserv.db_user }}', password=os.environ['ODOO_DB_PASSWORD'], dbname='{{ taskserv.db_name }}' ) cur = conn.cursor() cur.execute(\"SELECT 1 FROM pg_tables WHERE tablename='ir_module_module'\") print('yes' if cur.fetchone() else 'no') except Exception as e: print('no', file=sys.stderr) sys.exit(1) " 2>/dev/null) if [ "$initialized" = "yes" ]; then echo "[init-odoo-db] schema present — skipping initialization" exit 0 fi echo "[init-odoo-db] empty database — initializing Odoo base (this takes a few minutes)..." export PGPASSWORD="$ODOO_DB_PASSWORD" exec odoo \ -d "{{ taskserv.db_name }}" \ --db_host="{{ taskserv.db_host }}" \ --db_port="{{ taskserv.db_port }}" \ --db_user="{{ taskserv.db_user }}" \ --db_password="$ODOO_DB_PASSWORD" \ --addons-path="/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons" \ --init base \ --stop-after-init \ --without-demo=all \ --log-level=info envFrom: - secretRef: name: {{ taskserv.name }}-credentials volumeMounts: - name: extra-addons mountPath: /mnt/extra-addons # Reconcile DB schema with the cloned addon code. Odoo only emits # ALTER TABLE during an explicit module upgrade (-u), never on a plain # load, so when OCA code advances the schema drifts (missing columns → # UndefinedColumn at runtime). This runs -u on installed extra-addons # modules exactly when the resolved-SHA fingerprint differs from the one # stamped in ir_config_parameter, then re-stamps. No drift → no upgrade. - name: sync-odoo-schema image: {{ taskserv.image }} command: ["/bin/bash", "-c"] args: - | set -e FP_FILE=/mnt/extra-addons/.oca-fingerprint if [ ! -f "$FP_FILE" ]; then echo "[sync] no fingerprint file — skip"; exit 0 fi FP=$(cat "$FP_FILE") export PGPASSWORD="$ODOO_DB_PASSWORD" action=$(python3 - "$FP" <<'PYEOF' import sys, os, psycopg2 fp = sys.argv[1] try: conn = psycopg2.connect( host="{{ taskserv.db_host }}", port={{ taskserv.db_port }}, user="{{ taskserv.db_user }}", password=os.environ["ODOO_DB_PASSWORD"], dbname="{{ taskserv.db_name }}") except Exception: print("SKIP"); sys.exit(0) cur = conn.cursor() cur.execute("SELECT 1 FROM pg_tables WHERE tablename='ir_config_parameter'") if not cur.fetchone(): print("SKIP"); sys.exit(0) # DB not yet initialized cur.execute("SELECT value FROM ir_config_parameter WHERE key='oca_addons.fingerprint'") row = cur.fetchone() if row and row[0] == fp: print("SKIP"); sys.exit(0) # nothing changed disk = {e.name for e in os.scandir("/mnt/extra-addons") if e.is_dir() and os.path.isfile(os.path.join(e.path, "__manifest__.py"))} cur.execute("SELECT name FROM ir_module_module WHERE state='installed'") installed = {r[0] for r in cur.fetchall()} mods = sorted(disk & installed) print(("UPGRADE " + ",".join(mods)) if mods else "STAMP") PYEOF ) case "$action" in SKIP*) echo "[sync] fingerprint unchanged or DB not ready — skip"; exit 0 ;; STAMP*) echo "[sync] no managed modules installed yet — stamping fingerprint" ;; UPGRADE*) mods=$(echo "$action" | cut -d' ' -f2) echo "[sync] OCA addons changed — upgrading installed modules: $mods" # Non-fatal: a failed reconcile must not block the whole # instance from starting. Leave the fingerprint unstamped so # the upgrade is retried on the next start; the server comes # up on the existing schema (drift surfaces as a runtime error # to fix, not a total outage). if ! odoo -d "{{ taskserv.db_name }}" \ --db_host="{{ taskserv.db_host }}" \ --db_port="{{ taskserv.db_port }}" \ --db_user="{{ taskserv.db_user }}" \ --db_password="$ODOO_DB_PASSWORD" \ --addons-path="/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons" \ -u "$mods" --stop-after-init --no-http \ --max-cron-threads=0 --log-level=info; then echo "[sync] WARNING: module upgrade failed — starting without re-stamping; will retry next start" >&2 exit 0 fi ;; esac python3 - "$FP" <<'PYEOF' import sys, os, psycopg2 fp = sys.argv[1] conn = psycopg2.connect( host="{{ taskserv.db_host }}", port={{ taskserv.db_port }}, user="{{ taskserv.db_user }}", password=os.environ["ODOO_DB_PASSWORD"], dbname="{{ taskserv.db_name }}") conn.autocommit = True cur = conn.cursor() cur.execute("UPDATE ir_config_parameter SET value=%s, write_date=now() " "WHERE key='oca_addons.fingerprint'", (fp,)) if cur.rowcount == 0: cur.execute("INSERT INTO ir_config_parameter (key, value, create_date, write_date) " "VALUES ('oca_addons.fingerprint', %s, now(), now())", (fp,)) PYEOF echo "[sync] fingerprint stamped: $FP" env: # match the main container so `-u` can import addon external deps # (e.g. schwifty/packaging) that install-python-deps placed here. - name: PYTHONPATH value: "/mnt/extra-addons/python-packages" envFrom: - secretRef: name: {{ taskserv.name }}-credentials volumeMounts: - name: extra-addons mountPath: /mnt/extra-addons containers: - name: odoo image: {{ taskserv.image }} args: - "--db_host={{ taskserv.db_host }}" - "--db_port={{ taskserv.db_port }}" - "--db_user={{ taskserv.db_user }}" - "--db-filter={{ odoo_db_filter }}" - "--no-database-list" - "--addons-path=/usr/lib/python3/dist-packages/odoo/addons,/mnt/extra-addons" - "--proxy-mode" - "--workers={{ taskserv.workers }}" - "--max-cron-threads={{ taskserv.max_cron_threads }}" - "--limit-memory-hard=536870912" - "--limit-memory-soft=469762048" - "--limit-time-cpu=60" - "--limit-time-real=120" - "--log-level=info" ports: - containerPort: 8069 name: http protocol: TCP env: - name: DB_NAME value: "{{ taskserv.db_name }}" - name: HOST value: "{{ taskserv.db_host }}" - name: PORT value: "{{ taskserv.db_port }}" - name: USER value: "{{ taskserv.db_user }}" - name: PYTHONPATH value: "/mnt/extra-addons/python-packages" envFrom: - secretRef: name: {{ taskserv.name }}-credentials volumeMounts: - name: filestore mountPath: /var/lib/odoo - name: extra-addons mountPath: /mnt/extra-addons {% if taskserv.probes is defined and taskserv.probes.readiness is defined %} readinessProbe: httpGet: path: {{ taskserv.probes.readiness.path }} port: {{ taskserv.probes.readiness.port | default(value=8069) }} initialDelaySeconds: {{ taskserv.probes.readiness.initial_delay }} periodSeconds: {{ taskserv.probes.readiness.period }} failureThreshold: {{ taskserv.probes.readiness.failure_threshold }} {% else %} readinessProbe: httpGet: path: /web/login port: 8069 initialDelaySeconds: 30 periodSeconds: 10 failureThreshold: 12 {% endif %} {% if taskserv.probes is defined and taskserv.probes.liveness is defined %} livenessProbe: httpGet: path: {{ taskserv.probes.liveness.path }} port: {{ taskserv.probes.liveness.port | default(value=8069) }} initialDelaySeconds: {{ taskserv.probes.liveness.initial_delay }} periodSeconds: {{ taskserv.probes.liveness.period }} failureThreshold: {{ taskserv.probes.liveness.failure_threshold }} {% else %} livenessProbe: httpGet: path: /web/health port: 8069 initialDelaySeconds: 60 periodSeconds: 30 failureThreshold: 3 {% endif %} {% if taskserv.resources_effective is defined %} resources: requests: cpu: "{{ taskserv.resources_effective.cpu.request }}" memory: "{{ taskserv.resources_effective.memory.request }}" limits: cpu: "{{ taskserv.resources_effective.cpu.limit }}" memory: "{{ taskserv.resources_effective.memory.limit }}" {% else %} resources: requests: cpu: "100m" memory: "350Mi" limits: cpu: "1000m" memory: "1Gi" {% endif %} volumes: - name: filestore persistentVolumeClaim: claimName: {{ taskserv.filestore_pvc }} - name: extra-addons emptyDir: {}