Compare commits
2 commits
cbdc6c523d
...
6077b179af
| Author | SHA1 | Date | |
|---|---|---|---|
| 6077b179af | |||
| 6da1032ceb |
16 changed files with 245 additions and 32 deletions
|
|
@ -53,7 +53,11 @@ L’inventaire Ansible est **généré** depuis le plan.
|
|||
|
||||
Référence complète : **`docs/plan-et-generation.md`**.
|
||||
|
||||
**Plan de contrôle gelé en périmètre** : le GUI, le générateur, l'IPAM et la modélisation sont volontairement *maison* et **souverains**, mais leur périmètre est gelé. Ne pas y ajouter de fonctionnalités de type NetBox/AWX (RBAC, historique d'audit, détection de conflits IPAM, API riche) : le besoin réel d'une de ces fonctions est le **signal d'adopter l'outil mûr correspondant** (NetBox pour la source de vérité, AWX pour l'exécution), pas de le réimplémenter. Décision et seuils : **`docs/positionnement.md`**.
|
||||
**Plan de contrôle gelé en périmètre** : le GUI, le générateur et la modélisation sont volontairement *maison* et **souverains**, mais leur périmètre est gelé. Ne pas y ajouter de fonctionnalités de type NetBox/AWX — **historique d'audit applicatif, API riche, source de vérité partagée entre organisations** : le besoin réel d'une de ces fonctions est le **signal d'adopter l'outil mûr correspondant** (NetBox pour la source de vérité, AWX pour l'exécution), pas de le réimplémenter.
|
||||
|
||||
**Avant d'invoquer un seuil, vérifier qu'il n'est pas déjà couvert autrement (D-84).** Deux exemples cités ici jusqu'au 2026-09-08 ne tenaient plus : le **RBAC** est assuré par la séparation *cryptographique* des voûtes et des runners — l'adopter d'AWX serait régresser ; la **détection de conflits IPAM** est sans objet, rien ne s'alloue et cinq preuves (P20, P21, P23, P28, P33) tiennent déjà ce qu'un IPAM vérifierait. Une carte des seuils fausse ne fait pas perdre du temps : elle fait **franchir un seuil qui ne l'est pas**.
|
||||
|
||||
**Le gel porte sur les FONCTIONS, jamais sur les VUES.** Montrer à l'écran ce que le moteur sait déjà — l'écart d'un devis, l'état du diff, le périmètre sur lequel un ✅ a porté — ne franchit aucun seuil. Décision et seuils : **`docs/positionnement.md`**.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
| # | Preuve | Affirmations | Statut | Detail |
|
||||
|---|---|---|---|---|
|
||||
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK | [0m[0m |
|
||||
| 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 | |
|
||||
|
|
@ -72,7 +72,7 @@
|
|||
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (60 preuves, 65 roles, 40 groupes). |
|
||||
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
|
||||
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
|
||||
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `c9d31e9` (publie le 2026-09-08). |
|
||||
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ✅ OK | Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis `e2935ed` (publie le 2026-09-08). |
|
||||
|
||||
## Couverture des affirmations ✅ du registre
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ README de rôles). Cette page comble ces deux trous.
|
|||
| documents | 39 | `docs/*.md` |
|
||||
| pièces d'audit | 38 | `docs/audit/*` |
|
||||
| unités de wiki | 27 | `wiki/*.md` |
|
||||
| décisions en vigueur | 80 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||
| décisions en vigueur | 81 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
|
||||
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document |
|
||||
|
||||
## 1. À lire d'abord (dans l'ordre)
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ sont les seules vérifiables.
|
|||
|---|---|---|---|---|
|
||||
| **D-82** | **Patient 0 n'est le parent de personne.** Il est la **mise en œuvre de référence** du modèle `origine` — le plus petit écosystème complet — et un pair de la famille du génome, pas sa racine | trois faits l'ont retiré un par un : D-81 a donné l'autorité du génome à la forge du SITE (son dernier lecteur corrigé le 2026-08-26, Technolibre le 08-31) ; le dénominateur commun vit dans les modèles depuis le 08-24 ; et **l'ancêtre était locataire de son enfant** — index 29 sur la fabric de `SITE-Chezlepro`, qui descend de lui. Ce qu'il devait éliminer — le SPOF `eregion`, hors flotte — n'a PAS été éliminé mais **promu** : le poste y pousse, la forge du site en tire. Cette dette appartient désormais au SITE, et la nommer est le minimum : *un objectif qu'on abandonne sans le dire devient un objectif qu'on croit atteint* | `OPS-Patient0/README.md`, `docs/filiation-emancipation.md` | — |
|
||||
| **D-83** | **Patient 0 a été retiré** — ses machines n'existent plus (constaté le 2026-09-06) | D-82 lui avait laissé une raison d'être : la mise en œuvre de référence du modèle `origine`, et un **témoin** de plus du génome. Le retrait solde la première et **abaisse la seconde de trois copies vivantes à deux** (`eregion`, la forge du site). Ce qu'il devait éliminer — le SPOF `eregion` — reste entier, et sans lui il n'y a plus de miroir indépendant pour l'absorber. **Son plan reste sur disque et la fédération lui réserve toujours l'index 29** : tant que ce n'est pas tranché, le site ouvre SSH, apt, DNS et HTTPS à `10.29.0.0/16` — un périmètre vide | `OPS-Patient0/`, `SITE-Chezlepro/flux-genere/` | **P21** (index), **P23** |
|
||||
| **D-84** | **Le plan de contrôle reste gelé — c'est la CARTE DES SEUILS qui était fausse** | La question « et si on retirait le gel ? » a mis à l'épreuve les cinq seuils de `positionnement.md`, et deux ne tenaient pas. **RBAC** : couvert depuis que trois classes d'acteurs aux pouvoirs disjoints existent — poste, runner de site, runners de tenant — séparés **cryptographiquement** (une voûte, une clé, 2026-08-28) et non par une table de permissions qu'une faille applicative contournerait ; adopter AWX pour ce besoin serait **régresser**. **IPAM** : sans objet par construction — rien ne s'alloue, tout dérive du seed, et P20/P21/P23/P28/P33 tiennent déjà ce qu'un IPAM vérifierait *a posteriori*. Les deux lignes sont retirées du tableau : les garder aurait fait adopter un outil pour un besoin déjà rempli. **Et un seuil manquait** — l'**émancipation** : le GUI est mono-utilisateur (`127.0.0.1` + jeton), or la trajectoire mène à plusieurs humains aux portées disjointes, sur des machines qui ne sont pas les nôtres. Ce seuil n'appelle pas AWX, il appelle une décision non prise. *Un seuil qu'on ne nomme pas est un seuil qu'on franchit sans le voir.* Corollaire consigné : le gel porte sur les **fonctions**, jamais sur les **vues** — montrer à l'écran ce que le moteur sait déjà ne franchit aucun seuil | `positionnement.md` §3, §4, §5 | — |
|
||||
| **D-81** | **La forge du SITE fait autorité pour le génome.** Toute autre copie — y compris celle d'où le moteur a été poussé jusqu'ici — est un **miroir**. Le poste de l'exploitant ne route pas jusqu'à elle : c'est le **runner du site** qui publie, par `make genome-pousser` | un écosystème se reproduit depuis la forge de son site : c'est de là qu'il clone son moteur, ses plans, ses modèles. Si l'autorité est ailleurs, cette forge devient un cache qu'on croit à jour — et le 2026-08-26 elle était **quatre commits en arrière** sans que rien ne le signale, dont le correctif qui désarme le pare-feu Proxmox. **Un écosystème qui se reproduit depuis une forge en retard reproduit ses défauts.** Le poste n'a de patte que sur l'administration, et on ne perce pas de chemin pour lui : le runner existe pour ce travail | `playbooks/maintenance/genome_pousser.yml`, `scripts/genome_colis.py`, `Makefile` §genome-pousser | — |
|
||||
| **D-13** | Un **hébergeur** sert plusieurs **tenants** et a son tenant par défaut | Chezlepro est les deux à la fois, ce qui masquait la distinction | `frontiere-opnsense.md` §2 | — |
|
||||
| **D-14** | `underlay.yml` appartient à l'**hébergeur**, monté par symlink | ce sont ses commutateurs, ses câbles ; le moteur est générique, un tenant n'en possède pas | `sdn-evpn.md`, `underlay.yml.example` | — |
|
||||
|
|
|
|||
|
|
@ -59,13 +59,29 @@ Raisons assumées :
|
|||
|
||||
## 3. Ce que le maison NE fait PAS (et que les outils mûrs ont)
|
||||
|
||||
À garder en tête honnêtement — ce sont des fonctions qu'on n'a pas, pas des bugs :
|
||||
> **Cette liste a été corrigée le 2026-09-08.** Deux de ses cinq lignes n'étaient plus
|
||||
> vraies : elles décrivaient des manques que le dépôt a comblés **autrement**, et les
|
||||
> garder en « manques » aurait fini par justifier d'adopter un outil pour un besoin déjà
|
||||
> couvert. Une carte des seuils qui se trompe ne fait pas perdre du temps : elle fait
|
||||
> franchir un seuil qui ne l'est pas.
|
||||
|
||||
- historique/audit des changements (au-delà de `git`) ;
|
||||
- RBAC multi-utilisateurs ;
|
||||
- détection de conflits IPAM, réservations, gestion d'adresses à grande échelle ;
|
||||
- webhooks / intégrations tierces, API riche (REST/GraphQL) ;
|
||||
- écosystème de plugins, communauté, correctifs de sécurité maintenus par d'autres.
|
||||
Ce qui manque vraiment :
|
||||
|
||||
- **historique/audit des changements** au-delà de `git` — un journal applicatif, avec ses
|
||||
auteurs et ses motifs, que `git log` ne rend qu'imparfaitement ;
|
||||
- **webhooks / intégrations tierces, API riche** (REST/GraphQL) — il n'y en a aucune, et
|
||||
aucun consommateur ne la réclame aujourd'hui ;
|
||||
- **écosystème de plugins, communauté, correctifs de sécurité maintenus par d'autres.**
|
||||
C'est le seul point qui joue contre le maison **en permanence** : il ne dépend d'aucun
|
||||
seuil, il s'aggrave tout seul avec le temps. À relire chaque année, pas quand un besoin
|
||||
apparaît.
|
||||
|
||||
Ce qui était listé comme manquant et ne l'est plus :
|
||||
|
||||
| Ancien manque | Ce qui le couvre, et pourquoi c'est différent |
|
||||
|---|---|
|
||||
| ~~RBAC multi-utilisateurs~~ | **Résolu, et par un mécanisme plus fort.** Trois classes d'acteurs aux pouvoirs disjoints existent — le poste de l'exploitant, le **runner de site** (matérialiser ; ne rentre jamais chez un tenant) et les **runners de tenant** (configurer). La séparation est **cryptographique** — une voûte, une clé (2026-08-28) — et non applicative : c'est la *présence des fichiers* qui borne le pouvoir, jamais une table de permissions qu'une faille de l'application contournerait. |
|
||||
| ~~Détection de conflits IPAM, réservations~~ | **Sans objet par construction.** Un IPAM sert à *allouer* ; ici rien ne s'alloue, tout dérive du seed `index`. Et cinq preuves tiennent déjà ce qu'un IPAM vérifierait : **P20** (aucun adressage stocké), **P21** (collisions d'index), **P23** (chevauchement d'underlay), **P28** (pools), **P33** (ports co-localisés). Adopter un IPAM remplacerait une propriété *par construction* par un contrôle *a posteriori*. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -81,16 +97,38 @@ ne lui ajoute pas de fonctionnalités de type NetBox/AWX.
|
|||
Adopter l'outil du marché — **sans réécrire**, en branchant Ansible/notre GUI
|
||||
par-dessus — dès qu'un de ces besoins devient réel :
|
||||
|
||||
> **Ce tableau a été écrit avant que les runners existent**, et deux de ses lignes visaient
|
||||
> des besoins depuis couverts (§3). Corrigé le 2026-09-08.
|
||||
|
||||
| Besoin qui apparaît | Adopter |
|
||||
| --- | --- |
|
||||
| RBAC, plusieurs opérateurs, historique d'audit, secrets/credentials centralisés | **AWX** (exécution) |
|
||||
| IPAM sérieux, détection de conflits, source de vérité partagée, API/webhooks | **NetBox** (et `nb_inventory` remplace notre générateur) |
|
||||
| **Plusieurs HUMAINS, aux portées disjointes, sur des machines qui ne sont pas les tiennes** | à trancher — voir ci-dessous, c'est le seuil qui approche et qu'aucune ligne ne nommait |
|
||||
| Historique d'audit applicatif, secrets/credentials centralisés pour des tiers | **AWX** (exécution) |
|
||||
| Source de vérité **partagée** entre organisations, API/webhooks avec des consommateurs réels | **NetBox** (et `nb_inventory` remplacerait notre générateur) |
|
||||
| Catalogue de services / portail développeur, ownership, scaffolding | **Backstage** |
|
||||
| Provisionnement de VM déclaratif et reproductible à plus grande échelle | **Terraform** (Proxmox) ou **NixOS + Colmena** |
|
||||
|
||||
*Retiré de ce tableau : « RBAC » et « IPAM sérieux, détection de conflits ». Les deux sont
|
||||
couverts (§3), et les garder ici aurait fait adopter un outil pour un besoin déjà rempli.*
|
||||
|
||||
### Le seuil qui approche, et qu'aucune ligne ne nommait
|
||||
|
||||
**L'émancipation.** Le GUI écoute sur `127.0.0.1` avec un jeton par session : un modèle
|
||||
**mono-utilisateur**, parfait tant que l'exploitant est une personne à son poste. Le jour où
|
||||
un tenant est exploité par **son** organisation — c'est la trajectoire de
|
||||
[`filiation-emancipation.md`](filiation-emancipation.md), et l'offre destinée aux OBNL y
|
||||
mène — il y a plusieurs humains, aux portées disjointes, sur des machines qui ne sont pas
|
||||
les tiennes.
|
||||
|
||||
Ce n'est **pas** AWX qu'appelle ce seuil : les runners portent déjà la séparation des
|
||||
pouvoirs, cryptographiquement. Ce qu'il appelle, c'est une décision sur la **façon dont le
|
||||
GUI s'ouvre à quelqu'un d'autre** — et elle n'est pas prise. La nommer ici est le minimum :
|
||||
*un seuil qu'on ne nomme pas est un seuil qu'on franchit sans le voir.*
|
||||
|
||||
**Test simple** : si on se surprend à vouloir réimplémenter une de ces fonctions
|
||||
dans Set-OPS, c'est le signal d'adopter l'outil correspondant plutôt que de
|
||||
prolonger le maison.
|
||||
prolonger le maison. **Mais vérifier d'abord que le besoin n'est pas déjà couvert
|
||||
autrement** — c'est exactement l'erreur que ce tableau portait.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -98,6 +136,15 @@ prolonger le maison.
|
|||
|
||||
- On **garde** le plan de contrôle maison : il fonctionne, il est souverain et
|
||||
bien dimensionné pour aujourd'hui. Pas de réécriture sur NetBox « par principe ».
|
||||
- On **gèle** son périmètre fonctionnel (voir §4).
|
||||
- On **gèle** son périmètre fonctionnel (voir §4). **Le 2026-09-08, c'est la carte des
|
||||
seuils qui a été corrigée, pas le gel qui a été levé** — la question « et si on retirait
|
||||
le gel ? » a montré que deux seuils étaient mal posés, pas que le gel était de trop.
|
||||
- On **réévalue** à l'échéance d'un seuil ci-dessus, ou si la charge de
|
||||
maintenance du maison dépasse le coût d'héberger l'outil mûr.
|
||||
|
||||
> **Ce que le gel n'interdit pas, et qu'on confond souvent avec lui.** Il porte sur les
|
||||
> *fonctions de type NetBox/AWX*, pas sur les **vues**. Montrer à l'écran ce que le moteur
|
||||
> sait déjà — l'écart des dix devis, l'état du diff entre « Sauvegarder » et « Appliquer »,
|
||||
> le périmètre sur lequel un ✅ a porté, les témoins du génome et lequel a décroché — ne
|
||||
> franchit aucun seuil : rien de tout cela n'existe dans NetBox ou AWX, parce que rien de
|
||||
> tout cela n'existe hors de ce modèle.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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/<groupe>/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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
83
scripts/tests/test_ecriture_atomique.py
Normal file
83
scripts/tests/test_ecriture_atomique.py
Normal file
|
|
@ -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.")
|
||||
Loading…
Reference in a new issue