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>
262 lines
14 KiB
Python
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())
|