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:
parent
5d0f82792a
commit
6fe62f74e1
4 changed files with 201 additions and 8 deletions
35
Makefile
35
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
119
scripts/attendre_materialisation.py
Normal file
119
scripts/attendre_materialisation.py
Normal 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:]))
|
||||
|
|
@ -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": [],
|
||||
|
|
|
|||
Loading…
Reference in a new issue