Set-OPS-Public/roles/client_backup/templates/restaurer.sh.j2
Daniel Allaire 11b5bb5733 temoins : l'instantane d'avant rasage est garde, et l'etat remis lui est compare
M4 a reconstruit Technolibre par le site qui la nomme ; les 7 jeux sont revenus
de l'instantane de 11:34, mais le premier depot d'apres reconstruction l'a chasse
par --keep-daily le jour meme : plus rien a quoi comparer.

Le depot d'avant rasage est etiquete avant-raser et garde jusqu'a la
reconstruction suivante. Le bilan dit ce qui a ete restaure et si c'est encore
au depot. setops-restaurer temoins et make temoins-etat comparent l'etat vivant
a cet instantane : une perte ou une identite changee est un ecart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:48:00 -04:00

405 lines
17 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
# temoins [--candidat | --instantane ID] l'etat vivant contre l'instantane d'avant rasage
# (par defaut le dernier etiquete « avant-raser ») ; lecture seule ; code 1 = ecart,
# 4 = rien a comparer
# 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 %} )
# Les chemins de chaque jeu, separes par « : » — ce que les temoins extraient et comparent.
declare -A CHEMINS_JEU=({% for j in client_backup_jobs %} ["{{ j.nom }}"]="{{ j.chemins | join(':') }}"{% endfor %} )
ETIQUETTE_AVANT_RASER="{{ client_backup_etiquette_avant_raser }}"
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}"
}
# « ID HEURE » du dernier instantane etiquete avant rasage, ou rien ; code 2 = depot illisible.
avant_raser() {
local json rc
set +e
json=$(restic snapshots --host "${MOI}" --tag "${ETIQUETTE_AVANT_RASER}" --json 2>/dev/null)
rc=$?
set -e
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 json, sys
import datetime, re
def t(x):
s = re.sub(r"(\.\d{6})\d+", r"\1", x["time"])
return datetime.datetime.fromisoformat(re.sub(r"Z$", "+00:00", s)).timestamp()
s = sorted(json.load(sys.stdin) or [], key=t)
if s:
print(s[-1]["short_id"], s[-1]["time"][:19])
'
}
# 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)"
}
# LE CANDIDAT EST CALCULE MAINTENANT (2026-10-07). Il dit ce qu'une restauration lancee a
# cette minute choisirait, pas ce qui a ete restaure : au bilan de M4, il nommait
# l'instantane de la veille, et l'on a cru un moment que la journee etait perdue. Ce qui a
# ete restaure est dans le marqueur de chaque jeu ; on dit desormais s'il est encore au depot.
cmd_etat() {
local c a ids id present
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} (ce que choisirait une restauration lancee maintenant)"
a=$(avant_raser) || exit 2
echo "avant-raser: ${a:-aucun}"
ids=$(restic snapshots --host "${MOI}" --json 2>/dev/null \
| python3 -c 'import json, sys; print(" ".join(x["short_id"] for x in json.load(sys.stdin) or []))' \
2>/dev/null || true)
for j in "${JEUX[@]}"; do
if [[ -f "${MARQUES}/${j}" ]]; then
id=$(sed -n 's/^instantane=//p' "${MARQUES}/${j}")
present=""
if [[ -n "${id}" ]]; then
if [[ " ${ids} " == *" ${id} "* ]]; then present="(au depot)"; else present="(RETIRE du depot)"; fi
fi
echo "jeu ${j} : $(tr '\n' ' ' < "${MARQUES}/${j}")${present}"
else
echo "jeu ${j} : EN ATTENTE (aucun marqueur)"
fi
done
}
# LES TEMOINS (2026-10-07). L'etat vivant est-il celui d'avant le rasage ? On extrait
# l'instantane d'avant (etiquete, sinon le candidat ou celui qu'on impose), on lit
# l'annuaire vivant, et `setops-temoins` compare. Rien n'est ecrit hors du repertoire jetable.
cmd_temoins() {
local source="avant-raser" c heure base chemins=() args=()
while (( $# )); do
case "$1" in
--candidat) source="candidat"; shift ;;
--instantane) INSTANTANE="$2"; source="impose"; shift 2 ;;
*) mourir "temoins : option inconnue : $1" ;;
esac
done
if [[ -z "${JEUX[*]:-}" ]]; then echo "SANS OBJET : ${MOI} ne detient aucun etat."; exit 4; fi
case "${source}" in
avant-raser) c=$(avant_raser) || exit 2 ;;
candidat) c=$(candidat "") || exit 2 ;;
impose) c=$(candidat "${INSTANTANE}") || exit 2 ;;
esac
if [[ -z "${c}" ]]; then echo "SANS OBJET : aucun instantane ${source} pour ${MOI}."; exit 4; fi
INSTANTANE="${c%% *}"
heure="${c#* }"
for j in "${JEUX[@]}"; do
IFS=: read -r -a morceaux <<< "${CHEMINS_JEU[${j}]}"
chemins+=("${morceaux[@]}")
args+=(--jeu "${j}=${CHEMINS_JEU[${j}]}")
done
extraire "${chemins[@]}"
if [[ -s "${TMP}${LDIF}" ]]; then
base=$(sed -n 's/^dn: //p' "${TMP}${LDIF}" | head -n 1)
slapcat -b "${base}" > "${TMP}/vivant-annuaire.ldif" || mourir "slapcat ${base} a echoue"
args+=(--ldif "${LDIF}" --ldif-vivant "${TMP}/vivant-annuaire.ldif")
fi
set +e
/usr/local/sbin/setops-temoins --avant "${TMP}" --instantane "${INSTANTANE}" \
--heure "${heure}" --hote "${MOI}" --dump "${DUMP_PG}" "${args[@]}"
local rc=$?
set -e
exit "${rc}"
}
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 ;;
temoins) cmd_temoins "$@" ;;
*) mourir "sous-commande inconnue : ${sous}" ;;
esac