diff --git a/Makefile b/Makefile index 497c77d..91ccef1 100644 --- a/Makefile +++ b/Makefile @@ -355,6 +355,17 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE= python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) --dependances $(FICHIER_DEPENDANCES) verifier-dependances-groupe --groupe $(GROUPE) ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$(DOSSIER_PLAYBOOKS_GROUPES)/$(GROUPE).yml" --limit '$(GROUPE):&$(GROUPE_HOTES_ACTIFS)' +# ATTENDRE PLUTOT QUE CONSTATER (2026-08-27). `deployer` faisait un `ping` unique +# (`_verifier-acces-hote`). Depuis que `creer-vm` ne guette plus le SSH — il prouve la +# materialisation par l'agent invite, seul geste que le runner du SITE ait le droit de +# faire chez un tenant — c'est ICI qu'il faut attendre : enchainer `creer-vm` puis +# `deployer` tomberait sinon sur une machine encore en cloud-init. On y gagne au passage +# ce que le `ping` unique ne faisait pas : attendre que `dpkg` se libere, faute de quoi +# la premiere couche echoue sur un verrou APT. +# +# CONTREPARTIE ASSUMEE : un nom d'hote errone ne fait plus echouer tout de suite, il +# patiente ATTENTE_HOTE secondes. Echouer vite sur l'injoignable interdirait de chainer +# la creation au deploiement — le geste central d'une reconstruction. deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre du graphe — HOTE= @set -e; \ if [[ -z "$(HOTE)" ]]; then \ @@ -369,7 +380,7 @@ deployer: _instance-requise ## Deploie un hote, couche par couche, dans l'ordre exit 2; \ fi; \ $(VAULT_UNE_FOIS) \ - $(MAKE) _verifier-acces-hote LIMITE="$(HOTE)"; \ + $(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \ $(MAKE) _verifier-privileges-hote LIMITE="$(HOTE)"; \ for playbook in $$playbooks; do \ ansible-playbook -i $(INVENTAIRE_PRODUCTION) "$$playbook" --limit "$(HOTE)"; \ @@ -1018,11 +1029,25 @@ creer-vm: _instance-requise ## Cree une VM et attend qu'elle soit joignable — CLE_SSH_PUBLIQUE="$(CLE_SSH_PUBLIQUE)" \ DEMARRER="$(DEMARRER)" \ CLONE_COMPLET="$(CLONE_COMPLET)" - @# `creer-vm` rend une VM PRETE, pas seulement demarree : sans cette attente, - @# enchainer `creer-vm` puis `deployer` echoue presque toujours sur une machine - @# neuve. C'est ce qui separe une suite de commandes d'une reconstruction. + @# `creer-vm` CONFIRME LA MATERIALISATION, PAS LA JOIGNABILITE (2026-08-27). Il + @# attendait une reponse SSH — un geste que le runner du SITE ne peut pas faire : la + @# frontiere lui refuse d'entrer chez les tenants, et c'est le sens meme de leur + @# isolation. La premiere VM creee par le runner a donc ete declaree en echec apres + @# 600 secondes alors qu'elle tournait, avec la bonne adresse. + @# + @# L'agent invite repond a cette question sans rien ouvrir — l'API des hyperviseurs + @# est deja le flux par lequel la VM vient d'etre creee — et il PROUVE DAVANTAGE : + @# il rapporte le systeme, le noyau et l'adresse effectivement portee, la ou SSH ne + @# disait que « quelque chose ecoute sur le port 22 ». + @# + @# ATTENDRE LA DISPONIBILITE est un autre geste, deplace chez `deployer` : cloud-init + @# et dpkg se guettent juste avant de configurer, par celui qui va configurer. @if [[ "$(ATTENDRE)" != "false" ]]; then \ - $(MAKE) --no-print-directory _attendre-hote LIMITE="$(HOTE)"; \ + params="$$(python3 scripts/inventory_host.py --inventaire $(INVENTAIRE_PRODUCTION) parametres-proxmox --hote $(HOTE))"; \ + eval "$$params"; \ + python3 scripts/attendre_materialisation.py \ + --vmid "$$SETOPS_VMID" --ip "$$SETOPS_IP" --hote "$(HOTE)" \ + --delai $(ATTENTE_HOTE); \ fi inventaire-verifier: ansible-runtime _instance-requise ## Verifie que l'inventaire se parse (voute dechiffree) diff --git a/docs/audit/preuve-2026-08-27.md b/docs/audit/preuve-2026-08-27.md index f0d0fa0..7eb0ae6 100644 --- a/docs/audit/preuve-2026-08-27.md +++ b/docs/audit/preuve-2026-08-27.md @@ -7,7 +7,7 @@ > [`docs/audit/affirmations.md`](affirmations.md). - **Instance** : `instance` — inventaire `instance/inventories/production/hosts.yml` -- **Verdict** : ✅ CONFORME (51 OK · 0 echec · 0 saute) +- **Verdict** : ✅ CONFORME (52 OK · 0 echec · 0 saute) ## Preuves @@ -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 | 29 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 18, 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 | 50 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | +| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 51 scripts expliques et atteignables, 104 cibles make documentees, 61 roles avec README. | | P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 14 exigence(s) de role, toutes satisfaites (39 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 (16 groupes). | | P34 | Chaque document declare son lecteur | — | ✅ OK | 42 document(s) declarent leur lecteur (25 genere(s) exempte(s)). | @@ -53,7 +53,7 @@ | P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 36 role(s) serveur/client tous nommes, 37 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 : 46 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). | +| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 47 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 | | P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 5 machine(s) du plan retrouvees, 56 regle(s) du site. | | P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. | @@ -64,6 +64,7 @@ | P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (110 lignes). | | P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 121 regles `pass`), tous non consignes et tous motives. | | P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 | +| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. | ## Couverture des affirmations ✅ du registre diff --git a/scripts/attendre_materialisation.py b/scripts/attendre_materialisation.py new file mode 100644 index 0000000..a755a5f --- /dev/null +++ b/scripts/attendre_materialisation.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +"""Attend qu'une VM soit MATERIALISEE : demarree, et portant l'adresse que le plan lui destine. + +POURQUOI PAS SSH (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant +une reponse SSH. Deux choses clochaient. + +La premiere est une question de PERIMETRE. Le runner du SITE materialise le terrain de +tous les tenants, mais la frontiere lui refuse — deliberement — d'entrer chez eux : c'est +le sens meme de l'isolation inter-tenant. La premiere VM creee par le runner a donc ete +declaree en echec apres 600 secondes alors qu'elle tournait parfaitement. Ouvrir le SSH +du runner vers tous les tenants aurait « repare » la mesure en detruisant ce qu'elle +mesurait : une machine capable d'entrer chez chaque locataire est exactement ce que +l'architecture cherche a ne pas avoir. + +La seconde est que SSH PROUVE MOINS. « Quelque chose ecoute sur le port 22 » ne dit ni +quel systeme a demarre, ni si cloud-init a pose la bonne adresse. L'agent invite, lui, +rapporte le systeme, le noyau et l'adresse EFFECTIVEMENT portee par l'interface — on +verifie donc que la machine est celle que le plan decrivait, pas seulement qu'une machine +repond. + +Et il n'ouvre rien : l'API des hyperviseurs est deja le flux par lequel on a cree la VM. +Qui peut la creer peut la voir naitre. + +ATTENDRE LA DISPONIBILITE EST UN AUTRE GESTE, qui appartient a qui va CONFIGURER la +machine : `make deployer` attend desormais SSH, cloud-init et la liberation de dpkg, +juste avant d'en avoir besoin. Materialiser et configurer sont deux metiers. +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from proxmox_api import Cluster # noqa: E402 + + +def _noeud_de(api: Cluster, vmid: str) -> str | None: + """Sur quel noeud vit ce VMID. Le plan ne le dit pas toujours — la fabric, si.""" + noeuds = api("/nodes") + if not isinstance(noeuds, list): + return None + for n in noeuds: + nom = n.get("node") + vms = api(f"/nodes/{nom}/qemu") + if isinstance(vms, list) and any(str(v.get("vmid")) == str(vmid) for v in vms): + return nom + return None + + +def _adresses(api: Cluster, noeud: str, vmid: str) -> list[str]: + """Les IPv4 que l'agent invite declare, hors boucle locale. + + Un `_erreur` n'est pas un echec : tant que la machine demarre, l'agent ne repond pas + encore. C'est la difference entre « pas encore » et « pas du tout », et seule + l'expiration du delai tranche. + """ + rep = api(f"/nodes/{noeud}/qemu/{vmid}/agent/network-get-interfaces") + if not isinstance(rep, dict) or "result" not in rep: + return [] + out = [] + for i in rep["result"]: + if i.get("name") == "lo": + continue + for a in i.get("ip-addresses") or []: + if a.get("ip-address-type") == "ipv4": + out.append(str(a.get("ip-address"))) + return out + + +def main(argv: list[str]) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--vmid", required=True) + ap.add_argument("--ip", required=True, help="l'adresse que le plan destine a cette VM") + ap.add_argument("--hote", default="", help="son nom, pour les messages") + ap.add_argument("--delai", type=int, default=600) + ap.add_argument("--noeud", default="") + args = ap.parse_args(argv) + + api, _ = Cluster.depuis_hebergeur() + nom = args.hote or f"VMID {args.vmid}" + + noeud = args.noeud or _noeud_de(api, args.vmid) + if not noeud: + print(f"ECHEC: VMID {args.vmid} introuvable sur la fabric.", file=sys.stderr) + return 4 + + print(f"Attente de la materialisation de {nom} ({args.ip}) ", end="", flush=True) + fin = time.time() + args.delai + vues: list[str] = [] + while time.time() < fin: + vues = _adresses(api, noeud, args.vmid) + if args.ip in vues: + print(" ok") + osinfo = api(f"/nodes/{noeud}/qemu/{args.vmid}/agent/get-osinfo") + o = osinfo.get("result", {}) if isinstance(osinfo, dict) else {} + print(f" {nom} : {args.ip} portee sur {noeud} — " + f"{o.get('pretty-name') or o.get('name') or 'systeme inconnu'} " + f"{o.get('kernel-release', '')}".rstrip()) + return 0 + print(".", end="", flush=True) + time.sleep(5) + + print(f"\nECHEC: {nom} n'a pas porte {args.ip} apres {args.delai}s.", file=sys.stderr) + if vues: + print(f" L'agent invite rapporte pourtant : {', '.join(vues)}. " + f"La machine demarre mais son adressage n'est pas celui du plan — " + f"cloud-init, ou le pont/VLAN sur lequel elle est posee.", file=sys.stderr) + else: + print(" L'agent invite n'a rien rapporte : la machine ne demarre pas, ou " + "`qemu-guest-agent` est absent du gabarit dore.", file=sys.stderr) + return 4 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/prouver.py b/scripts/prouver.py index 1b01b32..1d45ad0 100644 --- a/scripts/prouver.py +++ b/scripts/prouver.py @@ -1396,6 +1396,52 @@ def preuve_carte_dit_vrai() -> tuple[bool, str]: f"{len(_carte_chiffres_mesures())} chiffres correspondent a la mesure.") +def preuve_materialiser_n_exige_pas_d_entrer() -> tuple[bool, str]: + """Creer une VM ne demande pas d'entrer dans le tenant qui l'heberge. + + POURQUOI (mesure du 2026-08-27). `creer-vm` confirmait son succes en attendant une + reponse SSH. Le runner du SITE materialise le terrain de TOUS les tenants, mais la + frontiere lui refuse d'entrer chez eux — c'est le sens meme de leur isolation. La + premiere VM qu'il a creee a donc ete declaree en echec apres 600 secondes alors + qu'elle tournait, avec l'adresse exacte que le plan lui destinait. + + La tentation etait d'ouvrir le SSH du runner vers tous les tenants. Ca aurait + « repare » la mesure en detruisant ce qu'elle protege : une machine capable d'entrer + chez chaque locataire est precisement ce que cette architecture refuse d'avoir. + + L'agent invite repond sans rien ouvrir, et prouve DAVANTAGE — le systeme, le noyau et + l'adresse effectivement portee, la ou SSH ne disait que « quelque chose ecoute sur le + port 22 ». Cette preuve garde le couplage ferme : materialiser et configurer sont deux + metiers, et seul le second exige d'entrer. + """ + mk = RACINE / "Makefile" + if not mk.is_file(): + return False, "Makefile introuvable." + txt = mk.read_text(encoding="utf-8") + debut = txt.find("\ncreer-vm:") + if debut < 0: + return False, "La cible `creer-vm` a disparu du Makefile." + # La recette court jusqu'a la prochaine cible en debut de ligne. + suite = txt[debut + 1:] + fin = re.search(r"\n[A-Za-z_][A-Za-z0-9_.-]*:", suite) + recette = suite[:fin.start()] if fin else suite + + outil = RACINE / "scripts" / "attendre_materialisation.py" + fautes = [] + if "_attendre-hote" in recette: + fautes.append("`creer-vm` appelle `_attendre-hote`, qui exige SSH : un runner de " + "SITE ne peut pas entrer chez un tenant, et ne doit pas pouvoir.") + if not outil.is_file(): + fautes.append("scripts/attendre_materialisation.py est absent.") + elif "attendre_materialisation.py" not in recette: + fautes.append("`creer-vm` ne confirme plus la materialisation : ni SSH ni agent " + "invite. Une creation qui ne verifie rien n'est pas une creation.") + if fautes: + return False, " | ".join(fautes) + return True, ("`creer-vm` confirme par l'agent invite (API des hyperviseurs, deja " + "utilisee pour creer), sans exiger d'entrer dans le tenant.") + + def preuve_collections_declarees_et_epinglees() -> tuple[bool, str]: """Toute collection appelee par le moteur est DECLAREE, et a une version EPINGLEE. @@ -1958,6 +2004,8 @@ PREUVES: list[dict] = [ "refs": [], "func": preuve_silences_declares}, {"id": "P51", "titre": "Collections : toutes declarees, toutes epinglees", "refs": [], "func": preuve_collections_declarees_et_epinglees}, + {"id": "P52", "titre": "Materialiser n'exige pas d'entrer dans le tenant", + "refs": [], "func": preuve_materialiser_n_exige_pas_d_entrer}, {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], "func": preuve_devis_frontiere_du_site}, {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],