reconstruction : monter-flotte et parefeu-*-flotte entrent dans le code

La sequence du runner (flux, socle, deployer-tout, valider) devient
make monter-flotte ; reconstruire s'appuie dessus. eprouver_parefeu.py
--flotte : toutes les VM une a une, runner en dernier, arret au premier
refus ; les reseaux des IPSet (zone d'administration) comptent enfin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-09-30 05:23:27 -04:00
parent 9167f67c7b
commit cc906af918
4 changed files with 155 additions and 9 deletions

View file

@ -1,5 +1,32 @@
# CHANGELOG — Set-OPS
## 2026-09-30 (51) — Ce qui était fait à la main dans les reconstructions entre dans le code
**Le bilan de l'exploitant** : aucune des deux reconstructions du jour n'est allée au bout
seule. Au-delà des défauts corrigés, trois gestes vivaient hors du dépôt :
1. **La séquence du runner du locataire** était un script écrit dans `/tmp` du runner.
Elle devient `make monter-flotte CONFIRMER=true` : `flux`, AC et DNS (`_amorcer-socle`),
`deployer-tout`, `valider` — étapes horodatées, arrêt à la première en échec.
`reconstruire` s'appuie désormais dessus (et gagne `valider`, qu'il n'avait pas).
2. **La réactivation du pare-feu Proxmox** était une boucle tapée à la main.
`eprouver_parefeu.py --flotte` porte la procédure pour toutes les VM, **une à une, le
runner en dernier, arrêt au premier refus** — la règle qui la rend sûre vit avec elle.
Cibles `parefeu-verifier-flotte` (mesure) et `parefeu-activer-flotte` (depuis le poste :
lui seul tient à la fois les accès du locataire et le runner du site).
3. **La sauvegarde fraîche avant de raser** reste une ÉTAPE du runbook `flotte-refaire`,
pas une condition de `raser` : décision de l'exploitant, « une confirmation suffit ».
**`--flotte` a trouvé un défaut dès sa première passe** (lecture seule, Technolibre) : il
s'est arrêté sur `infra-edge-01`, un flux `10.37.0.17 → 443` « hors des règles ». C'était le
navigateur de l'exploitant, depuis la zone d'administration du site — légitime, et
effectivement admis par Proxmox. La matrice ne retenait des IPSet que les membres qui sont
des MACHINES ; les RÉSEAUX (`t23-admin` : `10.37.0.0/24`…) étaient ignorés, et l'observateur
ne connaissait l'administration que sur le port 22. Les réseaux de chaque règle (exclusions
`!` comprises) comptent désormais sur tous les ports. Seconde passe : **13/13, runner en
dernier, aucun flux perdu**. Ce faux refus aurait bloqué une reconstruction le jour où
l'exploitant a un onglet ouvert sur ses services — c'est-à-dire presque toujours.
## 2026-09-30 (50) — Le web frontal n'est plus un détenteur d'état
Le frontal RELAIE : ses vhosts, son WAF et sa page 404 se redéploient depuis le dépôt. Le

View file

