diff --git a/Makefile b/Makefile index c924e6c..3b4dfee 100644 --- a/Makefile +++ b/Makefile @@ -1448,6 +1448,22 @@ monter-flotte: _instance-requise ## Monte une flotte deja creee : flux, AC et DN etape valider; $(MAKE) valider; \ printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)" +# RECONSTRUIRE UN LOCATAIRE DE BOUT EN BOUT, DEPUIS LE POSTE (2026-09-30). Les deux +# reconstructions du 2026-09-30 passaient par cinq endroits et des commandes tapees a la +# main ; la sequence vit desormais dans `scripts/reconstruire_locataire.py` (voir son +# en-tete). Le nom de l'ecosysteme s'ecrit en toutes lettres (`INSTANCE=`), comme pour +# `raser`. L'armement marque une pause, sauf `ARMER=oui`. `DEPUIS=` reprend apres un +# arret. Le journal complet va dans `/logs/`. +.PHONY: reconstruire-locataire +reconstruire-locataire: ## Rase et reconstruit un locataire, du site a la recette, etat remis — TENANT= INSTANCE= CONFIRMER=true + @if [[ -z "$(TENANT)" || -z "$(INSTANCE)" ]]; then \ + printf '%s\n' 'Refus: TENANT= et INSTANCE= requis.'; \ + exit 2; \ + fi + python3 scripts/reconstruire_locataire.py --tenant "$(TENANT)" --instance "$(INSTANCE)" \ + $(if $(filter true,$(CONFIRMER)),--confirmer) $(if $(DEPUIS),--depuis "$(DEPUIS)") \ + $(if $(filter oui,$(ARMER)),--armer) + # LE PARE-FEU PROXMOX D'UNE FLOTTE RECONSTRUITE (2026-09-30). Une VM clonee nait sans les # options de pare-feu : chaque reconstruction les perd. La procedure (une VM a la fois, # matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la diff --git a/docs/runbooks-construction.yml b/docs/runbooks-construction.yml index b7714eb..33837b7 100644 --- a/docs/runbooks-construction.yml +++ b/docs/runbooks-construction.yml @@ -86,6 +86,14 @@ variables: facultatif: true JEU: invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)" + DEPUIS: + invite: "Reprendre a cette etape (vide = depuis le debut)" + valeurs: [sauvegarder, raser, creer, inseminer, armer, monter, parefeu, bilan] + facultatif: true + ARMER: + invite: "Armer sans marquer la pause" + valeurs: [oui] + facultatif: true VMID: invite: "Identifiant Proxmox de la VM" DIALECTE: @@ -468,6 +476,17 @@ runbooks: La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le code seul. A ne lancer que sur un chantier — la prod vit ailleurs. etapes: + - cible: reconstruire-locataire + nature: destructif + portee: poste + duree: "long" + variables: [TENANT, INSTANCE, DEPUIS, ARMER] + fixes: {CONFIRMER: "true"} + pourquoi: >- + TOUT, d'une commande, depuis le poste : sauvegarder, raser et recreer (runner du + site), amorcer, armer, monter (runner du locataire, etat remis), pare-feu, bilan. + Arret a la premiere etape en echec ; `DEPUIS` reprend. Les etapes ci-dessous sont + les memes, une a une. - cible: sauvegarder-maintenant nature: ecriture pourquoi: >- diff --git a/scripts/reconstruire_locataire.py b/scripts/reconstruire_locataire.py new file mode 100644 index 0000000..91a2b93 --- /dev/null +++ b/scripts/reconstruire_locataire.py @@ -0,0 +1,224 @@ +#!/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, filtre: tuple[str, ...] = ()) -> 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: + 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") + 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())