Set-OPS-Public/scripts/site_machines.py

188 lines
7.9 KiB
Python
Raw Normal View History

#!/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.
clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines : 1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster. 2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve desormais, message de l'API compris. 3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend sans un mot. Le noeud se lit dans l'UPID lui-meme. 4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter. Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du gabarit au lieu de 40, sans que rien ne le dise. Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage, clone_complet) au lieu de l'heriter du tenant actif. Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues. 42 preuves vertes, ansible-lint profil production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 02:44:21 -04:00
GABARIT_DEFAUT = {"vcpu": 2, "memoire": 2048, "disque": "40G"}
def _taille(v) -> str:
"""Une taille de disque PORTE TOUJOURS SON UNITE.
`disque: 40` a produit un `resize` a « 40 » que Proxmox a lu comme un
RETRECISSEMENT : « shrinking disks is not supported ». Le playbook tolere cette
erreur — a raison, un disque deja assez grand n'est pas une panne — donc la machine
est nee avec les 16 Go du gabarit au lieu de 40, SANS QUE RIEN NE LE DISE.
Le plan des tenants ecrit `40G` depuis toujours ; le site ecrit pareil, et un entier
nu se voit rattacher son unite plutot que de repartir en silence.
"""
s = str(v).strip()
return f"{s}G" if s.isdigit() else s
def materialisation() -> dict:
"""Gabarit, stockage et mode de clonage — DECLARES par le site.
Sans ce bloc, le playbook de clonage prenait ces valeurs dans les group_vars du
TENANT ACTIF : `make site-creer` aurait clone depuis un gabarit different selon le
symlink `instance`, sans rien dire. Un accident silencieux vaut moins qu'un refus.
"""
m = (U.charger() or {}).get("materialisation") or {}
if not m.get("vmid_modele"):
raise SystemExit(
"underlay.yml : `materialisation.vmid_modele` est requis — sans lui, le site "
"clonerait le gabarit du tenant actif, ce qui depend du symlink `instance`.")
return m
def _machines() -> dict[str, dict]:
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
"""Les machines du PLAN du site — plus de l'underlay (2026-08-25).
Elles y avaient vecu quelques heures, faute d'avoir vu qu'un SITE A BIEN UN PLAN. Ce
qui le distingue d'un tenant n'est pas l'absence de plan mais l'absence de DERIVATION.
`underlay.yml` decrit ce qui est MESURE ; le plan, ce qui est VOULU.
"""
import site_inventaire as SI
d = SI.plan_dir()
f = (d / "serveurs.yml") if d else None
if not f or not f.is_file():
raise SystemExit(
"Aucun `plan/serveurs.yml` a cote de `underlay.yml` : le site n'a pas de plan, "
"il n'y a donc aucune machine a materialiser.")
import yaml
srv = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("serveurs") or {}
return {nom: {**s, "nom": nom} for nom, s in srv.items()
if str(s.get("etat", "actif")) == "actif"}
def _services_par_hote() -> dict[str, list[str]]:
"""Quels roles chaque machine porte — lu dans `plan/applications.yml`.
Les services ne vivent plus sur la machine mais dans le registre des applications,
comme chez un tenant : une machine dit OU elle est, une application dit CE QU'ELLE
FAIT et sur quelle machine. La jointure se fait ici.
"""
import site_inventaire as SI
import yaml
d = SI.plan_dir()
f = (d / "applications.yml") if d else None
if not f or not f.is_file():
return {}
apps = (yaml.safe_load(f.read_text(encoding="utf-8")) or {}).get("applications") or {}
out: dict[str, list[str]] = {}
for app in apps.values():
if app.get("hote") and app.get("groupe"):
out.setdefault(str(app["hote"]), []).append(str(app["groupe"]))
return {h: sorted(g) for h, g in out.items()}
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"]),
clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines : 1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster. 2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve desormais, message de l'API compris. 3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend sans un mot. Le noeud se lit dans l'UPID lui-meme. 4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter. Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du gabarit au lieu de 40, sans que rien ne le dise. Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage, clone_complet) au lieu de l'heriter du tenant actif. Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues. 42 preuves vertes, ansible-lint profil production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 02:44:21 -04:00
"SETOPS_DISQUE": _taille(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)
clonage : quatre defauts que la premiere VM placee hors du noeud du gabarit a reveles Toutes les VM naissaient sur le noeud du gabarit puis migraient. site-cache-01 est la premiere a etre placee ailleurs, et elle a fait tomber quatre defauts enchaines : 1. l'URL de clonage visait le noeud de DESTINATION, alors que l'API veut celui qui DETIENT le gabarit ; la destination se dit par `target`. D'ou un 500 sur tout clonage inter-noeuds. Le noeud du gabarit se decouvre dans l'inventaire du cluster. 2. ce 500 etait avale par failed_when: false + no_log: true. Une assertion le releve desormais, message de l'API compris. 3. l'attente interrogeait nodes/<destination>/tasks/<UPID> ; la tache vit sur le noeud du gabarit. 60 tentatives x 10 s pour un clonage termine en 87 s, puis la suite qui reprend sans un mot. Le noeud se lit dans l'UPID lui-meme. 4. la garde d'apres-attente retombait sur `exitstatus | default('OK')` : une attente qui n'avait rien observe passait pour un succes. Elle exige d'avoir VU la tache s'arreter. Et une taille de disque porte toujours son unite : `disque: 40` etait lu comme un retrecissement, erreur toleree a raison — la machine naissait donc avec les 16 Go du gabarit au lieu de 40, sans que rien ne le dise. Le site declare enfin ce qu'il materialise (materialisation: gabarit, stockage, clone_complet) au lieu de l'heriter du tenant actif. Chrono : clonage reel 59 s et 87 s pour 3,3 Gio alloues. 42 preuves vertes, ansible-lint profil production. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 02:44:21 -04:00
mat = materialisation()
p["SETOPS_VMID_MODELE"] = str(mat["vmid_modele"])
# L'explicite de la machine gagne sur le defaut du site : une machine peut demander
# un autre stockage sans que tout le site bascule.
p["SETOPS_STOCKAGE"] = str(m.get("stockage") or mat.get("stockage") or "")
p["SETOPS_CLONE_COMPLET"] = "true" if mat.get("clone_complet", True) else "false"
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"))
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
services = _services_par_hote()
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")),
site : un SITE a bien un plan — et huit defauts que lui seul pouvait reveler J'ai repete toute la journee qu'un SITE n'est pas un plan. C'etait faux : j'en reconstruisais un morceau par morceau dans underlay.yml sans le nommer. Ce qui est vrai, c'est qu'un site ne DERIVE pas — il declare ses adresses parce qu'il EST le terrain — et ne passe donc pas par `instancier`. Mais ne pas deriver n'est pas ne pas avoir de plan. SITE-Chezlepro/plan/{10-intrants,serveurs,applications}.yml ; underlay.yml ne garde que la fabric. Ce qui est MESURE d'un cote, ce qui est VOULU de l'autre. Les groupes viennent du registre des applications, les gardes ont suivi le plan (4 controles negatifs). Huit defauts, tous invisibles chez un tenant parce qu'il a toujours tout : - client_pki codait `infra-pki-01` EN DUR — vrai par coincidence de nomenclature ; - aucun moyen pour un service non-root de lire la cle (Forgejo tourne en `git`) ; - le certificat, PUBLIC par nature, restait en 0600 ; - l'unite Forgejo n'avait pas d'ExecReload : un cert renouvele aurait ete servi perime ; - le role ne savait pas servir TLS lui-meme (il y avait toujours un edge) ; - le cert ne couvrait pas le nom de SERVICE, faute d'`expose:` ; - les registres se chargeaient en tout-ou-rien : sans domaines.yml, plancher vide ; - le flux declarait 3000 en dur. Le message accusait presque toujours autre chose : le DNS quand c'etait un nom faux, une permission de fichier quand c'etait un port privilegie, rien du tout quand le plancher s'ecrivait vide. ROOT_URL est gravee dans les URL de clonage : la forge sert desormais sur 443, avec CAP_NET_BIND_SERVICE, et son URL n'a plus de port. Verifie depuis le reseau : Verify return code 0, https://forge.genese.internal/ -> 200. 42 preuves vertes, flux coherents (34 roles, 92 flux). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 13:15:34 -04:00
str(m.get("vmid")), ", ".join(services.get(nom, []))))
else:
for k, v in parametres(a.machine).items():
print(f"{k}={shlex.quote(v)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())