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
420 lines
16 KiB
Python
Executable file
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())
|