Set-OPS-Public/scripts/site_machines.py
Daniel Allaire 43666cff6e 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

187 lines
7.9 KiB
Python
Executable file

#!/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": "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]:
"""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"]),
"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)
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"))
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")),
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())