610 lines
22 KiB
Python
610 lines
22 KiB
Python
|
|
#!/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())
|