creer-vm : prouver la materialisation sans entrer chez le tenant

`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 :

    Attente de SSH sur ops-01 ............ ECHEC: injoignable apres 600s.

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 — l'API des hyperviseurs est deja le flux
par lequel la VM vient d'etre creee, donc qui peut la creer peut la voir naitre —
et il PROUVE DAVANTAGE. « Quelque chose ecoute sur le port 22 » ne dit ni quel
systeme a demarre, ni si cloud-init a pose la bonne adresse. Sur ops-01 :

    10.17.19.41 portee sur asgard — Debian GNU/Linux 13 (trixie) 6.12.101

Ses echecs distinguent deux causes tres differentes : « la machine demarre mais
rapporte une AUTRE adresse — cloud-init, ou le pont sur lequel elle est posee »,
et « introuvable sur la fabric ».

ATTENDRE LA DISPONIBILITE A CHANGE DE MAIN. Guetter cloud-init et la liberation de
dpkg appartient a qui va CONFIGURER : `deployer` le fait desormais, la ou il se
contentait d'un `ping` unique. Il y gagne ce qu'il n'avait pas — attendre le verrou
APT, faute de quoi la premiere couche echouait dessus. Contrepartie assumee : un
nom d'hote errone patiente au lieu d'echouer vite ; echouer vite interdirait de
chainer creation et deploiement, le geste central d'une reconstruction.

P52 garde le couplage ferme. Deux controles negatifs verifies.

make prouver : CONFORME, 52 OK, 0 echec.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Daniel Allaire 2026-08-27 22:48:49 -04:00
parent 5d0f82792a
commit 6fe62f74e1
4 changed files with 201 additions and 8 deletions

View file

@ -355,6 +355,17 @@ appliquer: ansible-runtime ## Applique un groupe a la flotte — GROUPE=<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=<nom>
@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)

View file

@ -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

View file

@ -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:]))

View file

@ -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": [],