feat(backup): restore subcommand (db + documents), proven on the sandbox

dolibarr-backup.sh restore --db|--docs <ts> --env <e> --yes — the recovery half of
the dedicated backup. DESTRUCTIVE (gated by --yes + explicit --env): scales the app
to 0, then
- --db: DROP OWNED BY <owner_role> CASCADE + pg_restore --no-owner --role (same
  mechanics as sandbox-lifecycle.sh), from s3://.../erp/<env>/db/<ts>.dump;
- --docs: clears /var/www/documents and untars s3://.../erp/<env>/docs/<ts>.tar.gz;
then scales the app back to 1. OWNER_ROLE per env (erp_role / erp_sandbox_role).
The key is the bare <ts> filename from `list`; --db/--docs selects the subpath.

Proven on the sandbox: backup → mutate MAIN_INFO_SOCIETE_NOM to a sentinel →
restore --db → the value reverted to the backup's ('Arcodange'). (First run caught
a path bug — the fetch missed the db/ subdir — now fixed.)

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
2026-06-30 18:05:19 +02:00
co-authored by Claude Opus 4.7
parent 590dbe1f26
commit a8b80f17e4
2 changed files with 91 additions and 8 deletions
+78 -3
View File
@@ -50,8 +50,8 @@ while [[ $# -gt 0 ]]; do
done
case "$ENV" in
prod) NS="erp"; DB="erp" ;;
sandbox) NS="erp-sandbox"; DB="erp-sandbox" ;;
prod) NS="erp"; DB="erp"; OWNER_ROLE="erp_role" ;;
sandbox) NS="erp-sandbox"; DB="erp-sandbox"; OWNER_ROLE="erp_sandbox_role" ;;
*) die "--env must be prod|sandbox" ;;
esac
PVC="$NS"
@@ -159,13 +159,88 @@ EOF
cleanup_secret; trap - EXIT
}
# restore a half from a stored key. DESTRUCTIVE: scales the app to 0, replaces the
# DB (DROP OWNED BY <owner> CASCADE + pg_restore) or the documents (clear + untar),
# then scales back. Mirrors ops/sandbox/sandbox-lifecycle.sh's reset mechanics.
run_restore() {
trap cleanup_secret EXIT
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
local SCRIPT VOLS="[]" MOUNTS="[]"
if [[ "$KIND" == "db" ]]; then
SCRIPT="$(cat <<EOF
$PREAMBLE
S3 cp "s3://$BUCKET/$PREFIX/db/$KEY" /tmp/r.dump
echo "fetched \$(wc -c < /tmp/r.dump) bytes"
psql -h "$PGHOST" -U "\$PGUSER" -d "$DB" -v ON_ERROR_STOP=1 -c "DROP OWNED BY $OWNER_ROLE CASCADE;"
pg_restore -h "$PGHOST" -U "\$PGUSER" -d "$DB" --no-owner --role=$OWNER_ROLE /tmp/r.dump \\
&& echo "RESTORED db" || echo "restored db (ignorable warnings)"
EOF
)"
else
VOLS="
- name: docs
persistentVolumeClaim: { claimName: ${PVC} }"
MOUNTS="
- { name: docs, mountPath: /docs }"
SCRIPT="$(cat <<EOF
$PREAMBLE
S3 cp "s3://$BUCKET/$PREFIX/docs/$KEY" /tmp/r.tgz
echo "fetched \$(wc -c < /tmp/r.tgz) bytes"
rm -rf /docs/* 2>/dev/null || true
tar -C /docs -xzf /tmp/r.tgz && echo "RESTORED docs"
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
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-restore, namespace: $NS }
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
volumes: ${VOLS}
containers:
- name: restore
image: $PG_IMAGE
envFrom:
- secretRef: { name: $TMP_S3_SECRET }
env:
- { name: PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
- { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
volumeMounts: ${MOUNTS}
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
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
log "Scaling ${DEPLOY} back to 1"
kubectl scale deploy "$DEPLOY" -n "$NS" --replicas=1 >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
log "Restore complete."
}
case "$CMD" in
backup) run_backup ;;
list) run_list ;;
restore)
[[ -n "$KEY" && -n "$KIND" ]] || die "restore needs --db <key> or --docs <key>"
[[ "$YES" == "1" ]] || die "restore is DESTRUCTIVE on '$ENV' — re-run with --yes"
die "restore: wired in the chart Job (next iteration) — key=$KEY kind=$KIND env=$ENV"
run_restore
;;
*) echo "usage: $0 {backup|list|restore} [--env prod|sandbox] [--db|--docs <key>] [--yes]" >&2; exit 2 ;;
esac