@ -1423,8 +1423,49 @@ reconstruire: _instance-requise ## Reconstruit un ecosysteme depuis zero : VM pu
$(MAKE) flux; \
$(MAKE) flotte-creer CONFIRMER=true; \
$(MAKE) _attendre-flotte; \
$(MAKE) _amorcer-socle; \
$(MAKE) deployer-tout CONFIRMER=true
$(MAKE) monter-flotte CONFIRMER=true
# MONTER UNE FLOTTE DONT LES VM EXISTENT DEJA (2026-09-30). C'est la seconde moitie de
# `reconstruire`, et TOUT ce que le runner d'un locataire peut en faire : il ne cree pas de VM
# (l'API du cluster est a l'hebergeur), il les recoit, armees, et les monte. Jusqu'ici cette
# sequence vivait dans un script ecrit a la main dans /tmp du runner — deux reconstructions
# l'ont portee, et aucune trace ne la gardait.
#
# `set -e` : la premiere etape en echec arrete tout. Chaque etape est horodatee, pour qu'un
# journal dise ou l'on en etait sans relire dix mille lignes d'Ansible. `valider` fait partie
# de la sequence : une flotte sans recette n'est pas montee, elle est deployee.
.PHONY: monter-flotte
monter-flotte: _instance-requise ## Monte une flotte deja creee : flux, AC et DNS, tout le reste, recette — CONFIRMER=true
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: MONTER la flotte (deploiement complet puis recette). Relancer avec CONFIRMER=true.'; \
exit 2; \
fi; \
etape() { printf '\n=== ETAPE %s %s\n' "$$1" "$$(date +%T)"; }; \
etape flux; $(MAKE) flux; \
etape _amorcer-socle; $(MAKE) _amorcer-socle; \
etape deployer-tout; $(MAKE) deployer-tout CONFIRMER=true; \
etape valider; $(MAKE) valider; \
printf '\n=== FLOTTE MONTEE %s\n' "$$(date +%T)"
# 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
# porte, avec la regle qui la rend sure : arret au premier refus.
#
# DEPUIS LE POSTE : la matrice se joue avec les acces du LOCATAIRE, l'activation passe par
# le runner du SITE, seul a joindre l'API du cluster. Le poste est le seul a tenir les deux.
.PHONY: parefeu-verifier-flotte parefeu-activer-flotte
parefeu-verifier-flotte: ## Verifie, sans rien activer, ce que le pare-feu Proxmox couperait sur chaque VM — INSTANCE=OPS-X
@if [[ -z "$(INSTANCE)" ]]; then printf '%s\n' 'Refus: INSTANCE=<OPS-X> requis.'; exit 2; fi
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --plan
parefeu-activer-flotte: ## Active le pare-feu Proxmox de chaque VM, une a une, arret au premier ecart — INSTANCE=OPS-X CONFIRMER=true
@if [[ -z "$(INSTANCE)" || "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: INSTANCE=<OPS-X> et CONFIRMER=true requis (active le pare-feu de TOUTES les VM, une a une).'; \
exit 2; \
fi
python3 scripts/eprouver_parefeu.py --instance "$(INSTANCE)" --flotte --activer
# « Go ahead, make my day. » — LE bouton rouge : alias de reconstruire (Clint Eastwood).
# Cree toutes les VM puis deploie tout, en une commande. Garde CONFIRMER=true.

View file

@ -382,6 +382,14 @@ runbooks:
pourquoi: >-
Les VM manquantes, clonees depuis le gabarit dore. VMID, IP et VLAN sont DERIVES
du plan : on ne les saisit nulle part.
- cible: monter-flotte
nature: ecriture
duree: "long"
fixes: {CONFIRMER: "true"}
pourquoi: >-
Des VM qui viennent de naitre (ou de renaitre) a la flotte recettee : flux, AC et
DNS d'abord, tout le reste, puis `valider`. C'est la sequence du runner apres une
reconstruction ; chaque proprietaire d'etat y remet celui d'avant.
- cible: deployer-tout
nature: ecriture
duree: "long"
@ -498,6 +506,21 @@ runbooks:
- cible: valider
nature: mesure
pourquoi: "Une reconstruction sans recette n'a rien prouve."
- cible: parefeu-verifier-flotte
nature: mesure
portee: poste
variables: [INSTANCE]
pourquoi: >-
Ce que le pare-feu Proxmox couperait, VM par VM, avant de l'activer : une VM
clonee nait sans ses options de pare-feu.
- cible: parefeu-activer-flotte
nature: ecriture
portee: poste
variables: [INSTANCE]
fixes: {CONFIRMER: "true"}
pourquoi: >-
L'activer une VM a la fois, matrice avant et apres, Icinga ; arret au premier
ecart, le runner en dernier.
# ─────────────────────────────────────────────────────────────────────────────
- id: gabarit

View file

@ -80,7 +80,7 @@ def _ansible(instance: str, hotes: str, commande: str) -> dict[str, str]:
return sorties
def matrice(instance: str, hote: str) -> tuple[list[dict], int]:
def matrice(instance: str, hote: str) -> tuple[list[dict], int, dict[str, list]]:
env = dict(os.environ, SETOPS_INSTANCE=str(DOSSIER_INSTANCES / instance))
r = subprocess.run([sys.executable, "scripts/devis_proxmox_fw.py", "--json"],
capture_output=True, text=True, cwd=RACINE, env=env)
@ -98,16 +98,29 @@ def matrice(instance: str, hote: str) -> tuple[list[dict], int]:
continue
groupes = {g["nom"]: g for g in b["groupes"]}
tests = []
# LES MEMBRES QUI SONT DES RESEAUX, PAS DES MACHINES (2026-09-30). On ne peut pas les
# TESTER (aucune machine a qui demander d'ouvrir la connexion), mais Proxmox les
# applique : `t23-admin` porte la zone d'administration du site. Les ignorer faisait
# prendre le navigateur de l'exploitant, sur l'edge en 443, pour un flux « hors des
# regles » — et `--flotte` s'arretait. `!reseau` = exclusion (`nomatch`).
reseaux: dict[str, list] = {}
for gn in a["groupes"]:
for regle in groupes[gn]["regles"]:
membres = b["ipsets"].get(regle["source"].lstrip("+"), {}).get("membres", [])
port = str(regle.get("dport") or regle.get("icmp_type"))
pos = [m for m in membres if "/" in str(m) and not str(m).startswith("!")]
neg = [str(m)[1:] for m in membres if str(m).startswith("!")]
if pos:
reseaux.setdefault(port, []).append(
([ipaddress.ip_network(x, strict=False) for x in pos],
[ipaddress.ip_network(x, strict=False) for x in neg]))
for m in membres:
src = h_par_ip.get(m)
if src and src != hote:
tests.append({"src": src, "ip": ips[hote], "proto": regle["proto"],
"port": str(regle.get("dport") or regle.get("icmp_type")),
"groupe": gn})
return tests, int(a["vmid"])
return tests, int(a["vmid"]), reseaux
raise SystemExit(f"REFUS : « {hote} » n'a aucune affectation au devis de {instance}.")
@ -144,7 +157,8 @@ def ecoutes(instance: str, hote: str) -> set[str]:
return ports
def observer(instance: str, hote: str, tests: list[dict]) -> list[str]:
def observer(instance: str, hote: str, tests: list[dict],
reseaux: dict[str, list] | None = None) -> list[str]:
"""Les connexions entrantes etablies qu'AUCUNE regle ne couvre."""
cmd = ('L=" $(ss -Hltn | awk \'{n=split($4,a,":"); print a[n]}\' | sort -u | tr "\\n" " ") "; '
'for i in 1 2 3; do ss -Hnt state established | awk -v L="$L" '
@ -173,6 +187,10 @@ def observer(instance: str, hote: str, tests: list[dict]) -> list[str]:
continue
if port == "22" and any(ipaddress.ip_address(ip) in n for n in admin):
continue
adr = ipaddress.ip_address(ip)
if any(any(adr in n for n in pos) and not any(adr in n for n in neg)
for pos, neg in (reseaux or {}).get(port, [])):
continue
hors.append(f"{src} -> {hote}:{port}")
return sorted(set(hors))
@ -206,10 +224,30 @@ def critiques_icinga(instance: str) -> list[str]:
return ["(Icinga illisible)"]
def ordre_flotte(instance: str) -> list[str]:
"""Toutes les VM du locataire, son RUNNER EN DERNIER.
C'est depuis le poste qu'on joue la matrice, mais c'est le runner qui conduit le reste
de la vie du locataire : s'il devait etre coupe, qu'il le soit apres que tout le reste
a ete prouve — et qu'on le voie seul en cause.
"""
import yaml
enfants = (yaml.safe_load(_inventaire(instance).read_text(encoding="utf-8"))
.get("all", {}).get("children", {}))
runners = set()
for g in ("serveur_ops_tenant", "serveur_ops"):
runners |= set(((enfants.get(g) or {}).get("hosts") or {}))
hotes = sorted(_ip_par_hote(instance))
return [h for h in hotes if h not in runners] + [h for h in hotes if h in runners]
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
ap.add_argument("--instance", required=True, help="le locataire, ex. OPS-Technolibre")
ap.add_argument("--hote", required=True, help="la VM, ex. idm-01")
cible = ap.add_mutually_exclusive_group(required=True)
cible.add_argument("--hote", help="la VM, ex. idm-01")
cible.add_argument("--flotte", action="store_true",
help="toutes les VM, une a une, le runner en dernier ; arret au premier refus")
mode = ap.add_mutually_exclusive_group(required=True)
mode.add_argument("--plan", action="store_true", help="verifier, sans rien activer")
mode.add_argument("--activer", action="store_true", help="verifier, activer, reverifier")
@ -217,11 +255,28 @@ def main() -> int:
ap.add_argument("--rebond", default="ansible@10.37.0.1", help="rebond vers le runner")
ap.add_argument("--racine-runner", default="/opt/setops")
args = ap.parse_args()
I, H = args.instance, args.hote
if args.hote:
return eprouver(args.instance, args.hote, args)
# UNE A UNE, ET ON S'ARRETE AU PREMIER REFUS (2026-09-30). C'etait une boucle tapee a
# la main apres chaque reconstruction ; la regle qui la rend sure — ne pas continuer
# apres un ecart — doit vivre avec elle, pas dans la memoire de celui qui la tape.
hotes = ordre_flotte(args.instance)
for n, h in enumerate(hotes, 1):
rc = eprouver(args.instance, h, args)
if rc != 0:
print(f"\nARRET sur {h} (code {rc}) : {n - 1}/{len(hotes)} VM traitees avant lui. "
"Rien d'autre n'a ete touche.")
return rc
print(f"\n{len(hotes)}/{len(hotes)} VM : pare-feu Proxmox "
+ ("verifiable" if args.plan else "actif") + ", aucun flux perdu.")
return 0
tests, vmid = matrice(I, H)
def eprouver(I: str, H: str, args: argparse.Namespace) -> int:
"""La procedure pour UNE VM (voir l'en-tete). 0 = conforme."""
tests, vmid, reseaux = matrice(I, H)
print(f"=== {H} ({I}, VM {vmid}) — {len(tests)} test(s) tires du devis")
hors = observer(I, H, tests)
hors = observer(I, H, tests, reseaux)
print(f" observation : {len(hors)} flux etabli(s) hors des regles")
for h in hors:
print(f" HORS : {h}")