Un ecosysteme Set-OPS ne se reproduit pas depuis ses machines, mais depuis QUATRE depots : le moteur, le plan du tenant, le depot de l'hebergeur (sa fabric) et les modeles. Perdre les VM coute du temps ; perdre ces quatre-la coute l'ecosysteme. Rien ne les nommait. `scripts/genome.py` les DERIVE au lieu de les declarer : le moteur est ce depot, l'instance vient de SETOPS_INSTANCE, l'hebergeur se lit du symlink underlay.yml, et les modeles se reconnaissent a leur FORME — des plans en sous-dossiers, aucun a la racine. Deux criteres appris d'un faux positif : sans le second, le detecteur designait le lab, qui porte un lien `OPS-Technolibre -> ../OPS-Technolibre` que le motif traversait. Un lien vers un frere n'est pas un contenu. Trois cibles : `make genome` (constater), `genome-inscrire` (ecrire la parente), `genome-verifier` (tient-elle encore ?). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
231 lines
9.4 KiB
Python
231 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
|
|
|
|
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 Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")).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())
|