Set-OPS-Public/scripts/instancier.py

391 lines
19 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
"""Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml.
NON destructif : ecrit hosts.genere.yml a cote de l'inventaire de l'instance et compare
SEMANTIQUEMENT (via ansible-inventory --list) avec l'inventaire actuel. Aucune
bascule tant que la comparaison n'est pas vide et validee.
Derivation :
- host vars : ansible_host/ansible_user + proxmox_* (IP/VMID/VLAN/passerelle
derives de la nomenclature ; placement/taille depuis instance/plan/serveurs.yml) ;
- groupes : socle (serveur_debian/durcis) + groupes de service derives des
applications de l'hote + groupe d'etat (hotes_actifs/hotes_planifies).
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
- Les integrations client_* : les universelles viennent de la POLITIQUE du role
(roles/client_*/meta/integration.yml), les facultatives de la liste du plan.
Voir inventory_rules.integrations_de() — source unique des trois lecteurs
(inventaire, voute, GUI).
"""
from __future__ import annotations
import os
import json
import subprocess
import sys
from pathlib import Path
import yaml
réseau : le VNet d'une VM se dérive, un hyperviseur a plusieurs pattes (D-55 à D-59) Le pont n'était pas seulement non portable, il était faux. proxmox_clone_pont faisait naître les VM sur vmbr1 avec une étiquette VLAN — l'ancien monde. En SDN une VM appartient à son VNet ; c'est ce qu'il a fallu corriger à la main sur infra-pki-01, et les treize suivantes auraient suivi. deriver_nomenclature() expose désormais la zone de sécurité, instancier en dérive proxmox_pont et une étiquette VIDE — le VNet porte déjà le tag, en poser un second donnerait un double étiquetage. La chaîne va jusqu'à make creer-vm : SETOPS_PONT='t11appl', SETOPS_VLAN=''. Trois pièges. Un doublon dans le Makefile passait PONT_PROXMOX deux fois dans la même cible, la seconde vide aurait écrasé la valeur dérivée. Un repli naïf sur proxmox_vlan aurait fait revenir l'étiquette en SDN : le repli ne s'applique que si la clé est ABSENTE, jamais si elle est présente et vide. Et le test unitaire est tombé, à raison — il couvre maintenant cette distinction. D-55 : le dépôt réseau porte le contrat entre l'Alliance et ses hébergeurs, et abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN. Mesuré : un tenant est à deux valeurs de la portabilité complète (noeud, stockage). D-57 : l'interface sysadmin d'un hyperviseur (vmbr0, 10.0.0.41/.43/.47) n'a pas de route par défaut ; celle-ci vit sur vlan40, vers la frontière. On n'atteint l'administration que depuis son propre domaine de diffusion. Ça tranche la question de la sortie des nœuds laissée ouverte ce matin — option A, mais sur une interface dédiée, ce qui lève l'objection qui la bloquait. D-58 : un hôte déclare par quelle interface (`via`) chaque réseau lui arrive ; le devis en dérive un port par interface et son type — trunk 11,40 sur bond3, accès VLAN 10 sur vmbr0. Sans ça, ajouter le VLAN 10 le remettait sur le trunk du transport, soit le domaine qu'on venait d'en sortir. D-59 : un VLAN qui ne porte que des adresses d'hôte n'a pas besoin de pont. Régression créée puis corrigée : le modèle public, qui ne déclare aucun hyperviseur, n'émettait plus rien pour ce port. Il émet maintenant tout l'underlay en disant que c'est un repli. 30 preuves OK, 4 tests unitaires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:20:56 -04:00
import underlay as underlay_mod # noqa: E402
from devis_sdn import vnet_de # noqa: E402
from inventory_rules import (
charger_applications,
charger_domaines,
charger_nomenclature,
charger_serveurs,
deriver_nomenclature,
deriver_ressources,
expositions_des_applications,
fonction_seq,
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
integrations_de,
integrations_universelles,
resolution d'instance : une seule, partagee — au lieu de neuf copies 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>
2026-08-22 14:19:55 -04:00
instance_courante,
inventaire_de,
Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00
liens_acceptes,
supernet_de,
)
RACINE = Path(__file__).resolve().parents[1]
ROLES = RACINE / "roles"
resolution d'instance : une seule, partagee — au lieu de neuf copies 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>
2026-08-22 14:19:55 -04:00
INSTANCE = instance_courante()
resolution d'instance : une seule, partagee — au lieu de neuf copies 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>
2026-08-22 14:19:55 -04:00
INVENTAIRE = inventaire_de(INSTANCE)
GENERE = INVENTAIRE.with_name("hosts.genere.yml")
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
GROUPES_SOCLE = ["serveur_debian", "serveur_durci"]
PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"),
("disque", "proxmox_disque_taille"), ("memoire", "proxmox_memoire"),
("coeurs", "proxmox_coeurs")]
# --- Bindings (liens) : relations declaratives entre applications -----------------
# Une application declare ses 'liens' ([{vers, role}]) dans plan/applications.yml ;
# chaque role decrit les liens qu'il accepte dans roles/<groupe>/meta/liens.yml
# (setops_liens.accepte). instancier resout la cible (FQDN interne derive) et injecte
# les variables en host_vars du consommateur. Cf. docs/bindings-conception.md.
def _domaine_interne() -> str:
"""Lit domaine_interne dans group_vars/all de l'instance (keystone, non chiffre)."""
base = INVENTAIRE.parent / "group_vars" / "all"
if base.is_dir():
for fichier in sorted(base.glob("*.yml")):
try:
data = yaml.safe_load(fichier.read_text(encoding="utf-8")) or {}
except yaml.YAMLError:
continue
if isinstance(data, dict) and str(data.get("domaine_interne", "")).strip():
return str(data["domaine_interne"]).strip()
return ""
def _attributs_cible(vers: str, apps: dict, serveurs: dict, nomenclature: dict, domaine: str) -> dict | None:
"""Resout une cible de lien en attributs substituables. Phase 1 : applications."""
if vers in apps:
hote = apps[vers].get("hote")
srv = serveurs.get(hote, {})
_, seq = fonction_seq(str(hote))
d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
return {
"genre": "application",
"hote": hote,
"fqdn": f"{hote}.{domaine}" if domaine else str(hote),
"ip": d.get("adresse_ip"),
}
return None
def _substituer(valeur, attrs: dict):
"""Remplace {cible.X} dans une chaine ; laisse bool/int tels quels."""
if not isinstance(valeur, str):
return valeur
for cle, val in attrs.items():
valeur = valeur.replace("{cible." + cle + "}", str(val))
return valeur
def resoudre_liens(apps: dict, serveurs: dict, nomenclature: dict) -> dict:
"""Derive {hote: {var: valeur}} depuis les 'liens' des applications (bindings)."""
domaine = _domaine_interne()
injections: dict = {}
for nom_app, app in apps.items():
accepte = None
for lien in (app.get("liens") or []):
vers, role = lien.get("vers"), lien.get("role")
if accepte is None:
Trois preuves qui regardent au-dela d'une seule instance + champ liens/websocket au GUI Le harnais ne verifiait qu'UNE instance et le seul modele socle. Tout ce qui vit a cote du moteur echappait au controle. Trois preuves ferment ces angles morts : - P17 (scripts/modeles.py) : TOUS les modeles valident, pas seulement socle. SETOPS_MODELES=../Set-OPS-Modeles inclut les modeles assembles prives. A trouve 6 modeles invalides sur 7 (corriges dans Set-OPS-Modeles). - P18 (scripts/voute.py) : le gabarit vault.yml.example couvre EXACTEMENT les secrets que le plan exige (bases + roles actifs + group_vars). Ne dechiffre jamais la vraie voute : compare des noms. - P19 (scripts/couverture_gui.py) : tout champ present dans un plan reel est editable par le GUI. A trouve applications.websocket (comble). Nomenclature toleree (trou connu). GUI : - champ « Liens (bindings) » dans l'inspecteur d'application : role -> cible en listes deroulantes, les roles proposes = ceux que le role porteur accepte (meta/liens.yml). Comble un manque : les bindings ne se declaraient qu'en editant le YAML a la main. - champ « WebSocket » (Collabora). - CHAMPS_ECRITS_PAR_GUI : declaration de ce que le GUI sait ecrire, verifiee par P19. Garde-fou de fond : valider_applications refuse une application posee sur un hote non declare (l'hote fantome exact qu'integral portait). Cable partout + POST du GUI. liens_acceptes()/catalogue_liens() dans inventory_rules : source unique partagee par le validateur, le GUI et instancier.py (dont la copie locale est retiree). Valide : make verifier rc=0, CONFORME 19/19, ansible-lint 0 echec, 7 modeles valident, DIFF VIDE, node --check du GUI OK. Piece justificative : docs/audit/preuve-2026-07-22.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 21:32:42 -04:00
accepte = liens_acceptes(str(app.get("groupe", "")), RACINE)
spec = accepte.get(role)
if not spec:
raise ValueError(
f"application '{nom_app}': lien role '{role}' non accepte par "
f"'{app.get('groupe')}' (roles/{app.get('groupe')}/meta/liens.yml).")
attrs = _attributs_cible(str(vers), apps, serveurs, nomenclature, domaine)
if attrs is None:
raise ValueError(f"application '{nom_app}': cible de lien '{vers}' introuvable.")
attendu = spec.get("cible")
if attendu and attendu != attrs["genre"]:
raise ValueError(
f"application '{nom_app}': lien '{role}' attend une cible '{attendu}', "
f"'{vers}' est '{attrs['genre']}'.")
sur_conso = spec.get("injecte_sur", "consommateur") == "consommateur"
cible_hote = app.get("hote") if sur_conso else attrs["hote"]
for var, gabarit in (spec.get("variables") or {}).items():
injections.setdefault(cible_hote, {})[var] = _substituer(gabarit, attrs)
return injections
def generer() -> dict:
serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {})
apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {})
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
injections = resoudre_liens(apps, serveurs, nomenclature)
# Expositions du plan (champ 'expose') -> pour deriver les SANs de chaque edge.
domaines = charger_domaines(FICHIER_DOMAINES)
expositions = expositions_des_applications({"applications": apps}, domaines)
services_par_hote: dict = {}
for app in apps.values():
services_par_hote.setdefault(app.get("hote"), set()).add(app.get("groupe"))
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
# Politique d'integration : lue une fois, appliquee a tous (voir D-33).
universelles = integrations_universelles(RACINE)
dependances : un ecosysteme minimal n'est pas un ecosysteme incomplet Le controle de dependances a REFUSE le deploiement de patient 0 : client_journal requiert serveur_loki, client_metrique requiert serveur_prometheus, serveur_forgejo requiert serveur_postgresql et serveur_postfix. Quatre refus, une seule racine — le moteur suppose que tout ecosysteme porte tous les services. Apres les intrants (P32) et les bases (P35), troisieme manifestation en cinq jours. Et le mur que rencontrerait toute offre plus petite que l'ecosysteme de reference. UNE INTEGRATION UNIVERSELLE A BESOIN D'UN INTERLOCUTEUR. « Tout hote est mesure » est vrai dans un ecosysteme qui porte un Prometheus ; ailleurs, la meme phrase pose sur chaque machine un client qui n'a personne a qui parler. La regle est desormais DERIVEE : le service central d'une integration est celui que le registre des dependances lui donne deja. Rien de neuf a tenir a jour, donc rien de neuf a oublier. Chezlepro -> diff VIDE (tous ses services existent, rien ne change) patient 0 -> client_backup, client_pki, client_unbound UNE EXIGENCE N'EST PAS TOUJOURS ABSOLUE. Deux notions manquaient au registre : `sauf_si` l'exigence tombe sous condition (Forgejo + SQLite) `utilise_si_present` un agrement, jamais bloquant (Forgejo notifie SI un MTA existe) Les confondre obligeait une forge a deployer une pile courriel entiere pour exister. ET LA LECON D'HIER A SERVI : trois lecteurs avaient besoin le meme jour de lire une variable d'instance (P35, les clauses sauf_si, le generateur). Trois copies auraient recommence ce qu'on venait de refermer. Il y en a UNE, dans inventory_rules. make verifier 41/41 ; make ci 41/41 ; inventaire de Chezlepro identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:54:00 -04:00
# TOUS les groupes de service du plan : une integration universelle dont le
# service central n'existe pas ici n'a personne a qui parler (2026-08-22).
services_du_plan = {g for gs in services_par_hote.values() for g in gs}
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
réseau : le VNet d'une VM se dérive, un hyperviseur a plusieurs pattes (D-55 à D-59) Le pont n'était pas seulement non portable, il était faux. proxmox_clone_pont faisait naître les VM sur vmbr1 avec une étiquette VLAN — l'ancien monde. En SDN une VM appartient à son VNet ; c'est ce qu'il a fallu corriger à la main sur infra-pki-01, et les treize suivantes auraient suivi. deriver_nomenclature() expose désormais la zone de sécurité, instancier en dérive proxmox_pont et une étiquette VIDE — le VNet porte déjà le tag, en poser un second donnerait un double étiquetage. La chaîne va jusqu'à make creer-vm : SETOPS_PONT='t11appl', SETOPS_VLAN=''. Trois pièges. Un doublon dans le Makefile passait PONT_PROXMOX deux fois dans la même cible, la seconde vide aurait écrasé la valeur dérivée. Un repli naïf sur proxmox_vlan aurait fait revenir l'étiquette en SDN : le repli ne s'applique que si la clé est ABSENTE, jamais si elle est présente et vide. Et le test unitaire est tombé, à raison — il couvre maintenant cette distinction. D-55 : le dépôt réseau porte le contrat entre l'Alliance et ses hébergeurs, et abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN. Mesuré : un tenant est à deux valeurs de la portabilité complète (noeud, stockage). D-57 : l'interface sysadmin d'un hyperviseur (vmbr0, 10.0.0.41/.43/.47) n'a pas de route par défaut ; celle-ci vit sur vlan40, vers la frontière. On n'atteint l'administration que depuis son propre domaine de diffusion. Ça tranche la question de la sortie des nœuds laissée ouverte ce matin — option A, mais sur une interface dédiée, ce qui lève l'objection qui la bloquait. D-58 : un hôte déclare par quelle interface (`via`) chaque réseau lui arrive ; le devis en dérive un port par interface et son type — trunk 11,40 sur bond3, accès VLAN 10 sur vmbr0. Sans ça, ajouter le VLAN 10 le remettait sur le trunk du transport, soit le domaine qu'on venait d'en sortir. D-59 : un VLAN qui ne porte que des adresses d'hôte n'a pas besoin de pont. Régression créée puis corrigée : le modèle public, qui ne déclare aucun hyperviseur, n'émettait plus rien pour ce port. Il émet maintenant tout l'underlay en disant que c'est un repli. 30 preuves OK, 4 tests unitaires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:20:56 -04:00
# Rattachement reseau d'une VM. En SDN, elle appartient a son VNet — pas a un pont
# de l'hyperviseur avec une etiquette. Le nom du VNet se DERIVE (index + zone),
# exactement comme le VMID et l'adresse : rien a declarer, rien qui puisse diverger.
#
# Sans cette derivation, `make creer-vm` posait les VM sur `proxmox_clone_pont`
# (`vmbr1`) avec l'etiquette du VLAN — l'ancien monde. C'est ce qu'il a fallu
# corriger a la main sur infra-pki-01, et les treize suivantes auraient suivi.
#
# L'ETIQUETTE DOIT ETRE VIDE en SDN : le VNet la porte deja. En poser une seconde
# au niveau de la carte donnerait un double etiquetage.
sdn = underlay_mod.routage_tenants(underlay_mod.charger()) == "sdn"
index_tenant = nomenclature.get("index")
# UNE MACHINE SANS FONCTION DECLAREE N'A PAS D'ADRESSE — ET LE DISAIT PAS (2026-08-24).
#
# `deriver_nomenclature(...) or {}` avale l'echec : une fonction absente de
# `nomenclature.fonctions` rend un dictionnaire vide, et la machine entre dans
# l'inventaire avec `ansible_host: None` et `vlan: None`. La generation se declare
# REUSSIE. La panne n'apparait qu'au deploiement, sous une forme incomprehensible —
# Ansible tentant de joindre une adresse qui n'existe pas.
#
# Constate en ajoutant `ops-01` aux modeles : `presence-web` range son socle en zone 1
# (« Fondations ») et n'a pas de categorie 4. La machine a ete posee, la fonction non,
# et rien ne l'a signale.
#
# On refuse en BLOC plutot qu'a la premiere fautive : l'exploitant corrige son plan
# une fois, pas une machine a la fois.
# CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24).
#
# J'y avais ajoute une branche « adressage declare » pour y faire entrer les machines
# d'un SITE. C'etait le debut d'une confusion : un site et un tenant ne sont pas deux
# variantes d'une meme chose. Un site est le TERRAIN, un tenant ce qui y pousse.
#
# Le symptome etait visible tout de suite — une branche `si l'adresse est declaree`,
# un `nomenclature.yml` de site reduit a une coquille vide, et un site exclu des devis
# par ABSENCE d'index plutot que par nature. Une exclusion fondee sur un manque est
# une exclusion qu'un ajout innocent casserait.
#
# Les machines de l'hebergeur se declarent desormais dans `underlay.yml`, a cote des
# switches et des hyperviseurs — du mobilier de fabric. Ce qui se partage entre les
# deux mondes, ce sont les ROLES, pas la forme du plan.
sans_fonction = [
nom for nom, srv in serveurs.items()
if not (deriver_nomenclature(str(srv.get("fonction", "")),
fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip")
]
if sans_fonction:
connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)"
raise SystemExit(
"Machines sans adresse derivable — leur `fonction` n'est pas declaree dans "
"`nomenclature.fonctions` :\n"
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
+ f"Fonctions connues de ce plan : {connues}")
children: dict = {
"modeles_vm": {"hosts": {}},
"hotes_actifs": {"hosts": {}},
"hotes_planifies": {"hosts": {}},
}
for nom, srv in serveurs.items():
_, seq = fonction_seq(nom)
d = deriver_nomenclature(str(srv.get("fonction", "")), seq, nomenclature) or {}
hostvars = {
# Supernet du tenant, DERIVE du seed comme tout le reste. Il repond a la
# question « quels clients mon service doit-il accepter ? », posee par
# PostgreSQL (pg_hba), Postfix (reseaux de confiance) et tout service qui
# filtre par reseau. Le figer a la main dans group_vars, c'etait garantir
# qu'il devienne faux : les deux valeurs trouvees le 2026-08-07 dataient de
# l'ancien monde (10.11.0.0/16 pour un tenant en 10.27.0.0/16), avec un
# commentaire « AJUSTER » jamais suivi. PostgreSQL refusait Keycloak.
"setops_supernet": supernet_de(int(index_tenant)) if index_tenant is not None else None,
"ansible_host": d.get("adresse_ip"),
"ansible_user": "ansible",
"proxmox_cidr": d.get("cidr"),
"proxmox_passerelle": d.get("passerelle"),
"proxmox_vlan": d.get("vlan"),
"proxmox_vmid": int(d["vmid"]) if d.get("vmid") else None,
}
réseau : le VNet d'une VM se dérive, un hyperviseur a plusieurs pattes (D-55 à D-59) Le pont n'était pas seulement non portable, il était faux. proxmox_clone_pont faisait naître les VM sur vmbr1 avec une étiquette VLAN — l'ancien monde. En SDN une VM appartient à son VNet ; c'est ce qu'il a fallu corriger à la main sur infra-pki-01, et les treize suivantes auraient suivi. deriver_nomenclature() expose désormais la zone de sécurité, instancier en dérive proxmox_pont et une étiquette VIDE — le VNet porte déjà le tag, en poser un second donnerait un double étiquetage. La chaîne va jusqu'à make creer-vm : SETOPS_PONT='t11appl', SETOPS_VLAN=''. Trois pièges. Un doublon dans le Makefile passait PONT_PROXMOX deux fois dans la même cible, la seconde vide aurait écrasé la valeur dérivée. Un repli naïf sur proxmox_vlan aurait fait revenir l'étiquette en SDN : le repli ne s'applique que si la clé est ABSENTE, jamais si elle est présente et vide. Et le test unitaire est tombé, à raison — il couvre maintenant cette distinction. D-55 : le dépôt réseau porte le contrat entre l'Alliance et ses hébergeurs, et abstrait le matériel en encapsulant chaque tenant dans sa zone EVPN. Mesuré : un tenant est à deux valeurs de la portabilité complète (noeud, stockage). D-57 : l'interface sysadmin d'un hyperviseur (vmbr0, 10.0.0.41/.43/.47) n'a pas de route par défaut ; celle-ci vit sur vlan40, vers la frontière. On n'atteint l'administration que depuis son propre domaine de diffusion. Ça tranche la question de la sortie des nœuds laissée ouverte ce matin — option A, mais sur une interface dédiée, ce qui lève l'objection qui la bloquait. D-58 : un hôte déclare par quelle interface (`via`) chaque réseau lui arrive ; le devis en dérive un port par interface et son type — trunk 11,40 sur bond3, accès VLAN 10 sur vmbr0. Sans ça, ajouter le VLAN 10 le remettait sur le trunk du transport, soit le domaine qu'on venait d'en sortir. D-59 : un VLAN qui ne porte que des adresses d'hôte n'a pas besoin de pont. Régression créée puis corrigée : le modèle public, qui ne déclare aucun hyperviseur, n'émettait plus rien pour ce port. Il émet maintenant tout l'underlay en disant que c'est un repli. 30 preuves OK, 4 tests unitaires. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 16:20:56 -04:00
if sdn and index_tenant is not None and d.get("zone") is not None:
hostvars["proxmox_pont"] = vnet_de(int(index_tenant), d["zone_libelle"])
hostvars["proxmox_etiquette_vlan"] = "" # le VNet porte deja le tag
else:
hostvars["proxmox_etiquette_vlan"] = d.get("vlan")
for cle_srv, cle_var in PLACEMENT:
if str(srv.get(cle_srv, "")).strip():
hostvars[cle_var] = srv[cle_srv]
# Ressources derivees des logiciels (groupes de service) + socle SE.
# setdefault : une valeur explicite du plan (PLACEMENT ci-dessus) gagne.
ressources = deriver_ressources(sorted(services_par_hote.get(nom, set())), ROLES)
hostvars.setdefault("proxmox_coeurs", ressources["coeurs"])
hostvars.setdefault("proxmox_memoire", ressources["memoire"])
hostvars.setdefault("proxmox_disque_taille", ressources["disque"])
# Variables derivees des liens (bindings) declares par les applications.
for var, val in injections.get(nom, {}).items():
hostvars[var] = val
intégrations : le rôle déclare sa politique ; le cluster passe à l'hébergeur Deux corrections de propriété, l'une dans le plan, l'autre dans les intrants. 1. Intégrations universelles (D-33/D-34, P26) Le plan portait 57 lignes d'intégration écrites à la main, dont 28 disaient oui à quelque chose de vrai pour tous les hôtes. Elles n'existaient que pour être oubliées — et elles l'avaient été : dans Chezlepro, backup-01 et infra-pki-01 n'étaient ni supervisés, ni journalisés, ni certifiés. Le rôle déclare désormais sa politique une fois, dans meta/integration.yml ; le plan ne garde que les vrais choix et refuse la recopie. Les exemptions se dérivent du service rendu (sauf_role), jamais d'un nom d'hôte : l'AC ne s'enrôle pas auprès d'elle-même, et l'exemption suit step-ca si on le déplace. Une seule fonction de résolution — integrations_de() — lue par l'inventaire, la voûte et le panneau. Sans le passage par la voûte, les secrets des intégrations universelles auraient cessé d'être exigés et P18 serait passé au vert sur une voûte incomplète. Vérifié : diff vide sur Technolibre (la politique reproduit exactement les 41 lignes retirées) ; sur Chezlepro, exactement les groupes manquants, et pas client_pki sur infra-pki-01. 2. Vue Intégrations : la matrice La fiche montrait les intégrations d'UN serveur ; le trou de Chezlepro n'a pas été trouvé par le panneau mais par le devis de pare-feu. Matrice serveurs x intégrations : colonnes de politique en lecture seule, facultatives cochables sur place, ligne de couverture n/N qui rend le motif visible sans le juger. 3. Propriété des intrants (D-35/D-36, P27) Le cluster Proxmox appartient à l'hébergeur, comme sa fabric et sa frontière. Recopié chez chaque tenant, son inventaire avait déjà divergé : deux listes de stockages contradictoires pour le même matériel. API/nœuds/stockages/ponts vont dans proxmox-hebergeur.yml, à côté d'underlay.yml, dont le chemin se dérive — l'hébergeur reste non déclaré (D-17). Restent au tenant son golden template et ses défauts de placement. Le panneau nomme désormais le propriétaire de chaque section : éditer une section « hébergeur » vaut pour tous ses tenants, et l'écran ne le disait pas. 26 preuves OK, 0 échec. --syntax-check des deux playbooks Proxmox. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:09:22 -04:00
services = services_par_hote.get(nom, set())
dependances : un ecosysteme minimal n'est pas un ecosysteme incomplet Le controle de dependances a REFUSE le deploiement de patient 0 : client_journal requiert serveur_loki, client_metrique requiert serveur_prometheus, serveur_forgejo requiert serveur_postgresql et serveur_postfix. Quatre refus, une seule racine — le moteur suppose que tout ecosysteme porte tous les services. Apres les intrants (P32) et les bases (P35), troisieme manifestation en cinq jours. Et le mur que rencontrerait toute offre plus petite que l'ecosysteme de reference. UNE INTEGRATION UNIVERSELLE A BESOIN D'UN INTERLOCUTEUR. « Tout hote est mesure » est vrai dans un ecosysteme qui porte un Prometheus ; ailleurs, la meme phrase pose sur chaque machine un client qui n'a personne a qui parler. La regle est desormais DERIVEE : le service central d'une integration est celui que le registre des dependances lui donne deja. Rien de neuf a tenir a jour, donc rien de neuf a oublier. Chezlepro -> diff VIDE (tous ses services existent, rien ne change) patient 0 -> client_backup, client_pki, client_unbound UNE EXIGENCE N'EST PAS TOUJOURS ABSOLUE. Deux notions manquaient au registre : `sauf_si` l'exigence tombe sous condition (Forgejo + SQLite) `utilise_si_present` un agrement, jamais bloquant (Forgejo notifie SI un MTA existe) Les confondre obligeait une forge a deployer une pile courriel entiere pour exister. ET LA LECON D'HIER A SERVI : trois lecteurs avaient besoin le meme jour de lire une variable d'instance (P35, les clauses sauf_si, le generateur). Trois copies auraient recommence ce qu'on venait de refermer. Il y en a UNE, dans inventory_rules. make verifier 41/41 ; make ci 41/41 ; inventaire de Chezlepro identique. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:54:00 -04:00
groupes = set(GROUPES_SOCLE) | services | integrations_de(srv, services, universelles, services_du_plan=services_du_plan)
# SANs d'exposition : les FQDN que cet edge sert (derives du plan) -> cert edge
# (client_pki_sans), au lieu d'une liste manuelle. Vide si l'hote n'est pas un edge.
sans = sorted({e["fqdn"] for e in expositions if e.get("edge") in groupes})
if sans:
hostvars["sans_exposition"] = sans
etat = "hotes_actifs" if srv.get("etat") == "actif" else "hotes_planifies"
children[etat]["hosts"][nom] = hostvars
for groupe in sorted(groupes):
children.setdefault(groupe, {"hosts": {}})["hosts"][nom] = None
return {"all": {"children": children}}
def ecrire(path: Path = GENERE) -> None:
entete = ("# Inventaire GENERE depuis le plan (make instancier / instancier-appliquer).\n"
"# NE PAS editer a la main : edite instance/plan/serveurs.yml + instance/plan/applications.yml.\n"
"# Source : instance/plan/serveurs.yml + instance/plan/applications.yml + instance/plan/nomenclature.yml.\n")
with path.open("w", encoding="utf-8") as fichier:
fichier.write(entete)
yaml.safe_dump(generer(), fichier, default_flow_style=False, sort_keys=True, allow_unicode=True)
def _resolu(fichier: Path) -> tuple[dict, dict]:
cmd = ["ansible-inventory", "-i", str(fichier), "--list"]
# Une voute chiffree (group_vars/all/vault.yml) fait echouer ansible-inventory sans
# mot de passe (exit 4). Si ANSIBLE_VAULT_PASSWORD_FILE n'est pas deja fourni, on
# tente le fichier conventionnel Set-OPS ~/.config/setops-vault-pass.
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE"):
conv = Path.home() / ".config" / "setops-vault-pass"
if conv.is_file():
cmd += ["--vault-password-file", str(conv)]
sortie = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
data = json.loads(sortie)
hostvars = data.get("_meta", {}).get("hostvars", {})
groupes: dict = {}
for groupe, info in data.items():
if groupe in ("_meta", "all", "ungrouped") or not isinstance(info, dict):
continue
for hote in (info.get("hosts") or []):
groupes.setdefault(hote, set()).add(groupe)
return hostvars, groupes
def compter_ecarts(reference: Path, genere: Path) -> int:
hv_act, grp_act = _resolu(reference)
hv_gen, grp_gen = _resolu(genere)
hotes = sorted(set(hv_act) | set(hv_gen))
ecarts = 0
for hote in hotes:
if hote not in hv_gen:
print(f" - {hote} : ABSENT du genere"); ecarts += 1; continue
if hote not in hv_act:
print(f" - {hote} : EN TROP dans le genere"); ecarts += 1; continue
g_manquants = grp_act.get(hote, set()) - grp_gen.get(hote, set())
g_surplus = grp_gen.get(hote, set()) - grp_act.get(hote, set())
vars_diff = [k for k in set(hv_act[hote]) | set(hv_gen[hote])
if hv_act[hote].get(k) != hv_gen[hote].get(k)]
if g_manquants or g_surplus or vars_diff:
ecarts += 1
print(f" - {hote} :")
if g_manquants:
print(f" groupes non reproduits : {', '.join(sorted(g_manquants))}")
if g_surplus:
print(f" groupes en trop : {', '.join(sorted(g_surplus))}")
if vars_diff:
details = ", ".join(f"{k} (inv={hv_act[hote].get(k)!r} gen={hv_gen[hote].get(k)!r})" for k in sorted(vars_diff))
print(f" vars differentes : {details}")
return ecarts
def comparer(strict: bool = False) -> int:
"""Compare le genere a l'applique. `strict` fait ECHOUER sur un ecart.
DEUX APPELANTS, DEUX BESOINS. `make instancier` est une INSPECTION : on veut voir
le diff avant de decider, et un code d'erreur y transformerait la lecture en panne.
La preuve P03, elle, AFFIRME que « le plan reproduit l'inventaire » — sans `strict`,
elle renvoyait 0 quel que soit le nombre d'hotes divergents et ne pouvait donc pas
echouer. Constate le 2026-08-12 : quatorze hotes divergeaient, la preuve passait.
"""
ecrire(GENERE)
ecarts = compter_ecarts(INVENTAIRE, GENERE)
if ecarts == 0:
print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.")
return 0
print(f"\n{ecarts} hote(s) avec ecart.")
if strict:
print("L'inventaire APPLIQUE ne correspond plus au plan. Soit le plan a change et "
"il faut le basculer (`make instancier-appliquer`), soit hosts.yml a ete "
"edite a la main — ce que le depot interdit.")
return 1 if strict else 0
def appliquer(force: bool = False) -> int:
"""Ecrit hosts.yml depuis le plan. Refuse si le diff n'est pas vide (sauf --force)."""
ecrire(GENERE)
ecarts = compter_ecarts(INVENTAIRE, GENERE)
if ecarts and not force:
print(f"\nDiff NON vide ({ecarts} hote(s)). Revois 'make instancier'. "
"Utilise FORCE=1 pour appliquer un changement intentionnel du plan.")
return 1
ecrire(INVENTAIRE)
print(f"\n{os.path.relpath(INVENTAIRE, RACINE)} (re)genere depuis le plan. "
"git est le filet : git diff / git checkout pour revenir.")
return 0
def main() -> int:
import argparse
parser = argparse.ArgumentParser(description="Generateur d'inventaire Set-OPS (Phase 3).")
sub = parser.add_subparsers(dest="commande", required=True)
sub.add_parser("generer", help="Ecrit hosts.genere.yml depuis le plan.")
sp_cmp = sub.add_parser("comparer", help="Compare le genere a l'inventaire actuel (semantique).")
sp_cmp.add_argument("--strict", action="store_true",
help="Sortir en erreur si le diff n'est pas vide (P03).")
pa = sub.add_parser("appliquer", help="Ecrit hosts.yml depuis le plan (refuse si diff non vide).")
pa.add_argument("--force", action="store_true", help="Applique meme si le diff n'est pas vide.")
args = parser.parse_args()
try:
if args.commande == "generer":
ecrire()
print(f"{os.path.relpath(GENERE, RACINE)} genere depuis le plan.")
elif args.commande == "comparer":
return comparer(strict=getattr(args, "strict", False))
elif args.commande == "appliquer":
return appliquer(force=args.force)
except Exception as exc:
print(f"erreur: {exc}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())