site : le chemin qui materialise les machines de l'hebergeur

- site_machines.py : traduit une declaration d'underlay en SETOPS_*, que cloner-vm
  consomme deja. Le clone reste le seul chemin eprouve.
- site_inventaire.py : inventaire DYNAMIQUE. Un site ne derivant de rien, sa declaration
  est deja sa forme finale — un hosts.yml genere ne rendrait rien plus inspectable.
  Les groupes sont les services : playbooks/groupes/<role>.yml trouve ses hotes seul.
- cibles site-decrire / site-inventaire / site-creer (CONFIRMER=true) / site-appliquer,
  toutes independantes d'une instance montee : le site existe avant tout tenant.
- playbook de groupe manquant pour serveur_cache_site, et son classement en couche.

Le premier garde-fou de site-appliquer refusait un GROUPE vide : il ne pouvait jamais
se declencher (GROUPE a un defaut global). Le vrai risque, observe en le testant : un
playbook de tenant contre l'inventaire du site ne matche aucun hote et sort avec 0 — un
succes qui n'a rien fait. Refus desormais de tout groupe absent de cet inventaire.

42 preuves vertes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-24 23:11:32 -04:00
parent 93dde5b4f2
commit 6e126dd8a9
7 changed files with 323 additions and 3 deletions

View file

@ -44,6 +44,25 @@ sortie ne passe pas par la frontière — le statut qu'ont déjà les hyperviseu
propre reste un VLAN de site sur `vmbr1` avec une patte sur la frontière ; elle demande un
trunk côté commutateur.
### Le chemin qui matérialise un site
`site_machines.py` traduit une déclaration d'underlay en les mêmes `SETOPS_*` que
`make cloner-vm` consomme déjà : le clone reste le seul chemin éprouvé, il n'y en a pas
deux. `site_inventaire.py` est un inventaire **dynamique** — un site ne dérivant de rien,
sa déclaration *est* déjà sa forme finale, et un `hosts.yml` généré ne rendrait rien de
plus inspectable. Les groupes sont les services : `playbooks/groupes/<rôle>.yml` trouve
ses hôtes sans qu'on câble quoi que ce soit.
Quatre cibles, indépendantes de toute instance montée — le site existe avant tout tenant
et doit pouvoir naître quand aucun n'est encore là : `site-decrire`, `site-inventaire`,
`site-creer` (`CONFIRMER=true`), `site-appliquer GROUPE=…`.
*Le premier garde-fou de `site-appliquer` refusait un `GROUPE` vide — il ne pouvait
jamais se déclencher, `GROUPE` ayant un défaut global. Le vrai risque, observé en le
testant : un playbook de tenant lancé contre l'inventaire du site n'y trouve aucun hôte,
n'a rien à faire, et sort avec 0 — un succès qui n'a rien fait. La cible refuse désormais
tout groupe absent de cet inventaire-ci.*
**`passerelle_amont`**, rôle d'hôte neuf : un routeur qui existe mais que nous
n'administrons pas. Le déclarer n'est pas l'adopter — c'est distinguer une passerelle
étrangère d'une passerelle fantôme.

View file

