2026-06-24 20:17:46 -04:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
"""Generateur d'inventaire Set-OPS (Phase 3) : plan -> hosts.yml.
|
|
|
|
|
|
2026-06-30 16:08:25 -04:00
|
|
|
NON destructif : ecrit hosts.genere.yml a cote de l'inventaire de l'instance et compare
|
2026-06-24 20:17:46 -04:00
|
|
|
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).
|
2026-06-24 20:17:46 -04:00
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
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
|
2026-09-13 19:57:11 -04:00
|
|
|
from inventory_rules import ( # noqa: E402
|
plan : l ecriture des registres devient atomique — tout, ou rien
`path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide.
Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et
instance/plan/serveurs.yml reste mutile.
L asymetrie fait la gravite : hosts.yml se regenere d un
make instancier-appliquer, le PLAN ne se regenere de rien. C est la source
unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe.
TREIZE SITES, UNE SEULE FONCTION
Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont
les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la
divergence que P41 garde depuis les neuf resolutions d instance. La fonction
vit donc dans inventory_rules.py, que les sept importaient deja. Une source,
pas douze.
TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT »
temporaire dans le MEME dossier os.replace n est atomique qu au sein d un
meme systeme de fichiers ; un /tmp sur une
autre partition casserait la garantie sans
rien dire
fsync AVANT le rename sinon le renommage peut atteindre le disque
avant le contenu : au retour d une coupure
brutale, un fichier neuf et VIDE — le defaut
qu on ferme, deplace d un cran
report des droits mkstemp cree en 0600, le plan est en 0664 et
doit rester lisible par le groupe sur les
runners
LE TEST PORTE SON PROPRE CONTROLE NEGATIF
scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et
verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne
prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une
garantie qu on n a jamais vue echouer n est pas une garantie, c est une
habitude.
Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance
maintenant trois tests. Corrige au passage.
LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT
Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot
de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ».
Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les
vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un
PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une
recette make recoit reellement : le mot de passe saisi ET les cinq cles
calculees par voutes.py.
Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est
l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne
couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST
le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne
« simplifie » en retirant l un des deux.
make prouver : CONFORME, 59 OK, 0 echec, 1 saute.
make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00
|
|
|
ecriture_atomique,
|
2026-06-24 20:17:46 -04:00
|
|
|
charger_applications,
|
2026-07-05 15:30:22 -04:00
|
|
|
charger_domaines,
|
2026-06-24 20:17:46 -04:00
|
|
|
charger_nomenclature,
|
|
|
|
|
charger_serveurs,
|
|
|
|
|
deriver_nomenclature,
|
2026-06-30 10:07:04 -04:00
|
|
|
deriver_ressources,
|
2026-09-13 19:57:11 -04:00
|
|
|
plancher_memoire,
|
2026-07-05 15:30:22 -04:00
|
|
|
expositions_des_applications,
|
2026-06-24 20:17:46 -04:00
|
|
|
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,
|
2026-07-22 21:32:42 -04:00
|
|
|
liens_acceptes,
|
2026-08-07 10:33:25 -04:00
|
|
|
supernet_de,
|
2026-06-24 20:17:46 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
2026-06-30 10:07:04 -04:00
|
|
|
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()
|
2026-06-30 16:08:25 -04:00
|
|
|
|
|
|
|
|
|
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)
|
2026-06-30 16:08:25 -04:00
|
|
|
GENERE = INVENTAIRE.with_name("hosts.genere.yml")
|
2026-06-24 20:17:46 -04:00
|
|
|
FICHIER_SERVEURS = INSTANCE / "plan/serveurs.yml"
|
|
|
|
|
FICHIER_APPLICATIONS = INSTANCE / "plan/applications.yml"
|
|
|
|
|
FICHIER_NOMENCLATURE = INSTANCE / "plan/nomenclature.yml"
|
2026-07-05 15:30:22 -04:00
|
|
|
FICHIER_DOMAINES = INSTANCE / "plan/domaines.yml"
|
2026-06-24 20:17:46 -04:00
|
|
|
|
|
|
|
|
GROUPES_SOCLE = ["serveur_debian", "serveur_durci"]
|
|
|
|
|
PLACEMENT = [("noeud", "proxmox_noeud"), ("stockage", "proxmox_stockage"),
|
|
|
|
|
("disque", "proxmox_disque_taille"), ("memoire", "proxmox_memoire"),
|
|
|
|
|
("coeurs", "proxmox_coeurs")]
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 08:33:30 -04:00
|
|
|
# --- 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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
reconstruction : quatre defauts que seule une flotte rasee pouvait montrer
Chezlepro detruite (15 VM, disques compris) et refaite depuis le gabarit
minimal. 15/15 hotes, 0 echec ; make valider passe, test de restitution
compris. Aucun defaut ne venait de la flotte ni du gabarit.
1. Un avertissement n est pas un echec. Proxmox rend WARNINGS: n pour une
tache ABOUTIE ; la garde n acceptait que OK et declarait perdues cinq VM
clonees a 100 pourcent. Le message parlait d etat stopped — celui de la
TACHE, pas de la VM.
2. client_artefacts se contredisait : son commentaire disait de degrader, son
code arretait. L autorite monte en premier, donc avant le cache du
locataire : aucun ordre ne pouvait satisfaire la garde.
3. harden-below-nxdomain etendait le NXDOMAIN signe de la racine pour le TLD
internal a toute la zone du site, sans jamais interroger l autoritatif.
Declencheur : toute question sur un nom absent sous internal, y compris la
zone d un autre locataire. Le cache contenait la bonne reponse ET un
message negatif ; c est le negatif qui etait servi.
aggressive-nsec: no avait semble marcher — c est le redemarrage qui vidait
le cache, pas le reglage.
4. Un locataire doit savoir a qui demander la zone de son hebergeur, sans quoi
il ne peut plus nommer son depot de sauvegarde. La derivation prenait
dns_amorcage pour le resolveur du site : faux chez Technolibre, dont
l amorcage est 9.9.9.9. P03 l a attrape avant tout deploiement.
Au passage : instancier tentait encore le mot de passe unique d avant la
separation des voutes ; comparer echouait en exit 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 09:59:15 -04:00
|
|
|
def _zones_deleguees() -> list:
|
|
|
|
|
"""La zone de l'HEBERGEUR, et l'adresse a qui la demander. [] si aucune carte.
|
|
|
|
|
|
|
|
|
|
POURQUOI CETTE DERIVATION EXISTE (mesure du 2026-09-02, reconstruction de Chezlepro
|
|
|
|
|
depuis zero). Un locataire depend de services du SITE par leur NOM : son cache
|
|
|
|
|
d'artefacts, sa forge, son depot de sauvegarde, son autorite. Ces noms vivent dans une
|
|
|
|
|
zone que SON resolveur ne sert pas.
|
|
|
|
|
|
|
|
|
|
Tant que ses machines pointaient directement sur le resolveur du site, ca marchait —
|
|
|
|
|
par accident. Reconstruites proprement, elles utilisent LEUR resolveur, qui ignorait
|
|
|
|
|
la zone de l'hebergeur : la recette a echoue sur la RESTITUTION d'une sauvegarde, avec
|
|
|
|
|
« Could not resolve hostname sauvegarde.genese.internal ». La sauvegarde etait
|
|
|
|
|
intacte ; c'est le chemin pour la NOMMER qui manquait.
|
|
|
|
|
|
|
|
|
|
DERIVEE DU PLAN DU SITE, PAS DE `dns_amorcage` — et la nuance a failli couter cher.
|
|
|
|
|
La premiere version prenait `dns_amorcage` pour « l'adresse du resolveur du site ».
|
|
|
|
|
C'est vrai chez Chezlepro ; c'est FAUX chez Technolibre, dont l'amorcage pointe sur
|
|
|
|
|
`9.9.9.9,149.112.112.112`. On aurait delegue la zone souveraine de l'hebergeur a
|
|
|
|
|
Quad9, qui n'en sait rien — et le NXDOMAIN rendu aurait ressemble a une zone vide.
|
|
|
|
|
P03 l'a attrape avant tout deploiement, en comparant l'inventaire de CHAQUE instance
|
|
|
|
|
a son plan.
|
|
|
|
|
#
|
|
|
|
|
L'adresse vient donc du PLAN DU SITE : la machine qui y porte `serveur_resolveur`.
|
|
|
|
|
C'est la seule source qui dise, par construction, qui resout la zone du site.
|
|
|
|
|
|
|
|
|
|
REND [] QUAND LA CARTE N'EST PAS MONTEE, et c'est le comportement d'un EMANCIPE : il
|
|
|
|
|
ne delegue plus rien, donc ne peut plus nommer les services du site. C'est ce qu'on
|
|
|
|
|
veut CONSTATER d'une emancipation, pas une panne a reparer.
|
|
|
|
|
"""
|
|
|
|
|
try:
|
|
|
|
|
import underlay as underlay_mod
|
|
|
|
|
zone = str((underlay_mod.lire_plan_site("10-intrants.yml") or {})
|
|
|
|
|
.get("domaine_interne") or "").strip()
|
|
|
|
|
except Exception:
|
|
|
|
|
return []
|
|
|
|
|
# Un site qui s'heberge lui-meme partage la zone de son instance : il n'y a alors
|
|
|
|
|
# rien a deleguer, et se declarer transitaire de soi-meme ferait une boucle.
|
|
|
|
|
if not zone or zone == _domaine_interne():
|
|
|
|
|
return []
|
|
|
|
|
try:
|
|
|
|
|
adresses = [a for a in underlay_mod.adresses_site_portant("serveur_resolveur") if a]
|
|
|
|
|
except Exception:
|
|
|
|
|
adresses = []
|
|
|
|
|
if not adresses:
|
|
|
|
|
return []
|
|
|
|
|
return [{"nom": zone, "adresses": adresses}]
|
|
|
|
|
|
|
|
|
|
|
2026-07-03 08:33:30 -04:00
|
|
|
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:
|
2026-07-22 21:32:42 -04:00
|
|
|
accepte = liens_acceptes(str(app.get("groupe", "")), RACINE)
|
2026-07-03 08:33:30 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-24 20:17:46 -04:00
|
|
|
def generer() -> dict:
|
|
|
|
|
serveurs = charger_serveurs(FICHIER_SERVEURS).get("serveurs", {})
|
|
|
|
|
apps = charger_applications(FICHIER_APPLICATIONS).get("applications", {})
|
|
|
|
|
nomenclature = charger_nomenclature(FICHIER_NOMENCLATURE)
|
2026-07-03 08:33:30 -04:00
|
|
|
injections = resoudre_liens(apps, serveurs, nomenclature)
|
2026-07-05 15:30:22 -04:00
|
|
|
# Expositions du plan (champ 'expose') -> pour deriver les SANs de chaque edge.
|
|
|
|
|
domaines = charger_domaines(FICHIER_DOMAINES)
|
|
|
|
|
expositions = expositions_des_applications({"applications": apps}, domaines)
|
2026-06-24 20:17:46 -04:00
|
|
|
|
2026-09-10 20:38:52 -04:00
|
|
|
# LE NOM PUBLIC D'UN SERVICE VIENT DU PLAN, PAS DU ROLE (2026-09-10).
|
|
|
|
|
#
|
|
|
|
|
# Quatre roles — grafana, forgejo, nextcloud, keycloak — portaient en defaut une
|
|
|
|
|
# DEVINETTE du nom sous lequel ils sont servis (`grafana.{{ domaine_interne }}`).
|
|
|
|
|
# Tant que le plan suit la meme convention, la devinette tombe juste et personne ne
|
|
|
|
|
# voit qu'il y a deux sources. Renommer une exposition la fait mentir : nginx sert le
|
|
|
|
|
# nouveau nom, le certificat le porte, la zone le publie — et le service continue de
|
|
|
|
|
# fabriquer ses URL de retour avec l'ancien. Le SSO renvoie alors dans le vide.
|
|
|
|
|
#
|
|
|
|
|
# On derive donc `<groupe>_hostname` de l'exposition declaree, quand elle est UNIQUE
|
|
|
|
|
# (deux expositions ne designent aucun nom canonique : on laisse le role choisir).
|
|
|
|
|
# `setdefault` cote hote : une valeur posee a la main dans les group_vars gagne encore.
|
2026-09-15 11:47:50 -04:00
|
|
|
# PAR HOTE *ET* PAR GROUPE, PAS PAR GROUPE SEUL (2026-09-15).
|
|
|
|
|
#
|
|
|
|
|
# Un ROLE peut servir DEUX applications sur DEUX machines. `serveur_oauth2_proxy` en
|
|
|
|
|
# est l'exemple : une instance devant la vigie sur le noeud de supervision, une autre
|
|
|
|
|
# devant la console sur le runner. Deux passerelles, deux clients Keycloak, deux noms.
|
|
|
|
|
#
|
|
|
|
|
# Indexee par groupe seul, la table gardait le DERNIER nom rencontre et le donnait a
|
|
|
|
|
# TOUS les hotes du groupe. Mesure du 2026-09-15 sur Chezlepro :
|
|
|
|
|
#
|
|
|
|
|
# mon-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
|
|
|
|
|
# ops-01 serveur_oauth2_proxy_hostname = console.chezlepro.internal
|
|
|
|
|
#
|
|
|
|
|
# La passerelle de la VIGIE se croyait donc la console : son URL de retour OIDC aurait
|
|
|
|
|
# vise l'autre machine, et le SSO de la supervision serait tombe. Le defaut du
|
|
|
|
|
# 2026-09-10 — « le service porte le nom que son role devine » — reapparaissait par
|
|
|
|
|
# une autre porte, et P67 ne le voyait pas puisqu'elle comparait a la valeur qu'elle
|
|
|
|
|
# derivait elle-meme de la meme facon.
|
|
|
|
|
#
|
|
|
|
|
# Une application nomme un COUPLE (machine, role). C'est la clef juste.
|
|
|
|
|
hostname_par_hote_groupe: dict = {}
|
2026-09-10 20:38:52 -04:00
|
|
|
for app in apps.values():
|
|
|
|
|
exp = [str(f).strip() for f in (app.get("expose") or []) if str(f).strip()]
|
2026-09-15 11:47:50 -04:00
|
|
|
groupe, hote = str(app.get("groupe") or ""), str(app.get("hote") or "")
|
|
|
|
|
if groupe and hote and len(exp) == 1:
|
|
|
|
|
hostname_par_hote_groupe[(hote, groupe)] = exp[0]
|
2026-09-10 20:38:52 -04:00
|
|
|
|
l heure vient de la frontiere : la derniere dependance vivante tombe
Mesure sur les quatorze : aucune sortie TCP vers une adresse publique, apt
par le cache, noms autoritaires en local, unattended-upgrades masked. Et
quatre pairs NTP publics par machine. Le role chrony posait le fuseau et
installait le demon sans jamais toucher a ses sources : le defaut de Debian
tenait depuis le premier jour, herite et jamais choisi.
L autorite est la frontiere, par decision de l exploitant. Elle etait deja
stratum 2 et ecoutait en 123 ; il ne manquait que le passage. 9 anciennes
regles port 123 vers !SETOPS_INTERNES retirees, 9 regles nommees vers
SETOPS_FRONTIERE posees : le changement resserre autant qu il centralise.
Deux chemins parce que la topologie en a deux. Un tenant n atteint pas la
frontiere par sa passerelle de zone — tenue par le SDN — mais par le lien de
transit. Une machine du site a la frontiere pour passerelle directe. Les deux
valeurs sont derivees de l underlay, via reseau_transit() plutot que d un
prefixe d adresse qui aurait menti chez le prochain hebergeur.
La patte face aux tenants manquait a opnsense_if_zones, pour la meme raison
que grappe-controle la veille.
Sonde horloge ecrite en meme temps : synchronisee ET contre la source
DECLAREE. Une machine peut etre parfaitement a l heure contre quatre serveurs
publics — c est exactement l etat d avant.
14 tenant + 7 site, toutes disciplinees, sources publiques = 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-11 12:08:13 -04:00
|
|
|
# La patte de la frontiere que les tenants traversent — calculee une fois.
|
|
|
|
|
_temps_frontiere: list = []
|
|
|
|
|
try:
|
|
|
|
|
_carte = underlay_mod.charger()
|
|
|
|
|
if _carte:
|
|
|
|
|
# LE LIEN DE TRANSIT SE DECLARE, IL NE SE DEVINE PAS. `reseau_transit()`
|
|
|
|
|
# le derive de `passerelle_sortie` — le champ qui dit « c'est par la qu'on
|
|
|
|
|
# sort ». Le reconnaitre a son prefixe d'adresse aurait marche ici et menti
|
|
|
|
|
# chez le prochain hebergeur.
|
|
|
|
|
_t = underlay_mod.reseau_transit(_carte) or {}
|
|
|
|
|
_res_transit = {str(_t.get("nom"))} if _t.get("nom") else set()
|
|
|
|
|
_temps_frontiere = sorted({str(h["ip"]) for h in underlay_mod.hotes(_carte)
|
|
|
|
|
if h.get("role") == "frontiere" and h.get("ip")
|
|
|
|
|
and str(h.get("reseau")) in _res_transit
|
|
|
|
|
and str(h.get("etat", "actif")) != "reserve"})
|
|
|
|
|
except Exception:
|
|
|
|
|
_temps_frontiere = []
|
|
|
|
|
|
2026-06-24 20:17:46 -04:00
|
|
|
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")
|
|
|
|
|
|
2026-08-24 11:42:32 -04:00
|
|
|
# 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.
|
2026-08-24 23:02:05 -04:00
|
|
|
# CE GENERATEUR EST CELUI DES TENANTS, ET DE PERSONNE D'AUTRE (2026-08-24).
|
2026-08-24 20:35:52 -04:00
|
|
|
#
|
2026-08-24 23:02:05 -04:00
|
|
|
# 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.
|
2026-08-24 20:35:52 -04:00
|
|
|
#
|
2026-08-24 23:02:05 -04:00
|
|
|
# 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.
|
2026-08-24 11:42:32 -04:00
|
|
|
sans_fonction = [
|
|
|
|
|
nom for nom, srv in serveurs.items()
|
2026-08-24 23:02:05 -04:00
|
|
|
if not (deriver_nomenclature(str(srv.get("fonction", "")),
|
|
|
|
|
fonction_seq(nom)[1], nomenclature) or {}).get("adresse_ip")
|
2026-08-24 11:42:32 -04:00
|
|
|
]
|
|
|
|
|
if sans_fonction:
|
|
|
|
|
connues = ", ".join(sorted((nomenclature.get("fonctions") or {}))) or "(aucune)"
|
|
|
|
|
raise SystemExit(
|
2026-08-24 23:02:05 -04:00
|
|
|
"Machines sans adresse derivable — leur `fonction` n'est pas declaree dans "
|
|
|
|
|
"`nomenclature.fonctions` :\n"
|
2026-08-24 11:42:32 -04:00
|
|
|
+ "".join(f" - {n} : fonction « {serveurs[n].get('fonction', '')} »\n" for n in sans_fonction)
|
|
|
|
|
+ f"Fonctions connues de ce plan : {connues}")
|
|
|
|
|
|
reconstruction : quatre defauts que seule une flotte rasee pouvait montrer
Chezlepro detruite (15 VM, disques compris) et refaite depuis le gabarit
minimal. 15/15 hotes, 0 echec ; make valider passe, test de restitution
compris. Aucun defaut ne venait de la flotte ni du gabarit.
1. Un avertissement n est pas un echec. Proxmox rend WARNINGS: n pour une
tache ABOUTIE ; la garde n acceptait que OK et declarait perdues cinq VM
clonees a 100 pourcent. Le message parlait d etat stopped — celui de la
TACHE, pas de la VM.
2. client_artefacts se contredisait : son commentaire disait de degrader, son
code arretait. L autorite monte en premier, donc avant le cache du
locataire : aucun ordre ne pouvait satisfaire la garde.
3. harden-below-nxdomain etendait le NXDOMAIN signe de la racine pour le TLD
internal a toute la zone du site, sans jamais interroger l autoritatif.
Declencheur : toute question sur un nom absent sous internal, y compris la
zone d un autre locataire. Le cache contenait la bonne reponse ET un
message negatif ; c est le negatif qui etait servi.
aggressive-nsec: no avait semble marcher — c est le redemarrage qui vidait
le cache, pas le reglage.
4. Un locataire doit savoir a qui demander la zone de son hebergeur, sans quoi
il ne peut plus nommer son depot de sauvegarde. La derivation prenait
dns_amorcage pour le resolveur du site : faux chez Technolibre, dont
l amorcage est 9.9.9.9. P03 l a attrape avant tout deploiement.
Au passage : instancier tentait encore le mot de passe unique d avant la
separation des voutes ; comparer echouait en exit 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 09:59:15 -04:00
|
|
|
zones_deleguees = _zones_deleguees()
|
2026-06-24 20:17:46 -04:00
|
|
|
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 = {
|
2026-08-07 10:33:25 -04:00
|
|
|
# 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,
|
2026-06-24 20:17:46 -04:00
|
|
|
"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")
|
2026-06-24 20:17:46 -04:00
|
|
|
for cle_srv, cle_var in PLACEMENT:
|
|
|
|
|
if str(srv.get(cle_srv, "")).strip():
|
|
|
|
|
hostvars[cle_var] = srv[cle_srv]
|
2026-06-30 10:07:04 -04:00
|
|
|
# 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"])
|
2026-09-13 19:57:11 -04:00
|
|
|
# LE PLANCHER — ce que l'hote peut REPRENDRE, derive de ce que la machine PORTE.
|
|
|
|
|
# Voir `inventory_rules.plancher_memoire` : une base de donnees garde un plancher
|
|
|
|
|
# haut, un service au repos non. `setdefault` laisse le plan trancher.
|
|
|
|
|
hostvars.setdefault("proxmox_memoire_min", plancher_memoire(
|
|
|
|
|
int(hostvars["proxmox_memoire"]), sorted(services_par_hote.get(nom, set()))))
|
2026-06-30 10:07:04 -04:00
|
|
|
hostvars.setdefault("proxmox_disque_taille", ressources["disque"])
|
2026-07-03 08:33:30 -04:00
|
|
|
# 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)
|
2026-07-05 15:30:22 -04:00
|
|
|
# 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
|
l heure vient de la frontiere : la derniere dependance vivante tombe
Mesure sur les quatorze : aucune sortie TCP vers une adresse publique, apt
par le cache, noms autoritaires en local, unattended-upgrades masked. Et
quatre pairs NTP publics par machine. Le role chrony posait le fuseau et
installait le demon sans jamais toucher a ses sources : le defaut de Debian
tenait depuis le premier jour, herite et jamais choisi.
L autorite est la frontiere, par decision de l exploitant. Elle etait deja
stratum 2 et ecoutait en 123 ; il ne manquait que le passage. 9 anciennes
regles port 123 vers !SETOPS_INTERNES retirees, 9 regles nommees vers
SETOPS_FRONTIERE posees : le changement resserre autant qu il centralise.
Deux chemins parce que la topologie en a deux. Un tenant n atteint pas la
frontiere par sa passerelle de zone — tenue par le SDN — mais par le lien de
transit. Une machine du site a la frontiere pour passerelle directe. Les deux
valeurs sont derivees de l underlay, via reseau_transit() plutot que d un
prefixe d adresse qui aurait menti chez le prochain hebergeur.
La patte face aux tenants manquait a opnsense_if_zones, pour la meme raison
que grappe-controle la veille.
Sonde horloge ecrite en meme temps : synchronisee ET contre la source
DECLAREE. Une machine peut etre parfaitement a l heure contre quatre serveurs
publics — c est exactement l etat d avant.
14 tenant + 7 site, toutes disciplinees, sources publiques = 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-11 12:08:13 -04:00
|
|
|
# L'AUTORITE DE TEMPS, DERIVEE DE L'UNDERLAY (2026-09-11).
|
|
|
|
|
#
|
|
|
|
|
# Un tenant atteint la frontiere par le LIEN DE TRANSIT : ses passerelles de zone
|
|
|
|
|
# (10.17.x.1) sont tenues par le SDN de Proxmox, pas par elle. On prend donc la
|
|
|
|
|
# patte de la frontiere active posee sur le reseau que les tenants traversent.
|
|
|
|
|
#
|
|
|
|
|
# `etat: reserve` est exclu : la seconde frontiere est declaree et ne porte encore
|
|
|
|
|
# aucune adresse. Vide si aucun underlay — le role ne touchera alors a rien.
|
|
|
|
|
if _temps_frontiere:
|
|
|
|
|
hostvars.setdefault("chrony_serveurs", _temps_frontiere)
|
|
|
|
|
|
2026-09-10 20:38:52 -04:00
|
|
|
# Nom public de chaque service porte par cet hote, tel que le plan l'expose.
|
2026-09-15 11:47:50 -04:00
|
|
|
for groupe_expose in sorted(groupes):
|
|
|
|
|
fqdn = hostname_par_hote_groupe.get((nom, groupe_expose))
|
|
|
|
|
if fqdn:
|
|
|
|
|
hostvars.setdefault(f"{groupe_expose}_hostname", fqdn)
|
reconstruction : quatre defauts que seule une flotte rasee pouvait montrer
Chezlepro detruite (15 VM, disques compris) et refaite depuis le gabarit
minimal. 15/15 hotes, 0 echec ; make valider passe, test de restitution
compris. Aucun defaut ne venait de la flotte ni du gabarit.
1. Un avertissement n est pas un echec. Proxmox rend WARNINGS: n pour une
tache ABOUTIE ; la garde n acceptait que OK et declarait perdues cinq VM
clonees a 100 pourcent. Le message parlait d etat stopped — celui de la
TACHE, pas de la VM.
2. client_artefacts se contredisait : son commentaire disait de degrader, son
code arretait. L autorite monte en premier, donc avant le cache du
locataire : aucun ordre ne pouvait satisfaire la garde.
3. harden-below-nxdomain etendait le NXDOMAIN signe de la racine pour le TLD
internal a toute la zone du site, sans jamais interroger l autoritatif.
Declencheur : toute question sur un nom absent sous internal, y compris la
zone d un autre locataire. Le cache contenait la bonne reponse ET un
message negatif ; c est le negatif qui etait servi.
aggressive-nsec: no avait semble marcher — c est le redemarrage qui vidait
le cache, pas le reglage.
4. Un locataire doit savoir a qui demander la zone de son hebergeur, sans quoi
il ne peut plus nommer son depot de sauvegarde. La derivation prenait
dns_amorcage pour le resolveur du site : faux chez Technolibre, dont
l amorcage est 9.9.9.9. P03 l a attrape avant tout deploiement.
Au passage : instancier tentait encore le mot de passe unique d avant la
separation des voutes ; comparer echouait en exit 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 09:59:15 -04:00
|
|
|
# Le resolveur de l'ecosysteme doit savoir a qui demander la zone de l'hebergeur.
|
|
|
|
|
if "serveur_resolveur" in groupes and zones_deleguees:
|
|
|
|
|
hostvars["serveur_resolveur_zones_deleguees"] = zones_deleguees
|
2026-06-24 20:17:46 -04:00
|
|
|
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")
|
plan : l ecriture des registres devient atomique — tout, ou rien
`path.open("w")` TRONQUE avant d ecrire : entre les deux, le fichier est vide.
Une exception dans yaml.safe_dump, un disque plein, un Ctrl-C, et
instance/plan/serveurs.yml reste mutile.
L asymetrie fait la gravite : hosts.yml se regenere d un
make instancier-appliquer, le PLAN ne se regenere de rien. C est la source
unique de verite. Git est le filet, mais encore faut-il savoir qu on est tombe.
TREIZE SITES, UNE SEULE FONCTION
Le defaut n etait pas dans le GUI seul : douze sites dans sept fichiers, dont
les miroirs CLI des MEMES registres. Corriger le GUI seul aurait recree la
divergence que P41 garde depuis les neuf resolutions d instance. La fonction
vit donc dans inventory_rules.py, que les sept importaient deja. Une source,
pas douze.
TROIS DETAILS QUI FONT LA DIFFERENCE ENTRE « CA MARCHE » ET « CA TIENT »
temporaire dans le MEME dossier os.replace n est atomique qu au sein d un
meme systeme de fichiers ; un /tmp sur une
autre partition casserait la garantie sans
rien dire
fsync AVANT le rename sinon le renommage peut atteindre le disque
avant le contenu : au retour d une coupure
brutale, un fichier neuf et VIDE — le defaut
qu on ferme, deplace d un cran
report des droits mkstemp cree en 0600, le plan est en 0664 et
doit rester lisible par le groupe sur les
runners
LE TEST PORTE SON PROPRE CONTROLE NEGATIF
scripts/tests/test_ecriture_atomique.py rejoue D ABORD l ancienne forme et
verifie qu elle DETRUIT. Sans ce controle, « le fichier est intact » ne
prouverait rien — il pourrait l etre parce que rien n a ete ecrit du tout. Une
garantie qu on n a jamais vue echouer n est pas une garantie, c est une
habitude.
Branche sur P02, dont le titre annoncait « inventory_host » alors qu il lance
maintenant trois tests. Corrige au passage.
LA VOUTE DU GUI : VERIFIEE, PAS DE DEFAUT
Le soupcon etait qu executer_flux pose ANSIBLE_VAULT_PASSWORD_FILE (un seul mot
de passe) alors que creer une VM ouvre DEUX voutes depuis « une voute, une cle ».
Eprouve contre deux voutes JETABLES a mots de passe distincts — jamais les
vraies. Les deux variables se CUMULENT : Ansible essaie tous les secrets, et un
PASSWORD_FILE errone n empeche rien. Confirme en sondant l environnement qu une
recette make recoit reellement : le mot de passe saisi ET les cinq cles
calculees par voutes.py.
Ce qui sauve ce chemin n est donc pas le mot de passe saisi, c est
l IDENTITY_LIST que make pose par-dessus. Chacun couvre ce que l autre ne
couvre pas — le PASSWORD_FILE sert le runner qui n a que sa cle, l IDENTITY_LIST
le poste qui les a toutes. Ecrit au-dessus du code, pour que personne ne
« simplifie » en retirant l un des deux.
make prouver : CONFORME, 59 OK, 0 echec, 1 saute.
make instancier : DIFF VIDE, quatre registres relus, droits 664 preserves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
2026-09-08 14:57:57 -04:00
|
|
|
with ecriture_atomique(path) as fichier:
|
2026-06-24 20:17:46 -04:00
|
|
|
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]:
|
2026-07-01 22:17:45 -04:00
|
|
|
cmd = ["ansible-inventory", "-i", str(fichier), "--list"]
|
|
|
|
|
# Une voute chiffree (group_vars/all/vault.yml) fait echouer ansible-inventory sans
|
reconstruction : quatre defauts que seule une flotte rasee pouvait montrer
Chezlepro detruite (15 VM, disques compris) et refaite depuis le gabarit
minimal. 15/15 hotes, 0 echec ; make valider passe, test de restitution
compris. Aucun defaut ne venait de la flotte ni du gabarit.
1. Un avertissement n est pas un echec. Proxmox rend WARNINGS: n pour une
tache ABOUTIE ; la garde n acceptait que OK et declarait perdues cinq VM
clonees a 100 pourcent. Le message parlait d etat stopped — celui de la
TACHE, pas de la VM.
2. client_artefacts se contredisait : son commentaire disait de degrader, son
code arretait. L autorite monte en premier, donc avant le cache du
locataire : aucun ordre ne pouvait satisfaire la garde.
3. harden-below-nxdomain etendait le NXDOMAIN signe de la racine pour le TLD
internal a toute la zone du site, sans jamais interroger l autoritatif.
Declencheur : toute question sur un nom absent sous internal, y compris la
zone d un autre locataire. Le cache contenait la bonne reponse ET un
message negatif ; c est le negatif qui etait servi.
aggressive-nsec: no avait semble marcher — c est le redemarrage qui vidait
le cache, pas le reglage.
4. Un locataire doit savoir a qui demander la zone de son hebergeur, sans quoi
il ne peut plus nommer son depot de sauvegarde. La derivation prenait
dns_amorcage pour le resolveur du site : faux chez Technolibre, dont
l amorcage est 9.9.9.9. P03 l a attrape avant tout deploiement.
Au passage : instancier tentait encore le mot de passe unique d avant la
separation des voutes ; comparer echouait en exit 4.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019f91zs9SDdwSzL2CVei6on
2026-09-02 09:59:15 -04:00
|
|
|
# mot de passe (exit 4).
|
|
|
|
|
#
|
|
|
|
|
# UNE VOUTE, UNE CLE (depuis le 2026-08-28). Ce bloc tentait
|
|
|
|
|
# `~/.config/setops-vault-pass` — le mot de passe UNIQUE d'avant la separation, qui
|
|
|
|
|
# n'ouvre plus rien. `instancier comparer` echouait donc en exit 4 sur un message qui
|
|
|
|
|
# ne parlait que d'`ansible-inventory` : on ne pouvait plus relire l'inventaire en
|
|
|
|
|
# place pour le comparer au genere, donc plus l'appliquer sans `--force`, donc plus
|
|
|
|
|
# voir ce qu'on changeait.
|
|
|
|
|
#
|
|
|
|
|
# `voutes.py identites` rend la liste `etiquette@chemin` de toutes les voutes
|
|
|
|
|
# connues ; Ansible essaie chacune et retient celle qui ouvre. On ne pose rien si
|
|
|
|
|
# l'appelant a deja renseigne l'environnement : sa valeur est plus precise que la
|
|
|
|
|
# notre.
|
|
|
|
|
if not os.environ.get("ANSIBLE_VAULT_PASSWORD_FILE") \
|
|
|
|
|
and not os.environ.get("ANSIBLE_VAULT_IDENTITY_LIST"):
|
|
|
|
|
try:
|
|
|
|
|
ident = subprocess.run(
|
|
|
|
|
[sys.executable, str(Path(__file__).parent / "voutes.py"), "identites"],
|
|
|
|
|
capture_output=True, text=True, check=True).stdout.strip()
|
|
|
|
|
except (subprocess.CalledProcessError, OSError):
|
|
|
|
|
ident = ""
|
|
|
|
|
if ident:
|
|
|
|
|
os.environ["ANSIBLE_VAULT_IDENTITY_LIST"] = ident
|
2026-07-01 22:17:45 -04:00
|
|
|
sortie = subprocess.run(cmd, capture_output=True, text=True, check=True).stdout
|
2026-06-24 20:17:46 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-08-12 16:28:30 -04:00
|
|
|
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.
|
|
|
|
|
"""
|
2026-06-24 20:17:46 -04:00
|
|
|
ecrire(GENERE)
|
|
|
|
|
ecarts = compter_ecarts(INVENTAIRE, GENERE)
|
|
|
|
|
if ecarts == 0:
|
|
|
|
|
print("DIFF VIDE : le plan reproduit exactement l'inventaire actuel. Bascule possible.")
|
2026-08-12 16:28:30 -04:00
|
|
|
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
|
2026-06-24 20:17:46 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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.")
|
2026-08-12 16:28:30 -04:00
|
|
|
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).")
|
2026-06-24 20:17:46 -04:00
|
|
|
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":
|
2026-08-12 16:28:30 -04:00
|
|
|
return comparer(strict=getattr(args, "strict", False))
|
2026-06-24 20:17:46 -04:00
|
|
|
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())
|