#!/usr/bin/env python3 """La reconstruction REMET l'etat d'avant — et chaque detenteur d'etat sait le faire. POURQUOI (2026-09-30). Technolibre a ete reconstruite, 0 echec, `make valider` vert — et rien n'etait revenu : ni l'annuaire (le mot de passe de `sysadmin` etait celui de l'amorcage), ni la racine de l'AC, ni les bases, ni Nextcloud. « Restauration verifiee » voulait dire « restauree dans un repertoire temporaire, lue, jetee ». Ce test verifie deux choses : 1. COUVERTURE (statique). Chaque groupe detenteur d'etat (`client_backup_groupes_etat`) inclut `restaurer.yml` depuis son propre role. Une liste qui en suit une autre prend du retard : ajouter un detenteur d'etat sans son point de restauration doit echouer ICI. 2. L'OUTIL DE NOEUD (`setops-restaurer`), rendu depuis son vrai gabarit, avec `restic`, `psql` & co. remplaces par des doublures : - le candidat est le dernier instantane ANTERIEUR a la naissance de la machine ; - un repertoire non vide n'est jamais ecrase sans `--remplacer` ; - la sauvegarde refuse de deposer tant qu'un etat d'avant attend (code 3), et reprend une fois chaque jeu acte ; un depot inexistant = premiere vie (« neuf ») ; - la section d'une base s'arrete avant le `DROP DATABASE postgres;` que `pg_dumpall --clean` place apres la derniere base (le piege du runbook, rencontre le 2026-09-30). """ from __future__ import annotations import json import os import stat import subprocess import sys import tempfile from pathlib import Path import jinja2 import yaml RACINE = Path(__file__).resolve().parents[2] GABARIT = RACINE / "roles/client_backup/templates/restaurer.sh.j2" ECHECS: list[str] = [] def verifier(cond: bool, msg: str) -> None: print(("OK " if cond else "ECHEC ") + msg) if not cond: ECHECS.append(msg) # --- 1. Couverture ------------------------------------------------------------------- def couverture() -> None: groupes = yaml.safe_load((RACINE / "roles/client_backup/vars/main.yml").read_text())[ "client_backup_groupes_etat"] for g in groupes: taches = RACINE / "roles" / g / "tasks" texte = "".join(p.read_text() for p in sorted(taches.glob("*.yml"))) if taches.is_dir() else "" verifier(f"client_backup_restaurer_jeu: {g}" in texte, f"{g} inclut son point de restauration (restaurer.yml)") verifier("{#" not in GABARIT.read_text(), "le gabarit ne contient aucune sequence accolade-diese") # UN ICINGA NEUF ENTEND LE NOEUD TOUT DE SUITE (2026-09-30) : sinon `restauration` reste # rouge jusqu'au dimanche apres chaque reconstruction. # PAS SUR LA COPIE DE L'AC : `client_sante` la depose aussi, plus tot — sur une flotte # neuve elle ne change donc jamais ici (reconstruction de Chezlepro, 2026-09-30). taches = yaml.safe_load((RACINE / "roles/client_backup/tasks/verifier.yml").read_text()) notifiants = [t.get("name", "") for t in taches if t.get("notify") == "Premier rapport a Icinga"] verifier(notifiants and all("AC d'Icinga pour la" not in n for n in notifiants) and any("jamais entendus" in n for n in notifiants), "le premier rapport part d'un marqueur propre a client_backup, pas de la copie partagee de l'AC") gest = yaml.safe_load((RACINE / "roles/client_backup/handlers/main.yml").read_text()) ecoute = [g for g in gest if g.get("listen") == "Premier rapport a Icinga"] ordre = [g.get("ansible.builtin.systemd", {}).get("name", "marqueur") for g in ecoute] verifier(ordre == ["setops-sauvegarde.service", "setops-verification-depot.service", "setops-verification-restauration.service", "marqueur"], f"premier rapport : deposer, verifier le depot, la restauration, PUIS retenir ({ordre})") # --- 2. L'outil de noeud, avec des doublures ------------------------------------------ def executable(chemin: Path, texte: str) -> None: chemin.write_text(texte) chemin.chmod(chemin.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) DUMP = r"""-- DROP DATABASE nextcloud; DROP DATABASE keycloak; CREATE DATABASE keycloak WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8'; ALTER DATABASE keycloak OWNER TO keycloak; \connect keycloak CREATE TABLE realm (id text); COPY realm FROM stdin; \. CREATE DATABASE nextcloud WITH TEMPLATE = template0 ENCODING = 'UTF8' LOCALE = 'fr_CA.UTF-8'; ALTER DATABASE nextcloud OWNER TO nextcloud; \connect nextcloud CREATE TABLE oc_users (uid text); REVOKE CONNECT,TEMPORARY ON DATABASE nextcloud FROM PUBLIC; DROP DATABASE postgres; CREATE DATABASE postgres WITH TEMPLATE = template0; \connect postgres """ def outil() -> None: with tempfile.TemporaryDirectory() as d: d = Path(d) bin_, fixt, log = d / "bin", d / "instantanes", d / "journal" for p in (bin_, fixt, log): p.mkdir() cle = d / "ssh_host_ed25519_key.pub" cle.write_text("cle\n") # nee MAINTENANT staging = d / "staging" # Deux instantanes : l'un d'AVANT la naissance, l'autre d'apres (l'etat neuf). avant, apres = fixt / "avant01", fixt / "apres02" for snap, contenu in ((avant, "ancien"), (apres, "neuf")): (snap / str(d / "vmail").lstrip("/") / "boite").mkdir(parents=True) (snap / str(d / "vmail").lstrip("/") / "boite" / "msg").write_text(contenu) dump = snap / str(staging).lstrip("/") / "postgresql" / "toutes-bases.sql" dump.parent.mkdir(parents=True) dump.write_text(DUMP) snaps = [ {"short_id": "avant01", "id": "avant01" + "0" * 56, "time": "2026-09-29T23:50:50.123456789-04:00"}, {"short_id": "apres02", "id": "apres02" + "0" * 56, "time": "2099-01-01T00:00:00Z"}, ] (d / "snapshots.json").write_text(json.dumps(snaps)) # restic : `snapshots --json` et `restore ID --target T --include P...`. # SETOPS_TEST_DEPOT_ABSENT=1 imite restic 0.17+ face a un depot inexistant (code 10). executable(bin_ / "restic", f"""#!/bin/bash if [[ -n "${{SETOPS_TEST_DEPOT_ABSENT:-}}" ]]; then exit 10; fi # L'outil passe `--retry-lock ` avant la sous-commande (74) : on l'exige, puis on le saute. [[ "$1" == --retry-lock && -n "$2" ]] || {{ echo "restic appele sans --retry-lock : $*" >&2; exit 99; }} shift 2 case "$1" in snapshots) cat "{d}/snapshots.json" ;; restore) id="$2"; shift 2; cible=""; inc=() while (( $# )); do case "$1" in --target) cible="$2"; shift 2;; --include) inc+=("$2"); shift 2;; *) shift;; esac; done for i in "${{inc[@]}}"; do [[ -e "{fixt}/$id$i" ]] || continue mkdir -p "$cible$(dirname "$i")"; cp -a "{fixt}/$id$i" "$cible$(dirname "$i")/" done ;; esac """) # PostgreSQL : on journalise, on garde la section rejouee ; toute base est vierge. executable(bin_ / "runuser", '#!/bin/bash\nwhile [[ "$1" != "--" ]]; do shift; done; shift; exec "$@"\n') # Sans `-c`, psql lit la section sur son entree standard : c'est ainsi que l'outil # doit la lui passer (`postgres` ne lit pas le repertoire jetable de root). executable(bin_ / "psql", f"""#!/bin/bash echo "psql $*" >> "{log}/pg" avec_c=0 while (( $# )); do case "$1" in -f) echo "psql -f INTERDIT" >> "{log}/pg"; shift 2;; -c) avec_c=1; [[ "$2" == select* ]] && echo 0; shift 2;; *) shift;; esac done if (( ! avec_c )); then cat > "{log}/section.sql"; fi """) executable(bin_ / "pg_dump", '#!/bin/bash\necho dump\n') executable(bin_ / "dropdb", f'#!/bin/bash\necho "dropdb $*" >> "{log}/pg"\n') rendu = jinja2.Environment(undefined=jinja2.StrictUndefined).from_string( GABARIT.read_text()).render( client_backup_repo="sftp:restic@depot:hote", inventory_hostname="hote", client_backup_restauration_marques=str(d / "marques"), client_backup_restauration_mise_de_cote=str(d / "mis-de-cote"), client_backup_staging=str(staging), client_backup_jobs=[{"nom": "courriel"}, {"nom": "postgresql"}], client_backup_attente_verrou="30m", ).replace("/etc/ssh/ssh_host_ed25519_key.pub", str(cle)) \ .replace("/etc/setops/restic.pass", str(d / "restic.pass")) script = d / "setops-restaurer" executable(script, rendu) env = {**os.environ, "PATH": f"{bin_}:{os.environ['PATH']}"} def lancer(*args: str, **extra: str) -> subprocess.CompletedProcess: return subprocess.run(["bash", str(script), *args], env={**env, **extra}, capture_output=True, text=True) r = lancer("choisir") verifier(r.returncode == 0 and json.loads(r.stdout)["instantane"] == "avant01", f"le candidat est l'instantane d'AVANT la naissance, pas l'etat neuf ({r.stdout.strip() or r.stderr.strip()})") r = lancer("garde") verifier(r.returncode == 3, f"la sauvegarde refuse tant qu'un etat d'avant attend (code {r.returncode})") vmail = d / "vmail" (vmail / "neuf").mkdir(parents=True) (vmail / "neuf" / "x").write_text("x") r = lancer("fichiers", str(vmail)) verifier(r.returncode != 0 and (vmail / "neuf" / "x").exists(), "un repertoire non vide n'est pas ecrase sans --remplacer") r = lancer("fichiers", "--remplacer", str(vmail)) verifier(r.returncode == 0 and (vmail / "boite" / "msg").read_text() == "ancien" and not (vmail / "neuf").exists(), f"--remplacer remet l'etat d'avant, a l'identique ({r.stdout.strip() or r.stderr.strip()})") verifier(any((d / "mis-de-cote").rglob("x")), "l'etat ecrase a ete mis de cote") r = lancer("base", "nextcloud") section = (log / "section.sql").read_text() if (log / "section.sql").exists() else "" verifier(r.returncode == 0 and "oc_users" in section, f"la section nextcloud est rejouee ({r.stderr.strip()[:120]})") verifier("DROP DATABASE" not in section and "realm" not in section, "la section s'arrete avant le DROP DATABASE postgres, et ne deborde sur aucune autre base") pg = (log / "pg").read_text() verifier("--single-transaction" in pg, "la base est rejouee en une seule transaction") verifier("INTERDIT" not in pg, "la section passe par l'entree standard, jamais par `psql -f`") r = lancer("base", "inexistante") verifier(r.returncode == 0 and "ABSENTE" in r.stdout, "une base absente de l'instantane est dite, pas inventee") for jeu in ("courriel", "postgresql"): lancer("acter", jeu, "restaure", "avant01") r = lancer("garde") verifier(r.returncode == 0, "la sauvegarde reprend une fois chaque jeu acte") for f in (d / "marques").iterdir(): f.unlink() r = lancer("garde", SETOPS_TEST_DEPOT_ABSENT="1") marques = sorted(p.name for p in (d / "marques").iterdir()) verifier(r.returncode == 0 and marques == ["courriel", "postgresql"] and "etat=neuf" in (d / "marques" / "courriel").read_text(), "depot inexistant = premiere vie : chaque jeu est acte « neuf »") def main() -> int: couverture() outil() if ECHECS: print(f"\n{len(ECHECS)} echec(s).") return 1 print("\nLa restauration est couverte et l'outil se comporte comme annonce.") return 0 if __name__ == "__main__": sys.exit(main())