Some checks are pending
verifier / verifier (push) Waiting to run
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>
232 lines
9.4 KiB
Python
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())
|