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' "$*"; }
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
ENV="prod"; KEY=""; KIND=""; YES=0
while [[ $# -gt 0 ]]; do
@@ -72,11 +95,11 @@ SH
copy_s3_secret() {
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))" \
| 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() { printf '%s' "$1" | base64 | tr -d '\n'; }
@@ -87,8 +110,8 @@ run_backup() {
copy_s3_secret
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
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
kubectl apply -f - >/dev/null <<EOF
K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-backup, namespace: $NS }
@@ -118,10 +141,10 @@ spec:
command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"]
EOF
kubectl 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"
kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
|| die "backup Job did not complete — K logs -n $NS job/dolibarr-backup"
K logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
K delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
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)"
EOF
)"
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF
K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-backup-list, namespace: $NS }
@@ -153,9 +176,9 @@ spec:
command: ["/bin/sh","-c"]
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
EOF
kubectl 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/^/ /'
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
K logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
K delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
}
@@ -167,8 +190,8 @@ run_restore() {
copy_s3_secret
local DEPLOY="$NS" # release/instance name == namespace (erp / erp-sandbox)
log "Restore ${KIND} on '${ENV}' from ${KEY} (scaling ${DEPLOY} to 0)"
kubectl 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 scale deploy "$DEPLOY" -n "$NS" --replicas=0 >/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="[]"
if [[ "$KIND" == "db" ]]; then
@@ -197,8 +220,8 @@ EOF
)"
fi
local B64; B64="$(b64 "$SCRIPT")"
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF
K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-restore, namespace: $NS }
@@ -221,19 +244,21 @@ spec:
command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"]
EOF
if ! kubectl 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/^/ /'
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
if ! K wait --for=condition=complete job/dolibarr-restore -n "$NS" --timeout=300s >/dev/null 2>&1; then
K logs -n "$NS" job/dolibarr-restore 2>&1 | sed 's/^/ /'
K scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
die "restore Job did not complete"
fi
kubectl logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
kubectl delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
K logs -n "$NS" job/dolibarr-restore | sed 's/^/ /'
K delete job dolibarr-restore -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
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
log "Restore complete."
}
assert_arcodange_cluster
case "$CMD" in
backup) run_backup ;;
list) run_list ;;