From 6e126dd8a953a23bdc5b805121bba8c0bd5dcda0 Mon Sep 17 00:00:00 2001 From: Daniel Allaire Date: Mon, 24 Aug 2026 23:11:32 -0400 Subject: [PATCH] site : le chemin qui materialise les machines de l'hebergeur MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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/.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 --- CHANGELOG.md | 19 ++++ Makefile | 71 ++++++++++++++ docs/audit/preuve-2026-08-24.md | 6 +- docs/couches-deploiement.yml | 6 ++ playbooks/groupes/serveur_cache_site.yml | 15 +++ scripts/site_inventaire.py | 93 ++++++++++++++++++ scripts/site_machines.py | 116 +++++++++++++++++++++++ 7 files changed, 323 insertions(+), 3 deletions(-) create mode 100644 playbooks/groupes/serveur_cache_site.yml create mode 100755 scripts/site_inventaire.py create mode 100755 scripts/site_machines.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5166661..0486f0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/.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. diff --git a/Makefile b/Makefile index 1003435..9daf024 100644 --- a/Makefile +++ b/Makefile @@ -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= + @# `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; \ diff --git a/docs/audit/preuve-2026-08-24.md b/docs/audit/preuve-2026-08-24.md index 380c77b..6179056 100644 --- a/docs/audit/preuve-2026-08-24.md +++ b/docs/audit/preuve-2026-08-24.md @@ -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 diff --git a/docs/couches-deploiement.yml b/docs/couches-deploiement.yml index 65fc764..37a9a72 100644 --- a/docs/couches-deploiement.yml +++ b/docs/couches-deploiement.yml @@ -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)." diff --git a/playbooks/groupes/serveur_cache_site.yml b/playbooks/groupes/serveur_cache_site.yml new file mode 100644 index 0000000..8b9a5cb --- /dev/null +++ b/playbooks/groupes/serveur_cache_site.yml @@ -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 diff --git a/scripts/site_inventaire.py b/scripts/site_inventaire.py new file mode 100755 index 0000000..6188beb --- /dev/null +++ b/scripts/site_inventaire.py @@ -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()) diff --git a/scripts/site_machines.py b/scripts/site_machines.py new file mode 100755 index 0000000..47cef6d --- /dev/null +++ b/scripts/site_machines.py @@ -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())