Set-OPS-Public/scripts/genome.py

233 lines
9.4 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""Le GENOME d'un ecosysteme : les depots qui le fabriquent, et sa parente.
Un ecosysteme Set-OPS ne se reproduit pas depuis ses machines : il se reproduit depuis
QUATRE depots. Le moteur, le plan du tenant, le depot de l'hebergeur (sa fabric), et les
modeles. Perdre les VM ne coute que du temps ; perdre ces quatre-la coute l'ecosysteme.
CE QUE CE SCRIPT REPOND, et que rien ne repondait :
1. de QUOI cet ecosysteme descend — quel depot, quel commit, quelle etiquette signee ;
2. cette filiation est-elle encore VERIFIABLE — le commit existe-t-il, la signature
tient-elle contre le registre des signataires du depot.
POURQUOI L'ECRIRE PLUTOT QUE LE DEDUIRE. Un enfant fabrique aujourd'hui depuis la forge
de son parent, et laisse a l'annee suivante le soin de deviner d'ou il vient. Sans
registre, cinq ecosystemes en un an deviennent cinq moteurs differents que plus personne
ne sait rapprocher : c'est la mutation sans memoire. Trois lignes par depot l'evitent.
CE QUE CE SCRIPT NE FAIT PAS : il n'ecrit rien sur une forge et ne pousse aucun miroir.
Il constate et inscrit. Le miroir des depots sur la forge de l'ecosysteme est le niveau
suivant — il exige un jeton d'API, donc un choix d'exploitant.
python3 scripts/genome.py lister # les quatre depots, leur etat
python3 scripts/genome.py inscrire # ecrit <instance>/parente.yml
python3 scripts/genome.py verifier # la parente inscrite tient-elle encore ?
"""
from __future__ import annotations
import argparse
import datetime as dt
import os
import subprocess
import sys
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402
plan : l ecriture des registres devient atomique — tout, ou rien `path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide. Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et instance/plan/serveurs.yml reste mutile. L asymetrie fait la gravite : hosts.yml se regenere d un make instancier-appliquer, le PLAN ne se regenere de rien. C est la source unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe. TREIZE SITES, UNE SEULE FONCTION Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la divergence que P41 garde depuis les neuf resolutions d instance. La fonction vit donc dans inventory_rules.py, que les sept importaient deja. Une source, pas douze. TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT » temporaire dans le MEME dossier os.replace n est atomique qu au sein d un meme systeme de fichiers ; un /tmp sur une autre partition casserait la garantie sans rien dire fsync AVANT le rename sinon le renommage peut atteindre le disque avant le contenu : au retour d une coupure brutale, un fichier neuf et VIDE — le defaut qu on ferme, deplace d un cran report des droits mkstemp cree en 0600, le plan est en 0664 et doit rester lisible par le groupe sur les runners LE TEST PORTE SON PROPRE CONTROLE NEGATIF scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une garantie qu on n a jamais vue echouer n est pas une garantie, c est une habitude. Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance maintenant trois tests. Corrige au passage. LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ». Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une recette make recoit reellement : le mot de passe saisi ET les cinq cles calculees par voutes.py. Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne « simplifie » en retirant l un des deux. make prouver : CONFORME, 59 OK, 0 echec, 1 saute. make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00
from inventory_rules import ecriture_atomique, instance_courante # noqa: E402
FICHIER_PARENTE = "parente.yml"
def _git(depot: Path, *args: str) -> tuple[int, str]:
r = subprocess.run(["git", "-C", str(depot), *args],
capture_output=True, text=True)
return r.returncode, (r.stdout or r.stderr).strip()
def _instance() -> Path:
resolution d'instance : une seule, partagee — au lieu de neuf copies Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? » Neuf modules portaient chacun leur reponse. - 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ; - 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et _frontiere_absente lisaient le symlink au lieu de la variable ; - 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ; - 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme. Aucune n'etait une faute d'inattention : chacune avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas l'erreur, mais la DERIVE, invisible depuis l'interieur d'un fichier. LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(), dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve — c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut. Vingt-huit modules y sont branches. CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure : 17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose. P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees : instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de la resolution. make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:19:55 -04:00
return instance_courante().resolve()
def _hebergeur() -> Path | None:
"""Le depot qui porte `underlay.yml` — la fabric du site."""
c = underlay_mod.chemin()
return c.resolve().parent if c else None
def _modeles() -> Path | None:
"""Le depot des modeles, trouve par sa FORME et non par son nom.
Un depot de modeles contient des dossiers qui sont chacun un plan
(`<modele>/plan/nomenclature.yml`) et n'en a PAS a sa propre racine. Chercher
« Set-OPS-Modeles » par son nom aurait marche jusqu'au jour ou quelqu'un le renomme.
Deux criteres, tous deux appris d'un faux positif (2026-08-21) :
- pas de plan a la racine, sinon on designe une INSTANCE ;
- ne pas suivre les SYMLINKS. Le premier jet a designe le lab, qui porte un lien
`OPS-Technolibre -> ../OPS-Technolibre` : le motif l'a traverse et a cru voir un
modele. Un lien vers un frere n'est pas un contenu.
"""
for frere in sorted(RACINE.parent.iterdir()):
if not frere.is_dir() or not (frere / ".git").exists():
continue
if (frere / "plan" / "nomenclature.yml").is_file():
continue
if any(d.is_dir() and not d.is_symlink()
and (d / "plan" / "nomenclature.yml").is_file()
for d in frere.iterdir()):
return frere.resolve()
return None
def depots_requis() -> list[dict]:
"""Les depots sans lesquels cet ecosysteme ne peut pas etre refabrique."""
vus: dict[Path, str] = {}
for role, chemin in (("moteur", RACINE.resolve()),
("instance", _instance()),
("hebergeur", _hebergeur()),
("modeles", _modeles())):
if chemin is None or not chemin.exists():
continue
# L'hebergeur et l'instance sont souvent le MEME depot (l'operateur heberge son
# propre tenant). On garde alors les deux roles sur une seule ligne : ce sont
# deux fonctions, pas deux copies a sauvegarder.
if chemin in vus:
vus[chemin] += f"+{role}"
else:
vus[chemin] = role
depots = []
for chemin, role in vus.items():
rc_r, remote = _git(chemin, "remote", "get-url", "origin")
rc_c, commit = _git(chemin, "rev-parse", "HEAD")
_, branche = _git(chemin, "rev-parse", "--abbrev-ref", "HEAD")
_, etiquette = _git(chemin, "describe", "--tags", "--abbrev=0")
_, propre = _git(chemin, "status", "--porcelain")
signee = None
if etiquette and not etiquette.startswith("fatal"):
rc_v, _ = _git(chemin, "verify-tag", etiquette)
signee = rc_v == 0
else:
etiquette = None
depots.append({
"role": role,
"nom": chemin.name,
"remote": remote if rc_r == 0 else None,
"commit": commit if rc_c == 0 else None,
"branche": branche or None,
"etiquette": etiquette,
"etiquette_signee": signee,
"arbre_propre": propre == "",
})
return depots
def lister() -> int:
depots = depots_requis()
print(f"Genome de « {_instance().name} » — {len(depots)} depot(s) requis "
f"a sa reproduction\n")
print(f" {'ROLE':<20} {'DEPOT':<22} {'COMMIT':<10} {'ETIQUETTE':<16} SIGNEE")
for d in depots:
sig = "-" if d["etiquette_signee"] is None else ("oui" if d["etiquette_signee"] else "NON")
print(f" {d['role']:<20} {d['nom']:<22} {(d['commit'] or '?')[:9]:<10} "
f"{(d['etiquette'] or '(aucune)'):<16} {sig}")
if not d["remote"]:
print(f" {'':<20} /!\\ aucun remote : ce depot n'existe qu'ici")
if not d["arbre_propre"]:
print(f" {'':<20} /!\\ arbre modifie : le commit inscrit ne dira pas tout")
return 0
def inscrire() -> int:
"""Ecrit la parente dans l'instance : d'ou elle vient, a quel etat."""
base = _instance()
depots = depots_requis()
contenu = {
"parente": {
"inscrite_le": dt.date.today().isoformat(),
"depots": {d["role"]: {k: d[k] for k in
("nom", "remote", "commit", "branche", "etiquette")}
for d in depots},
}
}
entete = (
"# PARENTE de cet ecosysteme — de quoi il descend, et a quel etat.\n"
"#\n"
"# GENERE par `make genome-inscrire`. Ne pas editer a la main : reinscrire.\n"
"#\n"
"# Sans ce fichier, un ecosysteme fabrique aujourd'hui ne sait plus, dans un an,\n"
"# de quel moteur il descend ni a quel etat. Cinq enfants sans registre font cinq\n"
"# moteurs differents que plus personne ne sait rapprocher.\n"
"#\n"
"# Il ne remplace pas les depots : il dit ou les retrouver et lequel etait le bon.\n"
"---\n")
cible = base / FICHIER_PARENTE
plan : l ecriture des registres devient atomique — tout, ou rien `path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide. Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et instance/plan/serveurs.yml reste mutile. L asymetrie fait la gravite : hosts.yml se regenere d un make instancier-appliquer, le PLAN ne se regenere de rien. C est la source unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe. TREIZE SITES, UNE SEULE FONCTION Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la divergence que P41 garde depuis les neuf resolutions d instance. La fonction vit donc dans inventory_rules.py, que les sept importaient deja. Une source, pas douze. TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT » temporaire dans le MEME dossier os.replace n est atomique qu au sein d un meme systeme de fichiers ; un /tmp sur une autre partition casserait la garantie sans rien dire fsync AVANT le rename sinon le renommage peut atteindre le disque avant le contenu : au retour d une coupure brutale, un fichier neuf et VIDE — le defaut qu on ferme, deplace d un cran report des droits mkstemp cree en 0600, le plan est en 0664 et doit rester lisible par le groupe sur les runners LE TEST PORTE SON PROPRE CONTROLE NEGATIF scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une garantie qu on n a jamais vue echouer n est pas une garantie, c est une habitude. Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance maintenant trois tests. Corrige au passage. LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ». Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une recette make recoit reellement : le mot de passe saisi ET les cinq cles calculees par voutes.py. Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne « simplifie » en retirant l un des deux. make prouver : CONFORME, 59 OK, 0 echec, 1 saute. make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00
with ecriture_atomique(cible) as f:
f.write(entete)
yaml.safe_dump(contenu, f, default_flow_style=False, sort_keys=False,
allow_unicode=True)
print(f"{cible} inscrit — {len(depots)} depot(s).")
return 0
def verifier() -> int:
"""La parente inscrite tient-elle encore ? Code 0 si oui."""
base = _instance()
cible = base / FICHIER_PARENTE
if not cible.is_file():
print(f"Aucune parente inscrite ({cible}). La poser : make genome-inscrire",
file=sys.stderr)
return 1
data = yaml.safe_load(cible.read_text(encoding="utf-8")) or {}
inscrits = (data.get("parente") or {}).get("depots") or {}
actuels = {d["role"]: d for d in depots_requis()}
fautes: list[str] = []
for role, d in actuels.items():
if role not in inscrits:
fautes.append(f"{role} ({d['nom']}) absent de la parente inscrite")
for role, ins in inscrits.items():
d = actuels.get(role)
if d is None:
fautes.append(f"{role} inscrit mais introuvable aujourd'hui "
f"({ins.get('nom')})")
continue
commit = ins.get("commit")
if commit:
chemin = RACINE.parent / ins["nom"] if ins["nom"] != RACINE.name else RACINE
rc, _ = _git(chemin, "cat-file", "-e", f"{commit}^{{commit}}")
if rc != 0:
fautes.append(f"{role} : le commit inscrit {commit[:9]} n'existe plus "
f"dans {ins['nom']} (histoire reecrite ?)")
if not ins.get("remote"):
fautes.append(f"{role} ({ins.get('nom')}) : aucun remote inscrit — "
f"ce depot n'existe nulle part ailleurs qu'ici")
if fautes:
for f in fautes:
print(f"parente : {f}", file=sys.stderr)
return 1
print(f"Parente coherente : {len(inscrits)} depot(s), tous retrouves, "
f"tous porteurs d'un remote.")
return 0
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
sub = ap.add_subparsers(dest="commande", required=True)
sub.add_parser("lister", help="Les depots requis a la reproduction, et leur etat.")
sub.add_parser("inscrire", help="Ecrit <instance>/parente.yml.")
sub.add_parser("verifier", help="La parente inscrite tient-elle encore ?")
a = ap.parse_args()
return {"lister": lister, "inscrire": inscrire, "verifier": verifier}[a.commande]()
if __name__ == "__main__":
sys.exit(main())