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; \
|
etape valider; $(MAKE) valider; \
|
||||||
printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)"
|
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
|
# 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,
|
# 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
|
# matrice avant/apres, Icinga) etait rejouee par une boucle tapee a la main ; `--flotte` la
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ variables:
|
||||||
facultatif: true
|
facultatif: true
|
||||||
JEU:
|
JEU:
|
||||||
invite: "Le jeu d'etat (openldap, postgresql, nextcloud... — voir restauration-etat)"
|
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:
|
VMID:
|
||||||
invite: "Identifiant Proxmox de la VM"
|
invite: "Identifiant Proxmox de la VM"
|
||||||
DIALECTE:
|
DIALECTE:
|
||||||
|
|
@ -468,6 +476,17 @@ runbooks:
|
||||||
La preuve la plus dure du moteur : detruire un ecosysteme et le refaire depuis le
|
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.
|
code seul. A ne lancer que sur un chantier — la prod vit ailleurs.
|
||||||
etapes:
|
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
|
- cible: sauvegarder-maintenant
|
||||||
nature: ecriture
|
nature: ecriture
|
||||||
pourquoi: >-
|
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