La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la main a trouvé, au quatrième étage, un invité 36 fois plus lent que le temps réel — 583 secondes d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une seconde — puis un noyau gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une fois, sur une machine, n'est pas un chiffre. D'où trois choses. L'algorithme, en fonctions pures. Deux ressources s'épuisent en descendant : la mémoire, chaque étage gardant de quoi faire tourner ses propres démons, et le disque, celui de l'enfant vivant DANS celui du parent. Une troisième se dégrade, et elle borne le vCPU à deux au-delà du premier étage : douze ont gelé le noyau invité, les mêmes deux avançaient. La mémoire n'est PAS bornée — la même VM gelait au même octet avec 9 Go et avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. Le plan est annoncé avant toute création, et jamais au-delà de ce qui tient. Le garde-fou dans l'écran. Il lisait la capacité de l'HÔTE et l'offrait en entier : sur un troisième étage à 14 cœurs, il a proposé 12 vCPU à une VM qui n'a jamais démarré. Le nombre n'était pas absurde pour la machine ; il l'était pour sa profondeur, que l'écran ignorait. Elle se compte maintenant sur la chaîne de ProxyJump — un rebond par étage, et c'est nous qui écrivons ces entrées. Le test long, dans LongTest/ et non dans test/ : le lanceur unitaire doit rester lançable en quelques secondes, partout, y compris sans virtualisation. La descente est uniforme — créer, attendre le ssh, installer, redémarrer et vérifier le noyau, remettre pmxcfs debout, contrôler le stockage — et s'arrête au premier étage qui échoue en NOMMANT l'étape. Il envoie notre install_proxmox.sh par scp plutôt que de laisser la VM cloner le dépôt : c'est notre code qu'on éprouve, et un correctif absent du distant a fait revenir le même défaut sur trois VM. --- EN --- The practicable nesting depth cannot be deduced, only measured. A manual measurement found, at the fourth level, a guest 36 times slower than real time — 583 seconds of wall clock for 16 seconds of guest time, each ACPI line taking a second — then a kernel frozen at the SAME byte whatever the resources. A number obtained once, on one machine, is not a number. Hence three things. The algorithm, in pure functions. Two resources run out going down: memory, each level keeping what its own daemons need, and disk, the child's living INSIDE the parent's. A third degrades, and it caps the vCPU at two beyond the first level: twelve froze the guest kernel, the same two progressed. Memory is NOT capped — the same VM froze at the same byte with 9 GB and with 2 GB, so trimming it would gain nothing and starve the level below. The plan is announced before anything is created, and never beyond what fits. The guard in the screen. It read the HOST's capacity and offered all of it: on a third level with 14 cores it proposed 12 vCPU to a VM that never booted. The number was not absurd for the machine; it was for its depth, which the screen did not know. It is now counted on the ProxyJump chain — one hop per level, and we are the ones writing those entries. The long test, in LongTest/ and not test/: the unit runner must stay runnable in seconds, anywhere, including without virtualisation. The descent is uniform — create, wait for ssh, install, reboot and check the kernel, bring pmxcfs back, check the storage — and stops at the first level that fails, NAMING the step. It sends our install_proxmox.sh over scp instead of letting the VM clone the repository: it is our code being exercised, and a fix absent from the remote made the same defect return on three VMs. Assisted-by: Claude Opus 5 (cherry picked from commit 4f70c461330cac6f46783a60e0f33052a979fa23)
609 lines
22 KiB
Python
Executable file
609 lines
22 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. Une mesure à la
|
|
main a montré, au quatrième étage, un invité 36 fois plus lent que le temps
|
|
réel — 583 secondes d'horloge pour 16 secondes de temps invité — puis un noyau
|
|
gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une
|
|
fois, sur une machine, n'est pas un chiffre : ce script le refait à la demande
|
|
et dit exactement OÙ ça casse.
|
|
|
|
La descente est UNIFORME. Chaque étage, le premier compris, passe par les
|
|
mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et
|
|
vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la
|
|
création diffère — libvirt en local, « qm » ensuite.
|
|
|
|
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.
|
|
|
|
./LongTest/deep_proxmox.py --depth 10 --dry-run
|
|
./LongTest/deep_proxmox.py --depth 10
|
|
./LongTest/deep_proxmox.py --detruire # défait ce que la descente a posé
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
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)
|
|
|
|
from script.proxmox import nesting # noqa: E402
|
|
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"
|
|
|
|
# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le
|
|
# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent
|
|
# que le précédent — c'est précisément ce qu'on mesure.
|
|
DELAIS = {
|
|
"creation": 1200,
|
|
"ssh": 2400,
|
|
"install": 7200,
|
|
"reboot": 2400,
|
|
"reparation": 600,
|
|
"controle": 180,
|
|
}
|
|
|
|
|
|
def dire(msg, journal=None):
|
|
ligne = f"[{time.strftime('%H:%M:%S')}] {msg}"
|
|
print(ligne, flush=True)
|
|
if journal:
|
|
with open(journal, "a", encoding="utf-8") as fh:
|
|
fh.write(ligne + "\n")
|
|
|
|
|
|
def capacite_hote():
|
|
"""(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle.
|
|
|
|
« available » et non « free » : c'est ce que le noyau promet de rendre sans
|
|
mettre la machine à genoux.
|
|
"""
|
|
coeurs = os.cpu_count() or 2
|
|
ram = 0
|
|
try:
|
|
with open("/proc/meminfo", encoding="utf-8") as fh:
|
|
for ligne in fh:
|
|
if ligne.startswith("MemAvailable:"):
|
|
ram = int(ligne.split()[1]) // 1024
|
|
break
|
|
except OSError:
|
|
pass
|
|
disque = 0
|
|
try:
|
|
st = os.statvfs("/var/lib/libvirt/images")
|
|
disque = (st.f_bavail * st.f_frsize) // (1024**3)
|
|
except OSError:
|
|
pass
|
|
return coeurs, ram, disque
|
|
|
|
|
|
def module_qemu():
|
|
"""deploy_qemu.py chargé comme module : il porte le catalogue d'images."""
|
|
import importlib.util
|
|
|
|
chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py")
|
|
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
def cle_publique():
|
|
for nom in ("id_ed25519.pub", "id_rsa.pub"):
|
|
chemin = os.path.expanduser(f"~/.ssh/{nom}")
|
|
if os.path.exists(chemin):
|
|
return chemin
|
|
return ""
|
|
|
|
|
|
def nom_etage(niveau):
|
|
return f"{NOM_BASE}-{niveau}"
|
|
|
|
|
|
def alias_etage(niveau, parent_alias):
|
|
"""« parent+enfant », la convention du dépôt : elle dit où la machine vit
|
|
et ne peut rien voler à un homonyme."""
|
|
if not parent_alias:
|
|
return nom_etage(niveau)
|
|
court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias)
|
|
return f"{court}+{nom_etage(niveau)}"
|
|
|
|
|
|
class Descente:
|
|
"""Un étage après l'autre, et ce qu'on en sait."""
|
|
|
|
def __init__(self, plan, journal, dry_run=False):
|
|
self.plan = plan
|
|
self.journal = journal
|
|
self.dry_run = dry_run
|
|
self.etages = []
|
|
|
|
def dire(self, msg):
|
|
dire(msg, self.journal)
|
|
|
|
# ---------------------------------------------------------------- #
|
|
# Parler aux machines
|
|
# ---------------------------------------------------------------- #
|
|
def executer(self, hote, remote, delai, etiquette, montrer=False):
|
|
if self.dry_run:
|
|
argv = pve.ssh_argv(
|
|
hote, pve.wrap_privilege(remote, hote.get("sudo") or "")
|
|
)
|
|
print(" " + " ".join(shlex.quote(a) for a in argv)[:200])
|
|
return 0, ""
|
|
debut = time.time()
|
|
code, sortie = pve.run(hote, remote, delai)
|
|
if code or montrer:
|
|
self.dire(
|
|
f" {etiquette} : code {code}"
|
|
f" en {int(time.time() - debut)} s"
|
|
)
|
|
if code:
|
|
for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]:
|
|
self.dire(f" {ligne}")
|
|
return code, sortie
|
|
|
|
def attendre_ssh(self, hote, delai):
|
|
"""Attend que la machine réponde. Rend les secondes, ou None.
|
|
|
|
Des connexions COURTES successives : cloud-init régénère les clés
|
|
d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une
|
|
session longue.
|
|
"""
|
|
if self.dry_run:
|
|
return 0
|
|
debut = time.time()
|
|
while time.time() - debut < delai:
|
|
code, _o = pve.run(hote, "true", 30)
|
|
if code == 0:
|
|
return int(time.time() - debut)
|
|
time.sleep(15)
|
|
return None
|
|
|
|
# ---------------------------------------------------------------- #
|
|
# Les six étapes, les mêmes à chaque étage
|
|
# ---------------------------------------------------------------- #
|
|
def installer_proxmox(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" sh {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
|
|
code, _o = self.executer(
|
|
dict(hote, sudo=""),
|
|
f"sh {distant}",
|
|
DELAIS["install"],
|
|
"install_proxmox.sh",
|
|
montrer=True,
|
|
)
|
|
return code == 0
|
|
|
|
def redemarrer_et_verifier(self, hote):
|
|
"""Redémarre, attend le retour, exige le noyau Proxmox.
|
|
|
|
Le script pose le noyau sans redémarrer — lancé par ssh, un reboot
|
|
couperait sa session et ferait passer l'installation pour un échec.
|
|
Sans ce redémarrage, la machine reste sur le noyau cloud de Debian,
|
|
dépouillé de tout netfilter : ni pont NAT, ni invité.
|
|
"""
|
|
if self.dry_run:
|
|
print(" reboot puis attente de *-pve dans uname -r")
|
|
return True
|
|
pve.run(hote, "systemctl reboot", 60)
|
|
debut = time.time()
|
|
while time.time() - debut < DELAIS["reboot"]:
|
|
time.sleep(20)
|
|
code, out = pve.run(dict(hote, sudo=""), "uname -r", 30)
|
|
noyau = pve.strip_ssh_noise(out).strip()
|
|
if code == 0 and "-pve" in noyau:
|
|
self.dire(
|
|
f" noyau {noyau} après {int(time.time() - debut)} s"
|
|
)
|
|
return True
|
|
self.dire(" ✗ pas revenue sur un noyau -pve")
|
|
return False
|
|
|
|
def reparer_pmxcfs(self, hote):
|
|
"""Gel de cloud-init, /etc/hosts, unités, constat du montage."""
|
|
if self.dry_run:
|
|
print(" gel cloud-init + /etc/hosts + unités + montage")
|
|
return True
|
|
_c, out = pve.run(
|
|
dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30
|
|
)
|
|
ip = pve.ssh_server_ip(out)
|
|
if not ip:
|
|
self.dire(" ✗ adresse d'accès inconnue")
|
|
return False
|
|
for cmd, etiquette in (
|
|
(pve.cloud_hosts_freeze_cmd(), "gel cloud-init"),
|
|
(pve.hosts_repair_cmd(ip), "/etc/hosts"),
|
|
):
|
|
code, sortie = self.executer(
|
|
hote, cmd, DELAIS["reparation"], etiquette
|
|
)
|
|
if code or "-KO" in pve.strip_ssh_noise(sortie):
|
|
self.dire(f" ✗ {etiquette}")
|
|
return False
|
|
for unite in pve.PVE_UNITS:
|
|
self.executer(
|
|
hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite
|
|
)
|
|
_c, out = self.executer(
|
|
hote, pve.mount_wait_cmd(), DELAIS["reparation"], "montage"
|
|
)
|
|
vu = pve.parse_mount_wait(out)
|
|
self.dire(f" /etc/pve : {vu['verdict']}")
|
|
return vu["verdict"] == "MONTE"
|
|
|
|
def preparer_parent(self, parent):
|
|
"""Stockage, pont et réseau interne du parent, ou None."""
|
|
_c, out = self.executer(
|
|
parent,
|
|
"pvesm status --content images",
|
|
DELAIS["controle"],
|
|
"pvesm",
|
|
)
|
|
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
|
|
_c, out = self.executer(
|
|
parent, "ip -o link show type bridge", DELAIS["controle"], "ponts"
|
|
)
|
|
ponts = pve.parse_bridges(out)
|
|
if not ponts:
|
|
_c, nets = self.executer(
|
|
parent, pve.USED_NETS_CMD, DELAIS["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",
|
|
)
|
|
return (
|
|
stockage or "local",
|
|
ponts[0],
|
|
pve.parse_bridge_config(cfg).get(ponts[0], {}),
|
|
)
|
|
|
|
def creer_etage1(self, res):
|
|
"""Une VM locale, par la CLI QEMU/KVM."""
|
|
nom = nom_etage(1)
|
|
argv = [
|
|
os.path.join(RACINE, ".venv.erplibre/bin/python"),
|
|
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
|
|
"--distro",
|
|
DISTRO,
|
|
"--name",
|
|
nom,
|
|
"--vcpus",
|
|
str(res["vcpu"]),
|
|
"--memory",
|
|
str(res["ram"]),
|
|
"--disk-size",
|
|
f"{res['disque']}G",
|
|
]
|
|
pub = cle_publique()
|
|
if pub:
|
|
argv += ["--ssh-key", pub]
|
|
if self.dry_run:
|
|
print(" " + " ".join(shlex.quote(a) for a in argv))
|
|
return nom
|
|
res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3)
|
|
if res_proc.returncode:
|
|
self.dire(" ✗ la CLI QEMU/KVM a échoué")
|
|
return None
|
|
return nom
|
|
|
|
def creer_enfant(self, parent, niveau, res, prepare):
|
|
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None)."""
|
|
stockage, pont, info_pont = 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", DELAIS["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)
|
|
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",
|
|
"start": True,
|
|
}
|
|
# 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é",
|
|
)
|
|
for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
|
vmid, spec
|
|
):
|
|
code, _o = self.executer(
|
|
parent, cmd, DELAIS["creation"], "qm create"
|
|
)
|
|
if code and not self.dry_run:
|
|
return None, None
|
|
return vmid, adresse
|
|
|
|
# ---------------------------------------------------------------- #
|
|
# La descente
|
|
# ---------------------------------------------------------------- #
|
|
def parcourir(self):
|
|
parent = None
|
|
parent_alias = ""
|
|
for res in self.plan["niveaux"]:
|
|
niveau = res["niveau"]
|
|
debut = time.time()
|
|
etage = {
|
|
"niveau": niveau,
|
|
"ressources": res,
|
|
"etape": "creation",
|
|
"ok": False,
|
|
}
|
|
self.dire(
|
|
f" ── étage {niveau} : {res['vcpu']} vCPU,"
|
|
f" {res['ram']} Mo, {res['disque']} Go"
|
|
)
|
|
if niveau == 1:
|
|
nom = self.creer_etage1(res)
|
|
if not nom:
|
|
self.etages.append(etage)
|
|
break
|
|
alias = nom
|
|
else:
|
|
prepare = self.preparer_parent(parent)
|
|
if not prepare:
|
|
etage["etape"] = "parent"
|
|
self.etages.append(etage)
|
|
break
|
|
vmid, adresse = self.creer_enfant(parent, niveau, res, prepare)
|
|
if vmid is None:
|
|
self.etages.append(etage)
|
|
break
|
|
etage["vmid"] = vmid
|
|
alias = alias_etage(niveau, parent_alias)
|
|
if not self.dry_run:
|
|
if not adresse:
|
|
# Sur un pont interne l'adresse est FIXE et dérivée du
|
|
# VMID. Vide, c'est que le parent n'a pas de pont
|
|
# interne — écrire un alias sans HostName donnerait
|
|
# une entrée qui ne mène nulle part.
|
|
self.dire(" ✗ pas d'adresse fixe pour l'enfant")
|
|
etage["etape"] = "adresse"
|
|
self.etages.append(etage)
|
|
break
|
|
self.ecrire_alias(alias, adresse, parent_alias)
|
|
cible = {"target": alias, "sudo": "sudo ", "jump": ""}
|
|
etage["alias"] = alias
|
|
|
|
etage["etape"] = "ssh"
|
|
attente = self.attendre_ssh(cible, DELAIS["ssh"])
|
|
if attente is None:
|
|
self.dire(" ✗ jamais joignable en ssh")
|
|
self.etages.append(etage)
|
|
break
|
|
etage["ssh_secondes"] = attente
|
|
self.dire(f" ssh après {attente} s")
|
|
|
|
for etape, action in (
|
|
("install", lambda: self.installer_proxmox(cible)),
|
|
("reboot", lambda: self.redemarrer_et_verifier(cible)),
|
|
("pmxcfs", lambda: self.reparer_pmxcfs(cible)),
|
|
):
|
|
etage["etape"] = etape
|
|
if not action():
|
|
self.etages.append(etage)
|
|
return self.rapport(interrompu=True)
|
|
|
|
etage["etape"] = "termine"
|
|
etage["ok"] = True
|
|
etage["secondes"] = int(time.time() - debut)
|
|
self.etages.append(etage)
|
|
self.dire(f" ✓ étage {niveau} en {etage['secondes']} s")
|
|
parent, parent_alias = cible, alias
|
|
return self.rapport()
|
|
|
|
def ecrire_alias(self, alias, adresse, parent_alias):
|
|
"""Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent."""
|
|
from script.todo.todo import TODO
|
|
|
|
todo = TODO.__new__(TODO)
|
|
prive = cle_publique()[:-4] if cle_publique() else None
|
|
todo._write_ssh_config_entry(
|
|
[alias],
|
|
"erplibre",
|
|
adresse,
|
|
proxy_jump=parent_alias or None,
|
|
identity_file=prive,
|
|
)
|
|
|
|
def rapport(self, interrompu=False):
|
|
atteint = sum(1 for e in self.etages if e["ok"])
|
|
print("")
|
|
self.dire(
|
|
f" profondeur atteinte : {atteint} / {self.plan['demandee']}"
|
|
)
|
|
for e in self.etages:
|
|
marque = "✓" if e["ok"] else "✗"
|
|
detail = f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"]
|
|
self.dire(f" {marque} étage {e['niveau']:2d} {detail}")
|
|
return {
|
|
"demandee": self.plan["demandee"],
|
|
"atteignable": self.plan["atteignable"],
|
|
"atteinte": atteint,
|
|
"interrompu": interrompu,
|
|
"etages": self.etages,
|
|
}
|
|
|
|
|
|
def detruire(journal=None):
|
|
"""Défait ce que la descente a posé, du plus profond au plus haut.
|
|
|
|
Du plus profond : détruire un parent d'abord emporterait ses enfants sans
|
|
qu'on ait pu les nommer, et laisserait des entrées ssh vers rien.
|
|
"""
|
|
from script.todo.todo import TODO
|
|
|
|
hosts = [h for h in TODO._ssh_config_hosts() if NOM_BASE in h]
|
|
hosts.sort(key=lambda h: -h.count("+"))
|
|
dire(f" {len(hosts)} entrée(s) ssh à défaire", journal)
|
|
for alias in hosts:
|
|
bloc = TODO._ssh_config_block(alias)
|
|
saut = (bloc or {}).get("proxyjump")
|
|
if saut:
|
|
parent = {"target": saut, "sudo": "sudo ", "jump": ""}
|
|
_c, out = pve.run(parent, "qm list", 120)
|
|
for vm in pve.parse_qm_list(out):
|
|
if NOM_BASE in (vm.get("name") or ""):
|
|
dire(f" qm destroy {vm['vmid']} sur {saut}", journal)
|
|
pve.run(
|
|
parent,
|
|
f"qm stop {vm['vmid']} --skiplock 1 || true;"
|
|
f" qm destroy {vm['vmid']} --purge 1",
|
|
300,
|
|
)
|
|
for niveau in range(1, 30):
|
|
nom = nom_etage(niveau)
|
|
if nom in hosts or niveau == 1:
|
|
subprocess.run(
|
|
["sudo", "virsh", "destroy", nom],
|
|
capture_output=True,
|
|
)
|
|
subprocess.run(
|
|
[
|
|
"sudo",
|
|
"virsh",
|
|
"undefine",
|
|
nom,
|
|
"--nvram",
|
|
"--remove-all-storage",
|
|
],
|
|
capture_output=True,
|
|
)
|
|
dire(
|
|
" ✓ défait. Les entrées ssh orphelines : menu de nettoyage.", journal
|
|
)
|
|
|
|
|
|
def principal(argv=None):
|
|
parseur = argparse.ArgumentParser(
|
|
description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?"
|
|
)
|
|
parseur.add_argument("--depth", type=int, default=10)
|
|
parseur.add_argument("--dry-run", action="store_true")
|
|
parseur.add_argument("--detruire", action="store_true")
|
|
args = parseur.parse_args(argv)
|
|
|
|
journal = os.path.expanduser(
|
|
f"~/.erplibre/longtest/deep-pve-{time.strftime('%Y%m%d-%H%M%S')}.log"
|
|
)
|
|
os.makedirs(os.path.dirname(journal), exist_ok=True)
|
|
if args.detruire:
|
|
detruire(journal)
|
|
return 0
|
|
|
|
coeurs, ram, disque = capacite_hote()
|
|
print(
|
|
f"\n machine : {coeurs} cœurs, {ram} Mo disponibles,"
|
|
f" {disque} Go de disque"
|
|
)
|
|
plan = nesting.nesting_plan(args.depth, coeurs, ram, disque)
|
|
print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}")
|
|
for n in plan["niveaux"]:
|
|
print(
|
|
f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo"
|
|
f" {n['disque']:>5} Go"
|
|
)
|
|
if plan["arret"]:
|
|
print(
|
|
f"\n ⚠ demandée {plan['demandee']}, atteignable"
|
|
f" {plan['atteignable']} — manque de {plan['arret']}"
|
|
)
|
|
if not plan["niveaux"]:
|
|
print("\n ✗ pas même un étage ne tient sur cette machine.\n")
|
|
return 1
|
|
print(f"\n journal : {journal}")
|
|
if args.dry_run:
|
|
print(" --dry-run : rien ne sera créé.\n")
|
|
descente = Descente(plan, journal, args.dry_run)
|
|
rapport = descente.parcourir()
|
|
chemin = journal[:-4] + ".json"
|
|
with open(chemin, "w", encoding="utf-8") as fh:
|
|
json.dump(rapport, fh, indent=2)
|
|
print(f"\n rapport : {chemin}\n")
|
|
return 0 if rapport["atteinte"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(principal())
|