resolution d'instance : une seule, partagee — au lieu de neuf copies
Some checks are pending
verifier / verifier (push) Waiting to run

Cinq jours, cinq defauts, tous de la meme famille : « quelle instance, quel inventaire ? »
Neuf modules portaient chacun leur reponse.

- 18 aout : P03 comparait chaque instance a l'inventaire d'une AUTRE ;
- 19 aout : verifier_ports codait `principal/` en dur ; verifier_intrants et
  _frontiere_absente lisaient le symlink au lieu de la variable ;
- 20 aout : devis_placement rendait un verdict juste sur le mauvais tenant ;
- 22 aout : P35, puis P36 — la dixieme, trouvee par la preuve elle-meme.

Aucune n'etait une faute d'inattention : chacune avait ete 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, invisible depuis l'interieur d'un fichier.

LA RESOLUTION UNIQUE. `inventory_rules` porte instance_courante(), inventaire_de(),
dossier_inventaire() et plan_de(). Trois niveaux de repli, dont le TROISIEME manquait a la
moitie des copies : un hosts.yml existant, puis un REPERTOIRE existant (instance neuve —
c'est ce qui faisait echouer `make instancier` sur le modele public), puis le defaut.
Vingt-huit modules y sont branches.

CE QUI REND CE REFACTOR SUR : avant de toucher quoi que ce soit, chaque module a ete
interroge sur ce qu'il resolvait, pour les DEUX ecosystemes. Apres refactor, meme mesure :
17 modules x 2 instances, diff VIDE. Aucune resolution n'a change — prouve, pas suppose.

P41 echoue des qu'un module reintroduit une copie. Eprouvee en negatif : une copie
replacee dans genome.py est signalee avec son numero de ligne. Trois exemptions nommees :
instances.py et inventory_gui.py manipulent le SYMLINK lui-meme (bascule d'instance), et
devis_opnsense lit deliberement quelle instance est ACTIVE. Elles parlent du lien, pas de
la resolution.

make verifier 41 OK, 0 echec, 0 saute ; make ci idem ; lint vert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-22 14:19:55 -04:00
parent bd1b897815
commit c18debc25e
28 changed files with 237 additions and 97 deletions

View file

@ -1,5 +1,63 @@
# CHANGELOG — Set-OPS # 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 ## 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. »* Doute de l'exploitant en relisant patient 0 : *« je doute de la pertinence de pgsql. »*

View file

@ -19,28 +19,18 @@ from inventory_rules import (
charger_bases_donnees, charger_bases_donnees,
charger_domaines, charger_domaines,
charger_serveurs, charger_serveurs,
inventaire_force, instance_courante,
inventaire_de,
valider_applications, valider_applications,
) )
RACINE = Path(__file__).resolve().parents[1] 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 = INSTANCE / "plan/applications.yml"
FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml" FICHIER_BASES = INSTANCE / "plan/bases-donnees.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml" FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
def _inventaire(instance: Path, *noms: str) -> Path: INVENTAIRE = inventaire_de(INSTANCE)
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")
# Groupes 'serveurs_*' qui sont des capacites de SOCLE (sur toutes les VM), # Groupes 'serveurs_*' qui sont des capacites de SOCLE (sur toutes les VM),
# pas des applications a part entiere. # pas des applications a part entiere.

View file

@ -12,6 +12,7 @@ from pathlib import Path
import yaml import yaml
from inventory_rules import ( from inventory_rules import (
instance_courante,
chaine_connexion, chaine_connexion,
charger_applications, charger_applications,
charger_bases_donnees, charger_bases_donnees,
@ -19,7 +20,7 @@ from inventory_rules import (
) )
RACINE = Path(__file__).resolve().parents[1] 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 = INSTANCE / "plan/bases-donnees.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"

View file

@ -11,11 +11,11 @@ import sys
import yaml import yaml
from inventory_rules import inventaire_force from inventory_rules import instance_courante, inventaire_force
RACINE = Path(__file__).resolve().parents[1] 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: def _inventaire_dir(instance: Path, *noms: str) -> Path:

View file

@ -35,8 +35,10 @@ import yaml
from inventory_gui import CHAMPS_ECRITS_PAR_GUI from inventory_gui import CHAMPS_ECRITS_PAR_GUI
from modeles import decouvrir as decouvrir_modeles from modeles import decouvrir as decouvrir_modeles
from inventory_rules import instance_courante, instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parents[1] 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" SOURCE_GUI = RACINE / "scripts" / "inventory_gui.py"
# (fichier du plan, cle racine, nom du registre) — les registres tables-de-tables. # (fichier du plan, cle racine, nom du registre) — les registres tables-de-tables.

View file

@ -28,8 +28,10 @@ import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent 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 # 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 # etre recharge avant. Genereux par rapport au renouvellement (~14 min), serre par

View file

@ -19,8 +19,10 @@ import re
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent 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: def _valeur(brut: str, cle: str) -> str:

View file

@ -24,8 +24,10 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent 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 # 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 # l'IdP, ou un 401/403 quand l'authentification est exigee. Ce qui compte est qu'il ait

View file

@ -28,6 +28,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
# Adresse de controle : documentee comme non routable et attribuee a personne. Si elle # 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) json.dump(cibles(json.load(sys.stdin)), sys.stdout)
return 0 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(): if not chemin.is_file():
raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.") raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make frontiere-mesurer`.")
ecarts, lignes = analyser(json.loads(chemin.read_text())) ecarts, lignes = analyser(json.loads(chemin.read_text()))

View file

@ -22,8 +22,10 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
RELEVE = RACINE / "instance" / "devis-identite.json" RELEVE = instance_courante() / "devis-identite.json"
class Ecart: class Ecart:

View file

@ -31,6 +31,8 @@ import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
@ -73,7 +75,7 @@ def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description=__doc__) ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--releve", default=None) ap.add_argument("--releve", default=None)
a = ap.parse_args(argv) 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(): if not chemin.is_file():
raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make mtu-mesurer`.") raise SystemExit(f"Aucun releve : {chemin}\nLancer d'abord `make mtu-mesurer`.")
ecarts, lignes = analyser(json.loads(chemin.read_text()), zones_attendues()) ecarts, lignes = analyser(json.loads(chemin.read_text()), zones_attendues())

View file

@ -37,6 +37,7 @@ RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402 import underlay as underlay_mod # noqa: E402
from inventory_rules import instance_courante # noqa: E402
from proxmox_api import Cluster # noqa: E402 from proxmox_api import Cluster # noqa: E402
INVENTAIRES = ("principal", "production", "lab") 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 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). 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: for nom in INVENTAIRES:
f = base / "inventories" / nom / "group_vars" / "proxmox.yml" f = base / "inventories" / nom / "group_vars" / "proxmox.yml"
if f.is_file(): if f.is_file():

View file

@ -21,8 +21,10 @@ import json
import sys import sys
from pathlib import Path from pathlib import Path
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent 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")) LOCAL = (ipaddress.ip_network("127.0.0.0/8"), ipaddress.ip_network("::1/128"))

View file

@ -42,7 +42,8 @@ import yaml
RACINE = Path(__file__).resolve().parents[1] RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
from inventory_rules import ( # noqa: E402 from inventory_rules import (
instance_courante, # noqa: E402
charger_serveurs, charger_serveurs,
deriver_nomenclature, deriver_nomenclature,
fonction_seq, fonction_seq,
@ -65,7 +66,7 @@ def pool_actif() -> str:
peuvent pas nommer le pool differemment. Chaine vide si aucune instance n'est 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. 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" nomenclature = instance / "plan" / "nomenclature.yml"
if not nomenclature.is_file(): if not nomenclature.is_file():
return "" return ""

View file

@ -10,6 +10,7 @@ import sys
from pathlib import Path from pathlib import Path
from inventory_rules import ( from inventory_rules import (
instance_courante,
charger_applications, charger_applications,
charger_domaines, charger_domaines,
expositions_des_applications, expositions_des_applications,
@ -17,7 +18,7 @@ from inventory_rules import (
) )
RACINE = Path(__file__).resolve().parents[1] 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 = INSTANCE / "plan/domaines.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"

View file

@ -39,6 +39,7 @@ RACINE = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(RACINE / "scripts")) sys.path.insert(0, str(RACINE / "scripts"))
import underlay as underlay_mod # noqa: E402 import underlay as underlay_mod # noqa: E402
from inventory_rules import instance_courante # noqa: E402
FICHIER_PARENTE = "parente.yml" FICHIER_PARENTE = "parente.yml"
@ -50,7 +51,7 @@ def _git(depot: Path, *args: str) -> tuple[int, str]:
def _instance() -> Path: def _instance() -> Path:
return Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")).resolve() return instance_courante().resolve()
def _hebergeur() -> Path | None: def _hebergeur() -> Path | None:

View file

@ -40,34 +40,18 @@ from inventory_rules import (
fonction_seq, fonction_seq,
integrations_de, integrations_de,
integrations_universelles, integrations_universelles,
inventaire_force, instance_courante,
inventaire_de,
liens_acceptes, liens_acceptes,
supernet_de, supernet_de,
) )
RACINE = Path(__file__).resolve().parents[1] RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles" ROLES = RACINE / "roles"
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) INSTANCE = instance_courante()
def _inventaire(instance: Path, *noms: str) -> Path: INVENTAIRE = inventaire_de(INSTANCE)
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")
GENERE = INVENTAIRE.with_name("hosts.genere.yml") GENERE = INVENTAIRE.with_name("hosts.genere.yml")
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml" FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml" FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"

View file

@ -21,6 +21,8 @@ from urllib.parse import urlparse
import yaml import yaml
import devis_reseau import devis_reseau
from inventory_rules import instance_courante # noqa: E402
from inventory_rules import ( from inventory_rules import (
GROUPE_HOTES_ACTIFS, GROUPE_HOTES_ACTIFS,
GROUPE_HOTES_PLANIFIES, GROUPE_HOTES_PLANIFIES,
@ -44,7 +46,7 @@ from inventory_rules import (
) )
RACINE = Path(__file__).resolve().parents[1] 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: def _inventaire(instance: Path, *noms: str) -> Path:

View file

@ -18,6 +18,58 @@ GROUPES_ETAT_HOTE = {GROUPE_HOTES_ACTIFS, GROUPE_HOTES_PLANIFIES}
RACINE_DEPOT = Path(__file__).resolve().parents[1] 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: def inventaire_force(instance: Path) -> Path | None:
"""Inventaire impose par SETOPS_INVENTAIRE, ou None. REFUSE s'il vise AILLEURS. """Inventaire impose par SETOPS_INVENTAIRE, ou None. REFUSE s'il vise AILLEURS.

View file

@ -33,7 +33,11 @@ from pathlib import Path
import yaml import yaml
RACINE = Path(__file__).resolve().parents[1] 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" DOSSIER_AUDIT = RACINE / "docs" / "audit"
GROUPES = "playbooks/groupes" GROUPES = "playbooks/groupes"
DEPENDANCES = "docs/dependances-groupes.yml" DEPENDANCES = "docs/dependances-groupes.yml"
@ -277,7 +281,7 @@ def preuve_propriete_des_intrants() -> tuple[bool, str]:
if not underlay.exists(): if not underlay.exists():
return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet." return True, "Aucun hebergeur monte (pas d'underlay.yml) : separation sans objet."
hebergeur = underlay.resolve().parent hebergeur = underlay.resolve().parent
instance = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) instance = instance_courante()
if instance.resolve() == hebergeur.resolve(): if instance.resolve() == hebergeur.resolve():
cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux cles = {} # l'hebergeur est aussi tenant : son propre depot porte les deux
else: else:
@ -312,7 +316,7 @@ def preuve_integrations_universelles() -> tuple[bool, str]:
universelles = integrations_universelles(RACINE) universelles = integrations_universelles(RACINE)
if not universelles: if not universelles:
return False, "Aucune integration universelle declaree : la politique a disparu." 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 {}) serveurs = (charger_serveurs(instance / "plan/serveurs.yml").get("serveurs") or {})
apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {}) apps = (charger_applications(instance / "plan/applications.yml").get("applications") or {})
services: dict[str, set[str]] = {} 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 ce soit. Une unite verte sur un depot vide resterait invisible ici — c'est au reel de
le dire, pas au depot. le dire, pas au depot.
""" """
plan = RACINE / "instance" / "plan" plan = plan_de()
defauts = RACINE / "roles" / "client_backup" / "defaults" / "main.yml" defauts = RACINE / "roles" / "client_backup" / "defaults" / "main.yml"
if not (plan / "serveurs.yml").is_file() or not defauts.is_file(): if not (plan / "serveurs.yml").is_file() or not defauts.is_file():
return True, "Aucun plan ou aucun role client_backup : rien a verifier." 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]: def preuve_parente_inscrite() -> tuple[bool, str]:
"""L'ecosysteme sait de QUOI il descend, et cette filiation tient encore. """L'ecosysteme sait de QUOI il descend, et cette filiation tient encore.
@ -1142,6 +1192,8 @@ PREUVES: list[dict] = [
"func": preuve_glossaire_enseigne}, "func": preuve_glossaire_enseigne},
{"id": "P40", "titre": "Parente : l'ecosysteme sait de quoi il descend", "refs": [], {"id": "P40", "titre": "Parente : l'ecosysteme sait de quoi il descend", "refs": [],
"func": preuve_parente_inscrite}, "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": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],
"cmds": [[sys.executable, "scripts/verifier_ports.py"]]}, "cmds": [[sys.executable, "scripts/verifier_ports.py"]]},
] ]

View file

@ -36,12 +36,14 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent)) sys.path.insert(0, str(Path(__file__).resolve().parent))
from proxmox_api import Cluster # noqa: E402 from proxmox_api import Cluster # noqa: E402
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
def instance_active() -> tuple[Path, str]: def instance_active() -> tuple[Path, str]:
"""Dossier de l'instance montee, et son nom court (celui qu'il faudra taper).""" """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(): if not base.exists():
raise SystemExit("Aucune instance montee : rien a raser.") raise SystemExit("Aucune instance montee : rien a raser.")
reel = base.resolve() reel = base.resolve()

View file

@ -25,12 +25,12 @@ from pathlib import Path
import yaml import yaml
from inventory_rules import est_groupe_operationnel from inventory_rules import instance_courante, est_groupe_operationnel
RACINE = Path(__file__).resolve().parents[1] RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles" ROLES = RACINE / "roles"
REGISTRE = RACINE / "docs" / "registre-flux.md" REGISTRE = RACINE / "docs" / "registre-flux.md"
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) INSTANCE = instance_courante()
SENS = {"ingress", "egress"} SENS = {"ingress", "egress"}
# `icmp` n'a pas de port : le champ `port` porte alors le TYPE (ex. `frag-needed`). # `icmp` n'a pas de port : le champ `port` porte alors le TYPE (ex. `frag-needed`).

View file

@ -22,27 +22,17 @@ from inventory_rules import (
charger_serveurs, charger_serveurs,
fonction_seq, fonction_seq,
integrations_universelles, integrations_universelles,
inventaire_force, instance_courante,
inventaire_de,
reconcilier_serveur, reconcilier_serveur,
valider_serveurs, valider_serveurs,
) )
RACINE = Path(__file__).resolve().parents[1] 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 = INSTANCE / "plan/serveurs.yml"
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml" FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
def _inventaire(instance: Path, *noms: str) -> Path: INVENTAIRE = inventaire_de(INSTANCE)
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")
# Champs de placement / dimensionnement NON derivables (proviennent du plan). # Champs de placement / dimensionnement NON derivables (proviennent du plan).
CHAMPS_PLAN = [("noeud", "noeud"), ("stockage", "stockage"), CHAMPS_PLAN = [("noeud", "noeud"), ("stockage", "stockage"),

View file

@ -19,6 +19,8 @@ from pathlib import Path
import yaml import yaml
from inventory_rules import instance_courante # noqa: E402
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
# L'ordre compte et n'est pas alphabetique : le DNS a besoin d'un certificat, l'autorite # 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: def main() -> int:
base = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) base = instance_courante()
plan = base / "plan" / "applications.yml" plan = base / "plan" / "applications.yml"
if not plan.exists(): if not plan.exists():
print(f"Plan introuvable : {plan}", file=sys.stderr) print(f"Plan introuvable : {plan}", file=sys.stderr)

View file

@ -60,7 +60,7 @@ import yaml
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE / "scripts")) 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. # Les VLAN tenant commencent a 1000+index*10+zone (>= 1011). L'underlay reste en dessous.
SEUIL_VLAN_TENANT = 1000 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 sous-reseaux ou il participe — on retient une adresse, pas treize. Declare une
seule fois (`reservations.passerelle`), jamais code en dur ici. 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(): if not p.is_file():
return None return None
n = yaml.safe_load(p.read_text(encoding="utf-8")) or {} n = yaml.safe_load(p.read_text(encoding="utf-8")) or {}

View file

@ -32,6 +32,8 @@ from pathlib import Path
import yaml import yaml
from inventory_rules import instance_courante
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
# Les deux idiomes d'exigence du depot. `| bool` n'en est pas un : une valeur fausse est # 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 # 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. # en dur rendait cette preuve inapplicable des qu'on visait une autre instance.
base = Path(a.instance) if a.instance else \ base = Path(a.instance) if a.instance else instance_courante()
Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance"))
if not base.exists(): if not base.exists():
print("Aucune instance montee : rien a verifier.", file=sys.stderr) print("Aucune instance montee : rien a verifier.", file=sys.stderr)
return 2 return 2

View file

@ -34,26 +34,11 @@ from pathlib import Path
import yaml import yaml
from inventory_rules import inventaire_de
RACINE = Path(__file__).resolve().parent.parent 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]]: 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 # (principal > production). Le chemin `principal/` etait code en dur ici, et rendait
# cette preuve inapplicable a toute instance en `production/` — dont le modele public, # cette preuve inapplicable a toute instance en `production/` — dont le modele public,
# donc dont un depot fraichement clone (mesure du 2026-08-19). # 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(): if not inventaire.is_file():
print(f"Inventaire introuvable : {inventaire}", file=sys.stderr) print(f"Inventaire introuvable : {inventaire}", file=sys.stderr)
return 2 return 2

View file

@ -30,11 +30,12 @@ from pathlib import Path
import yaml 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) integrations_universelles)
RACINE = Path(__file__).resolve().parents[1] RACINE = Path(__file__).resolve().parents[1]
INSTANCE = Path(os.environ.get("SETOPS_INSTANCE") or (RACINE / "instance")) INSTANCE = instance_courante()
ROLES = RACINE / "roles" ROLES = RACINE / "roles"
MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)") MOTIF_VAULT = re.compile(r"\b(vault_[a-z0-9_]+)")