genome : les quatre depots sans lesquels un ecosysteme ne renait pas
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>
This commit is contained in:
parent
643604c3e3
commit
742bcbf4b0
2 changed files with 244 additions and 0 deletions
13
Makefile
13
Makefile
|
|
@ -249,6 +249,19 @@ verifier: lint test inventaire-verifier site-verifier flux-verifier syntaxe ## R
|
|||
# `make ci` passait sur un depot nu et echouait chez un exploitant dont le symlink
|
||||
# `underlay.yml` designe une autre fabric — quatre hotes « en ecart », pour un ecart
|
||||
# fabrique par la commande elle-meme.
|
||||
# LE GENOME : les depots sans lesquels cet ecosysteme ne peut pas etre refabrique, et la
|
||||
# PARENTE qu'il inscrit (de quel moteur il descend, a quel commit, sous quelle etiquette
|
||||
# signee). Perdre les VM coute du temps ; perdre ces depots coute l'ecosysteme.
|
||||
.PHONY: genome genome-inscrire genome-verifier
|
||||
genome: ## Les depots requis a la reproduction de cet ecosysteme, et leur etat
|
||||
python3 scripts/genome.py lister
|
||||
|
||||
genome-inscrire: ## Inscrit la parente dans l'instance (instance/parente.yml)
|
||||
python3 scripts/genome.py inscrire
|
||||
|
||||
genome-verifier: ## La parente inscrite tient-elle encore ? (code de sortie)
|
||||
python3 scripts/genome.py verifier
|
||||
|
||||
.PHONY: ci
|
||||
ci: ## Verifie le depot comme la CI : modele public monte a l'ecart, sans toucher ton instance
|
||||
@set -e ; \
|
||||
|
|
|
|||
231
scripts/genome.py
Normal file
231
scripts/genome.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
#!/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())
|
||||
Loading…
Reference in a new issue