Set-OPS-Public/roles/client_backup/templates/restaurer.sh.j2
Daniel Allaire 1167cc12e0 client_backup : un depot verrouille n est plus une panne
Les quatre scripts qui touchent au depot appellent restic avec
--retry-lock (client_backup_attente_verrou, 30 min) : deux minuteurs
partis ensemble ne fabriquent plus de faux critique (aucun instantane,
depot corrompu). Eprouve sur un depot jetable, dans les deux sens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 14:27:11 -04:00

323 lines
14 KiB
Django/Jinja

#!/bin/bash
# GENERE par Set-OPS (role client_backup). Ne pas editer a la main.
#
# setops-restaurer — REMETTRE EN SERVICE l'etat d'une incarnation precedente de CE noeud.
#
# POURQUOI IL EXISTE (2026-09-30). Technolibre a ete rasee et reconstruite par ses
# runners : 0 echec, `make valider` vert, « restauration verifiee ». Et le mot de passe de
# `sysadmin` etait revenu a celui de l'amorcage. RIEN n'etait revenu : ni l'annuaire, ni
# la racine de l'AC, ni les bases, ni les fichiers de Nextcloud. « Restauration verifiee »
# voulait dire : restauree dans un repertoire temporaire, lue, jetee. Chaque
# reconstruction de Chezlepro etait repartie a vide de la meme facon.
#
# QUELLE INCARNATION. La cle d'hote SSH nait au clonage (`machine-id`, lui, vient du
# gabarit : tous les noeuds portent la meme date). L'instantane a remettre est donc le
# DERNIER PRIS AVANT LA NAISSANCE de cette cle — celui de la machine qu'on a rasee.
# `--instantane ID` impose un autre choix.
#
# LE MARQUEUR. Un jeu restaure, abandonne, ou trouve deja en place laisse une trace dans
# {{ client_backup_restauration_marques }}/<jeu>. La sauvegarde s'y fie (`garde`) : tant
# qu'un etat anterieur n'a ete ni remis ni ecarte, ce noeud REFUSE de deposer — sans quoi
# `restic forget --keep-daily` chasserait l'instantane d'avant au profit de l'etat neuf du
# meme jour.
#
# RIEN N'EST ECRASE SANS ETRE MIS DE COTE, dans {{ client_backup_restauration_mise_de_cote }}.
#
# Sous-commandes :
# etat ce qui serait restaure, et les marqueurs
# choisir [--instantane ID] (pour Ansible) JSON de l'instantane candidat
# fichiers [opts] CHEMIN... remettre des repertoires (rsync --delete)
# --proprietaire U:G --remplacer --vers DOSSIER (repetition, rien n'est touche)
# annuaire [opts] BASE_DN remplacer la base LDAP (slapadd)
# --essai (slapadd -u a blanc, rien n'est touche)
# base [opts] NOM rejouer une base depuis le pg_dumpall
# --remplacer --vers AUTRE_NOM (repetition dans une base jetable)
# acter JEU ETAT [INSTANTANE] poser le marqueur (restaure, neuf, en_place, abandonne)
# garde (pour la sauvegarde) refuser si un etat attend
# Options communes : --instantane ID.
set -euo pipefail
export RESTIC_REPOSITORY="{{ client_backup_repo }}"
export RESTIC_PASSWORD_FILE="/etc/setops/restic.pass"
# UN VERROU N'EST PAS UNE PANNE (2026-10-04). La sauvegarde, la verification du depot, le
# controle de restauration et `setops-restaurer` partagent ce depot ; leurs minuteurs peuvent
# partir ensemble (`Persistent=true` rattrape au demarrage tout ce qui a ete manque). Sans
# attente, restic refuse aussitot un depot verrouille, et une sonde concluait « aucun
# instantane » ou « depot corrompu ». On attend le verrou au lieu de conclure.
restic() { command restic --retry-lock {{ client_backup_attente_verrou }} "$@"; }
MOI="{{ inventory_hostname }}"
MARQUES="{{ client_backup_restauration_marques }}"
MISE_DE_COTE="{{ client_backup_restauration_mise_de_cote }}"
CLE_HOTE="/etc/ssh/ssh_host_ed25519_key.pub"
LDIF="{{ client_backup_staging }}/openldap/annuaire.ldif"
DUMP_PG="{{ client_backup_staging }}/postgresql/toutes-bases.sql"
JEUX=({% for j in client_backup_jobs %} "{{ j.nom }}"{% endfor %} )
dire() { printf '%s\n' "$*" >&2; }
mourir() { dire "setops-restaurer: $*"; exit 1; }
naissance() {
local b
b=$(stat -c %W "${CLE_HOTE}")
if [[ "${b}" -le 0 ]]; then b=$(stat -c %Y "${CLE_HOTE}"); fi
printf '%s\n' "${b}"
}
# Imprime « ID HEURE » du candidat, ou rien. Code 2 : depot illisible — et c'est une
# ERREUR, pas une absence : conclure « rien a restaurer » parce que le dépôt ne repond pas
# ferait marquer « neuf » une machine dont l'etat attend chez l'hebergeur.
candidat() {
local impose="${1:-}" json rc
set +e
json=$(restic snapshots --host "${MOI}" --json 2>/dev/null)
rc=$?
set -e
# restic 0.17+ : 10 = le depot n'existe pas. Premiere vie de l'ecosysteme.
if (( rc == 10 )); then return 0; fi
if (( rc != 0 )); then dire "depot illisible (restic rc=${rc}) : ${RESTIC_REPOSITORY}"; return 2; fi
printf '%s' "${json}" | python3 -c '
import datetime, json, re, sys
naissance, impose = int(sys.argv[1]), sys.argv[2]
def t(s):
s = re.sub(r"(\.\d{6})\d+", r"\1", s)
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
snaps = json.load(sys.stdin) or []
if impose:
c = [x for x in snaps if impose in (x["short_id"], x["id"])]
if not c:
sys.exit("instantane impose introuvable pour cet hote : " + impose)
else:
c = [x for x in snaps if t(x["time"]) < naissance]
c.sort(key=lambda x: t(x["time"]))
if c:
print(c[-1]["short_id"], c[-1]["time"][:19])
' "$(naissance)" "${impose}"
}
# Instantane a utiliser : impose, sinon le candidat. Vide = rien a restaurer.
INSTANTANE=""
resoudre_instantane() {
local c
c=$(candidat "${INSTANTANE}") || exit 2
INSTANTANE="${c%% *}"
}
# Le repertoire jetable ou l'instantane est extrait. Detruit a la sortie, quoi qu'il arrive.
TMP=""
trap 'if [[ -n "${TMP}" ]]; then rm -rf "${TMP}"; fi' EXIT
extraire() { # remplit TMP avec les chemins demandes de l'instantane
local inc=()
TMP=$(mktemp -d /var/tmp/setops-restauration.XXXXXX)
for c in "$@"; do inc+=(--include "${c}"); done
restic restore "${INSTANTANE}" --target "${TMP}" "${inc[@]}" >/dev/null \
|| mourir "restic restore ${INSTANTANE} a echoue"
}
MIS_DE_COTE=""
mettre_de_cote() { # $1 = chemin vivant ; MIS_DE_COTE = ou il est parti
MIS_DE_COTE="${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)$1"
mkdir -p "$(dirname "${MIS_DE_COTE}")"
cp -a "$1" "${MIS_DE_COTE}"
dire "etat en place mis de cote : ${MIS_DE_COTE}"
}
vide() { # vrai si le chemin est absent, ou un repertoire sans contenu
[[ ! -e "$1" ]] || { [[ -d "$1" ]] && [[ -z "$(find "$1" -mindepth 1 -print -quit)" ]]; }
}
cmd_fichiers() {
local proprio="" remplacer=0 vers="" chemins=() src dest n
while (( $# )); do
case "$1" in
--proprietaire) proprio="$2"; shift 2 ;;
--remplacer) remplacer=1; shift ;;
--vers) vers="$2"; shift 2 ;;
--instantane) INSTANTANE="$2"; shift 2 ;;
*) chemins+=("$1"); shift ;;
esac
done
[[ -n "${chemins[*]:-}" ]] || mourir "fichiers : aucun chemin"
resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
extraire "${chemins[@]}"
for c in "${chemins[@]}"; do
src="${TMP}${c}"
dest="${vers}${c}"
if [[ ! -e "${src}" ]]; then echo "ABSENT de ${INSTANTANE} : ${c}"; continue; fi
if ! vide "${dest}"; then
if [[ -z "${vers}" ]] && (( ! remplacer )); then
mourir "${dest} n'est pas vide : --remplacer pour l'ecraser (il sera mis de cote)"
fi
mettre_de_cote "${dest}"
fi
mkdir -p "$(dirname "${dest}")"
if [[ -d "${src}" ]]; then
mkdir -p "${dest}"
rsync -a --delete "${src}/" "${dest}/"
else
cp -a "${src}" "${dest}"
fi
if [[ -n "${proprio}" ]]; then chown -R "${proprio}" "${dest}"; fi
n=$(find "${dest}" -type f | wc -l)
echo "RESTAURE ${INSTANTANE} : ${c} -> ${dest} (${n} fichier(s))"
done
}
cmd_annuaire() {
local essai=0 base="" ldif dir n
while (( $# )); do
case "$1" in
--essai) essai=1; shift ;;
--instantane) INSTANTANE="$2"; shift 2 ;;
*) base="$1"; shift ;;
esac
done
[[ -n "${base}" ]] || mourir "annuaire : BASE_DN requis"
resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
extraire "$(dirname "${LDIF}")"
ldif="${TMP}${LDIF}"
[[ -s "${ldif}" ]] || mourir "${INSTANTANE} n'emporte pas d'annuaire (${LDIF})"
n=$(grep -c '^dn:' "${ldif}")
# A BLANC D'ABORD : un LDIF que le schema EN PLACE refuse (overlay pas encore charge,
# attribut inconnu) doit echouer ici, pas apres qu'on a deplace la base vivante.
slapadd -u -q -b "${base}" -l "${ldif}" || mourir "slapadd a blanc refuse ${INSTANTANE}"
if (( essai )); then echo "ESSAI OK : ${n} entree(s) rejouables depuis ${INSTANTANE}."; return 0; fi
dir=$(slapcat -n 0 -a "(olcSuffix=${base})" | awk '/^olcDbDirectory:/{print $2}')
[[ -d "${dir}" ]] || mourir "repertoire de la base introuvable pour ${base}"
systemctl stop slapd
mettre_de_cote "${dir}"
find "${dir}" -mindepth 1 -delete
if ! slapadd -q -b "${base}" -l "${ldif}"; then
dire "slapadd a echoue : remise de la base d'avant"
find "${dir}" -mindepth 1 -delete
cp -a "${MIS_DE_COTE}/." "${dir}/"
chown -R openldap:openldap "${dir}"
systemctl start slapd
exit 1
fi
chown -R openldap:openldap "${dir}"
systemctl start slapd
echo "RESTAURE ${INSTANTANE} : annuaire ${base}, ${n} entree(s)."
}
tables() { # nombre de tables hors schemas systeme ; -1 si la base n'existe pas
runuser -u postgres -- psql -tAX -d "$1" -c \
"select count(*) from information_schema.tables where table_schema not in ('pg_catalog','information_schema')" \
2>/dev/null || echo -1
}
cmd_base() {
local remplacer=0 vers="" nom="" cible dump section creation proprio n
while (( $# )); do
case "$1" in
--remplacer) remplacer=1; shift ;;
--vers) vers="$2"; shift 2 ;;
--instantane) INSTANTANE="$2"; shift 2 ;;
*) nom="$1"; shift ;;
esac
done
[[ -n "${nom}" ]] || mourir "base : NOM requis"
cible="${vers:-${nom}}"
resoudre_instantane
if [[ -z "${INSTANTANE}" ]]; then echo "RIEN : aucune incarnation precedente."; return 0; fi
extraire "$(dirname "${DUMP_PG}")"
dump="${TMP}${DUMP_PG}"
[[ -s "${dump}" ]] || mourir "${INSTANTANE} n'emporte pas de dump PostgreSQL"
creation=$(grep -m1 -E "^CREATE DATABASE ${nom} " "${dump}" || true)
if [[ -z "${creation}" ]]; then echo "ABSENTE de ${INSTANTANE} : base ${nom}"; return 0; fi
proprio=$(grep -m1 -oP "^ALTER DATABASE ${nom} OWNER TO \K[^;]+" "${dump}" || echo postgres)
# LE PIEGE DE pg_dumpall (runbook §5) : un `CREATE DATABASE <suivante>` et, avec
# `--clean`, un `DROP DATABASE postgres;` precedent le `\connect` suivant. On coupe sur
# les trois, puis on REFUSE toute section qui viserait encore une autre base.
section="${TMP}/section.sql"
awk -v db="${nom}" '$0 == "\\connect " db {f=1; next}
f && (/^\\connect / || /^CREATE DATABASE / || /^DROP DATABASE /) {exit} f' "${dump}" > "${section}"
if grep -qE '^(DROP|CREATE|ALTER) DATABASE|^\\connect' "${section}"; then
mourir "section ${nom} hors perimetre : refus"
fi
if [[ -n "${vers}" ]]; then sed -i -E "/ ON DATABASE ${nom} /d" "${section}"; fi
n=$(tables "${cible}")
if (( n > 0 )) && (( ! remplacer )); then
mourir "la base ${cible} contient ${n} table(s) : --remplacer pour l'ecraser (elle sera mise de cote)"
fi
if (( n >= 0 )); then
mkdir -p "${MISE_DE_COTE}"
runuser -u postgres -- pg_dump -Fc "${cible}" > "${MISE_DE_COTE}/$(date +%Y%m%d-%H%M%S)-${cible}.dump"
runuser -u postgres -- dropdb --force "${cible}"
fi
runuser -u postgres -- psql -X -q -v ON_ERROR_STOP=1 -d postgres \
-c "${creation/CREATE DATABASE ${nom} /CREATE DATABASE ${cible} }" \
-c "ALTER DATABASE ${cible} OWNER TO ${proprio};"
# UNE SEULE TRANSACTION : une erreur au milieu laisse une base VIDE, que la prochaine
# passe retentera — pas une base a moitie chargee qu'on prendrait pour la bonne.
# Par l'ENTREE STANDARD, pas `-f` : psql tourne en `postgres`, qui ne peut pas lire le
# repertoire jetable de root (0700). Trouve en repetition sur data-sql-01, 2026-09-30.
runuser -u postgres -- psql -X -q --single-transaction -v ON_ERROR_STOP=1 \
-d "${cible}" < "${section}" >/dev/null
echo "RESTAURE ${INSTANTANE} : base ${nom} -> ${cible}, $(tables "${cible}") table(s)."
}
cmd_acter() {
local jeu="${1:?jeu}" etat="${2:?etat}" id="${3:-}"
install -d -m 0700 "${MARQUES}"
printf 'etat=%s\ninstantane=%s\ndate=%s\n' "${etat}" "${id}" "$(date -Is)" > "${MARQUES}/${jeu}"
echo "ACTE ${jeu} : ${etat}${id:+ (${id})}"
}
cmd_choisir() {
[[ "${1:-}" == "--instantane" ]] && INSTANTANE="${2:-}"
local c
c=$(candidat "${INSTANTANE}") || exit 2
python3 -c 'import json,sys; print(json.dumps({"instantane": sys.argv[1], "heure": sys.argv[2], "naissance": int(sys.argv[3])}))' \
"${c%% *}" "$( [[ -n "${c}" ]] && printf '%s' "${c#* }" )" "$(naissance)"
}
cmd_etat() {
local c
echo "hote : ${MOI}"
echo "naissance : $(date -d "@$(naissance)" -Is)"
c=$(candidat "") || exit 2
# (Pas d'apostrophe dans `${c:-...}` : entre guillemets doubles, bash l'y lit comme un
# guillemet ouvrant, et tout le script devient illisible.)
if [[ -z "${c}" ]]; then c="aucun (premiere vie, ou rien avant la naissance)"; fi
echo "candidat : ${c}"
for j in "${JEUX[@]}"; do
if [[ -f "${MARQUES}/${j}" ]]; then
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")"
else
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
fi
done
}
cmd_garde() {
local attente=() c
for j in "${JEUX[@]}"; do
[[ -f "${MARQUES}/${j}" ]] || attente+=("${j}")
done
if [[ -z "${attente[*]:-}" ]]; then return 0; fi
c=$(candidat "") || exit 2
if [[ -z "${c}" ]]; then
for j in "${attente[@]}"; do cmd_acter "${j}" neuf >/dev/null; done
return 0
fi
dire "REFUS : l'etat d'une incarnation precedente (${c}) attend dans le depot, non remis"
dire "pour : ${attente[*]}. Deposer maintenant pousserait l'instantane d'avant hors de"
dire "la retention. Le remettre (redeployer le role proprietaire), ou l'ecarter :"
dire " make restauration-renoncer HOTE=${MOI} JEU=<jeu> CONFIRMER=true"
exit 3
}
sous="${1:-etat}"; shift || true
case "${sous}" in
etat) cmd_etat ;;
choisir) cmd_choisir "$@" ;;
fichiers) cmd_fichiers "$@" ;;
annuaire) cmd_annuaire "$@" ;;
base) cmd_base "$@" ;;
acter) cmd_acter "$@" ;;
garde) cmd_garde ;;
*) mourir "sous-commande inconnue : ${sous}" ;;
esac