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

Merged
arcodange merged 1 commits from claude/dolibarr-backup-restore into main 2026-06-30 18:05:50 +02:00
2 changed files with 91 additions and 8 deletions
Showing only changes of commit a8b80f17e4 - Show all commits
+13 -5
View File
@@ -60,14 +60,22 @@ orchestrator and the scheduled CronJob (see "Automation" below).
(`erp/prod/db/…` 1.2 MB, `erp/prod/docs/…` 12.5 MB). Proven end-to-end live on the
sandbox (dump + tar + GCS upload + retention prune).
## Restore (manual, for now)
## Restore
```sh
# DB: aws s3 cp s3://arcodange-backup/erp/<env>/db/<ts>.dump - | pg_restore -h <host> -U <user> -d <db> --clean
# docs: aws s3 cp s3://arcodange-backup/erp/<env>/docs/<ts>.tar.gz - | tar -C /var/www/documents -xzf -
ops/backup/dolibarr-backup.sh list --env <e> # find the <ts> key
ops/backup/dolibarr-backup.sh restore --db <ts>.dump --env <e> --yes
ops/backup/dolibarr-backup.sh restore --docs <ts>.tar.gz --env <e> --yes
```
The sandbox iso-prod refresh (`ops/sandbox/sandbox-lifecycle.sh`) is the natural
restore-drill bench. A `restore` subcommand is wired next.
**DESTRUCTIVE** (requires `--yes` + an explicit `--env`): scales the app to 0, then
- **`--db`**: `DROP OWNED BY <owner_role> CASCADE` + `pg_restore --no-owner --role`
(same reset mechanics as `ops/sandbox/sandbox-lifecycle.sh`), then scales back;
- **`--docs`**: clears `/var/www/documents` and untars the archive, then scales back.
The key is the bare `<ts>` filename from `list`; `--db`/`--docs` selects the
`db/` or `docs/` subpath. Proven on the sandbox: a mutated `MAIN_INFO_SOCIETE_NOM`
was reverted to the backup's value by `restore --db`.
## Automation — the CronJob (gated on creds)
+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