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:
@@ -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 ;;
|
||||
|
||||
@@ -173,9 +173,117 @@ sync_documents() {
|
||||
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}" >&2; exit 2 ;;
|
||||
*) echo "usage: $0 {refresh-from-prod|sync-documents|refresh|blank --yes}" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user