erplibre/long_test/deep_proxmox.py
Mathieu Benoit cfbdd9406e [REF] format : passer l'outillage et les tests sous ruff
Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît
aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier,
d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient
presque entièrement aux chaînes coupées à la main que ruff recolle quand elles
tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune
valeur ne change, et les clés de traduction non plus.
Vérifié : la suite unitaire reste verte après le passage, et le contrôle de
syntaxe ne signale rien.

--- EN ---

This repository's formatter is ruff since it replaced black, which knows no
target beyond py313; this pass applies its standard to the whole tree at once,
so that no substantive commit has to carry style. The difference is almost
entirely the hand-split strings ruff joins back when they fit on one line, and
the multiple "with" it merges: no value changes, nor do the translation keys.
Checked: the unit suite stays green after the pass, and the syntax check
reports nothing.

Assisted-by: Claude Opus 5
2026-09-24 14:40:38 -04:00

420 lines
16 KiB
Python
Executable file

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?
Ce n'est pas un test unitaire : il crée de vraies machines et prend des
HEURES. Il vit donc hors de `test/`, que le lanceur unitaire balaie.
Ce qu'il établit, et pourquoi cela valait un script : la profondeur
d'imbrication praticable ne se déduit pas, elle se mesure. Mesuré ici, sur une
machine à 28 cœurs : trois étages coûtent 34 minutes, et le quatrième 4 h 20
d'amorçage plus 7 h 18 d'installation. Tout y est 15 à 30 fois plus lent — et
c'est là que les fabricants cessent de documenter l'imbrication.
La descente et ce qu'elle sait sont dans `descente.py`, partagés avec
`deep_qemu.py`. Ce fichier-ci n'a que les VERBES de Proxmox : « qm create »
chez le parent, install_proxmox.sh, le noyau -pve, pmxcfs debout, un stockage
capable d'accueillir l'étage suivant.
Il envoie NOTRE install_proxmox.sh par scp au lieu de laisser la VM cloner le
dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent
en retard sur le checkout — un correctif absent du distant a fait « revenir »
le même défaut sur trois VM de suite.
./long_test/deep_proxmox.py # trois étages, ~34 minutes
./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment
./long_test/deep_proxmox.py --dry-run # le plan, rien de créé
./long_test/deep_proxmox.py --detruire # défaire ce qui a été posé
"""
import os
import re
import shlex
import subprocess
import sys
import time
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, RACINE)
sys.path.insert(0, os.path.join(RACINE, "long_test"))
import descente # noqa: E402
from descente import ( # noqa: E402,F401
DELAIS,
Famille,
_lance_une_descente,
a_defaire,
autre_descente,
capacite_hote,
cle_publique,
dernier_rapport,
descente_vivante,
detruire,
detruire_etage1,
dire,
identite_de,
mener,
module_qemu,
retirer_alias,
)
from descente import (
alias_etage as _alias_etage,
)
from descente import (
nom_etage as _nom_etage,
)
from script.proxmox import proxmox_deploy as pve # noqa: E402
# L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe
# SUR une Debian — Proxmox ne publie pas d'image cloud.
DISTRO = "proxmox"
NOM_BASE = "deep-pve"
OUTIL = "deep_proxmox"
def nom_etage(niveau):
return _nom_etage(niveau, NOM_BASE)
def alias_etage(niveau, parent_alias):
return _alias_etage(niveau, parent_alias, NOM_BASE)
class Descente(descente.Descente):
"""Les verbes de Proxmox. Le reste est dans `descente.Descente`."""
OUTIL = OUTIL
NOM_BASE = NOM_BASE
DISTRO = DISTRO
def noyau_convient(self, noyau):
"""Le noyau Proxmox, et pas celui de Debian.
Sans lui la machine reste sur le noyau cloud, dépouillé de tout
netfilter : ni pont NAT, ni invité.
"""
return "-pve" in noyau
def installer(self, hote):
"""Envoie NOTRE script et l'exécute. Rend True si Proxmox est posé."""
local = os.path.join(RACINE, "script/proxmox/install_proxmox.sh")
distant = "/tmp/install_proxmox.sh"
if self.dry_run:
print(f" scp {local} <hôte>:{distant}")
print(f" bash {distant}")
return True
argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande
cible = argv[-1]
options = argv[1:-1]
res = subprocess.run(
["scp", "-q"] + options + [local, f"{cible}:{distant}"],
capture_output=True,
text=True,
timeout=300,
)
if res.returncode:
self.dire(f" ✗ scp : {res.stderr.strip()[:200]}")
return False
# « bash » et non « sh » : le script porte « set -euo pipefail » et un
# shebang bash. Sur Debian /bin/sh est dash, qui répond « set: Illegal
# option -o pipefail » et sort à la PREMIÈRE ligne — vérifié. Chaque
# étage aurait échoué sur l'installation, à tous les coups.
code, _o = self.executer(
dict(hote, sudo=""),
f"bash {distant}",
self.delai("install"),
"install_proxmox.sh",
montrer=True,
)
return code == 0
def preparer_parent(self, parent):
"""Stockage, pont et réseau interne du parent, ou None.
Les codes de retour des LECTURES sont regardés, et c'est tout le
sujet ici. Ailleurs dans ce dépôt un code de retour ne prouve rien —
celui d'une commande distante composée est celui du dernier maillon.
Mais pour une lecture, il est la SEULE chose qui distingue « j'ai lu,
il n'y a rien » de « je n'ai pas pu lire ».
La différence n'est pas académique : de l'absence de pont on
RECONFIGURE le réseau du parent. Un « ip link show » qui échoue — un
hoquet ssh, un sudo pas encore prêt — se lisait « pas de pont », et on
posait un pont et un NAT sur une machine qui en avait déjà un.
"""
code, out = self.executer(
parent,
"pvesm status --content images",
DELAIS["controle"],
"pvesm",
)
if code and not self.dry_run:
self.dire(" ✗ « pvesm status » a échoué : rien conclu")
return None
stockage = pve.pick_storage(pve.parse_storages(out))
if not stockage and not self.dry_run:
self.dire(" ✗ aucun stockage sur le parent")
return None
code, out = self.executer(
parent,
"ip -o link show type bridge",
self.delai("controle"),
"ponts",
)
if code and not self.dry_run:
self.dire(
" ✗ liste des ponts illisible : on ne touche PAS au"
" réseau du parent"
)
return None
ponts = pve.parse_bridges(out)
if not ponts:
_c, nets = self.executer(
parent, pve.USED_NETS_CMD, self.delai("controle"), "réseaux"
)
cidr = pve.pick_internal_cidr(nets) or pve.INTERNAL_CIDR
_c, rt = self.executer(
parent,
"ip -o -4 route show default",
DELAIS["controle"],
"uplink",
)
trouve = re.search(r"dev\s+(\S+)", rt or "")
uplink = trouve.group(1) if trouve else ""
self.dire(f" pont {cidr}, NAT par {uplink or '—'}")
for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink):
code, _o = self.executer(
parent, cmd, DELAIS["reparation"], "pont"
)
if code and not self.dry_run:
return None
ponts = [pve.INTERNAL_BRIDGE]
_c, cfg = self.executer(
parent,
"cat /etc/network/interfaces",
DELAIS["controle"],
"interfaces",
)
# Le DNS de l'hôte. « --ipconfig0 » ne le porte PAS : une VM en
# adresse fixe route mais ne résout rien, et install_proxmox.sh meurt
# sur « apt update » sans que rien ne l'explique. Le rapport imputerait
# à l'installation ce qui est un défaut de résolveur.
_c, resolv = self.executer(
parent, pve.RESOLV_CMD, self.delai("controle"), "resolv"
)
return (
stockage or "local",
ponts[0],
pve.parse_bridge_config(cfg).get(ponts[0], {}),
pve.parse_nameservers(resolv),
)
def creer_enfant(self, parent, niveau, res, prepare, noter=None):
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).
`noter` reçoit le VMID AVANT la première commande qui peut créer la
VM. Sans lui, le VMID ne remontait qu'au RETOUR : une création qui
échouait à la quatrième de ses six commandes — « qm resize » sur un
stockage plein, par exemple — laissait une VM allumée et un disque
alloué que le rapport ne nommait nulle part, donc que « --detruire »
ne pouvait pas défaire.
"""
stockage, pont, info_pont, dns = prepare
mod = module_qemu()
version = mod.DISTROS[DISTRO][1]
code_img = mod.DISTROS[DISTRO][0][version][0]
url = mod.image_url(DISTRO, code_img, "amd64", version)
image = mod.default_image_name(DISTRO, code_img, "amd64", version)
_c, out = self.executer(
parent, "qm list", self.delai("controle"), "qm list"
)
vmid = pve.next_vmid(pve.parse_qm_list(out))
ipconfig = pve.ipconfig_for(info_pont, vmid)
adresse = pve.ip_from_ipconfig(ipconfig)
# AVANT de télécharger l'image et de démarrer quoi que ce soit : sur un
# pont relié au LAN, ipconfig_for rend « ip=dhcp » et l'adresse est
# vide. Le contrôle venait après la création : on laissait une VM
# allumée, un disque alloué, et une machine que --detruire ne
# connaissait pas.
if not adresse and self.dry_run:
# En essai à blanc on n'a rien lu du parent : conclure « pas de
# pont interne » serait une affirmation tirée d'une mesure qui
# n'a pas eu lieu. On prend une adresse plausible pour dérouler
# le plan jusqu'au bout.
adresse = "10.10.10.150"
ipconfig = f"ip={adresse}/24,gw=10.10.10.1"
if not adresse:
self.dire(" ✗ pas d'adresse fixe : le parent n'a pas de")
self.dire(" pont interne, et l'enfant serait injoignable")
return None, None
spec = {
"name": nom_etage(niveau),
"storage": stockage,
"image": image,
"memory": res["ram"],
"vcpus": res["vcpu"],
"bridge": pont,
"disk": f"{res['disque']}G",
"user": "erplibre",
"ipconfig": ipconfig,
"sshkey_path": "/root/.ssh/longtest.pub",
"nameservers": dns,
"start": True,
# Du CATALOGUE, jamais en dur : create_cmds lit cette clé, et un
# spec qui l'omet vaut SeaBIOS en silence — sur une image sans
# secteur d'amorçage BIOS, une VM « running » à la console muette.
"uefi": mod.requiert_uefi(DISTRO),
}
# La clé publique doit être un FICHIER sur le parent : « --sshkeys »
# n'accepte pas la clé en ligne.
pub = cle_publique()
if pub and not self.dry_run:
with open(pub, encoding="utf-8") as fh:
contenu = fh.read().strip()
self.executer(
parent,
f"mkdir -p /root/.ssh && printf '%s\\n'"
f" {shlex.quote(contenu)} > /root/.ssh/longtest.pub",
DELAIS["controle"],
"clé",
)
# Le VMID est annoncé MAINTENANT. Un numéro noté pour une VM qui
# n'existera jamais ne coûte rien — « --detruire » lit « qm list » et
# la dit absente — alors qu'une VM créée et non notée reste sur le
# parent, invisible.
if noter:
noter(vmid)
for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
):
code, _o = self.executer(
parent, cmd, self.delai("creation"), "qm create"
)
if code and not self.dry_run:
return None, None
return vmid, adresse
# ---------------------------------------------------------------- #
# La descente
def remettre_debout(self, hote):
"""Les unités PVE, puis le CONSTAT que /etc/pve est monté."""
if self.dry_run:
print(" unités PVE + montage de /etc/pve")
return True
# « pve_unit_cmd » joint le journal de l'unité à un échec — « la seule
# façon de dire la cause à quelqu'un dont le seul accès à l'hôte est
# cet outil », dit son propre commentaire. On le JETAIT : quand le
# montage échouait ensuite, il ne restait qu'un « /etc/pve : ABSENT »
# sans cause, et il fallait retourner sur la machine pour la chercher.
echecs = []
for unite in pve.PVE_UNITS:
code, sortie = self.executer(
hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite
)
propre = pve.strip_ssh_noise(sortie)
if code or "-KO" in propre:
echecs.append((unite, propre))
_c, out = self.executer(
hote, pve.mount_wait_cmd(), self.delai("reparation"), "montage"
)
vu = pve.parse_mount_wait(out)
self.dire(f" /etc/pve : {vu['verdict']}")
if vu["verdict"] != "MONTE":
for unite, propre in echecs:
self.dire(f" ↳ {unite} : {propre.strip()[-400:]}")
if not echecs:
# Toutes debout et le montage absent : le dire, plutôt que de
# laisser croire qu'on n'a pas regardé.
self.dire(" ↳ toutes les unités PVE sont debout")
return vu["verdict"] == "MONTE"
def controler(self, hote):
"""Ce parent peut-il héberger l'étage suivant ?
Sixième étape, et elle manquait : le contrôle du stockage était celui
du DÉBUT de l'étage suivant, si bien qu'un étage marqué « terminé »
pouvait n'avoir aucun stockage capable d'accueillir une image — et le
compteur d'étages atteints mentait d'autant.
"""
if self.dry_run:
print(" pvesm status : un stockage pour les images")
return True
code, out = self.executer(
hote,
"pvesm status --content images",
DELAIS["controle"],
"pvesm",
)
if code:
self.dire(" ✗ « pvesm status » a échoué : rien conclu")
return False
stockage = pve.pick_storage(pve.parse_storages(out))
if not stockage:
self.dire(" ✗ aucun stockage pour les images")
return False
self.dire(f" stockage : {stockage}")
return True
def detruire_une(parent_alias, vmid, nom, journal):
"""Arrête puis détruit UNE VM, par son VMID. Rend True si elle a disparu.
Par le VMID et par égalité stricte du nom : un filtre par sous-chaîne
aurait pris une « deep-pve-lab » de production, et « --purge » emporte les
disques ET les entrées de sauvegarde.
L'arrêt est CONSTATÉ avant la destruction : « qm stop » rend la main dès
que la tâche est lancée, et sur un hyperviseur imbriqué mesuré 36 fois
plus lent, « qm destroy » arrivait alors que la VM tournait encore et
refusait avec « VM is running ».
"""
parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""}
code, out = pve.run(parent, "qm list", 180)
if code:
dire(f" ✗ {parent_alias} injoignable : rien touché", journal)
return False
presentes = {
int(v["vmid"]): (v.get("name") or "") for v in pve.parse_qm_list(out)
}
if vmid not in presentes:
dire(f" — {vmid} déjà absente de {parent_alias}", journal)
return True
if presentes[vmid] != nom:
dire(
f" ✗ {vmid} sur {parent_alias} s'appelle"
f" « {presentes[vmid]} », pas « {nom} » : rien touché",
journal,
)
return False
pve.run(parent, f"qm stop {vmid} --skiplock 1 || true", 300)
for _ in range(20):
_c, etat = pve.run(parent, f"qm status {vmid}", 120)
if "stopped" in pve.strip_ssh_noise(etat):
break
time.sleep(6)
code, out = pve.run(parent, f"qm destroy {vmid} --purge 1", 600)
if code:
dire(f" ✗ qm destroy {vmid} : code {code}", journal)
for ligne in pve.strip_ssh_noise(out).strip().splitlines()[-3:]:
dire(f" {ligne}", journal)
return False
dire(f" ✓ {nom} ({vmid}) sur {parent_alias}", journal)
return True
FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une)
def principal(argv=None):
return mener(
argv,
"Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?",
FAMILLE,
Descente,
)
if __name__ == "__main__":
sys.exit(principal())