#!/usr/bin/env python3 """Reconstruire un locataire de bout en bout, depuis le poste : une commande, un journal. POURQUOI (2026-09-30). Les deux reconstructions du jour (Technolibre, puis Chezlepro) ont reussi, mais l'enchainement vivait dans la tete de celui qui les conduisait : sauvegarder depuis le poste, raser et recreer depuis le runner du SITE, amorcer, armer depuis le poste, monter depuis le runner du LOCATAIRE, reactiver le pare-feu depuis le poste. Cinq endroits, des commandes SSH tapees a la main, et un script ecrit dans /tmp. Ce qui n'est pas dans le depot ne se rejoue pas a l'identique — et ne se transmet pas. POURQUOI DEPUIS LE POSTE. C'est le seul endroit qui tient a la fois les secrets du locataire (sa voute, pour l'armer) et l'acces au runner du site (l'API du cluster). Le site ne detient pas les secrets du locataire, et c'est voulu : l'armement reste un geste du poste. LES ETAPES, dans l'ordre (`--depuis` reprend a l'une d'elles apres un arret) : sauvegarder poste deposer l'etat de chaque noeud MAINTENANT (sinon on perd la journee), etiquete « avant-raser » : garde jusqu'a la reconstruction suivante raser runner du site detruire les VM creer runner du site recreer les VM depuis le gabarit inseminer runner du site amorcer le runner du locataire (socle + moteur, sans secret) armer poste lui remettre sa voute et sa cle — pause, sauf `--armer` monter runner locataire `make monter-flotte` : socle, tout, recette ; chaque role proprietaire d'un etat y remet celui d'avant parefeu poste reactiver le pare-feu Proxmox, VM par VM, runner en dernier bilan poste ce que chaque jeu d'etat est devenu, puis les TEMOINS : l'etat vivant contre l'instantane d'avant rasage (une perte = arret) La premiere etape en echec arrete tout, et le dit. `raser` n'a pas d'autre condition que sa confirmation (decision de l'exploitant) : c'est la PROCEDURE qui sauvegarde avant. LE NOM N'EST DEMANDE QU'UNE FOIS. `raser`, seul, exige qu'on tape le nom de l'ecosysteme parce qu'il rase celui du lien `instance/`, qui peut pointer n'importe ou. Ici l'ecosysteme est deja designe en toutes lettres par `TENANT=` : le nom que `raser` attend en est DERIVE (`raser.nom_court`), pas redemande. Une premiere version l'exigeait deux fois — une occasion de se tromper de plus, et aucune protection de plus (2026-09-30). """ from __future__ import annotations import argparse import os import shlex import subprocess import sys import time from pathlib import Path RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) from devis_reseau import DOSSIER_INSTANCES # noqa: E402 from eprouver_parefeu import _ip_par_hote # noqa: E402 from raser import nom_court # noqa: E402 ETAPES = ["sauvegarder", "raser", "creer", "inseminer", "armer", "monter", "parefeu", "bilan"] SSH = ["ssh", "-o", "BatchMode=yes", "-o", "ConnectTimeout=15", "-o", "ServerAliveInterval=30", "-o", "LogLevel=ERROR"] # Le runner du locataire RENAIT a chaque reconstruction : sa cle d'hote SSH change. On ne # l'epingle donc pas — son identite, c'est la voute qui la porte (serveur_ops_tenant). SSH_NEUF = SSH + ["-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"] JOURNAL: Path | None = None def dire(msg: str) -> None: print(msg, flush=True) if JOURNAL: with JOURNAL.open("a", encoding="utf-8") as f: f.write(msg + "\n") def lancer(cmd: list[str], *, env: dict | None = None, filtre: tuple[str, ...] = ()) -> int: """Execute, verse TOUT au journal, n'affiche que les lignes qui disent ou l'on en est. SANS TAMPON (2026-09-30). Ecrivant dans un tuyau, un Python enfant garde sa sortie par blocs de plusieurs kilo-octets : l'etape `parefeu` est restee muette douze minutes a la premiere reconstruction lancee par l'exploitant, qui s'est demande si tout etait gele. `PYTHONUNBUFFERED` vaut pour tous les enfants Python, Ansible compris. """ env = dict(env or os.environ, PYTHONUNBUFFERED="1") with subprocess.Popen(cmd, cwd=RACINE, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, text=True) as p: assert p.stdout for ligne in p.stdout: ligne = ligne.rstrip("\n") if JOURNAL: with JOURNAL.open("a", encoding="utf-8") as f: f.write(ligne + "\n") if filtre and any(m in ligne for m in filtre): print(" " + ligne[:200], flush=True) return p.returncode def sur_site(args, commande: str, filtre: tuple[str, ...] = ()) -> int: """Une commande en `setops` sur le runner du SITE (par le rebond).""" distante = (f"cd {args.racine_runner}/Set-OPS-public && export PATH={args.racine_runner}/venv/bin:$PATH " f"&& {commande}") return lancer(SSH + ["-J", args.rebond, args.runner, "sudo -u setops bash -lc " + shlex.quote(distante)], filtre=filtre) def site_a_jour(args, tenant: str) -> int: """Le runner du SITE tire les trois depots qu'il va utiliser, AVANT sa premiere etape. Le moteur (Set-OPS-public), le depot du locataire (sa face reseau publiee), et le depot du SITE — deduit du lien `underlay.yml`, pas nomme : un autre site s'appelle autrement. Jusqu'au 2026-09-30 seul `raser` tirait, et seulement les deux premiers : une reprise `DEPUIS=creer` partait de ce que le runner avait sous la main, et `flotte-creer` lisait un plan du site jamais tire. AVANCE RAPIDE SEULEMENT, et refus devant une modification locale : un runner n'edite pas ce qu'il tire. S'il porte un ecart, c'est a comprendre, pas a ecraser. """ r = args.racine_runner commande = ( 'site=$(dirname "$(readlink -f underlay.yml)"); ' f'for d in {r}/Set-OPS-public {r}/{tenant} "$site"; do ' ' git -C "$d" rev-parse --git-dir >/dev/null 2>&1 ' ' || { echo "REFUS : $d n est pas un depot git"; exit 1; }; ' ' if [ -n "$(git -C "$d" status --porcelain --untracked-files=no)" ]; then ' ' echo "REFUS : modification locale dans $d"; exit 1; fi; ' ' git -C "$d" pull -q --ff-only ' ' || { echo "REFUS : $d ne se met pas a jour en avance rapide"; exit 1; }; ' ' echo "a jour : $(basename "$d") $(git -C "$d" rev-parse --short HEAD)"; ' 'done') return sur_site(args, commande, filtre=("a jour :", "REFUS")) def sur_locataire(ip: str, commande: str, journaliser: bool = True) -> tuple[int, str]: distante = "sudo -u setops bash -lc " + shlex.quote(commande) r = subprocess.run(SSH_NEUF + [f"ansible@{ip}", distante], capture_output=True, text=True, stdin=subprocess.DEVNULL) if JOURNAL and journaliser: with JOURNAL.open("a", encoding="utf-8") as f: f.write(r.stdout + r.stderr) return r.returncode, r.stdout def runner_du_locataire(tenant: str) -> tuple[str, str]: import yaml inv = DOSSIER_INSTANCES / tenant / "inventories" / "principal" / "hosts.yml" enfants = yaml.safe_load(inv.read_text(encoding="utf-8"))["all"]["children"] hotes = sorted(((enfants.get("serveur_ops_tenant") or {}).get("hosts") or {})) if len(hotes) != 1: raise SystemExit(f"REFUS : {tenant} doit avoir exactement un runner (serveur_ops_tenant), " f"il en a {len(hotes)}.") return hotes[0], _ip_par_hote(tenant)[hotes[0]] def main() -> int: global JOURNAL ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) ap.add_argument("--tenant", required=True, help="dossier du locataire, ex. OPS-Technolibre") ap.add_argument("--confirmer", action="store_true", help="sans lui, rien n'est fait") ap.add_argument("--depuis", choices=ETAPES, default=ETAPES[0], help="reprendre a cette etape") ap.add_argument("--armer", action="store_true", help="armer sans marquer la pause (la console, ou un exploitant qui l'a decide)") ap.add_argument("--runner", default="ansible@10.37.31.11", help="runner du site") ap.add_argument("--rebond", default="ansible@10.37.0.1", help="rebond vers le runner du site") ap.add_argument("--racine-runner", default="/opt/setops") ap.add_argument("--delai-montage", type=int, default=3 * 3600, help="secondes (defaut 3 h)") args = ap.parse_args() T = args.tenant dossier = DOSSIER_INSTANCES / T ecosysteme = nom_court(dossier) inv = dossier / "inventories" / "principal" / "hosts.yml" if not inv.is_file(): print(f"REFUS : {inv} introuvable.") return 2 runner_nom, runner_ip = runner_du_locataire(T) a_faire = ETAPES[ETAPES.index(args.depuis):] if not args.confirmer: print(f"Refus : RECONSTRUCTION de {T} ({ecosysteme}) — ses VM seront DETRUITES puis refaites.") print(f" etapes : {' -> '.join(a_faire)} ; runner du locataire : {runner_nom} ({runner_ip})") print(" Relancer avec CONFIRMER=true.") return 2 (dossier / "logs").mkdir(exist_ok=True) JOURNAL = dossier / "logs" / f"reconstruction-{time.strftime('%Y%m%d-%H%M%S')}.log" env = dict(os.environ, SETOPS_INSTANCE=str(dossier)) debut = time.time() dire(f"RECONSTRUCTION de {T} — {time.strftime('%F %T')} — journal : {JOURNAL}") def etape(nom: str) -> None: dire(f"\n=== ETAPE {nom} {time.strftime('%T')}") def arret(nom: str, rc: int, quoi: str = "") -> int: dire(f"\nARRET a l'etape « {nom} » (code {rc}){' — ' + quoi if quoi else ''}.") dire(f"Reprendre, une fois la cause comprise : make reconstruire-locataire TENANT={T} " f"CONFIRMER=true DEPUIS={nom}") return rc or 1 site_tire = False for nom in a_faire: etape(nom) if nom in ("raser", "creer", "inseminer") and not site_tire: rc = site_a_jour(args, T) if rc != 0: return arret(nom, rc, "le runner du site n'a pas pu se mettre a jour") site_tire = True if nom == "sauvegarder": rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml", "-e", "restauration_action=sauvegarder"], env=env, filtre=("fatal:", "PLAY RECAP", "failed=")) # LE SITE NOMME SON LOCATAIRE, IL NE LE MONTE PLUS (2026-10-05, materialisation M3). # Raser et creer lisaient le depot du locataire monte comme instance sur le runner du # site (`SETOPS_INSTANCE=…/OPS-x`). Ils lisent desormais sa face reseau publiee : memes # VMID, memes parametres de clonage, meme pool, a l'argument pres (P93, P94, # `test_appels_locataire.py`). Le depot du locataire reste tire : sa face y est publiee. elif nom == "raser": rc = sur_site(args, f"make locataire-raser TENANT={shlex.quote(T)} CONFIRMER=true " f"INSTANCE={shlex.quote(ecosysteme)}", filtre=("detruite", "VM detruites", "Refus", "REFUS", "ERREUR")) elif nom == "creer": rc = sur_site(args, f"make locataire-creer TENANT={shlex.quote(T)} " f"CONFIRMER=true PARALLELE=4", filtre=("=== Creation", "a ECHOUE", "Toutes les VM", "Refus")) elif nom == "inseminer": rc = sur_site(args, f"make inseminer TENANT={T}", filtre=("Insemination de", ": ok=", "porte desormais", "fatal", "Refus")) elif nom == "armer": if not args.armer: if not sys.stdin.isatty(): return arret(nom, 3, "l'armement attend une decision : relancer avec ARMER=oui") rep = input(f"Armer {runner_nom} : lui remettre la voute de {T} et sa cle ? [oui/NON] ") if rep.strip().lower() != "oui": return arret(nom, 3, "armement refuse") rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/groupes/serveur_ops_tenant.yml"], env=env, filtre=("fatal:", f"{runner_nom} ", "failed=")) elif nom == "monter": rc, _ = sur_locataire(runner_ip, f"git -C {args.racine_runner}/Set-OPS-public pull -q --ff-only " f"&& git -C {args.racine_runner}/{T} pull -q --ff-only") if rc != 0: return arret(nom, rc, "le runner n'a pas pu tirer le genome et son plan") # DETACHE, SEUL SUR SA LIGNE : un `&` place au bout d'une chaine `&&` detache la # CHAINE ENTIERE, dont la sortie tient la session SSH ouverte jusqu'a la fin. rc, _ = sur_locataire(runner_ip, f"cd {args.racine_runner}/Set-OPS-public\n" f"export PATH={args.racine_runner}/venv/bin:$PATH\n" "setsid nohup make monter-flotte CONFIRMER=true " f"> {args.racine_runner}/monter-flotte.log 2>&1 < /dev/null &\n") if rc != 0: return arret(nom, rc, "monter-flotte n'a pas demarre") vu, limite = set(), time.time() + args.delai_montage while True: time.sleep(30) _, sortie = sur_locataire(runner_ip, f"pgrep -u setops -x make >/dev/null && echo EN_COURS; " f"grep -E '^=== |^make.*Error|failed=[1-9]' " f"{args.racine_runner}/monter-flotte.log", journaliser=False) # le releve se repete : seul le NOUVEAU va au journal for ligne in sortie.splitlines(): if ligne.startswith("===") or "Error" in ligne or "failed=" in ligne: if ligne not in vu: vu.add(ligne) dire(" " + ligne[:200]) if "EN_COURS" not in sortie: rc = 0 if any("FLOTTE MONTEE" in v for v in vu) else 1 break if time.time() > limite: return arret(nom, 4, "delai de surveillance depasse — le montage continue sur le runner") elif nom == "parefeu": # PAR LES SONDES, PAS PAR LA MATRICE (2026-09-30) : tout d'un coup, juge par la sonde # `connectivite` que chaque VM rapporte a la minute. Trois minutes au lieu de vingt. rc = lancer([sys.executable, "-u", "scripts/eprouver_parefeu.py", "--instance", T, "--flotte", "--activer", "--sondes", "--runner", args.runner, "--rebond", args.rebond, "--racine-runner", args.racine_runner], env=env, filtre=(" ",)) else: # bilan rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env, filtre=("jeu ", "avant-raser:", "fatal:")) # LES TEMOINS (2026-10-07) : le bilan dit ce que chaque role a FAIT ; les temoins # mesurent ce qui est revenu, contre l'instantane depose a l'etape `sauvegarder`. if rc == 0: rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml", "-e", "restauration_action=temoins"], env=env, filtre=("TEMOINS", "IDENTIQUE", "CONFORME", "ECART", "SANS OBJET", "perdu", "IDENTITE", "fatal:")) if rc != 0: return arret(nom, rc) dire(f" etape « {nom} » : OK") dire(f"\nRECONSTRUCTION de {T} TERMINEE en {int((time.time() - debut) / 60)} min — journal : {JOURNAL}") return 0 if __name__ == "__main__": sys.exit(main())