403 lines
17 KiB
Text
403 lines
17 KiB
Text
|
|
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: {}
|