#!/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 }}/. 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 ` 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= 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