From 742bcbf4b0e1322bfa5e0dee14c94bcad6898c53 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Fri, 21 Aug 2026 12:46:42 -0400 Subject: [PATCH] genome : les quatre depots sans lesquels un ecosysteme ne renait pas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Makefile | 13 +++ scripts/genome.py | 231 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 244 insertions(+) create mode 100644 scripts/genome.py diff --git a/Makefile b/Makefile index 10a3477..a4fcc20 100644 --- a/Makefile +++ b/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 ; \ diff --git a/scripts/genome.py b/scripts/genome.py new file mode 100644 index 0000000..012512b --- /dev/null +++ b/scripts/genome.py @@ -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 /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 + (`/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 /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())