diff --git a/docs/audit/preuve-2026-09-08.md b/docs/audit/preuve-2026-09-08.md index 6a76186..e3126de 100644 --- a/docs/audit/preuve-2026-09-08.md +++ b/docs/audit/preuve-2026-09-08.md @@ -14,7 +14,7 @@ | # | Preuve | Affirmations | Statut | Detail | |---|---|---|---|---| | P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  | -| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee | +| P02 | Tests unitaires (inventaire, raser, ecriture atomique) | — | ✅ OK | >>> l'ecriture du plan est atomique, eprouvee contre son propre controle. | | P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. | | P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | | | P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | | diff --git a/scripts/applications.py b/scripts/applications.py index 8b8e9fa..54ec7be 100644 --- a/scripts/applications.py +++ b/scripts/applications.py @@ -13,6 +13,7 @@ import yaml from inventory_gui import charger_yaml, liste_hotes from inventory_rules import ( + ecriture_atomique, bases_de_application, chaine_connexion, charger_applications, @@ -48,7 +49,7 @@ def ecrire(registre: dict) -> None: "# Une VM peut porter plusieurs applications ; une base se lie a une application.\n" "---\n" ) - with FICHIER.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(FICHIER) as fichier: fichier.write(entete) yaml.safe_dump({"applications": registre.get("applications", {}) or {}}, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/scripts/bases_donnees.py b/scripts/bases_donnees.py index 4b46af8..67f10d6 100644 --- a/scripts/bases_donnees.py +++ b/scripts/bases_donnees.py @@ -12,6 +12,7 @@ from pathlib import Path import yaml from inventory_rules import ( + ecriture_atomique, instance_courante, chaine_connexion, charger_applications, @@ -37,7 +38,7 @@ def ecrire(registre: dict) -> None: "serveurs_bd": registre.get("serveurs_bd", {}) or {}, "bases_donnees": registre.get("bases_donnees", {}) or {}, } - with FICHIER.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(FICHIER) as fichier: fichier.write(entete) yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/scripts/config_proxmox.py b/scripts/config_proxmox.py index 613f4f0..adc1e07 100644 --- a/scripts/config_proxmox.py +++ b/scripts/config_proxmox.py @@ -11,7 +11,7 @@ import sys import yaml -from inventory_rules import instance_courante, inventaire_force +from inventory_rules import ecriture_atomique, instance_courante, inventaire_force RACINE = Path(__file__).resolve().parents[1] @@ -99,8 +99,7 @@ def charger_yaml(path: Path) -> dict: def ecrire_yaml(path: Path, data: dict) -> None: - path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write("---\n") fichier.write("# Parametres non sensibles pour les operations Proxmox.\n") fichier.write("# Les secrets vont dans la voûte unique group_vars/all/vault.yml.\n\n") diff --git a/scripts/genome.py b/scripts/genome.py index f1eaff4..de35922 100644 --- a/scripts/genome.py +++ b/scripts/genome.py @@ -39,7 +39,7 @@ RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) import underlay as underlay_mod # noqa: E402 -from inventory_rules import instance_courante # noqa: E402 +from inventory_rules import ecriture_atomique, instance_courante # noqa: E402 FICHIER_PARENTE = "parente.yml" @@ -168,7 +168,7 @@ def inscrire() -> int: "# 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: + with ecriture_atomique(cible) as f: f.write(entete) yaml.safe_dump(contenu, f, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/scripts/instancier.py b/scripts/instancier.py index 6307baa..197115b 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -30,6 +30,7 @@ import yaml import underlay as underlay_mod # noqa: E402 from devis_sdn import vnet_de # noqa: E402 from inventory_rules import ( + ecriture_atomique, charger_applications, charger_domaines, charger_nomenclature, @@ -324,7 +325,7 @@ def ecrire(path: Path = GENERE) -> None: entete = ("# Inventaire GENERE depuis le plan (make instancier / instancier-appliquer).\n" "# NE PAS editer a la main : edite instance/plan/serveurs.yml + instance/plan/applications.yml.\n" "# Source : instance/plan/serveurs.yml + instance/plan/applications.yml + instance/plan/nomenclature.yml.\n") - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump(generer(), fichier, default_flow_style=False, sort_keys=True, allow_unicode=True) diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index f54317d..1cd0605 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -24,6 +24,7 @@ import devis_reseau from inventory_rules import instance_courante # noqa: E402 from inventory_rules import ( + ecriture_atomique, GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES, GROUPES_ETAT_HOTE, @@ -276,7 +277,7 @@ def charger_yaml(path: Path) -> dict: def ecrire_yaml(path: Path, data: dict) -> None: - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: yaml.safe_dump(data, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) @@ -695,7 +696,7 @@ def _ecrire_intrants_fichier(path: Path, valeurs: dict, entete: str) -> None: path.write_text(_fusion_chirurgicale(path.read_text(encoding="utf-8"), valeurs), encoding="utf-8") return - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump(valeurs, fichier, default_flow_style=False, sort_keys=True, allow_unicode=True) @@ -846,7 +847,7 @@ def ecrire_bases(path: Path, registre: dict) -> None: "serveurs_bd": registre.get("serveurs_bd", {}) or {}, "bases_donnees": registre.get("bases_donnees", {}) or {}, } - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump(contenu, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) @@ -860,7 +861,7 @@ def ecrire_applications(path: Path, registre: dict) -> None: "# les roles acceptes sont ceux de roles//meta/liens.yml.\n" "---\n" ) - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump({"applications": registre.get("applications", {}) or {}}, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) @@ -873,7 +874,7 @@ def ecrire_serveurs(path: Path, registre: dict) -> None: "# VMID/IP/VLAN/passerelle sont DERIVES de la fonction via instance/plan/nomenclature.yml.\n" "---\n" ) - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump({"serveurs": registre.get("serveurs", {}) or {}}, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) @@ -886,7 +887,7 @@ def ecrire_domaines(path: Path, registre: dict) -> None: "# autorite : primaire-cache | auto-heberge | delegue (label descriptif).\n" "---\n" ) - with path.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(path) as fichier: fichier.write(entete) yaml.safe_dump({"domaines_publics": registre.get("domaines_publics", {}) or {}}, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) @@ -2978,6 +2979,24 @@ class Gestionnaire(BaseHTTPRequestHandler): "creer": "creer-vm"}.get(mode, "verifier-deploiement") env = dict(os.environ, PYTHONUNBUFFERED="1", ANSIBLE_FORCE_COLOR="0") if vault: + # LE MOT DE PASSE SAISI S'AJOUTE AUX CLES, IL NE LES REMPLACE PAS. + # + # Verifie empiriquement le 2026-09-08, contre deux voutes jetables a mots + # de passe distincts : `ANSIBLE_VAULT_PASSWORD_FILE` et + # `ANSIBLE_VAULT_IDENTITY_LIST` se CUMULENT — Ansible essaie tous les + # secrets, et un PASSWORD_FILE errone n'empeche rien. + # + # Ca compte depuis « une voute, une cle » (2026-08-28) : creer une VM + # ouvre DEUX voutes dans la meme execution — celle du tenant et + # `underlay.vault.yml`. Un seul mot de passe ne pourrait pas les ouvrir + # toutes les deux. Ce qui sauve ce chemin, c'est que `make` calcule + # `ANSIBLE_VAULT_IDENTITY_LIST` par-dessus (Makefile, `?=`), et que les + # deux coexistent dans l'environnement de la recette. + # + # NE PAS « SIMPLIFIER » EN RETIRANT L'UN DES DEUX. Le PASSWORD_FILE sert + # le cas ou la cle n'est pas sur la machine (un runner qui n'a que la + # sienne) ; l'IDENTITY_LIST sert le poste de l'exploitant, qui les a + # toutes. Chacun couvre ce que l'autre ne couvre pas. fd, fichier_vault = tempfile.mkstemp(prefix="setops-vault-") # 0600 par defaut os.write(fd, str(vault).encode("utf-8")) os.close(fd) diff --git a/scripts/inventory_host.py b/scripts/inventory_host.py index 0290d81..70848cb 100644 --- a/scripts/inventory_host.py +++ b/scripts/inventory_host.py @@ -10,6 +10,7 @@ import sys import yaml from inventory_rules import ( + ecriture_atomique, variable_effective, GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES, @@ -35,7 +36,7 @@ def charger_inventaire(path: Path) -> dict: def ecrire_inventaire(path: Path, data: dict) -> None: - with path.open("w", encoding="utf-8") as inventory_file: + with ecriture_atomique(path) as inventory_file: yaml.safe_dump( data, inventory_file, diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 3452281..c4becff 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -5,6 +5,8 @@ from __future__ import annotations import os import re +import tempfile +from contextlib import contextmanager from pathlib import Path import yaml @@ -22,6 +24,58 @@ ORDRE_INVENTAIRE = ("principal", "production") ORDRE_INVENTAIRE_MODELE = ("lab", "principal", "production") +@contextmanager +def ecriture_atomique(path: Path, encoding: str = "utf-8"): + """Ouvre `path` en ecriture de facon ATOMIQUE : tout, ou rien. + + POURQUOI, ET POURQUOI ICI (2026-09-08). Douze sites du moteur ecrivaient le plan par + `path.open("w")`. Cette forme TRONQUE le fichier avant d'ecrire : entre les deux, il + est VIDE. Une exception dans `yaml.safe_dump`, un disque plein, un Ctrl-C — et + `instance/plan/serveurs.yml` reste tronque. + + L'asymetrie est ce qui rend le defaut grave : `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. + + Le remede tient en deux gestes : ecrire dans un temporaire du MEME dossier, puis + `os.replace()` — atomique sur POSIX (meme systeme de fichiers, donc simple rename). + Le lecteur voit l'ancien fichier, puis le nouveau. Jamais rien entre les deux. + + POSE DANS CE MODULE, ET PAS DANS CHAQUE APPELANT. Les sept ecrivains l'importent + deja. Douze copies d'un meme geste ne vieillissent pas ensemble — c'est exactement la + lecon des neuf resolutions d'instance que **P41** garde depuis. Une source, pas douze. + + Usage : + + with ecriture_atomique(chemin) as f: + f.write(entete) + yaml.safe_dump(donnees, f) + """ + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + # Le temporaire vit DANS le dossier cible : `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. + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding=encoding) as fichier: + yield fichier + fichier.flush() + # Sans `fsync`, le rename peut atteindre le disque AVANT le contenu : au + # retour d'une coupure brutale, on aurait un fichier neuf et vide — le defaut + # qu'on vient de fermer, deplace d'un cran. + os.fsync(fichier.fileno()) + # Conserver les droits d'un fichier existant : `mkstemp` cree en 0600, et le plan + # est lisible par le groupe sur les runners. + if path.exists(): + os.chmod(tmp, path.stat().st_mode & 0o7777) + os.replace(tmp, path) + tmp = None + finally: + if tmp is not None and os.path.exists(tmp): + os.unlink(tmp) + + def dependances_groupes(racine: Path | None = None) -> dict: """Le registre des dependances causales entre groupes (docs/dependances-groupes.yml).""" f = (racine or RACINE_DEPOT) / "docs" / "dependances-groupes.yml" diff --git a/scripts/prouver.py b/scripts/prouver.py index 7bdaf5b..2d0085b 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -2634,9 +2634,10 @@ def preuve_documentation_outillage() -> tuple[bool, str]: PREUVES: list[dict] = [ {"id": "P01", "titre": "Lint (ansible-lint)", "refs": ["AFF-006"], "cmds": [["ansible-lint", "-q"]]}, - {"id": "P02", "titre": "Tests unitaires (inventory_host)", "refs": [], + {"id": "P02", "titre": "Tests unitaires (inventaire, raser, ecriture atomique)", "refs": [], "cmds": [[sys.executable, "scripts/tests/test_inventory_host.py"], - [sys.executable, "scripts/tests/test_raser.py"]]}, + [sys.executable, "scripts/tests/test_raser.py"], + [sys.executable, "scripts/tests/test_ecriture_atomique.py"]]}, {"id": "P03", "titre": "Diff-vide du plan — TOUTES les instances", "refs": ["AFF-001", "AFF-004", "AFF-030", "AFF-031", "AFF-032"], "func": preuve_diff_vide_toutes_instances}, {"id": "P04", "titre": "Groupes <-> playbooks homonymes", "refs": ["AFF-008"], diff --git a/scripts/serveurs.py b/scripts/serveurs.py index e39fea3..5d4456c 100644 --- a/scripts/serveurs.py +++ b/scripts/serveurs.py @@ -25,6 +25,7 @@ import yaml from inventory_gui import charger_yaml, liste_hotes from inventory_rules import ( + ecriture_atomique, charger_nomenclature, charger_serveurs, fonction_seq, @@ -57,7 +58,7 @@ def ecrire(registre: dict) -> None: "# instance/plan/nomenclature.yml (jamais stockes ici).\n" "---\n" ) - with FICHIER.open("w", encoding="utf-8") as fichier: + with ecriture_atomique(FICHIER) as fichier: fichier.write(entete) yaml.safe_dump({"serveurs": registre.get("serveurs", {}) or {}}, fichier, default_flow_style=False, sort_keys=False, allow_unicode=True) diff --git a/scripts/tests/test_ecriture_atomique.py b/scripts/tests/test_ecriture_atomique.py new file mode 100644 index 0000000..a49ab7b --- /dev/null +++ b/scripts/tests/test_ecriture_atomique.py @@ -0,0 +1,83 @@ +"""L'ecriture du plan est ATOMIQUE : une panne en cours d'ecriture ne tronque rien. + +POURQUOI CE TEST EXISTE. Jusqu'au 2026-09-08, treize sites du moteur ecrivaient les +registres par `path.open("w")` — une forme qui TRONQUE le fichier avant d'ecrire. Entre +les deux, il est vide. Une exception dans `yaml.safe_dump`, un disque plein, un Ctrl-C, et +`instance/plan/serveurs.yml` reste tronque. + +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. + +CE TEST PORTE SON PROPRE CONTROLE NEGATIF, et c'est le point. Il rejoue d'abord l'ANCIENNE +forme pour montrer qu'elle DETRUIT — sans quoi le succes de la nouvelle ne prouverait +rien. Une garantie qu'on n'a jamais vue echouer n'est pas une garantie, c'est une +habitude. + +Aucun fichier reel n'est touche : tout se passe dans un dossier temporaire. +""" +import pathlib +import sys +import tempfile + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +from inventory_rules import ecriture_atomique # noqa: E402 + +CONTENU = "serveurs:\n infra-pki-01:\n fonction: infra-pki\n" + + +def _neuf() -> pathlib.Path: + d = pathlib.Path(tempfile.mkdtemp(prefix="setops-atomique-")) + f = d / "serveurs.yml" + f.write_text(CONTENU, encoding="utf-8") + return f + + +# --- CONTROLE NEGATIF : l'ancienne forme detruit-elle vraiment ? ----------------------- +f = _neuf() +try: + with f.open("w", encoding="utf-8") as fichier: + fichier.write("moitie...") + raise RuntimeError("panne simulee au milieu de l'ecriture") +except RuntimeError: + pass +perdu = f.read_text(encoding="utf-8") +print(f">>> ancienne forme, apres panne : {perdu!r}") +assert perdu != CONTENU, "LE CONTROLE NE CONTROLE RIEN : l'ancienne forme n'a rien casse" +print(">>> le controle mord : `open(\"w\")` laisse bien un fichier mutile") + +# --- LA GARANTIE : la nouvelle forme survit-elle a la meme panne ? --------------------- +f = _neuf() +try: + with ecriture_atomique(f) as fichier: + fichier.write("moitie...") + raise RuntimeError("panne simulee au milieu de l'ecriture") +except RuntimeError: + pass +assert f.read_text(encoding="utf-8") == CONTENU, "LE FICHIER A ETE TRONQUE" +print(">>> apres la meme panne, le fichier est INTACT") + +# Aucun residu : un `.serveurs.yml.xxxx.tmp` oublie finirait par etre committe. +restes = [p.name for p in f.parent.iterdir() if p.name != f.name] +assert not restes, f"RESIDU LAISSE DERRIERE : {restes}" +print(">>> aucun temporaire laisse dans le dossier") + +# --- L'ecriture normale remplace bien le contenu --------------------------------------- +f = _neuf() +with ecriture_atomique(f) as fichier: + fichier.write("serveurs: {}\n") +assert f.read_text(encoding="utf-8") == "serveurs: {}\n", "L'ECRITURE NORMALE A ECHOUE" +print(">>> l'ecriture normale remplace le contenu") + +# --- LES DROITS SURVIVENT --------------------------------------------------------------- +# `mkstemp` cree en 0600 ; le plan est en 0664, lisible par le groupe sur les runners. +# Sans report explicite des droits, le correctif rendait le plan illisible pour eux. +f = _neuf() +f.chmod(0o664) +with ecriture_atomique(f) as fichier: + fichier.write(CONTENU) +mode = f.stat().st_mode & 0o777 +assert mode == 0o664, f"DROITS PERDUS : {oct(mode)} au lieu de 0o664" +print(">>> les droits du fichier sont conserves (0664)") + +print("\n>>> l'ecriture du plan est atomique, eprouvee contre son propre controle.")