diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d67811..adbd1ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,63 @@ # CHANGELOG — Set-OPS +## 2026-08-22 — Neuf copies d'une même question, et la pièce qui manquait + +Cinq jours, cinq défauts, tous de la même famille : *quelle instance, quel inventaire ?* +Neuf modules portaient chacun leur réponse. + +| Découvert | Ce que la copie faisait | +|---|---| +| 18 août | **P03** comparait chaque instance à l'inventaire d'une **autre** | +| 19 août | `verifier_ports` codait `principal/` en dur ; `verifier_intrants` et `_frontiere_absente` lisaient le symlink au lieu de la variable | +| 20 août | `devis_placement` rendait un verdict **juste sur le mauvais tenant** | +| 22 août | **P35**, puis **P36** — la dixième, trouvée par la preuve elle-même | + +Aucune n'était une faute d'inattention. Chacune avait été écrite de bonne foi, à un +moment où le besoin semblait local. **C'est le mode de panne de la duplication : pas +l'erreur, mais la dérive** — invisible depuis l'intérieur d'un fichier, parce que chaque +copie a l'air correcte chez elle. + +### La résolution unique + +`inventory_rules` porte désormais `instance_courante()`, `inventaire_de()`, +`dossier_inventaire()` et `plan_de()`. Trois niveaux de repli, dont **le troisième +manquait à la moitié des copies** : un `hosts.yml` existant, puis un **répertoire** +existant — le cas d'une instance neuve, celui qui faisait échouer `make instancier` sur le +modèle public — puis le défaut. + +**Vingt-huit modules** y sont branchés. + +### Ce qui rend ce refactor sûr + +Avant de toucher quoi que ce soit, chaque module a été interrogé sur ce qu'il résolvait, +pour **les deux écosystèmes**. Après refactor, la même mesure : + +``` +17 modules × 2 instances → diff vide +``` + +Aucune résolution n'a changé. Le refactor est prouvé neutre, pas supposé tel. + +### P41, et ses trois exemptions + +La preuve échoue dès qu'un module réintroduit une copie. **Éprouvée en négatif** : une +copie replacée dans `genome.py` est signalée avec son numéro de ligne. + +Trois exemptions, nommées pour rester des choix : `instances.py` et `inventory_gui.py` +manipulent le **symlink lui-même** — c'est la bascule d'instance —, et `devis_opnsense` +lit délibérément quelle instance est **active** pour se situer dans la fédération. Ces +trois-là parlent du lien, pas de la résolution. + +Elle a d'ailleurs trouvé une dixième copie à sa première exécution : **P36**, dans le +fichier même qui l'héberge. + +`make verifier` : **41 OK, 0 échec, 0 sauté**. Lint vert. + +> **Une preuve qui trouve un défaut le jour où on l'écrit a payé son coût immédiatement.** +> Celle-ci en a trouvé un dixième, dans `prouver.py` — et dans ma propre docstring, qui +> contenait le motif qu'elle interdit. + + ## 2026-08-22 — Une forge n'a pas besoin d'un serveur de bases pour trois personnes Doute de l'exploitant en relisant patient 0 : *« je doute de la pertinence de pgsql. »* diff --git a/scripts/applications.py b/scripts/applications.py index d2b3008..8b8e9fa 100644 --- a/scripts/applications.py +++ b/scripts/applications.py @@ -19,28 +19,18 @@ from inventory_rules import ( charger_bases_donnees, charger_domaines, charger_serveurs, - inventaire_force, + instance_courante, + inventaire_de, valider_applications, ) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() FICHIER = INSTANCE / "plan/applications.yml" FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml" FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml" FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" -def _inventaire(instance: Path, *noms: str) -> Path: - forced = inventaire_force(instance) # refuse une cible hors de `instance` - if forced: - return forced - for nom in noms: - p = instance / "inventories" / nom / "hosts.yml" - if p.exists(): - return p - return instance / "inventories" / noms[0] / "hosts.yml" - - -INVENTAIRE = _inventaire(INSTANCE, "principal", "production") +INVENTAIRE = inventaire_de(INSTANCE) # Groupes 'serveurs_*' qui sont des capacites de SOCLE (sur toutes les VM), # pas des applications a part entiere. diff --git a/scripts/bases_donnees.py b/scripts/bases_donnees.py index 0c554ad..4b46af8 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 ( + instance_courante, chaine_connexion, charger_applications, charger_bases_donnees, @@ -19,7 +20,7 @@ from inventory_rules import ( ) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() FICHIER = INSTANCE / "plan/bases-donnees.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" diff --git a/scripts/config_proxmox.py b/scripts/config_proxmox.py index ae92112..613f4f0 100644 --- a/scripts/config_proxmox.py +++ b/scripts/config_proxmox.py @@ -11,11 +11,11 @@ import sys import yaml -from inventory_rules import inventaire_force +from inventory_rules import instance_courante, inventaire_force RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() def _inventaire_dir(instance: Path, *noms: str) -> Path: diff --git a/scripts/couverture_gui.py b/scripts/couverture_gui.py index 2f1efe5..15ff324 100644 --- a/scripts/couverture_gui.py +++ b/scripts/couverture_gui.py @@ -35,8 +35,10 @@ import yaml from inventory_gui import CHAMPS_ECRITS_PAR_GUI from modeles import decouvrir as decouvrir_modeles +from inventory_rules import instance_courante, instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() SOURCE_GUI = RACINE / "scripts" / "inventory_gui.py" # (fichier du plan, cle racine, nom du registre) — les registres tables-de-tables. diff --git a/scripts/devis_certificats.py b/scripts/devis_certificats.py index 450cbfd..ff710c5 100755 --- a/scripts/devis_certificats.py +++ b/scripts/devis_certificats.py @@ -28,8 +28,10 @@ import sys from datetime import datetime, timezone from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent -MOTIF = str(RACINE / "instance" / "devis-certificats.json.*") +MOTIF = str(instance_courante() / "devis-certificats.json.*") # Un certificat servi qui expire dans moins que ça n'a plus de marge : le service doit # etre recharge avant. Genereux par rapport au renouvellement (~14 min), serre par diff --git a/scripts/devis_courriel.py b/scripts/devis_courriel.py index 5aaf3ac..cb31e99 100755 --- a/scripts/devis_courriel.py +++ b/scripts/devis_courriel.py @@ -19,8 +19,10 @@ import re import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent -MOTIF = str(RACINE / "instance" / "devis-courriel.json.*") +MOTIF = str(instance_courante() / "devis-courriel.json.*") def _valeur(brut: str, cle: str) -> str: diff --git a/scripts/devis_expositions.py b/scripts/devis_expositions.py index 2a20f98..c38301b 100755 --- a/scripts/devis_expositions.py +++ b/scripts/devis_expositions.py @@ -24,8 +24,10 @@ import json import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent -RELEVE = RACINE / "instance" / "devis-expositions.json" +RELEVE = instance_courante() / "devis-expositions.json" # Un service VIVANT peut legitimement repondre autre chose que 200 : une redirection vers # l'IdP, ou un 401/403 quand l'authentification est exigee. Ce qui compte est qu'il ait diff --git a/scripts/devis_frontiere.py b/scripts/devis_frontiere.py index 54a1aad..46a298e 100644 --- a/scripts/devis_frontiere.py +++ b/scripts/devis_frontiere.py @@ -28,6 +28,8 @@ import subprocess import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent # Adresse de controle : documentee comme non routable et attribuee a personne. Si elle @@ -179,7 +181,7 @@ def main(argv: list[str] | None = None) -> int: json.dump(cibles(json.load(sys.stdin)), sys.stdout) return 0 - chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-frontiere.json" + chemin = Path(a.releve) if a.releve else instance_courante() / "devis-frontiere.json" if not chemin.is_file(): raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.") ecarts, lignes = analyser(json.loads(chemin.read_text())) diff --git a/scripts/devis_identite.py b/scripts/devis_identite.py index 5b83831..335e825 100755 --- a/scripts/devis_identite.py +++ b/scripts/devis_identite.py @@ -22,8 +22,10 @@ import json import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent -RELEVE = RACINE / "instance" / "devis-identite.json" +RELEVE = instance_courante() / "devis-identite.json" class Ecart: diff --git a/scripts/devis_mtu.py b/scripts/devis_mtu.py index d80ea7c..e2f0a4c 100644 --- a/scripts/devis_mtu.py +++ b/scripts/devis_mtu.py @@ -31,6 +31,8 @@ import subprocess import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent @@ -73,7 +75,7 @@ def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--releve", default=None) a = ap.parse_args(argv) - chemin = Path(a.releve) if a.releve else RACINE / "instance" / "devis-mtu.json" + chemin = Path(a.releve) if a.releve else instance_courante() / "devis-mtu.json" if not chemin.is_file(): raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make mtu-mesurer`.") ecarts, lignes = analyser(json.loads(chemin.read_text()), zones_attendues()) diff --git a/scripts/devis_placement.py b/scripts/devis_placement.py index b218e5a..66b3c02 100644 --- a/scripts/devis_placement.py +++ b/scripts/devis_placement.py @@ -37,6 +37,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 proxmox_api import Cluster # noqa: E402 INVENTAIRES = ("principal", "production", "lab") @@ -51,7 +52,7 @@ def placement_du_tenant() -> tuple[dict, Path | None]: pour le mauvais tenant (mesure du 2026-08-20 — les deux avaient les memes quatre valeurs, ce qui est exactement la circonstance ou l'erreur ne se voit pas). """ - base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + base = instance_courante() for nom in INVENTAIRES: f = base / "inventories" / nom / "group_vars" / "proxmox.yml" if f.is_file(): diff --git a/scripts/devis_postgresql.py b/scripts/devis_postgresql.py index b57f60f..3425989 100755 --- a/scripts/devis_postgresql.py +++ b/scripts/devis_postgresql.py @@ -21,8 +21,10 @@ import json import sys from pathlib import Path +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent -MOTIF = str(RACINE / "instance" / "devis-postgresql.json.*") +MOTIF = str(instance_courante() / "devis-postgresql.json.*") LOCAL = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128")) diff --git a/scripts/devis_proxmox_pools.py b/scripts/devis_proxmox_pools.py index 5542686..7c02d6b 100644 --- a/scripts/devis_proxmox_pools.py +++ b/scripts/devis_proxmox_pools.py @@ -42,7 +42,8 @@ import yaml RACINE = Path(__file__).resolve().parents[1] sys.path.insert(0, str(RACINE / "scripts")) -from inventory_rules import ( # noqa: E402 +from inventory_rules import ( + instance_courante, # noqa: E402 charger_serveurs, deriver_nomenclature, fonction_seq, @@ -65,7 +66,7 @@ def pool_actif() -> str: peuvent pas nommer le pool differemment. Chaine vide si aucune instance n'est liee — l'appelant omet alors le parametre plutot que d'inventer un nom. """ - instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + instance = instance_courante() nomenclature = instance / "plan" / "nomenclature.yml" if not nomenclature.is_file(): return "" diff --git a/scripts/domaines.py b/scripts/domaines.py index c82da5c..ed6e8e7 100644 --- a/scripts/domaines.py +++ b/scripts/domaines.py @@ -10,6 +10,7 @@ import sys from pathlib import Path from inventory_rules import ( + instance_courante, charger_applications, charger_domaines, expositions_des_applications, @@ -17,7 +18,7 @@ from inventory_rules import ( ) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() FICHIER = INSTANCE / "plan/domaines.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" diff --git a/scripts/genome.py b/scripts/genome.py index 012512b..f1eaff4 100644 --- a/scripts/genome.py +++ b/scripts/genome.py @@ -39,6 +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 FICHIER_PARENTE = "parente.yml" @@ -50,7 +51,7 @@ def _git(depot: Path, *args: str) -> tuple[int, str]: def _instance() -> Path: - return Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")).resolve() + return instance_courante().resolve() def _hebergeur() -> Path | None: diff --git a/scripts/instancier.py b/scripts/instancier.py index d7cad88..ed6b5d5 100644 --- a/scripts/instancier.py +++ b/scripts/instancier.py @@ -40,34 +40,18 @@ from inventory_rules import ( fonction_seq, integrations_de, integrations_universelles, - inventaire_force, + instance_courante, + inventaire_de, liens_acceptes, supernet_de, ) RACINE = Path(__file__).resolve().parents[1] ROLES = RACINE / "roles" -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() -def _inventaire(instance: Path, *noms: str) -> Path: - for nom in noms: - p = instance / "inventories" / nom / "hosts.yml" - if p.exists(): - return p - # Aucun hosts.yml encore genere : viser le repertoire d'inventaire deja present - # (ses group_vars y sont), sinon le premier nom par defaut. Evite d'ecrire dans - # 'principal/' alors que l'instance (ex. modele socle) est en 'production/'. - for nom in noms: - if (instance / "inventories" / nom).is_dir(): - return instance / "inventories" / nom / "hosts.yml" - return instance / "inventories" / noms[0] / "hosts.yml" - - -# Inventaire unique de l'instance (rétro-compat : principal > production). -# SETOPS_INVENTAIRE force la cible — mais seulement A L'INTERIEUR de l'instance visée : -# `inventaire_force` refuse une cible qui désigne une AUTRE instance (2026-08-18, P03). -INVENTAIRE = inventaire_force(INSTANCE) or _inventaire(INSTANCE, "principal", "production") +INVENTAIRE = inventaire_de(INSTANCE) GENERE = INVENTAIRE.with_name("hosts.genere.yml") FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" diff --git a/scripts/inventory_gui.py b/scripts/inventory_gui.py index 1384654..7739529 100644 --- a/scripts/inventory_gui.py +++ b/scripts/inventory_gui.py @@ -21,6 +21,8 @@ from urllib.parse import urlparse import yaml import devis_reseau + +from inventory_rules import instance_courante # noqa: E402 from inventory_rules import ( GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES, @@ -44,7 +46,7 @@ from inventory_rules import ( ) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() def _inventaire(instance: Path, *noms: str) -> Path: diff --git a/scripts/inventory_rules.py b/scripts/inventory_rules.py index 4786bda..68ca110 100644 --- a/scripts/inventory_rules.py +++ b/scripts/inventory_rules.py @@ -18,6 +18,58 @@ GROUPES_ETAT_HOTE = {GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES} RACINE_DEPOT = Path(__file__).resolve().parents[1] +ORDRE_INVENTAIRE = ("principal", "production") +ORDRE_INVENTAIRE_MODELE = ("lab", "principal", "production") + + +def instance_courante() -> Path: + """L'instance VISEE : `SETOPS_INSTANCE`, sinon le symlink `instance/`. + + SOURCE UNIQUE. Neuf modules en portaient chacun leur copie, et cinq defauts en sont + sortis en cinq jours (2026-08-18 au 22) : P03 comparait chaque instance a l'inventaire + d'une autre ; `verifier_ports` codait `principal/` en dur ; `verifier_intrants` et + `_frontiere_absente` lisaient le symlink au lieu de la variable ; `devis_placement` + rendait un verdict juste sur le mauvais tenant ; P35 aussi. + + Aucun n'etait une faute d'inattention : chacun etait une copie ecrite de bonne foi, a + un moment ou le besoin semblait local. C'est le mode de panne de la duplication — pas + l'erreur, mais la DERIVE, qui ne se voit jamais depuis l'interieur d'un fichier. + """ + return Path(os.environ.get("SETOPS_INSTANCE") or (RACINE_DEPOT / "instance")) + + +def dossier_inventaire(instance: Path | None = None, + ordre: tuple[str, ...] = ORDRE_INVENTAIRE) -> Path: + """Le REPERTOIRE d'inventaire de l'instance, dans l'ordre de preference donne. + + Trois niveaux, et le troisieme manquait a la moitie des copies : un `hosts.yml` + existant, puis un REPERTOIRE existant (le cas d'une instance neuve, dont l'inventaire + n'est pas encore genere — c'est ainsi que `make instancier` echouait sur le modele + public), puis le premier nom par defaut. + """ + base = Path(instance) if instance is not None else instance_courante() + for nom in ordre: + if (base / "inventories" / nom / "hosts.yml").is_file(): + return base / "inventories" / nom + for nom in ordre: + if (base / "inventories" / nom).is_dir(): + return base / "inventories" / nom + return base / "inventories" / ordre[0] + + +def inventaire_de(instance: Path | None = None, + ordre: tuple[str, ...] = ORDRE_INVENTAIRE) -> Path: + """Le fichier `hosts.yml` de l'instance — `SETOPS_INVENTAIRE` le force (et est garde).""" + base = Path(instance) if instance is not None else instance_courante() + return inventaire_force(base) or (dossier_inventaire(base, ordre) / "hosts.yml") + + +def plan_de(instance: Path | None = None) -> Path: + """Le repertoire `plan/` de l'instance visee.""" + base = Path(instance) if instance is not None else instance_courante() + return base / "plan" + + def inventaire_force(instance: Path) -> Path | None: """Inventaire impose par SETOPS_INVENTAIRE, ou None. REFUSE s'il vise AILLEURS. diff --git a/scripts/prouver.py b/scripts/prouver.py index e368633..d037019 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -33,7 +33,11 @@ from pathlib import Path import yaml RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE", "instance")) +sys.path.insert(0, str(RACINE / "scripts")) + +from inventory_rules import instance_courante, plan_de # noqa: E402 + +INSTANCE = instance_courante() DOSSIER_AUDIT = RACINE / "docs" / "audit" GROUPES = "playbooks/groupes" DEPENDANCES = "docs/dependances-groupes.yml" @@ -277,7 +281,7 @@ def preuve_propriete_des_intrants() -> tuple[bool, str]: if not underlay.exists(): return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet." hebergeur = underlay.resolve().parent - instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + instance = instance_courante() if instance.resolve() == hebergeur.resolve(): cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux else: @@ -312,7 +316,7 @@ def preuve_integrations_universelles() -> tuple[bool, str]: universelles = integrations_universelles(RACINE) if not universelles: return False, "Aucune integration universelle declaree : la politique a disparu." - instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + instance = instance_courante() serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {}) apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {}) services: dict[str, set[str]] = {} @@ -659,7 +663,7 @@ def preuve_etat_sauvegarde() -> tuple[bool, str]: ce soit. Une unite verte sur un depot vide resterait invisible ici — c'est au reel de le dire, pas au depot. """ - plan = RACINE / "instance" / "plan" + plan = plan_de() defauts = RACINE / "roles" / "client_backup" / "defaults" / "main.yml" if not (plan / "serveurs.yml").is_file() or not defauts.is_file(): return True, "Aucun plan ou aucun role client_backup : rien a verifier." @@ -778,6 +782,52 @@ JARGON_A_ENSEIGNER = [ ] +def preuve_resolution_unique() -> tuple[bool, str]: + """Aucun module ne reecrit sa propre resolution d'instance. + + POURQUOI CETTE PREUVE EXISTE (2026-08-22). Neuf modules portaient chacun leur copie de + « quelle instance, quel inventaire ». Aucune n'etait une faute d'inattention : chacune + avait ete ecrite de bonne foi, a un moment ou le besoin semblait local. En cinq jours, + cinq defauts en sont sortis — P03 comparait chaque instance a l'inventaire d'une autre, + `verifier_ports` codait `principal/` en dur, `verifier_intrants` et + `_frontiere_absente` lisaient le symlink au lieu de la variable, `devis_placement` et + P35 rendaient un verdict juste sur le mauvais ecosysteme. + + C'est le mode de panne de la duplication : pas l'erreur, mais la DERIVE — invisible + depuis l'interieur d'un fichier, parce que chaque copie a l'air correcte chez elle. + + La resolution vit donc dans `inventory_rules` (`instance_courante`, `inventaire_de`, + `dossier_inventaire`, `plan_de`). Cette preuve garde l'unicite : elle echoue des qu'un + module relit la variable d'environnement ou recompose le chemin du symlink + lui-meme au lieu d'appeler la resolution partagee. + + TROIS EXEMPTIONS, nommees pour rester des choix et non des trous : `instances.py` et + `inventory_gui.py` manipulent le SYMLINK lui-meme (c'est la bascule d'instance), et + `devis_opnsense` lit deliberement quelle instance est ACTIVE pour se situer dans la + federation. Ces trois-la parlent du lien, pas de la resolution. + """ + exemptes = {"inventory_rules.py", "instances.py", "inventory_gui.py", "devis_opnsense.py"} + motifs = (re.compile(r'os\.environ\.get\(\s*["\']SETOPS_INSTANCE'), + re.compile(r'RACINE(?:_DEPOT)?\s*/\s*["\']instance["\']')) + copies: list[str] = [] + for f in sorted((RACINE / "scripts").glob("*.py")): + if f.name in exemptes: + continue + texte = f.read_text(encoding="utf-8", errors="ignore") + for i, ligne in enumerate(texte.splitlines(), 1): + if ligne.lstrip().startswith("#"): + continue + if any(m.search(ligne) for m in motifs): + copies.append(f"{f.name}:{i}") + if copies: + return False, (f"{len(copies)} copie(s) de la resolution d'instance : " + + ", ".join(copies[:6]) + ("…" if len(copies) > 6 else "") + + " — passer par inventory_rules.instance_courante().") + n = len(list((RACINE / "scripts").glob("*.py"))) - len(exemptes) + return True, (f"Resolution unique : {n} script(s) passent par " + f"`inventory_rules`, {len(exemptes) - 1} exemption(s) nommee(s).") + + def preuve_parente_inscrite() -> tuple[bool, str]: """L'ecosysteme sait de QUOI il descend, et cette filiation tient encore. @@ -1142,6 +1192,8 @@ PREUVES: list[dict] = [ "func": preuve_glossaire_enseigne}, {"id": "P40", "titre": "Parente : l'ecosysteme sait de quoi il descend", "refs": [], "func": preuve_parente_inscrite}, + {"id": "P41", "titre": "Resolution d'instance : une seule, partagee", "refs": [], + "func": preuve_resolution_unique}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], "cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, ] diff --git a/scripts/raser.py b/scripts/raser.py index f38106a..b7542ce 100755 --- a/scripts/raser.py +++ b/scripts/raser.py @@ -36,12 +36,14 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) from proxmox_api import Cluster # noqa: E402 +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent def instance_active() -> tuple[Path, str]: """Dossier de l'instance montee, et son nom court (celui qu'il faudra taper).""" - base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + base = instance_courante() if not base.exists(): raise SystemExit("Aucune instance montee : rien a raser.") reel = base.resolve() diff --git a/scripts/resoudre_flux.py b/scripts/resoudre_flux.py index 8b94961..705dfa7 100644 --- a/scripts/resoudre_flux.py +++ b/scripts/resoudre_flux.py @@ -25,12 +25,12 @@ from pathlib import Path import yaml -from inventory_rules import est_groupe_operationnel +from inventory_rules import instance_courante, est_groupe_operationnel RACINE = Path(__file__).resolve().parents[1] ROLES = RACINE / "roles" REGISTRE = RACINE / "docs" / "registre-flux.md" -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() SENS = {"ingress", "egress"} # `icmp` n'a pas de port : le champ `port` porte alors le TYPE (ex. `frag-needed`). diff --git a/scripts/serveurs.py b/scripts/serveurs.py index 5162622..195de69 100644 --- a/scripts/serveurs.py +++ b/scripts/serveurs.py @@ -22,27 +22,17 @@ from inventory_rules import ( charger_serveurs, fonction_seq, integrations_universelles, - inventaire_force, + instance_courante, + inventaire_de, reconcilier_serveur, valider_serveurs, ) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() FICHIER = INSTANCE / "plan/serveurs.yml" FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml" -def _inventaire(instance: Path, *noms: str) -> Path: - forced = inventaire_force(instance) # refuse une cible hors de `instance` - if forced: - return forced - for nom in noms: - p = instance / "inventories" / nom / "hosts.yml" - if p.exists(): - return p - return instance / "inventories" / noms[0] / "hosts.yml" - - -INVENTAIRE = _inventaire(INSTANCE, "principal", "production") +INVENTAIRE = inventaire_de(INSTANCE) # Champs de placement / dimensionnement NON derivables (proviennent du plan). CHAMPS_PLAN = [("noeud", "noeud"), ("stockage", "stockage"), diff --git a/scripts/socle_amorcage.py b/scripts/socle_amorcage.py index f438061..264e172 100755 --- a/scripts/socle_amorcage.py +++ b/scripts/socle_amorcage.py @@ -19,6 +19,8 @@ from pathlib import Path import yaml +from inventory_rules import instance_courante # noqa: E402 + RACINE = Path(__file__).resolve().parent.parent # L'ordre compte et n'est pas alphabetique : le DNS a besoin d'un certificat, l'autorite @@ -28,7 +30,7 @@ APPLICATIONS = ["step_ca", "powerdns"] def main() -> int: - base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + base = instance_courante() plan = base / "plan" / "applications.yml" if not plan.exists(): print(f"Plan introuvable : {plan}", file=sys.stderr) diff --git a/scripts/underlay.py b/scripts/underlay.py index 3bf52e6..132d763 100644 --- a/scripts/underlay.py +++ b/scripts/underlay.py @@ -60,7 +60,7 @@ import yaml RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE / "scripts")) -from inventory_rules import supernet_de, vlan_de # noqa: E402 +from inventory_rules import instance_courante, supernet_de, vlan_de # noqa: E402 # Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous. SEUIL_VLAN_TENANT = 1000 @@ -315,7 +315,7 @@ def octet_passerelle(plan_nomenclature: Path | None = None) -> int | None: sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une seule fois (`reservations.passerelle`), jamais code en dur ici. """ - p = plan_nomenclature or (RACINE / "instance" / "plan" / "nomenclature.yml") + p = plan_nomenclature or (instance_courante() / "plan" / "nomenclature.yml") if not p.is_file(): return None n = yaml.safe_load(p.read_text(encoding="utf-8")) or {} diff --git a/scripts/verifier_intrants.py b/scripts/verifier_intrants.py index 3e74c5b..f7b529f 100755 --- a/scripts/verifier_intrants.py +++ b/scripts/verifier_intrants.py @@ -32,6 +32,8 @@ from pathlib import Path import yaml +from inventory_rules import instance_courante + RACINE = Path(__file__).resolve().parent.parent # Les deux idiomes d'exigence du depot. `| bool` n'en est pas un : une valeur fausse est @@ -202,8 +204,7 @@ def main(argv: list[str] | None = None) -> int: # SETOPS_INSTANCE d'abord : le symlink `instance/` n'est que le cas courant. Le coder # en dur rendait cette preuve inapplicable des qu'on visait une autre instance. - base = Path(a.instance) if a.instance else \ - Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) + base = Path(a.instance) if a.instance else instance_courante() if not base.exists(): print("Aucune instance montee : rien a verifier.", file=sys.stderr) return 2 diff --git a/scripts/verifier_ports.py b/scripts/verifier_ports.py index 602799e..1566b50 100755 --- a/scripts/verifier_ports.py +++ b/scripts/verifier_ports.py @@ -34,26 +34,11 @@ from pathlib import Path import yaml +from inventory_rules import inventaire_de + RACINE = Path(__file__).resolve().parent.parent -def _inventaire_par_defaut() -> Path: - """L'inventaire de l'instance montee — meme precedence que le reste du moteur. - - CINQ endroits resolvent l'inventaire dans ce depot, et deux defauts en sont sortis le - meme jour (2026-08-19) : celui-ci codait `principal/` en dur, ce qui rendait la preuve - inapplicable a toute instance en `production/` — dont le modele public. A consolider : - une seule resolution partagee, plutot que cinq copies qui derivent chacune de son cote. - """ - force = os.environ.get("SETOPS_INVENTAIRE") - if force: - return Path(force) - instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) - for nom in ("principal", "production"): - p = instance / "inventories" / nom / "hosts.yml" - if p.is_file(): - return p - return instance / "inventories" / "principal" / "hosts.yml" def _groupes(inventaire: Path) -> dict[str, set[str]]: @@ -98,7 +83,7 @@ def main(argv: list[str] | None = None) -> int: # (principal > production). Le chemin `principal/` etait code en dur ici, et rendait # cette preuve inapplicable a toute instance en `production/` — dont le modele public, # donc dont un depot fraichement clone (mesure du 2026-08-19). - inventaire = Path(a.inventaire) if a.inventaire else _inventaire_par_defaut() + inventaire = Path(a.inventaire) if a.inventaire else inventaire_de() if not inventaire.is_file(): print(f"Inventaire introuvable : {inventaire}", file=sys.stderr) return 2 diff --git a/scripts/voute.py b/scripts/voute.py index 808ef5e..2f81a4e 100644 --- a/scripts/voute.py +++ b/scripts/voute.py @@ -30,11 +30,12 @@ from pathlib import Path import yaml -from inventory_rules import (charger_applications, charger_bases_donnees, charger_serveurs, +from inventory_rules import ( + instance_courante,charger_applications, charger_bases_donnees, charger_serveurs, integrations_universelles) RACINE = Path(__file__).resolve().parents[1] -INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) +INSTANCE = instance_courante() ROLES = RACINE / "roles" MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")