Set-OPS-Public/scripts/genome.py
Daniel Allaire 6077b179af
Some checks are pending
verifier / verifier (push) Waiting to run
plan : l ecriture des registres devient atomique — tout, ou rien
`path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide.
Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et
instance/plan/serveurs.yml reste mutile.

L asymetrie fait la gravite : hosts.yml se regenere d un
make instancier-appliquer, le PLAN ne se regenere de rien. C est la source
unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe.

TREIZE SITES, UNE SEULE FONCTION

Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont
les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la
divergence que P41 garde depuis les neuf resolutions d instance. La fonction
vit donc dans inventory_rules.py, que les sept importaient deja. Une source,
pas douze.

TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT »

  temporaire dans le MEME dossier   os.replace n est atomique qu au sein d un
                                    meme systeme de fichiers ; un /tmp sur une
                                    autre partition casserait la garantie sans
                                    rien dire
  fsync AVANT le rename             sinon le renommage peut atteindre le disque
                                    avant le contenu : au retour d une coupure
                                    brutale, un fichier neuf et VIDE — le defaut
                                    qu on ferme, deplace d un cran
  report des droits                 mkstemp cree en 0600, le plan est en 0664 et
                                    doit rester lisible par le groupe sur les
                                    runners

LE TEST PORTE SON PROPRE CONTROLE NEGATIF

scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et
verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne
prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une
garantie qu on n a jamais vue echouer n est pas une garantie, c est une
habitude.

Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance
maintenant trois tests. Corrige au passage.

LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT

Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot
de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ».

Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les
vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un
PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une
recette make recoit reellement : le mot de passe saisi ET les cinq cles
calculees par voutes.py.

Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est
l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne
couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST
le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne
« simplifie » en retirant l un des deux.

make prouver : CONFORME, 59 OK, 0 echec, 1 saute.
make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00

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 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
(`<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 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 <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())