#!/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 from inventory_rules import ecriture_atomique, 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 (`/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 ecriture_atomique(cible) 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())