reconstruction : make reconstruire-locataire conduit tout depuis le poste
Sauvegarder, raser/creer/inseminer (runner du site), armer (pause sauf ARMER=oui), monter-flotte (runner du locataire), pare-feu, bilan. Arret a la premiere etape en echec, reprise par DEPUIS=, journal dans <locataire>/logs/. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
cc906af918
commit
14bf977bd6
3 changed files with 259 additions and 0 deletions
16
Makefile
16
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=<etape>` reprend apres un
|
||||
# arret. Le journal complet va dans `<locataire>/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=<OPS-X> et INSTANCE=<nom de l ecosysteme, en toutes lettres> 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
|
||||
|
|
|
|||
|
|
@ -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: >-
|
||||
|
|
|
|||
224
scripts/reconstruire_locataire.py
Normal file
224
scripts/reconstruire_locataire.py
Normal file
|
|
@ -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())
|
||||
Loading…
Reference in a new issue