@ -700,6 +700,77 @@ flotte-creer: _instance-requise ## Cree les VM manquantes de la flotte depuis le
if (( echec )); then printf '\nAu moins une VM n a pas ete creee — rien ne continue.\n'; exit 2; fi; \
printf '\nToutes les VM actives sont creees.\n'
# --- LES MACHINES DU SITE -----------------------------------------------------
#
# UN SITE N'EST PAS UN PLAN. Un tenant se derive de son `index` ; un site n'en a pas et
# ne derive de rien — il EST le terrain sur lequel les tenants derivent. Ses machines se
# declarent dans `underlay.yml`, a cote des switches et des hyperviseurs qui les portent.
#
# D'ou des cibles distinctes de `flotte-*` : elles ne demandent AUCUNE instance montee.
# Le site existe avant tout tenant, et doit pouvoir naitre quand aucun n'est encore la.
#
# Le clone lui-meme reste `cloner-vm` — le seul chemin eprouve, et il n'y en aura pas
# deux. `site_machines.py` ne fait que traduire la declaration en ses parametres.
.PHONY: site-decrire
site-decrire: ## Les machines de l'hebergeur declarees dans l'underlay
@python3 scripts/site_machines.py decrire
.PHONY: site-inventaire
site-inventaire: ## L'inventaire dynamique du site, tel qu'Ansible le voit
@ansible-inventory -i scripts/site_inventaire.py --graph
.PHONY: site-appliquer
site-appliquer: ## Applique un role aux machines du site — GROUPE=<serveur_ops_site|...>
@# `GROUPE` a un defaut global (GROUPE_DEBIAN) : tester s'il est VIDE ne protege de
@# rien, ce garde-fou-la ne pourrait jamais se declencher. Le vrai risque, observe :
@# lancer un playbook de TENANT contre l'inventaire du SITE. Ansible n'y trouve aucun
@# hote, n'a rien a faire, et sort avec 0 — un succes qui n'a rien fait. On refuse donc
@# tout groupe qui n'existe pas DANS CET INVENTAIRE-CI.
@set -e; \
if [[ ! -f "playbooks/groupes/$(GROUPE).yml" ]]; then \
printf 'Refus: aucun playbook pour le groupe %s.\n' "$(GROUPE)"; exit 2; \
fi; \
connus="$$(python3 scripts/site_inventaire.py --list | python3 -c \
'import json,sys; print(" ".join(k for k in json.load(sys.stdin) if k != "_meta"))')"; \
if [[ " $$connus " != *" $(GROUPE) "* ]]; then \
printf 'Refus: le groupe %s n existe pas au site.\n' "$(GROUPE)"; \
printf 'Groupes du site : %s\n' "$$connus"; \
exit 2; \
fi; \
ansible-playbook -i scripts/site_inventaire.py "playbooks/groupes/$(GROUPE).yml" $(ARGS)
.PHONY: site-creer
site-creer: ## Cree les machines du site depuis l'underlay — CONFIRMER=true
@set -e; \
if [[ "$(CONFIRMER)" != "true" ]]; then \
printf '%s\n' 'Refus: creation des VM de l hebergeur (clone Proxmox).'; \
printf '%s\n' 'Relancer avec CONFIRMER=true.'; \
exit 2; \
fi; \
machines="$$(python3 scripts/site_machines.py lister)"; \
if [[ -z "$$machines" ]]; then printf '%s\n' 'Refus: aucune machine declaree dans l underlay.'; exit 2; fi; \
for m in $$machines; do \
printf '\n=== Machine du site: %s ===\n' "$$m"; \
params="$$(python3 scripts/site_machines.py parametres-proxmox --machine "$$m")"; \
eval "$$params"; \
$(MAKE) cloner-vm \
HOTE="$$m" \
VMID="$$SETOPS_VMID" \
ADRESSE_IP="$$SETOPS_IP" \
CIDR="$$SETOPS_CIDR" \
PASSERELLE="$$SETOPS_PASSERELLE" \
VLAN="$${SETOPS_VLAN:-}" \
PONT_PROXMOX="$$SETOPS_PONT" \
STOCKAGE_PROXMOX="$${SETOPS_STOCKAGE:-$(STOCKAGE_PROXMOX)}" \
TAILLE_DISQUE="$$SETOPS_DISQUE" \
COEURS="$$SETOPS_COEURS" \
MEMOIRE="$$SETOPS_MEMOIRE" \
NOEUD_PROXMOX="$$SETOPS_NOEUD" \
VMID_MODELE="$(VMID_MODELE)" \
FORMAT_DISQUE="$(FORMAT_DISQUE)"; \
done; \
printf '\nLes machines du site sont creees.\n'
.PHONY: _attendre-flotte
_attendre-flotte: ansible-runtime
@set -e; \

View file

