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
## 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. »*

View file

@ -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.

View file

@ -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"

View file

@ -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:

View file

@ -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.

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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()))

View file

@ -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:

View file

@ -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())

View file

@ -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():

View file

@ -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"))

View file

@ -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 ""

View file

@ -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"

View file

@ -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:

View file

@ -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"

View file

@ -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:

View file

@ -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.

View file

@ -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"]]},
]

View file

@ -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()

View file

@ -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`).

View file

@ -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"),

View file

@ -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)

View file

@ -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 {}

View file

@ -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

View file

@ -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

View file

@ -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_]+)")