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]>
272 lines
11 KiB
Bash
Executable File
272 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
#
|
|
# dolibarr-backup.sh — dedicated, offsite backup for the Arcodange Dolibarr ERP.
|
|
#
|
|
# Critical-data-aware (10-year accounting retention) and INDEPENDENT of the generic
|
|
# Longhorn platform backup — which today does NOT cover the erp volume (its volume
|
|
# sits in the orphaned `default` recurring-job group, lastBackupAt=never). Backs up
|
|
# BOTH halves of Dolibarr state to the existing object store (s3://arcodange-backup
|
|
# on GCS), under erp/<env>/:
|
|
# - the Postgres DB (pg_dump -Fc, restorable) -> erp/<env>/db/<ts>.dump
|
|
# - the documents PVC (/var/www/documents, RWX, ro) -> erp/<env>/docs/<ts>.tar.gz
|
|
# then prunes to a tiered retention: daily 30d, monthly 12m, yearly 10y.
|
|
#
|
|
# Safety, mirroring ops/sandbox/sandbox-lifecycle.sh:
|
|
# - the DB is read with the app's OWN dynamic creds (vso-db-credentials), scoped
|
|
# to its env; prod and sandbox never cross.
|
|
# - S3 creds are a TRANSIENT copy of the Longhorn GCS secret (deleted on exit);
|
|
# no secret value is ever printed.
|
|
# - the whole in-container script is shipped base64 (no nested-heredoc/quoting).
|
|
#
|
|
# Usage:
|
|
# dolibarr-backup.sh backup [--env prod|sandbox] # one-shot backup + prune
|
|
# dolibarr-backup.sh list [--env prod|sandbox] # what's in the store
|
|
# dolibarr-backup.sh restore --db <key> --env <e> --yes # restore DB (DESTRUCTIVE)
|
|
# dolibarr-backup.sh restore --docs <key> --env <e> --yes # restore documents
|
|
#
|
|
set -euo pipefail
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
|
PG_IMAGE="postgres:16-alpine"
|
|
PGHOST="192.168.1.202" # direct Postgres (NOT pgbouncer)
|
|
BUCKET="${ARCO_BACKUP_BUCKET:-arcodange-backup}"
|
|
S3_SRC_NS="longhorn-system" # where the GCS HMAC creds live today
|
|
S3_SRC_SECRET="longhorn-gcs-backup-credentials"
|
|
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
|
|
case "$1" in
|
|
--env) ENV="${2:?}"; shift 2 ;;
|
|
--db) KIND="db"; KEY="${2:?}"; shift 2 ;;
|
|
--docs) KIND="docs"; KEY="${2:?}"; shift 2 ;;
|
|
--yes) YES=1; shift ;;
|
|
*) die "unknown arg '$1'" ;;
|
|
esac
|
|
done
|
|
|
|
case "$ENV" in
|
|
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"
|
|
PREFIX="${ARCO_BACKUP_PREFIX:-erp/${ENV}}"
|
|
|
|
# in-container preamble: install tools, export region, define S3()
|
|
read -r -d '' PREAMBLE <<'SH' || true
|
|
set -eu
|
|
apk add --no-cache aws-cli tar gzip >/dev/null 2>&1 || { echo "ABORT apk add"; exit 1; }
|
|
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
|
|
# GCS / S3-compatible stores reject aws-cli v2.23+ default integrity checksums
|
|
# ("SignatureDoesNotMatch / Invalid argument"); only sign/validate when required.
|
|
export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
|
|
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
|
|
aws --version 2>&1 | head -1
|
|
S3() { aws --endpoint-url "$AWS_ENDPOINTS" s3 "$@"; }
|
|
SH
|
|
|
|
copy_s3_secret() {
|
|
command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it"
|
|
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))" \
|
|
| K apply -f - >/dev/null
|
|
}
|
|
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'; }
|
|
|
|
run_backup() {
|
|
trap cleanup_secret EXIT
|
|
log "Copying GCS creds into a transient secret in $NS (values stay base64)"
|
|
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")")"
|
|
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 }
|
|
spec:
|
|
backoffLimit: 0
|
|
ttlSecondsAfterFinished: 600
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
volumes:
|
|
- name: docs
|
|
persistentVolumeClaim: { claimName: $PVC, readOnly: true }
|
|
containers:
|
|
- name: backup
|
|
image: $PG_IMAGE
|
|
envFrom:
|
|
- secretRef: { name: $TMP_S3_SECRET }
|
|
env:
|
|
- { name: BUCKET, value: "$BUCKET" }
|
|
- { name: PREFIX, value: "$PREFIX" }
|
|
- { name: DB, value: "$DB" }
|
|
- { name: PGHOST, value: "$PGHOST" }
|
|
- { name: PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
|
|
- { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
|
|
volumeMounts:
|
|
- { name: docs, mountPath: /docs, readOnly: true }
|
|
command: ["/bin/sh","-c"]
|
|
args: ["echo $B64 | base64 -d | sh"]
|
|
EOF
|
|
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."
|
|
}
|
|
|
|
run_list() {
|
|
trap cleanup_secret EXIT; copy_s3_secret
|
|
local SCRIPT
|
|
SCRIPT="$(cat <<EOF
|
|
$PREAMBLE
|
|
echo "db/:"; S3 ls "s3://$BUCKET/$PREFIX/db/" || echo " (empty)"
|
|
echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)"
|
|
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 }
|
|
spec:
|
|
backoffLimit: 0
|
|
ttlSecondsAfterFinished: 300
|
|
template:
|
|
spec:
|
|
restartPolicy: Never
|
|
containers:
|
|
- name: list
|
|
image: $PG_IMAGE
|
|
envFrom: [ { secretRef: { name: $TMP_S3_SECRET } } ]
|
|
command: ["/bin/sh","-c"]
|
|
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
|
|
EOF
|
|
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
|
|
}
|
|
|
|
# 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)"
|
|
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
|
|
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")"
|
|
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 }
|
|
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 ! 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
|
|
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"
|
|
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 ;;
|
|
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"
|
|
run_restore
|
|
;;
|
|
*) echo "usage: $0 {backup|list|restore} [--env prod|sandbox] [--db|--docs <key>] [--yes]" >&2; exit 2 ;;
|
|
esac
|