Set-OPS-Public/scripts/site_machines.py
Daniel Allaire 810670cd30 site : le premier site construit depuis zero, et ce que le moteur supposait deja en place
- cloner_vm_debian / supprimer_vm_debian suivent SETOPS_UNDERLAY (le clonage visait le
  cluster du lien underlay.yml) ; sept cibles du Makefile aussi pour la voute.
- site_machines.py transmet au clone la cle, le domaine et le resolveur du plan du site.
- La machine qui porte le cache recoit artefacts_amorcage vide ; le socle retire alors le
  proxy herite du gabarit. P79 n'admet ce vide que sur la machine qui porte le cache.
- serveur_icingaweb2 : la base et le compte d'amorcage apres l'installation des paquets.
- genome_pousser : cle du runner, organisation genome et depots crees s'ils manquent.
- common_packages : la lecture apt-daily s'execute en simulation (check_mode: false).

95 preuves (make verifier), make test passe. CHANGELOG 146.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 23:46:46 -04:00

234 lines
11 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 os
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"}
# Le plancher de memoire suit la MEME regle que pour un tenant — un site n'est pas un
# genre different de machine, seulement un autre proprietaire.
from inventory_rules import plancher_memoire as _plancher # noqa: E402
def _groupes_de(nom: str) -> list[str]:
"""Les groupes que cette machine du site porte, d'apres le plan."""
apps = U.lire_plan_site("applications.yml").get("applications") or {}
return sorted({str(a.get("groupe")) for a in apps.values() if a.get("hote") == nom})
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 — le gabarit vient du PLAN du site.
Sans declaration du site, 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.
LE REMEDE AVAIT CREE UNE SECONDE DECLARATION (corrige le 2026-09-13). Le VMID vivait
a la fois dans `plan/10-intrants.yml` (`gabarit:`) et dans `underlay.yml`
(`materialisation.vmid_modele`). Les deux ont diverge : le plan disait 9006, l'underlay
99998 — que le plan nomme justement `precedent`.
Consequence, invisible parce que les deux gabarits clonent : les MACHINES DU SITE
naissaient de l'ancien, les machines des LOCATAIRES du nouveau. Le Makefile, lui,
derivait deja `VMID_MODELE` de `underlay.gabarit()`, c'est-a-dire du plan.
On lit donc la meme source que tout le monde. Le motif d'origine est preserve :
`underlay.gabarit()` lit le plan DU SITE, jamais celui du tenant actif — il ne depend
d'aucun symlink `instance`.
"""
m = dict((U.charger() or {}).get("materialisation") or {})
g = U.gabarit()
if not g.get("vmid"):
raise SystemExit(
"plan/10-intrants.yml : `gabarit.vmid` est requis — c'est de lui que descend "
"chaque VM du site, et il ne peut pas etre decide par un tenant.")
m["vmid_modele"] = g["vmid"]
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"]),
# LE PLANCHER — meme regle que pour un tenant, derivee de ce que la machine PORTE.
# Un plancher ecrit au plan (`gabarit.memoire_min`) prime.
"SETOPS_MEMOIRE_MIN": str(g.get("memoire_min") or _plancher(
int(g["memoire"]), _groupes_de(nom))),
"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"
# CE QUE LE PLAN DU SITE DECLARE DEJA, TRANSMIS AU CLONE (2026-10-13). `site-creer` ne
# passait ni cle, ni domaine, ni resolveur : la VM recevait ceux du GABARIT, ne au site de
# Chezlepro — sa cle (`royaume chezlepro`) et `searchdomain: chezlepro.ca`. Au site de
# TechnoLibre, dont le plan dit `cle_ssh: …technolibre`, les neuf VM seraient nees
# FERMEES a Ansible. La cle manque : refus, plutot qu'une VM qu'on ne peut plus joindre.
intr = U.lire_plan_site("10-intrants.yml") or {}
cle = str(intr.get("cle_ssh") or "").strip()
if cle:
pub = Path(os.path.expanduser(cle + ".pub"))
if not pub.is_file():
raise SystemExit(f"machine '{nom}': `cle_ssh: {cle}` au plan du site, mais "
f"{pub} est absente — la VM naitrait sans cle qu'Ansible possede")
p["SETOPS_CLE_PUBLIQUE"] = str(pub)
if str(intr.get("domaine_interne") or "").strip():
p["SETOPS_DOMAINE"] = str(intr["domaine_interne"]).strip()
if str(intr.get("dns_amorcage") or "").strip():
p["SETOPS_DNS"] = str(intr["dns_amorcage"]).strip()
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())