feat(ops): garde de cluster sur la sauvegarde, et mode vierge (incomplet)

DEUX CHOSES, dont une inachevée et dite comme telle.

1. LA SAUVEGARDE N'AVAIT AUCUNE GARDE DE CLUSTER. 24 appels kubectl nus, aucun
contexte épinglé — alors que le script lit des secrets, crée des Jobs et sait
RESTAURER une base. Lancé sur le contexte courant d'une station de travail, il
serait parti chercher les secrets Longhorn du cluster d'un client. Même patron
que ops/sandbox : ERP_KUBE_CONTEXT, wrapper K(), empreinte positive vérifiée
avant tout dispatch. Testé contre le cluster client et contre un contexte
inexistant : il refuse les deux.

Sauvegarde de production passée dans la foulée. La base a été dédupliquée à
juste titre — rien n'avait été écrit depuis la sauvegarde automatique de 01:00.
Au passage, le CronJob quotidien tourne bien depuis juillet ; il était encore
noté comme à faire.

2. LE MODE VIERGE PURGE MAIS NE RECONSTRUIT PAS. `blank --yes` vide la base du
bac à sable (295 tables -> 0, vérifié) avec trois protections empilées : refus
sans --yes, garde de cluster, et une relecture de current_database() DANS le Job
lui-même — une purge sur la mauvaise base ne se rattrape pas par un refresh,
contrairement à la bonne.

Mais l'instance ne se reconstruit pas. Premier essai : install.lock vit sur le
volume documents, que la purge ne touche pas, si bien que Dolibarr servait un
login sur un schéma inexistant. Correctif appliqué — retrait du verrou puis
redémarrage. Second essai : le verrou reste absent, et le schéma reste à ZÉRO
table. L'entrypoint de l'image ne lance aucune installation automatique.

CE QU'IL MANQUE est donc nommé dans le script : un « golden empty », pg_dump
d'une instance fraîchement installée — schéma et données de référence, aucune
donnée métier. `blank` le restaurerait au lieu de laisser la base vide, comme
refresh-from-prod restaure le dump de production. Seule la source change.

