Bash lit ses scripts au fil de l'eau. Éditer le fichier en cours de route décale les offsets et corrompt l'exécution : un refresh-from-prod en cours s'est mis à afficher les messages de blank_sandbox, puis a fini sur « syntax error ». Le travail avait bien abouti — sandbox restaurée, 295 tables, 10 factures, 12 tiers — mais la trace mentait sur la fonction qui tournait. Sur un script qui purge des bases, une trace qui ment est inacceptable. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
298 lines
17 KiB
Bash
Executable File
298 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# sandbox-lifecycle.sh — seed / refresh the erp-sandbox Dolibarr from prod, and
|
|
# sync its uploaded documents, with prod integrity guaranteed structurally.
|
|
#
|
|
# Implements ADR-0003 (factory vibe/ADR/0003-sandbox-state-lifecycle.md):
|
|
# - prod is read ONLY (pg_dump runs in a default_transaction_read_only session);
|
|
# - the restore writes ONLY to erp-sandbox, using the sandbox's own dynamic
|
|
# credentials (a member of erp_sandbox_role, which owns only the sandbox DB),
|
|
# so it is structurally incapable of touching prod 'erp' (owned by erp_role);
|
|
# - no DROP/CREATE DATABASE, no CREATEDB, no superuser — wipe is
|
|
# `DROP OWNED BY erp_sandbox_role CASCADE`, reload is `pg_restore`.
|
|
#
|
|
# The only prod-capable credential on the platform is the superuser provider in
|
|
# factory postgres/iac, exercised solely in the human-gated postgres.yaml CI —
|
|
# this script never uses it.
|
|
#
|
|
# Requires: kubectl (context on the lab cluster), and a postgres:16 image
|
|
# reachable by the cluster. Run from anywhere.
|
|
#
|
|
# Usage:
|
|
# ./sandbox-lifecycle.sh refresh-from-prod # iso-prod seed: prod DB -> erp-sandbox
|
|
# ./sandbox-lifecycle.sh sync-documents # copy mycompany/ uploads (logo, PDFs)
|
|
# ./sandbox-lifecycle.sh refresh # refresh-from-prod + sync-documents
|
|
#
|
|
set -euo pipefail
|
|
|
|
PROD_NS="erp"
|
|
SB_NS="erp-sandbox"
|
|
PROD_DB="erp"
|
|
SB_DB="erp-sandbox"
|
|
SB_ROLE="erp_sandbox_role" # snake-case owner role (ADR-0002 elision rule)
|
|
PGHOST="192.168.1.202" # direct Postgres (NOT pgbouncer — pooler breaks pg_dump)
|
|
PG_IMAGE="postgres:16-alpine"
|
|
DOC_ROOT="/var/www/documents" # dolibarr_main_data_root
|
|
TMP_PROD_SECRET="prod-db-ro-temp" # transient copy of prod creds, deleted on exit
|
|
|
|
# ATTENTION — NE JAMAIS ÉDITER CE FICHIER PENDANT QU'IL S'EXÉCUTE.
|
|
# Bash lit ses scripts au fil de l'eau : modifier le fichier en cours de route
|
|
# décale les offsets et corrompt l'exécution. Constaté le 2026-08-14 — un
|
|
# refresh-from-prod en cours s'est mis à afficher les messages de blank_sandbox
|
|
# puis a fini sur « syntax error ». Le travail était fait, mais la trace mentait
|
|
# sur ce qui tournait : sur un script qui purge des bases, c'est inacceptable.
|
|
# Attendre la fin, ou éditer une copie.
|
|
|
|
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
|
|
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
|
|
|
|
sb_pod() { K get pod -n "$SB_NS" -l app.kubernetes.io/instance=erp-sandbox -o name 2>/dev/null | head -1; }
|
|
prod_pod() { K get pod -n "$PROD_NS" -l app.kubernetes.io/instance=erp -o name 2>/dev/null | head -1; }
|
|
|
|
cleanup_secret() { K delete secret "$TMP_PROD_SECRET" -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true; }
|
|
|
|
# erp-sandbox is ArgoCD-managed with self-heal ON, which reverts `kubectl scale
|
|
# --replicas=0` within seconds — so without pausing it the seed runs while Dolibarr
|
|
# is still connected, and the restore collides with the app re-creating tables.
|
|
# Pause self-heal for the duration so the scale-down holds; always re-arm it.
|
|
ARGOCD_NS="argocd"; ARGOCD_APP="erp-sandbox"
|
|
|
|
# --- Cluster guard -----------------------------------------------------------
|
|
# This script scales deployments to zero, patches the ArgoCD Application and runs
|
|
# `DROP OWNED ... CASCADE`. Until now every one of those ran against whatever
|
|
# kube-context happened to be current — and this workstation also carries a
|
|
# CLIENT production cluster (observed 2026-07-25: the current context was the
|
|
# client's, and refresh only failed because that cluster has no `erp` namespace).
|
|
# Never trust the ambient context: resolve an explicit one and prove it is the
|
|
# Arcodange homelab before touching anything.
|
|
ERP_KUBE_CONTEXT="${ERP_KUBE_CONTEXT:-default}"
|
|
K() { kubectl --context "$ERP_KUBE_CONTEXT" "$@"; }
|
|
|
|
assert_arcodange_cluster() {
|
|
kubectl config get-contexts -o name 2>/dev/null | grep -qx "$ERP_KUBE_CONTEXT" \
|
|
|| die "kube-context '$ERP_KUBE_CONTEXT' does not exist (set ERP_KUBE_CONTEXT)"
|
|
# Positive fingerprint: the three namespaces AND the ArgoCD Application this
|
|
# script drives. A client cluster cannot match all four by accident.
|
|
for ns in "$PROD_NS" "$SB_NS" "$ARGOCD_NS"; do
|
|
K get ns "$ns" >/dev/null 2>&1 \
|
|
|| die "kube-context '$ERP_KUBE_CONTEXT' has no '$ns' namespace — refusing to run against it.
|
|
This script is destructive (scale-to-0, DROP OWNED CASCADE, ArgoCD patch) and must only
|
|
ever target the Arcodange homelab. Current context is '$(kubectl config current-context 2>/dev/null)'.
|
|
Set ERP_KUBE_CONTEXT to the homelab context and retry."
|
|
done
|
|
K get application "$ARGOCD_APP" -n "$ARGOCD_NS" >/dev/null 2>&1 \
|
|
|| die "kube-context '$ERP_KUBE_CONTEXT' has no ArgoCD Application '$ARGOCD_APP' — refusing to run."
|
|
log "cluster guard OK — context '$ERP_KUBE_CONTEXT' (namespaces $PROD_NS/$SB_NS/$ARGOCD_NS + app $ARGOCD_APP)"
|
|
}
|
|
set_selfheal() { K patch application "$ARGOCD_APP" -n "$ARGOCD_NS" --type merge \
|
|
-p "{\"spec\":{\"syncPolicy\":{\"automated\":{\"selfHeal\":$1,\"prune\":true}}}}" >/dev/null 2>&1 || true; }
|
|
# Safety net (EXIT trap): whatever happens, bring the app back, re-arm self-heal, drop the secret.
|
|
restore_state() { set_selfheal true; K scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null 2>&1 || true; cleanup_secret; }
|
|
|
|
refresh_from_prod() {
|
|
command -v python3 >/dev/null || die "python3 required to copy the prod secret without exposing it"
|
|
trap restore_state EXIT
|
|
|
|
log "Pausing ArgoCD self-heal so the scale-to-0 holds (else it is reverted in seconds)"
|
|
set_selfheal false
|
|
|
|
log "Copying prod DB creds into a transient, read-only-intent secret in $SB_NS (values stay base64)"
|
|
K get secret vso-db-credentials -n "$PROD_NS" -o json \
|
|
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_PROD_SECRET','namespace':'$SB_NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('username','password')}; print(json.dumps(d))" \
|
|
| K apply -f - >/dev/null
|
|
|
|
log "Scaling erp-sandbox to 0 (exclusive DB access for the restore)"
|
|
K scale deploy erp-sandbox -n "$SB_NS" --replicas=0 >/dev/null
|
|
K wait --for=delete pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=120s >/dev/null 2>&1 || true
|
|
|
|
log "Running the seed Job (pg_dump prod read-only -> DROP OWNED -> pg_restore into sandbox)"
|
|
K delete job sandbox-seed -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
|
|
K apply -f - >/dev/null <<EOF
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata: { name: sandbox-seed, namespace: $SB_NS }
|
|
spec:
|
|
backoffLimit: 0
|
|
ttlSecondsAfterFinished: 900
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: seed
|
|
image: $PG_IMAGE
|
|
env:
|
|
- { name: PROD_PGUSER, valueFrom: { secretKeyRef: { name: $TMP_PROD_SECRET, key: username } } }
|
|
- { name: PROD_PGPASSWORD, valueFrom: { secretKeyRef: { name: $TMP_PROD_SECRET, key: password } } }
|
|
- { name: SB_PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
|
|
- { name: SB_PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
|
|
- { name: PGHOST, value: "$PGHOST" }
|
|
- { name: PGSSLMODE, value: "disable" }
|
|
command: ["/bin/sh","-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
SBDB=\$(PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc 'select current_database()')
|
|
[ "\$SBDB" = "$SB_DB" ] || { echo "ABORT: target is '\$SBDB' not $SB_DB"; exit 1; }
|
|
echo "source=$PROD_DB (read-only) target=$SB_DB ok"
|
|
# 1. dump prod — full public schema (incl. helper functions + triggers), read-only session
|
|
PGPASSWORD=\$PROD_PGPASSWORD PGOPTIONS='-c default_transaction_read_only=on' \\
|
|
pg_dump -h "\$PGHOST" -U "\$PROD_PGUSER" -d $PROD_DB -n public -Fc -f /tmp/golden.dump
|
|
# drop provisioner-owned infra (pgbouncer user_lookup) from the TOC: it already
|
|
# exists in the sandbox and is not app data, so restoring it conflicts.
|
|
pg_restore -l /tmp/golden.dump | grep -vi 'user_lookup' > /tmp/golden.toc
|
|
echo "dump: \$(ls -l /tmp/golden.dump | awk '{print \$5}') bytes, tables=\$(grep -c 'TABLE DATA ' /tmp/golden.toc)"
|
|
# 2. wipe sandbox app objects (everything owned by the app role; infra untouched)
|
|
PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -v ON_ERROR_STOP=1 \\
|
|
-c "DROP OWNED BY $SB_ROLE CASCADE;"
|
|
# 3. restore golden, owned by the sandbox role. MUST pass -U: without it
|
|
# pg_restore connects as the container's OS user (root) and auth-fails.
|
|
# pg_restore also exits non-zero on the harmless "schema public already
|
|
# exists" notice, so its exit code is NOT trustworthy — verify by count below.
|
|
Q() { PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc "\$1"; }
|
|
PGPASSWORD=\$SB_PGPASSWORD \\
|
|
pg_restore -h "\$PGHOST" -U "\$SB_PGUSER" -L /tmp/golden.toc --no-owner --role=$SB_ROLE -d $SB_DB /tmp/golden.dump 2>/tmp/restore.err \\
|
|
&& echo "restore: clean" || echo "restore: pg_restore rc=\$? — verifying by table count, not exit code"
|
|
# 4. verify — FAIL the Job if the restore did not actually populate the schema
|
|
N=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
|
|
[ "\$N" -ge 250 ] || { echo "ABORT: only \$N llx tables after restore — restore failed. Last errors:"; tail -5 /tmp/restore.err; exit 1; }
|
|
echo "llx tables=\$N company=\$(Q "select value from llx_const where name='MAIN_INFO_SOCIETE_NOM'") lang=\$(Q "select value from llx_const where name='MAIN_LANG_DEFAULT'") owner=\$(Q "select tableowner from pg_tables where tablename='llx_societe'")"
|
|
echo "DONE."
|
|
EOF
|
|
K wait --for=condition=complete job/sandbox-seed -n "$SB_NS" --timeout=300s >/dev/null 2>&1 \
|
|
|| die "seed Job did not complete — see: K logs -n $SB_NS job/sandbox-seed"
|
|
K logs -n "$SB_NS" job/sandbox-seed | sed 's/^/ /'
|
|
K delete job sandbox-seed -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
|
|
|
|
log "Restoring app (replicas=1) + re-arming ArgoCD self-heal"
|
|
set_selfheal true
|
|
K scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null
|
|
cleanup_secret; trap - EXIT
|
|
log "Refresh complete. Run 'sync-documents' to also copy the company logo + uploads."
|
|
}
|
|
|
|
sync_documents() {
|
|
local pp sp
|
|
pp=$(prod_pod); sp=$(sb_pod)
|
|
[ -n "$pp" ] || die "no prod erp pod found"
|
|
[ -n "$sp" ] || die "no erp-sandbox pod found"
|
|
log "Syncing $DOC_ROOT/mycompany (logo + uploads) ${pp##*/} -> ${sp##*/} via tar pipe"
|
|
K exec -n "$PROD_NS" "${pp#pod/}" -- tar -C "$DOC_ROOT" -cf - mycompany 2>/dev/null \
|
|
| K exec -i -n "$SB_NS" "${sp#pod/}" -- tar -C "$DOC_ROOT" -xf -
|
|
log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)"
|
|
}
|
|
|
|
# --- mode vierge -------------------------------------------------------------
|
|
# ÉTAT : INCOMPLET. La purge fonctionne, l'instance ne se reconstruit PAS.
|
|
#
|
|
# Vérifié le 2026-08-14 : après DROP OWNED et retrait de install.lock, Dolibarr
|
|
# sert une page de login sur un schéma à ZÉRO table et l'API répond « Module Api
|
|
# must be enabled ». L'entrypoint de l'image NE LANCE PAS d'installation
|
|
# automatique — retirer le verrou ne suffit pas.
|
|
#
|
|
# CE QU'IL MANQUE : un « golden empty », c'est-à-dire un pg_dump d'une instance
|
|
# Dolibarr fraîchement installée — schéma + données de référence (dictionnaires,
|
|
# pays, plan comptable) — sans aucune donnée métier. `blank` restaurerait ce dump
|
|
# au lieu de laisser la base vide, exactement comme `refresh-from-prod` restaure
|
|
# le dump de production. Seule la source change.
|
|
#
|
|
# En l'état, `blank` laisse le bac à sable INUTILISABLE : ne l'employer que suivi
|
|
# de `refresh-from-prod`, ou une fois le golden empty constitué.
|
|
#
|
|
# Vide la base du bac à sable SANS rien restaurer, pour qu'un rejeu d'exercice
|
|
# reparte de zéro. `refresh-from-prod` réimporterait précisément les défauts que
|
|
# le rejeu doit corriger : reconstruire suppose donc une base vide, pas iso-prod.
|
|
#
|
|
# CE QUI REND L'OPÉRATION SÛRE : elle est ANNULABLE. `refresh-from-prod` restaure
|
|
# un bac à sable iso-prod en trois minutes. On peut donc casser sans regret — et
|
|
# c'est la seule raison pour laquelle une commande qui détruit une base entière
|
|
# est acceptable ici.
|
|
#
|
|
# Elle ne vise QUE $SB_NS. Le namespace de production n'apparaît nulle part dans
|
|
# cette fonction, et la garde de cluster refuse tout contexte qui n'est pas le
|
|
# homelab.
|
|
blank_sandbox() {
|
|
[ "${1:-}" = "--yes" ] || die "blank détruit toutes les données du bac à sable — relancer avec --yes"
|
|
trap restore_state EXIT
|
|
|
|
log "Pause du self-heal ArgoCD (sinon le scale-to-0 est repris en quelques secondes)"
|
|
set_selfheal false
|
|
|
|
log "erp-sandbox à 0 réplique (accès exclusif à la base)"
|
|
K scale deploy erp-sandbox -n "$SB_NS" --replicas=0 >/dev/null
|
|
K wait --for=delete pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=120s >/dev/null 2>&1 || true
|
|
|
|
log "Job de purge (DROP OWNED, aucune restauration)"
|
|
K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
|
|
K apply -f - >/dev/null <<EOF
|
|
apiVersion: batch/v1
|
|
kind: Job
|
|
metadata: { name: sandbox-blank, namespace: $SB_NS }
|
|
spec:
|
|
backoffLimit: 0
|
|
ttlSecondsAfterFinished: 900
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: blank
|
|
image: $PG_IMAGE
|
|
env:
|
|
- { name: SB_PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
|
|
- { name: SB_PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
|
|
- { name: PGHOST, value: "$PGHOST" }
|
|
- { name: PGSSLMODE, value: "disable" }
|
|
command: ["/bin/sh","-c"]
|
|
args:
|
|
- |
|
|
set -eu
|
|
Q() { PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc "\$1"; }
|
|
# Ceinture ET bretelles : on refuse d'agir si la base courante n'est
|
|
# pas celle du bac à sable. Une purge sur la mauvaise base ne se
|
|
# rattrape pas par un refresh.
|
|
CUR=\$(Q 'select current_database()')
|
|
[ "\$CUR" = "$SB_DB" ] || { echo "ABORT: base courante '\$CUR' au lieu de $SB_DB"; exit 1; }
|
|
echo "cible=\$CUR role=$SB_ROLE"
|
|
AVANT=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
|
|
echo "tables llx_ avant purge : \$AVANT"
|
|
PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -v ON_ERROR_STOP=1 \\
|
|
-c "DROP OWNED BY $SB_ROLE CASCADE;"
|
|
APRES=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
|
|
echo "tables llx_ après purge : \$APRES"
|
|
[ "\$APRES" -eq 0 ] || { echo "ABORT: \$APRES tables subsistent — purge incomplète"; exit 1; }
|
|
echo "DONE."
|
|
EOF
|
|
K wait --for=condition=complete job/sandbox-blank -n "$SB_NS" --timeout=180s >/dev/null 2>&1 \
|
|
|| die "le Job de purge n'a pas abouti — voir : K logs -n $SB_NS job/sandbox-blank"
|
|
K logs -n "$SB_NS" job/sandbox-blank | sed 's/^/ /'
|
|
K delete job sandbox-blank -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
|
|
|
|
# Le verrou d'installation vit sur le VOLUME DOCUMENTS, que la purge de la base
|
|
# ne touche pas. Tant qu'il est là, Dolibarr saute l'installeur et sert une page
|
|
# de login sur un schéma inexistant : l'instance a l'air vivante et ne l'est pas.
|
|
# Le retirer laisse l'entrypoint de l'image réinstaller un schéma neuf.
|
|
log "Retrait du verrou d'installation (sinon Dolibarr sert un login sur une base sans schéma)"
|
|
K scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null
|
|
K wait --for=condition=ready pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=180s >/dev/null 2>&1 || true
|
|
POD=$(sb_pod)
|
|
[ -n "$POD" ] && K exec -n "$SB_NS" "$POD" -- rm -f /var/www/documents/install.lock >/dev/null 2>&1 || true
|
|
|
|
log "Redémarrage pour déclencher l'installation"
|
|
K rollout restart deploy erp-sandbox -n "$SB_NS" >/dev/null
|
|
K rollout status deploy erp-sandbox -n "$SB_NS" --timeout=300s >/dev/null 2>&1 || true
|
|
|
|
log "Réarmement du self-heal ArgoCD"
|
|
set_selfheal true
|
|
|
|
log "Base vidée et verrou retiré. Vérifier que le schéma se reconstruit :"
|
|
log " curl -s https://erp-sandbox.arcodange.lab/api/index.php/status"
|
|
log "Annulation à tout moment : $0 refresh-from-prod"
|
|
}
|
|
|
|
case "${1:-}" in
|
|
refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;;
|
|
blank) assert_arcodange_cluster; shift; blank_sandbox "${1:-}" ;;
|
|
sync-documents) assert_arcodange_cluster; sync_documents ;;
|
|
refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;;
|
|
*) echo "usage: $0 {refresh-from-prod|sync-documents|refresh|blank --yes}" >&2; exit 2 ;;
|
|
esac
|