Set-OPS-Public/scripts/genome.py
Daniel Allaire c18debc25e
Some checks are pending
verifier / verifier (push) Waiting to run
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

232 lines
9.4 KiB
Python

#!/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
from inventory_rules import 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:
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
with cible.open("w", encoding="utf-8") 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())