En l'état `blank` laisse le bac à sable inutilisable, et le script le dit. Le
bac à sable a été remis iso-prod avant de rendre la main.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-08-14 19:11:18 +02:00
co-authored by Claude Opus 5
parent b49cbd77a9
commit a3540982a9
2 changed files with 158 additions and 25 deletions
+49 -24
View File
@@ -37,6 +37,29 @@ TMP_S3_SECRET="dolibarr-backup-s3-temp"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; } log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; } die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
# --- garde de cluster -------------------------------------------------------
# Ce script lit des secrets, crée des Jobs et peut RESTAURER une base. Lancé sur
# le mauvais contexte kubectl, il part sur l'infrastructure de quelqu'un d'autre.
# Le contexte courant d'une station de travail n'est pas une garantie : il suffit
# d'un `K config use-context` oublié. On l'épingle donc, et on vérifie une
# empreinte POSITIVE du homelab avant d'agir — mêmes garde-fous que
# ops/sandbox/sandbox-lifecycle.sh.
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' n'existe pas (définir ERP_KUBE_CONTEXT)"
for ns in erp erp-sandbox "$S3_SRC_NS"; do
K get ns "$ns" >/dev/null 2>&1 \
|| die "le contexte '$ERP_KUBE_CONTEXT' n'a pas de namespace '$ns' — refus de s'y exécuter.
Ce script lit des secrets et peut restaurer une base ; il ne doit viser que le homelab Arcodange.
Contexte courant : '$(kubectl config current-context 2>/dev/null)'.
Définir ERP_KUBE_CONTEXT sur le contexte du homelab et réessayer."
done
log "garde de cluster OK — contexte '$ERP_KUBE_CONTEXT' (namespaces erp/erp-sandbox/$S3_SRC_NS)"
}
CMD="${1:-}"; shift || true CMD="${1:-}"; shift || true
ENV="prod"; KEY=""; KIND=""; YES=0 ENV="prod"; KEY=""; KIND=""; YES=0
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@@ -72,11 +95,11 @@ SH
copy_s3_secret() { copy_s3_secret() {
command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it" command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it"
kubectl get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \ K get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \ | python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \
| kubectl apply -f - >/dev/null | K apply -f - >/dev/null
} }
cleanup_secret() { kubectl delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; } cleanup_secret() { K delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; }
# b64-encode an in-container script (host vars already substituted by the caller) # b64-encode an in-container script (host vars already substituted by the caller)
b64() { printf '%s' "$1" | base64 | tr -d '\n'; } b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
@@ -87,8 +110,8 @@ run_backup() {
copy_s3_secret copy_s3_secret
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/" log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")" local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")"
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-backup, namespace: $NS } metadata: { name: dolibarr-backup, namespace: $NS }
@@ -118,10 +141,10 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"] args: ["echo $B64 | base64 -d | sh"]
EOF EOF
kubectl wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \ K wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
|| die "backup Job did not complete — kubectl logs -n $NS job/dolibarr-backup" || die "backup Job did not complete — K logs -n $NS job/dolibarr-backup"
kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /' K logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
log "Backup complete." log "Backup complete."
} }
@@ -135,8 +158,8 @@ echo "db/:"; S3 ls "s3://$BUCKET/$PREFIX/db/" || echo " (empty)"
echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)" echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)"
EOF EOF
)" )"
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-backup-list, namespace: $NS } metadata: { name: dolibarr-backup-list, namespace: $NS }
@@ -153,9 +176,9 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"] args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
EOF EOF
kubectl wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true K wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
kubectl logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /' K logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
} }
@@ -167,8 +190,8 @@ run_restore() {
copy_s3_secret copy_s3_secret
local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox) local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox)
log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)" log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)"
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/dev/null 2>&1 || true
kubectl wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true K wait --for=delete pod -l app.kubernetes.io/instance="$NS" -n "$NS" --timeout=120s >/dev/null 2>&1 || true
local SCRIPT VOLS="[]" MOUNTS="[]" local SCRIPT VOLS="[]" MOUNTS="[]"
if [[ "$KIND" == "db" ]]; then if [[ "$KIND" == "db" ]]; then
@@ -197,8 +220,8 @@ EOF
)" )"
fi fi
local B64; B64="$(b64 "$SCRIPT")" local B64; B64="$(b64 "$SCRIPT")"
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF K apply -f - >/dev/null <<EOF
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: { name: dolibarr-restore, namespace: $NS } metadata: { name: dolibarr-restore, namespace: $NS }
@@ -221,19 +244,21 @@ spec:
command: ["/bin/sh","-c"] command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"] args: ["echo $B64 | base64 -d | sh"]
EOF EOF
if ! kubectl wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then if ! K wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then
kubectl logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /' K logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
die "restore Job did not complete" die "restore Job did not complete"
fi fi
kubectl logs -n "$NS" job/dolibarr-restore | sed 's/^/ /' K logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
log "Scaling ${DEPLOY} back to 1" log "Scaling ${DEPLOY} back to 1"
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT cleanup_secret; trap - EXIT
log "Restore complete." log "Restore complete."
} }
assert_arcodange_cluster
case "$CMD" in case "$CMD" in
backup) run_backup ;; backup) run_backup ;;
list) run_list ;; list) run_list ;;
+109 -1
View File
@@ -173,9 +173,117 @@ sync_documents() {
log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)" 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 case "${1:-}" in
refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;; refresh-from-prod) assert_arcodange_cluster; refresh_from_prod ;;
blank) assert_arcodange_cluster; shift; blank_sandbox "${1:-}" ;;
sync-documents) assert_arcodange_cluster; sync_documents ;; sync-documents) assert_arcodange_cluster; sync_documents ;;
refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;; refresh) assert_arcodange_cluster; refresh_from_prod; sync_documents ;;
*) echo "usage: $0 {refresh-from-prod|sync-documents|refresh}" >&2; exit 2 ;; *) echo "usage: $0 {refresh-from-prod|sync-documents|refresh|blank --yes}" >&2; exit 2 ;;
esac esac