#!/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) raser runner du site detruire les VM — le nom de l'ecosysteme s'ecrit en toutes lettres 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 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. """ 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 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.""" 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 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("--instance", required=True, help="nom de l'ecosysteme, ecrit en toutes lettres (celui qu'exige `raser`)") 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 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} ({args.instance}) — 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"INSTANCE={args.instance} CONFIRMER=true DEPUIS={nom}") return rc or 1 for nom in a_faire: etape(nom) 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=")) elif nom == "raser": rc = sur_site(args, f"git pull -q --ff-only && git -C {args.racine_runner}/{T} pull -q --ff-only " f"&& SETOPS_INSTANCE={args.racine_runner}/{T} make raser CONFIRMER=true " f"INSTANCE={shlex.quote(args.instance)}", filtre=("detruite", "VM detruites", "Refus", "REFUS", "ERREUR")) elif nom == "creer": rc = sur_site(args, f"SETOPS_INSTANCE={args.racine_runner}/{T} make flotte-creer " 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": rc = lancer([sys.executable, "scripts/eprouver_parefeu.py", "--instance", T, "--flotte", "--activer", "--runner", args.runner, "--rebond", args.rebond, "--racine-runner", args.racine_runner], env=env, filtre=("=== ", "avant :", "apres :", "Icinga", "ARRET", "VM : pare-feu", "REFUS")) else: # bilan rc = lancer(["ansible-playbook", "-i", str(inv), "playbooks/backup/restauration.yml"], env=env, filtre=("jeu ", "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())