Set-OPS-Public/scripts/reconstruire_locataire.py
Daniel Allaire 249d37d2c4 reconstruire-locataire : le nom de l'ecosysteme n'est plus demande deux fois
TENANT= le designe en toutes lettres ; le nom qu'exige raser en est
derive par raser.nom_court (une seule derivation). raser seul garde
son garde-fou.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 15:56:42 -04:00

262 lines
14 KiB
Python

#!/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
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.
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."""
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 plan du locataire, 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="))
elif nom == "raser":
rc = sur_site(args, f"SETOPS_INSTANCE={args.racine_runner}/{T} make raser CONFIRMER=true "
f"INSTANCE={shlex.quote(ecosysteme)}",
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())