Set-OPS-Public/scripts/reconstruire_locataire.py

226 lines
12 KiB
Python
Raw Normal View History

#!/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())