@ -20,7 +20,7 @@
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 35 groupes classes, aucun cycle, aucune arete en arriere. |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 36 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 34 rôles, 87 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | playbook: playbooks/proxmox/cloner_vm_debian.yml |
@ -43,7 +43,7 @@
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 35 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 28 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 17, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 47 scripts expliques et atteignables, 98 cibles make documentees, 60 roles avec README. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 49 scripts expliques et atteignables, 102 cibles make documentees, 60 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (125 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (22 genere(s) exempte(s)). |
@ -53,7 +53,7 @@
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 35 role(s) serveur/client tous nommes, 36 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 43 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 45 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
## Couverture des affirmations ✅ du registre

View file

@ -53,6 +53,12 @@ couches:
# La source d'artefacts vient AVANT ceux qui installent des paquets — c'est tout
# son objet. Placee plus tard, elle serait remplie apres avoir servi.
- serveur_artefacts
# LA RACINE DE LA CHAINE DE CACHES, et elle appartient au SITE, pas au tenant :
# VM du tenant -> cache du tenant -> cache du SITE -> Debian
# Classee ici pour que son ordre soit dit, mais elle ne se deploie pas dans le meme
# mouvement : elle vit dans l'underlay de l'hebergeur et se joint par
# `scripts/site_inventaire.py`. Un tenant ne la deploie jamais — il la CONSOMME.
- serveur_cache_site
- nom: apps
raison: "Les applications métier, qui consomment les services (base, SSO, courriel, edge)."

View file

@ -0,0 +1,15 @@
---
- name: Appliquer le groupe serveur_cache_site
hosts: serveur_cache_site
become: true
gather_facts: true
pre_tasks:
- name: Vérifier que la cible est Debian
ansible.builtin.assert:
that:
- ansible_facts.distribution == "Debian"
fail_msg: "Ce playbook est prévu pour Debian."
roles:
- serveur_cache_site

93
scripts/site_inventaire.py Executable file
View file

@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""Inventaire dynamique des machines de l'HEBERGEUR, lu dans `underlay.yml`.
ansible-playbook -i scripts/site_inventaire.py playbooks/groupes/serveur_ops_site.yml
POURQUOI DYNAMIQUE, ET PAS UN `hosts.yml` GENERE.
La regle du depot est qu'un inventaire de tenant est un ARTEFACT GENERE, jamais edite a
la main : on edite le plan, puis `make instancier`. Cette regle existe parce qu'un tenant
DERIVE tout de son index — le fichier intermediaire est le prix a payer pour rendre la
derivation inspectable.
Un site ne derive de rien. Sa declaration EST deja sa forme finale : `underlay.yml` dit
l'adresse, le nœud, le pont, les services. Materialiser un second fichier qui repete la
meme chose ne rendrait rien plus inspectable — ca creerait seulement un artefact de plus
a garder en phase, et une occasion de plus de le laisser deriver de sa source.
LES GROUPES SONT LES SERVICES. Une machine qui declare `services: [serveur_ops_site]`
atterrit dans le groupe `serveur_ops_site`, et `playbooks/groupes/serveur_ops_site.yml`
la trouve sans qu'on ait rien a cabler. C'est la meme convention que pour les tenants :
ce qui se partage entre un site et un tenant, ce sont les ROLES, pas la forme du plan.
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import underlay as U # noqa: E402
# L'inventaire des tenants passe par ce compte ; le site n'a aucune raison d'en differer.
UTILISATEUR_DEFAUT = "ansible"
def inventaire() -> dict:
u = U.charger()
if u is None:
# Aucun underlay monte : un inventaire VIDE, pas une erreur. Ansible doit pouvoir
# etre lance sans site sans que ca ressemble a une panne.
return {"_meta": {"hostvars": {}}}
# La carte est-elle seulement valide ? Un inventaire tire d'un underlay fautif
# produirait des machines plausibles et fausses — le pire des deux mondes.
erreurs = [e for e in U.valider(u) if e.startswith("machine")]
if erreurs:
raise SystemExit("underlay.yml refuse :\n - " + "\n - ".join(erreurs))
par_reseau = {r.get("nom"): r for r in U.reseaux(u)}
hostvars: dict[str, dict] = {}
groupes: dict[str, list[str]] = {}
for m in U.machines(u):
nom = m["nom"]
r = par_reseau.get(m.get("reseau"), {})
hostvars[nom] = {
"ansible_host": str(m["ip"]),
"ansible_user": m.get("utilisateur", UTILISATEUR_DEFAUT),
# De quoi qu'un role puisse se situer sans redemander la carte.
"site_reseau": m.get("reseau"),
"site_pont": r.get("pont"),
"site_noeud": m.get("noeud"),
"proxmox_vmid": m.get("vmid"),
# CE QUI DISTINGUE CES MACHINES DE TOUTES LES AUTRES. Un role partage entre
# un site et un tenant peut avoir besoin de savoir de quel cote il tourne —
# par exemple pour ne PAS chercher un plan la ou il n'y en a pas.
"setops_site": True,
}
for service in m.get("services") or []:
groupes.setdefault(service, []).append(nom)
out: dict = {g: {"hosts": h} for g, h in groupes.items()}
out["site"] = {"hosts": sorted(hostvars)}
out["_meta"] = {"hostvars": hostvars}
return out
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--list", action="store_true")
ap.add_argument("--host")
a = ap.parse_args()
if a.host:
print(json.dumps(inventaire().get("_meta", {}).get("hostvars", {}).get(a.host, {})))
else:
print(json.dumps(inventaire(), indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())

116
scripts/site_machines.py Executable file
View file

@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""Les machines de l'HEBERGEUR, lues dans `underlay.yml` — pas dans un plan.
POURQUOI UN SCRIPT SEPARE, ET PAS UNE OPTION DE `inventory_host.py`.
Un tenant se DERIVE : de son seul `index` descendent son supernet, ses VLAN, ses VMID,
ses VNet SDN. Un site ne derive de rien — il n'a pas d'index, il EST le terrain sur lequel
les tenants derivent. J'avais d'abord fait entrer le site dans le generateur des tenants,
avec une branche « si l'adresse est declaree, elle gagne » ; elle est retiree, et ce
fichier existe pour qu'elle ne revienne pas.
CE QU'IL NE FAIT PAS. Il ne clone rien, ne parle a aucune API. Il TRADUIT une declaration
d'underlay en les memes `SETOPS_*` que `make cloner-vm` consomme deja. Le travail reel —
le clone, l'attente, la cle SSH — reste au chemin eprouve, et il n'en existe qu'un.
Les gardes sont dans `underlay.valider()`, pas ici : ce script suppose une carte deja
validee, et `make prouver` s'en assure avant.
"""
from __future__ import annotations
import argparse
import shlex
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import underlay as U # noqa: E402
# Ce que vaut une machine du site quand son `gabarit:` ne le dit pas. Volontairement
# modeste : une machine de l'hebergeur sert UN role, elle n'heberge pas de charge.
GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": 40}
def _machines() -> dict[str, dict]:
return {m["nom"]: m for m in U.machines(U.charger())}
def _reseau_de(machine: dict) -> dict:
for r in U.reseaux(U.charger()):
if r.get("nom") == machine.get("reseau"):
return r
raise SystemExit(f"machine '{machine.get('nom')}': reseau "
f"'{machine.get('reseau')}' introuvable dans l'underlay")
def parametres(nom: str) -> dict[str, str]:
m = _machines().get(nom)
if not m:
raise SystemExit(f"machine '{nom}' inconnue — `site-lister` donne les noms")
r = _reseau_de(m)
if not str(r.get("pont", "") or "").strip():
# La meme garde que le validateur, refaite au moment d'agir. Un reseau sans pont
# est un reseau qu'aucun hyperviseur ne porte : la VM naitrait sourde, et le
# decouvrir apres le clone coute une VM a detruire.
raise SystemExit(f"machine '{nom}': le reseau '{r.get('nom')}' ne declare aucun "
f"`pont:` — aucune VM ne peut y naitre")
g = {**GABARIT_DEFAUT, **(m.get("gabarit") or {})}
sous = str(r["sous_reseau"])
p = {
"SETOPS_VMID": str(m["vmid"]),
"SETOPS_IP": str(m["ip"]),
"SETOPS_CIDR": sous.split("/")[1],
"SETOPS_PASSERELLE": str(r.get("passerelle") or ""),
"SETOPS_PONT": str(r["pont"]),
"SETOPS_NOEUD": str(m["noeud"]),
"SETOPS_COEURS": str(g["vcpu"]),
"SETOPS_MEMOIRE": str(g["memoire"]),
"SETOPS_DISQUE": str(g["disque"]),
}
# UN SEGMENT NON ETIQUETE N'A PAS DE TAG, et lui en donner un le couperait du fil.
# `segment_physique` le dit ; `vlan: 1` (le natif) ne s'etiquette pas davantage.
vlan = r.get("vlan")
if not r.get("segment_physique") and isinstance(vlan, int) and vlan > 1:
p["SETOPS_VLAN"] = str(vlan)
if m.get("stockage"):
p["SETOPS_STOCKAGE"] = str(m["stockage"])
return p
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__)
sp = ap.add_subparsers(dest="cmd", required=True)
sp.add_parser("lister", help="noms des machines du site, un par ligne")
sp.add_parser("decrire", help="tableau lisible : ou vit quoi, et pour quel role")
q = sp.add_parser("parametres-proxmox", help="SETOPS_* evaluables par le shell")
q.add_argument("--machine", required=True)
a = ap.parse_args()
if U.charger() is None:
print("Aucun underlay monte (underlay.yml absent) — aucun site a materialiser.",
file=sys.stderr)
return 2
if a.cmd == "lister":
for nom in _machines():
print(nom)
elif a.cmd == "decrire":
ms = _machines()
if not ms:
print("Aucune machine declaree dans l'underlay.")
return 0
print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format(
"machine", "reseau", "adresse", "noeud", "vmid", "services"))
for nom, m in ms.items():
print("{:16s} {:18s} {:16s} {:8s} {:>7s} {}".format(
nom, str(m.get("reseau")), str(m.get("ip")), str(m.get("noeud")),
str(m.get("vmid")), ", ".join(m.get("services") or [])))
else:
for k, v in parametres(a.machine).items():
print(f"{k}={shlex.quote(v)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())