Some checks are pending
verifier / verifier (push) Waiting to run
`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
232 lines
9.4 KiB
Python
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())
|