Rapporté. La chaîne, en trois maillons : sur un hôte sans pont, « ip -o link show type bridge » ne rend RIEN, la sortie ne contient donc que l'avertissement de ssh sur la clé d'hôte — que le lecteur a pris pour un nom de pont. « (ED25519) » s'est retrouvé dans « --net0 virtio,bridge=… », enrobé de « sudo sh -c », et dash a répondu ce que l'utilisateur a lu. Le bruit de ssh est maintenant retiré à la source, et un pont doit avoir la forme d'un lien pour en être un. Éprouvé sur l'hôte réel, VM créée puis détruite : le pont ne montait pas (ifupdown2 accuse « another instance » quand /run/network manque — un mensonge), le noyau Debian n'a ni module bridge ni table NAT, et une VM en adresse fixe n'avait aucun résolveur. Le déploiement écrit désormais un journal par VM sous ~/.erplibre/proxmox-deploy et en donne le chemin. --- EN --- Reported. The chain, in three links: on a host with no bridge, "ip -o link show type bridge" returns NOTHING, so the output holds only ssh's host-key warning — which the parser took for a bridge name. "(ED25519)" landed in "--net0 virtio,bridge=…", wrapped in "sudo sh -c", and dash answered what the user read. Ssh's noise is now stripped at the source, and a bridge must have the shape of a link to be one. Proven on the real host, VM created then destroyed: the bridge would not come up (ifupdown2 claims "another instance" when /run/network is missing — a lie), the Debian kernel has neither the bridge module nor the NAT table, and a statically addressed VM had no resolver at all. Deployment now writes one log per VM under ~/.erplibre/proxmox-deploy and prints its path. Assisted-by: Claude Opus 5
1400 lines
58 KiB
Python
1400 lines
58 KiB
Python
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Menu Proxmox VE : déployer et gérer des VM sur un hôte DISTANT.
|
|
|
|
Sorti de todo.py, qui passait 13 000 lignes. Toute la différence avec le menu
|
|
QEMU/KVM tient en une phrase : l'hyperviseur est ailleurs. On choisit donc
|
|
l'hôte, on le vérifie, et tout part par SSH — voir script/proxmox/README.md.
|
|
|
|
Mixin : ses méthodes vivent sur la classe TODO, elles peuvent donc appeler
|
|
librement les helpers généraux (self._is_yes, self.fill_help_info) et ceux du
|
|
menu QEMU (self._qemu_prompt_distro, self._qemu_install_erplibre_monitored),
|
|
qu'elles réutilisent volontairement plutôt que de les redire."""
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import time
|
|
|
|
import click
|
|
|
|
from script.todo import todo_prefs
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
class ProxmoxMenuMixin:
|
|
"""Menu Proxmox VE : déployer et gérer des VM sur un hôte DISTANT."""
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# QEMU / KVM (libvirt) VM deployment
|
|
# ------------------------------------------------------------------ #
|
|
# ----------------------------------------------------------------- #
|
|
# Proxmox VE : l'hyperviseur est AILLEURS
|
|
# ----------------------------------------------------------------- #
|
|
# Toute la différence avec QEMU/KVM tient là : ici on n'exécute rien sur
|
|
# la machine locale. Il faut donc d'abord SAVOIR OÙ, et le retenir — sans
|
|
# quoi chacune des dix-sept commandes reposerait la question.
|
|
_PVE_PREF_KEY = "proxmox_host"
|
|
# Le script qui transforme une Debian en hyperviseur. Autonome : il se
|
|
# laisse exécuter par un tube, sans être copié d'abord.
|
|
PVE_INSTALL_SCRIPT = "script/proxmox/install_proxmox.sh"
|
|
|
|
def _pve_host(self, ask=True):
|
|
"""Hôte Proxmox retenu, ou None. Demande au besoin.
|
|
|
|
Mémorisé dans les préférences : le menu compte dix-sept entrées, et
|
|
redemander l'hôte à chacune serait insupportable. Le choix reste
|
|
affiché en tête du menu, et se change par son entrée dédiée.
|
|
"""
|
|
cache = getattr(self, "_pve_host_cache", None)
|
|
if cache:
|
|
return cache
|
|
garde = todo_prefs.get(self._PVE_PREF_KEY) or {}
|
|
if garde.get("target"):
|
|
self._pve_host_cache = garde
|
|
return garde
|
|
return self._pve_pick_host() if ask else None
|
|
|
|
def _pve_forget_host(self):
|
|
self._pve_host_cache = None
|
|
todo_prefs.set(self._PVE_PREF_KEY, {})
|
|
|
|
def _pve_remember_host(self, host):
|
|
self._pve_host_cache = host
|
|
todo_prefs.set(self._PVE_PREF_KEY, host)
|
|
|
|
@staticmethod
|
|
def _pve_label(host):
|
|
"""« root@10.0.0.5 (par rebond) », pour l'afficher en tête de menu."""
|
|
if not host:
|
|
return ""
|
|
lab = host.get("target", "?")
|
|
if host.get("jump"):
|
|
lab += f" ({t('through')} {host['jump']})"
|
|
if host.get("version"):
|
|
lab += f" — PVE {host['version']}"
|
|
return lab
|
|
|
|
def _pve_pick_host(self):
|
|
"""Choisit l'hôte Proxmox : VM locale, adresse, ou ~/.ssh/config."""
|
|
print(f"\n{t('Which Proxmox host?')}")
|
|
print(f" [1] {t('From the local QEMU VMs')}")
|
|
print(f" [2] {t('Type an address')}")
|
|
print(f" [3] {t('From ~/.ssh/config')}")
|
|
actuel = todo_prefs.get(self._PVE_PREF_KEY) or {}
|
|
if actuel.get("target"):
|
|
print(f" [4] {t('Keep')} : {self._pve_label(actuel)}")
|
|
choix = input(t("Choice: ")).strip()
|
|
if choix == "4" and actuel.get("target"):
|
|
self._pve_host_cache = actuel
|
|
return actuel
|
|
if choix == "1":
|
|
host = self._pve_host_from_qemu()
|
|
elif choix == "3":
|
|
host = self._pve_host_from_ssh_config()
|
|
elif choix == "2":
|
|
host = self._pve_host_manual()
|
|
else:
|
|
print(t("Cancelled."))
|
|
return None
|
|
if not host:
|
|
return None
|
|
return self._pve_confirm_host(host)
|
|
|
|
def _pve_host_manual(self):
|
|
"""Saisie libre. « root@ » par défaut : « qm » exige les privilèges."""
|
|
brut = input(t("Address (user@host, default user root): ")).strip()
|
|
if not brut:
|
|
print(t("Cancelled."))
|
|
return None
|
|
cible = brut if "@" in brut else f"root@{brut}"
|
|
jump = input(t("SSH jump host (blank = none): ")).strip()
|
|
return {"target": cible, "jump": jump}
|
|
|
|
def _pve_host_from_qemu(self):
|
|
"""Une VM Proxmox déployée ICI, prise dans la liste libvirt.
|
|
|
|
C'est le cas du parc : on déploie une VM « proxmox » avec le menu
|
|
QEMU/KVM, puis on déploie DEDANS. L'IP est celle du bail DHCP, pas une
|
|
adresse à retaper.
|
|
"""
|
|
noms = self._qemu_list_domains()
|
|
if not noms:
|
|
print(f"\n{t('No VM found.')}")
|
|
return None
|
|
print(f"\n{t('Local VMs:')}")
|
|
ips = {}
|
|
for i, nom in enumerate(noms, 1):
|
|
ip = self._qemu_vm_ip_now(nom) or ""
|
|
ips[nom] = ip
|
|
etat = self._qemu_domstate(nom)
|
|
print(f" [{i}] {nom:<32} {ip or '-':<16} {etat}")
|
|
sel = input(t("Selection (number): ")).strip()
|
|
if not sel.isdigit() or not 1 <= int(sel) <= len(noms):
|
|
print(t("Invalid selection!"))
|
|
return None
|
|
nom = noms[int(sel) - 1]
|
|
ip = ips.get(nom)
|
|
if not ip:
|
|
print(f" ⚠ {t('No IP for this VM: is it running?')}")
|
|
return None
|
|
return {"target": f"root@{ip}", "jump": "", "vm": nom}
|
|
|
|
def _pve_host_from_ssh_config(self):
|
|
"""Un alias de ~/.ssh/config : il porte déjà utilisateur, port et
|
|
ProxyJump — rien à redemander, et le rebond traverse."""
|
|
entrees = self._ssh_config_entries(os.path.expanduser("~/.ssh/config"))
|
|
if not entrees:
|
|
print(f"\n{t('No SSH hosts found in ~/.ssh/config')}")
|
|
return None
|
|
print()
|
|
for i, (nom, info) in enumerate(entrees, 1):
|
|
hn = info.get("hostname", nom)
|
|
u = info.get("user", "")
|
|
desc = nom + (f" ({hn})" if hn != nom else "")
|
|
print(f" [{i}] {desc}{f' [{u}]' if u else ''}")
|
|
sel = input(t("Select SSH host number: ")).strip()
|
|
if not sel.isdigit() or not 1 <= int(sel) <= len(entrees):
|
|
print(t("Invalid selection!"))
|
|
return None
|
|
alias = entrees[int(sel) - 1][0]
|
|
# L'alias SEUL : ssh y lira l'utilisateur, le port et le ProxyJump.
|
|
return {"target": alias, "jump": ""}
|
|
|
|
@staticmethod
|
|
def _pve_hostkey_missing(sortie):
|
|
"""La sortie de ssh dénonce-t-elle une clé d'hôte inconnue ou changée ?"""
|
|
bas = (sortie or "").lower()
|
|
return (
|
|
"host key verification failed" in bas
|
|
or "authenticity of host" in bas
|
|
or "no ed25519 host key is known" in bas
|
|
)
|
|
|
|
@staticmethod
|
|
def _pve_clean_output(sortie):
|
|
"""Les lignes de la sortie qui APPRENNENT quelque chose.
|
|
|
|
« Warning: Permanently added … to the list of known hosts » arrive sur
|
|
stderr à chaque connexion d'un hôte en UserKnownHostsFile=/dev/null.
|
|
Affichée comme preuve d'un échec, elle envoyait chercher du côté de la
|
|
clé d'hôte un problème qui n'avait rien à voir — rapporté.
|
|
"""
|
|
gardees = []
|
|
for ligne in (sortie or "").splitlines():
|
|
nue = ligne.strip()
|
|
if not nue or nue.startswith("Warning: Permanently added"):
|
|
continue
|
|
gardees.append(nue)
|
|
return gardees
|
|
|
|
def _pve_ssh_alive(self, host):
|
|
"""(ssh passe-t-il ?, ce qu'il a dit) — sans rien exiger de la machine.
|
|
|
|
C'est la question qu'il fallait poser AVANT de conclure : une machine
|
|
qui répond mais n'a pas Proxmox n'est pas « injoignable », et les deux
|
|
pannes ne se corrigent pas du même côté."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
code, out = pve.run(host, "true", timeout=20)
|
|
lignes = self._pve_clean_output(out)
|
|
return code == 0, (lignes[0] if lignes else t("no answer"))
|
|
|
|
def _pve_install_hint(self, host):
|
|
"""La commande qui poserait Proxmox VE sur cette machine.
|
|
|
|
Le script du dépôt, poussé par le tube : il est autonome, donc
|
|
« bash -s » suffit et il n'y a rien à copier d'abord."""
|
|
return (
|
|
f"cat {self.PVE_INSTALL_SCRIPT} | "
|
|
f"ssh {host['target']} sudo bash -s"
|
|
)
|
|
|
|
def _pve_add_hostkey(self, host):
|
|
"""Enregistre la clé d'hôte, après accord explicite.
|
|
|
|
ssh-keyscan et non « StrictHostKeyChecking=no » : la clé est écrite
|
|
UNE fois dans known_hosts, et toute substitution ultérieure sera
|
|
détectée. Désactiver la vérification l'aurait masquée pour toujours.
|
|
"""
|
|
cible = host["target"].split("@")[-1]
|
|
# Un alias de ~/.ssh/config n'est pas un nom de machine : ssh seul sait
|
|
# vers quoi il pointe.
|
|
resolu = self._ssh_resolve(host["target"])
|
|
nom = resolu.get("hostname") or cible
|
|
port = resolu.get("port") or host.get("port") or "22"
|
|
print(f"\n ⚠ {t('SSH does not know this host key yet.')}")
|
|
print(f" {t('Would record:')} ssh-keyscan -p {port} {nom}")
|
|
if not self._is_yes(input(f" {t('Record it?')} (o/N) : ")):
|
|
return False
|
|
try:
|
|
res = subprocess.run(
|
|
["ssh-keyscan", "-p", str(port), nom],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=30,
|
|
)
|
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
print(f" ✗ ssh-keyscan : {exc}")
|
|
return False
|
|
if res.returncode != 0 or not res.stdout.strip():
|
|
print(f" ✗ {t('No host key obtained.')}")
|
|
return False
|
|
chemin = os.path.expanduser("~/.ssh/known_hosts")
|
|
os.makedirs(os.path.dirname(chemin), exist_ok=True)
|
|
with open(chemin, "a", encoding="utf-8") as fh:
|
|
fh.write(
|
|
res.stdout if res.stdout.endswith("\n") else res.stdout + "\n"
|
|
)
|
|
lignes = len(res.stdout.strip().splitlines())
|
|
print(f" ✓ {lignes} {t('key(s) recorded in ~/.ssh/known_hosts')}")
|
|
return True
|
|
|
|
def _pve_confirm_host(self, host):
|
|
"""Vérifie que c'en est un, et le retient. Sinon, dit ce qu'il a vu.
|
|
|
|
« pveversion » est la preuve : une adresse saisie à la main peut être
|
|
n'importe quelle machine, et sans ce contrôle la première commande
|
|
« qm » échouerait sur un « command not found » qui n'explique rien.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
print(f"\n {t('Checking')} {host['target']}…")
|
|
code, out = pve.run(host, "pveversion", timeout=30)
|
|
version = pve.parse_pveversion(out)
|
|
if not version and self._pve_hostkey_missing(out):
|
|
# Première connexion : ssh refuse un hôte dont il n'a pas la clé.
|
|
# On ne DÉSACTIVE pas la vérification — un hyperviseur n'est pas
|
|
# une VM jetable — on propose de l'enregistrer, une fois.
|
|
if self._pve_add_hostkey(host):
|
|
code, out = pve.run(host, "pveversion", timeout=30)
|
|
version = pve.parse_pveversion(out)
|
|
if not version:
|
|
# Un seul message confondait deux pannes : « ou il est
|
|
# injoignable » envoyait vérifier le réseau alors que la machine
|
|
# répondait, et la seule ligne montrée était l'avertissement de
|
|
# ssh sur la clé d'hôte. On demande donc à ssh s'il passe.
|
|
joignable, detail = self._pve_ssh_alive(host)
|
|
# Ce que « pveversion » a répondu, et non ce que la sonde a dit :
|
|
# « command not found » est LA preuve utile.
|
|
dit = self._pve_clean_output(out)
|
|
if joignable:
|
|
print(f" ✗ {t('Reachable, but Proxmox VE is not there:')}")
|
|
print(
|
|
f" ssh {host['target']} : ok — pveversion : "
|
|
f"{dit[0] if dit else t('absent')}"
|
|
)
|
|
print(f" → {t('Install it:')}")
|
|
print(f" {self._pve_install_hint(host)}")
|
|
print(
|
|
f" → {t('Or redeploy the VM with the hypervisor profile.')}"
|
|
)
|
|
else:
|
|
print(f" ✗ {t('SSH does not get through:')}")
|
|
print(f" {detail}")
|
|
print(
|
|
f" → {t('Check the address, the SSH access and pveversion.')}"
|
|
)
|
|
return None
|
|
# « qm » exige les privilèges. La voie « VM QEMU locale » donne
|
|
# l'accès d'erplibre, pas de root : il faut donc sudo, et il faut le
|
|
# VÉRIFIER — un sudo qui réclame un mot de passe bloquerait chaque
|
|
# commande du menu sur une invite que personne ne voit.
|
|
prefixe = ""
|
|
_c, qui = pve.run(host, "id -u", timeout=20)
|
|
if qui.strip() != "0":
|
|
code, _o = pve.run(host, "sudo -n true", timeout=20)
|
|
if code:
|
|
print(
|
|
f" ✗ {t('qm needs root: no root, and sudo asks for a password.')}"
|
|
)
|
|
print(f" → {t('Connect as root@, or allow NOPASSWD sudo.')}")
|
|
return None
|
|
prefixe = "sudo "
|
|
print(f" ✓ sudo")
|
|
host = dict(host, version=version, sudo=prefixe)
|
|
print(f" ✓ Proxmox VE {version}")
|
|
# Le noyau DÉCIDE de ce qui marche : sans le noyau Proxmox, ni module
|
|
# bridge ni table NAT — donc aucun pont à créer et aucune VM à
|
|
# démarrer. Vécu sur l'hôte d'essai, où ifupdown2 répondait
|
|
# « Another instance of this program is already running » au lieu de
|
|
# « Operation not supported ». On le dit ici, une fois, plutôt que de
|
|
# laisser chercher.
|
|
noyau = pve.parse_kernel(out)
|
|
if noyau and "-pve" not in noyau:
|
|
print(f" ⚠ {t('Still on the distribution kernel:')} {noyau}")
|
|
print(f" → {t('Reboot the host: no bridge, no NAT until then.')}")
|
|
self._pve_remember_host(host)
|
|
return host
|
|
|
|
# -- Exécution sur l'hôte ------------------------------------------ #
|
|
def _pve_show(self, remote, timeout=120, quiet=False):
|
|
"""Exécute `remote` sur l'hôte Proxmox et montre ce qui a été lancé.
|
|
|
|
La commande est AFFICHÉE avant sa sortie : c'est ce qui rend chaque
|
|
étape rejouable à la main, et c'est ainsi que les pannes de ce module
|
|
ont été diagnostiquées.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
host = self._pve_host()
|
|
if not host:
|
|
return 255, ""
|
|
if not quiet:
|
|
# La forme RÉELLEMENT envoyée, enrobage sudo compris : une
|
|
# commande affichée doit pouvoir être recopiée telle quelle.
|
|
reel = pve.wrap_privilege(remote, host.get("sudo") or "")
|
|
print(f"\n{t('Will execute:')} ssh {host['target']} {reel}")
|
|
code, out = pve.run(host, remote, timeout)
|
|
if out.strip() and not quiet:
|
|
print(out.rstrip())
|
|
if code and not quiet:
|
|
print(f" ⚠ {t('exit code')} {code}")
|
|
return code, out
|
|
|
|
def _pve_vms(self):
|
|
"""[{vmid, name, status, …}] des VM de l'hôte, ou []."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
code, out = self._pve_show("qm list", quiet=True)
|
|
return pve.parse_qm_list(out) if code == 0 else []
|
|
|
|
def _pve_pick_vm(self, titre="", multiple=False):
|
|
"""Choisit une VM de l'hôte (numéro de la liste, jamais le VMID à
|
|
retaper). Renvoie un dict, une liste si `multiple`, ou None."""
|
|
vms = self._pve_vms()
|
|
if not vms:
|
|
print(f"\n{t('No VM on this Proxmox host.')}")
|
|
return [] if multiple else None
|
|
print(f"\n{titre or t('VMs on this host:')}")
|
|
for i, vm in enumerate(vms, 1):
|
|
print(f" [{i}] {vm['vmid']:<6} {vm['name']:<28} {vm['status']}")
|
|
if multiple:
|
|
print(f" [all] {t('select all')}")
|
|
brut = input(t("Selection (number): ")).strip()
|
|
if multiple:
|
|
if brut.lower() in ("all", "*"):
|
|
return vms
|
|
choisis = []
|
|
for jeton in re.split(r"[\s,]+", brut):
|
|
if jeton.isdigit() and 1 <= int(jeton) <= len(vms):
|
|
choisis.append(vms[int(jeton) - 1])
|
|
return choisis
|
|
if brut.isdigit() and 1 <= int(brut) <= len(vms):
|
|
return vms[int(brut) - 1]
|
|
print(t("Invalid selection!"))
|
|
return None
|
|
|
|
# -- Les commandes du menu ----------------------------------------- #
|
|
def _pve_list(self):
|
|
"""« qm list », mis en tableau avec le total."""
|
|
vms = self._pve_vms()
|
|
if not vms:
|
|
print(f"\n{t('No VM on this Proxmox host.')}")
|
|
return
|
|
print(
|
|
f"\n{'VMID':<7} {'Nom':<30} {'État':<10} {'RAM (Mo)':>9}"
|
|
f" {'Disque':>10}"
|
|
)
|
|
print("─" * 70)
|
|
for vm in vms:
|
|
print(
|
|
f"{vm['vmid']:<7} {vm['name'][:30]:<30} {vm['status']:<10}"
|
|
f" {vm['mem']:>9} {vm['disk']:>10}"
|
|
)
|
|
actives = sum(1 for v in vms if v["status"] == "running")
|
|
print(f"\n {len(vms)} VM, {actives} {t('running')}")
|
|
|
|
def _pve_vm_ip(self):
|
|
"""Adresse d'une VM, par l'agent invité.
|
|
|
|
Sans agent, Proxmox ne connaît PAS l'adresse de ses invités : il ne la
|
|
distribue pas lui-même. Le dire vaut mieux qu'afficher « rien ».
|
|
"""
|
|
vm = self._pve_pick_vm()
|
|
if not vm:
|
|
return
|
|
# _pve_guest_ip et non l'agent seul : il enchaîne agent PUIS voisinage
|
|
# de l'hôte. L'image cloud Debian n'embarque pas qemu-guest-agent, et
|
|
# cette entrée du menu répondait « aucune adresse » alors que « ip
|
|
# neigh » la connaissait — deux chemins pour la même question, dont un
|
|
# seul savait répondre.
|
|
ip = self._pve_guest_ip(vm["vmid"], attente=20)
|
|
if ip:
|
|
print(f"\n {vm['name']} : {ip}")
|
|
print(f" ssh erplibre@{ip}")
|
|
return
|
|
print(f"\n ⚠ {t('No address for this VM.')}")
|
|
print(f" → {t('Is qemu-guest-agent installed and the VM started?')}")
|
|
print(f" → {t('A static address is visible right after creation.')}")
|
|
|
|
def _pve_console(self):
|
|
"""Console série d'une VM. Demande un terminal : on passe donc par
|
|
l'exécuteur du dépôt, qui en a un."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
vm = self._pve_pick_vm()
|
|
if not vm:
|
|
return
|
|
host = self._pve_host()
|
|
if not host:
|
|
return
|
|
cmd = " ".join(
|
|
shlex.quote(a)
|
|
for a in pve.ssh_argv(
|
|
host,
|
|
pve.wrap_privilege(
|
|
pve.console_cmd(vm["vmid"]), host.get("sudo") or ""
|
|
),
|
|
tty=True,
|
|
)
|
|
)
|
|
print(f"\n {t('Ctrl+O to quit the serial console.')}")
|
|
print(f"\n{t('Will execute:')} {cmd}")
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
|
|
|
def _pve_resize(self):
|
|
"""Agrandit un disque. Proxmox REFUSE de rétrécir : on le dit avant."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
vm = self._pve_pick_vm()
|
|
if not vm:
|
|
return
|
|
print(f"\n ⚠ {t('Proxmox can only GROW a disk, never shrink it.')}")
|
|
taille = input(t("Size (+10G to add, 40G for a target): ")).strip()
|
|
if not re.match(r"^\+?\d+[MGT]$", taille):
|
|
print(t("Invalid selection!"))
|
|
return
|
|
self._pve_show(pve.resize_cmd(vm["vmid"], taille))
|
|
|
|
def _pve_delete(self):
|
|
"""Efface des VM, avec DOUBLE validation — « --purge » emporte les
|
|
disques et les sauvegardes, il n'y a pas de retour."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
vms = self._pve_pick_vm(multiple=True)
|
|
if not vms:
|
|
return
|
|
noms = ", ".join(f"{v['vmid']} ({v['name']})" for v in vms)
|
|
print(f"\n ⚠ {t('This also destroys their disks and backups.')}")
|
|
if not self._is_yes(input(f"{t('Apply:')} {noms} ? (o/N) : ")):
|
|
print(t("Cancelled."))
|
|
return
|
|
if not self._is_yes(input(t("Confirm for real? (y/N): "))):
|
|
print(t("Cancelled."))
|
|
return
|
|
for vm in vms:
|
|
for cmd in pve.destroy_cmds(vm["vmid"]):
|
|
self._pve_show(cmd, timeout=300)
|
|
|
|
def _pve_cleanup(self):
|
|
"""Volumes de disque qu'aucune VM ne réclame plus.
|
|
|
|
Proxmox ne les efface pas de lui-même : une création interrompue ou un
|
|
« destroy » sans « --purge » en laisse. On les liste et on demande.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
code, out = self._pve_show(pve.orphan_disks_cmd(), quiet=True)
|
|
if code:
|
|
print(f"\n ⚠ {t('exit code')} {code}")
|
|
return
|
|
vmids = [v["vmid"] for v in self._pve_vms()]
|
|
orphelins = pve.parse_orphans(out, vmids)
|
|
if not orphelins:
|
|
print(f"\n ✓ {t('Nothing orphaned.')}")
|
|
return
|
|
total = sum(t2 for _v, t2 in orphelins)
|
|
print(f"\n{t('Orphan disks:')}")
|
|
for volid, taille in orphelins:
|
|
print(f" {volid:<48} {taille / (1 << 30):>8.1f} Go")
|
|
print(f" {t('Total:')} {total / (1 << 30):.1f} Go")
|
|
if not self._is_yes(input(f"{t('Free them?')} (o/N) : ")):
|
|
print(t("Cancelled."))
|
|
return
|
|
for volid, _taille in orphelins:
|
|
self._pve_show(f"pvesm free {shlex.quote(volid)}", timeout=300)
|
|
|
|
def _pve_guest_ip(self, vmid, attente=120):
|
|
"""Adresse d'une VM Proxmox : agent invité, sinon voisinage de l'hôte.
|
|
|
|
Deux voies parce qu'aucune ne suffit seule. L'agent est le plus sûr,
|
|
mais l'image cloud Debian ne l'embarque pas. Le voisinage (« ip neigh »
|
|
sur l'hôte) marche dès que la VM a émis un paquet — un bail DHCP suffit
|
|
— et ne demande RIEN à l'invité.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
fin = time.time() + attente
|
|
mac = ""
|
|
while True:
|
|
code, out = self._pve_show(
|
|
pve.guest_ip_cmd(vmid), timeout=30, quiet=True
|
|
)
|
|
ips = pve.parse_guest_ips(out) if code == 0 else []
|
|
if ips:
|
|
return ips[0]
|
|
if not mac:
|
|
_c, cfg = self._pve_show(
|
|
f"qm config {vmid}", timeout=30, quiet=True
|
|
)
|
|
mac = pve.mac_from_config(cfg)
|
|
if mac:
|
|
_c, neigh = self._pve_show(
|
|
"ip -4 neigh show", timeout=30, quiet=True
|
|
)
|
|
ip = pve.ip_from_neigh(neigh, mac)
|
|
if ip:
|
|
return ip
|
|
if time.time() >= fin:
|
|
return ""
|
|
time.sleep(5)
|
|
|
|
def _pve_push_key(self, chemin_local):
|
|
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
|
|
FICHIER là-bas, pas une clé en ligne."""
|
|
try:
|
|
with open(
|
|
os.path.expanduser(chemin_local), encoding="utf-8"
|
|
) as fh:
|
|
cle = fh.read().strip()
|
|
except OSError as exc:
|
|
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
|
|
return ""
|
|
distant = "/root/.ssh/erplibre-deploy.pub"
|
|
code, _out = self._pve_show(
|
|
"mkdir -p /root/.ssh && printf '%s\\n' "
|
|
f"{shlex.quote(cle)} > {distant}",
|
|
quiet=True,
|
|
)
|
|
return distant if code == 0 else ""
|
|
|
|
def _pve_offer_bridge(self):
|
|
"""Aucun pont sur l'hôte : en proposer un, sans risquer l'accès.
|
|
|
|
Une Proxmox installée SUR Debian n'a pas de vmbr0 — l'ISO en crée un,
|
|
pas la procédure sur Debian. Or « qm create » exige un pont.
|
|
|
|
On ne propose donc PAS d'ajouter l'interface physique au pont : cela
|
|
déplace l'adresse de la machine et coupe la session SSH en cours, sans
|
|
retour possible à distance. Un pont INTERNE, lui, ne touche à rien —
|
|
les VM s'y parlent, et le masquerading leur donne l'extérieur.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
print(f"\n ⚠ {t('No network bridge on this host.')}")
|
|
print(f" {t('qm create needs one. Two ways:')}")
|
|
print(
|
|
f" [1] {t('create an internal')} {pve.INTERNAL_BRIDGE}"
|
|
f" ({pve.INTERNAL_CIDR}) + NAT — {t('touches no physical NIC')}"
|
|
)
|
|
print(f" [2] {t('do it myself (bridge-ports <nic>, needs console)')}")
|
|
if input(t("Choice: ")).strip() != "1":
|
|
print(f"\n {t('To bridge the LAN, on the host:')}")
|
|
print(" auto vmbr0")
|
|
print(" iface vmbr0 inet static")
|
|
print(" address <ip-de-l-hôte>/24")
|
|
print(" gateway <passerelle>")
|
|
print(" bridge-ports <interface>")
|
|
print(
|
|
f" ⚠ {t('This moves the host address: do it from a console.')}"
|
|
)
|
|
return ""
|
|
_c, sortie = self._pve_show("ip -o -4 route show default", quiet=True)
|
|
uplink = ""
|
|
parts = (sortie or "").split()
|
|
if "dev" in parts:
|
|
uplink = parts[parts.index("dev") + 1]
|
|
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
|
|
for cmd in pve.bridge_setup_cmds(uplink=uplink):
|
|
code, _o = self._pve_show(cmd, timeout=120)
|
|
if code:
|
|
print(f" ✗ {t('Step failed, stopping here.')}")
|
|
return ""
|
|
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
|
|
ponts = pve.parse_bridges(out)
|
|
if pve.INTERNAL_BRIDGE not in ponts:
|
|
print(f" ✗ {t('The bridge did not come up.')}")
|
|
return ""
|
|
print(f" ✓ {pve.INTERNAL_BRIDGE}")
|
|
return pve.INTERNAL_BRIDGE
|
|
|
|
def _pve_deploy(self, dry_run=False):
|
|
"""Déploie une ou plusieurs VM SUR l'hôte Proxmox choisi.
|
|
|
|
L'écran d'abord, les invites en repli : c'est le même choix qu'en
|
|
QEMU/KVM, et pour la même raison — un plan de plusieurs machines se
|
|
vérifie d'un coup d'œil, pas en relisant vingt réponses déjà données.
|
|
Sans terminal graphique (ou sur refus), on retombe sur les questions.
|
|
"""
|
|
host = self._pve_host()
|
|
if not host:
|
|
return
|
|
mod = self._qemu_import_module()
|
|
try:
|
|
from script.todo.proxmox_deploy_form import run_proxmox_form
|
|
|
|
ctx = self._pve_form_context(mod, host)
|
|
spec = run_proxmox_form(ctx)
|
|
except ImportError as exc:
|
|
print(f" ⚠ {t('TUI unavailable')} : {exc}")
|
|
spec = {}
|
|
if spec is None:
|
|
print(t("Cancelled."))
|
|
return
|
|
if spec:
|
|
return self._pve_deploy_spec(host, spec, mod, dry_run)
|
|
return self._pve_deploy_prompts(dry_run)
|
|
|
|
def _pve_form_context(self, mod, host):
|
|
"""Tout ce que l'écran doit savoir, LU AVANT de l'ouvrir.
|
|
|
|
Chaque lecture passe par ssh, et certaines par sudo : une invite de
|
|
mot de passe pendant que Textual affiche casserait l'écran. On paie
|
|
donc tout ici, une fois, terminal encore à nous.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
print(f"\n{t('Loading (host, storage, bridges, VMs)...')}")
|
|
native = self._native_arch()
|
|
arches = ["amd64", "arm64", "s390x"]
|
|
if native not in arches:
|
|
arches.insert(0, native)
|
|
catalog = {}
|
|
for a in arches:
|
|
distros = list(mod.DISTROS)
|
|
allowed = self._qemu_arch_distros(a)
|
|
if allowed is not None:
|
|
distros = [d for d in distros if d in allowed]
|
|
entries = self._qemu_catalog_entries(mod, distros, a)
|
|
for e in entries:
|
|
e["name"] = self._qemu_infra_name(
|
|
e["distro"], e["version"], e["arch"]
|
|
)
|
|
catalog[a] = entries
|
|
|
|
vms = self._pve_vms()
|
|
_c, out = self._pve_show("pvesm status --content images", quiet=True)
|
|
stockages = pve.parse_storages(out)
|
|
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
|
|
ponts = pve.parse_bridges(out)
|
|
_c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True)
|
|
infos = pve.parse_bridge_config(cfg)
|
|
cpu, ram_libre = self._pve_capacity()
|
|
# Le DNS de l'hôte, pour les VM en adresse fixe : sans lui elles
|
|
# routent mais ne résolvent rien, et « apt update » échoue sans que
|
|
# rien ne l'explique. Mesuré sur la VM d'essai.
|
|
_c, resolv = self._pve_show(pve.RESOLV_CMD, quiet=True)
|
|
serveurs_dns = pve.parse_nameservers(resolv)
|
|
|
|
def ipconfig(pont, vmid):
|
|
return pve.ipconfig_for(infos.get(pont, {}), vmid)
|
|
|
|
def build_command(vm, spec):
|
|
"""Les commandes qui seraient lancées pour CETTE VM."""
|
|
return self._pve_vm_commands(mod, vm, spec)
|
|
|
|
return {
|
|
"host": dict(host, label=self._pve_label(host)),
|
|
"node": self._pve_node_name(),
|
|
"catalog": catalog,
|
|
"arches": arches,
|
|
"native": native,
|
|
"names": [v["name"] for v in vms if v.get("name")],
|
|
"vmids": [v["vmid"] for v in vms],
|
|
"next_vmid": pve.next_vmid(vms),
|
|
"storages": [s["name"] for s in stockages if s.get("actif")],
|
|
"storage": pve.pick_storage(stockages),
|
|
# La place libre par stockage, en octets : « pvesm status » la
|
|
# donne dans la même sortie, donc l'écran peut dire si le plan
|
|
# rentre sans un aller-retour de plus vers l'hôte.
|
|
"storage_avail": {
|
|
s["name"]: s.get("avail") or 0 for s in stockages
|
|
},
|
|
"bridges": ponts,
|
|
"bridge": pve.pick_bridge(ponts),
|
|
"ipconfig": ipconfig,
|
|
"nameservers": serveurs_dns,
|
|
"build_command": build_command,
|
|
"branches": self._qemu_branch_list() or ["master"],
|
|
"install_profiles": self._qemu_install_profiles(),
|
|
# Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on
|
|
# installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et
|
|
# Odoo 18, comme l'écran d'à côté avant correction.
|
|
"distro_profiles": {
|
|
d: self._qemu_distro_profile(d)
|
|
for d in self._QEMU_DISTRO_PROFILE
|
|
if self._qemu_distro_profile(d)
|
|
},
|
|
"ssh_key": self._qemu_default_ssh_key(),
|
|
"cpu_presets": self._QEMU_CPU_PRESETS,
|
|
"ram_presets": self._QEMU_RAM_PRESETS,
|
|
"disk_presets": self._QEMU_DISK_PRESETS,
|
|
"base_vcpus": self._QEMU_BASE_VCPUS,
|
|
# Les cœurs et la mémoire de L'HÔTE DISTANT : ceux d'ici ne
|
|
# disent rien de ce qu'on peut y loger.
|
|
"host_cpu": cpu,
|
|
"free_ram": ram_libre,
|
|
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
|
|
}
|
|
|
|
def _pve_capacity(self):
|
|
"""(cœurs, Mo de RAM libre) de l'hôte, ou un repli prudent.
|
|
|
|
Deux valeurs en une commande : le formulaire s'en sert pour borner les
|
|
vCPU et pour prévenir quand le plan demande plus de mémoire que
|
|
l'hôte n'en a de libre."""
|
|
code, out = self._pve_show(
|
|
"nproc; free -m | awk '/^Mem:/ {print $7}'", quiet=True
|
|
)
|
|
lignes = [x.strip() for x in (out or "").splitlines() if x.strip()]
|
|
if code or len(lignes) < 2:
|
|
return 2, 0
|
|
cpu = int(lignes[0]) if lignes[0].isdigit() else 2
|
|
libre = int(lignes[1]) if lignes[1].isdigit() else 0
|
|
return cpu, libre
|
|
|
|
def _pve_node_name(self):
|
|
"""Nom du nœud Proxmox, tel qu'il se nomme lui-même."""
|
|
code, out = self._pve_show("hostname", quiet=True)
|
|
return out.strip().splitlines()[0] if code == 0 and out.strip() else ""
|
|
|
|
def _pve_vm_commands(self, mod, vm, spec):
|
|
"""Les commandes de création d'UNE VM, dans l'ordre : l'image puis
|
|
« qm ». Sert à l'aperçu comme à l'exécution — un aperçu qui ne
|
|
montrerait pas exactement ce qui va tourner ne servirait à rien."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
code, _v = mod.DISTROS[vm["distro"]][0][vm["version"]][:2]
|
|
url = mod.image_url(vm["distro"], code, vm["arch"], vm["version"])
|
|
image = mod.default_image_name(
|
|
vm["distro"], code, vm["arch"], vm["version"]
|
|
)
|
|
detail = {
|
|
"name": vm["name"],
|
|
"memory": vm["ram"],
|
|
"vcpus": vm["vcpus"],
|
|
"disk": vm["disk"],
|
|
"storage": spec["storage"],
|
|
"bridge": spec["bridge"],
|
|
"image": image,
|
|
"user": spec.get("user") or "erplibre",
|
|
"start": spec.get("start", True),
|
|
"ipconfig": vm.get("ipconfig") or "ip=dhcp",
|
|
# Le DNS de l'hôte : « --ipconfig0 » ne le porte pas, et une VM
|
|
# en adresse fixe se retrouvait sans résolveur.
|
|
"nameservers": spec.get("nameservers") or (),
|
|
}
|
|
if spec.get("sshkey_path"):
|
|
detail["sshkey_path"] = spec["sshkey_path"]
|
|
return [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
|
vm["vmid"], detail
|
|
)
|
|
|
|
def _pve_deploy_spec(self, host, spec, mod, dry_run=False):
|
|
"""Exécute la spec rendue par l'écran.
|
|
|
|
Les images D'ABORD, une par une : deux téléchargements simultanés du
|
|
même fichier se marcheraient dessus. Les VM ensuite, en parallèle si
|
|
on l'a demandé — chacune est une suite « qm » indépendante.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
from script.todo.deploy_form_lib import run_deploy_progress
|
|
|
|
# Le stockage et le pont AVANT tout : l'écran les vérifie déjà, mais
|
|
# cette méthode s'appelle aussi d'ailleurs. Sans ce garde-fou, on
|
|
# téléchargeait 350 Mio d'image pour finir sur « net0: invalid format
|
|
# - missing key » — vécu sur l'hôte d'essai.
|
|
for valeur, message in (
|
|
(spec.get("storage"), t("No storage able to hold a VM disk.")),
|
|
(spec.get("bridge"), t("No bridge on the host.")),
|
|
):
|
|
if not valeur:
|
|
print(f"\n ✗ {message}")
|
|
return
|
|
if not dry_run and not self._pve_confirm_spec(host, spec):
|
|
print(t("Cancelled."))
|
|
return
|
|
cle_locale = spec.get("ssh_key") or self._qemu_default_ssh_key()
|
|
if cle_locale and not dry_run:
|
|
if self._pve_push_key(cle_locale):
|
|
spec["sshkey_path"] = "/root/.ssh/erplibre-deploy.pub"
|
|
else:
|
|
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
|
|
travaux, commandes = [], {}
|
|
for vm in spec["vms"]:
|
|
cmds = self._pve_vm_commands(mod, vm, spec)
|
|
commandes[vm["name"]] = cmds
|
|
if dry_run:
|
|
print(f"\n── {vm['name']} ({t('VMID')} {vm['vmid']}) ──")
|
|
for cmd in cmds:
|
|
print(f" {cmd}")
|
|
continue
|
|
# UNE seule commande distante par VM : l'enchaînement par « && »
|
|
# s'arrête à la première étape qui cède, et le journal de la VM
|
|
# porte toute sa création.
|
|
remote = " && ".join(cmds)
|
|
travaux.append(
|
|
(
|
|
vm["name"],
|
|
vm["name"],
|
|
pve.ssh_argv(
|
|
host,
|
|
pve.wrap_privilege(remote, host.get("sudo") or ""),
|
|
),
|
|
)
|
|
)
|
|
if dry_run:
|
|
return
|
|
if spec["existing"]:
|
|
print(f" ⏭ {t('already there')} : {', '.join(spec['existing'])}")
|
|
if not travaux:
|
|
return
|
|
# Le journal AVANT de lancer : la vue de progression se referme et
|
|
# emporte tout ce qu'elle montrait. Rapporté — « il manque plein
|
|
# d'informations qu'il y avait avant, où est le fichier de log ? ».
|
|
# L'ancienne voie par questions imprimait chaque commande et sa
|
|
# sortie ; celle-ci les écrit, ce qui vaut mieux qu'un défilement.
|
|
session = self._pve_log_dir()
|
|
print(f"\n {t('Log:')} {session}")
|
|
resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1)
|
|
reussies = [nom for nom, code, _o, _d in resultats if code == 0]
|
|
for nom, code, sortie, duree in resultats:
|
|
chemin = self._pve_write_log(
|
|
session,
|
|
nom,
|
|
spec,
|
|
commandes.get(nom) or [],
|
|
code,
|
|
sortie,
|
|
duree,
|
|
)
|
|
marque = "✓" if code == 0 else "✗"
|
|
print(f" {marque} {nom} : {chemin}")
|
|
if code:
|
|
print(f" {t('exit code')} {code}")
|
|
# Les dernières lignes à l'écran, le reste dans le journal :
|
|
# c'est l'échec qu'on veut lire tout de suite.
|
|
propre = pve.collapse_progress(pve.strip_ssh_noise(sortie))
|
|
for ligne in propre.rstrip().splitlines()[-12:]:
|
|
print(f" {ligne}")
|
|
if not reussies:
|
|
return
|
|
self._pve_after_create(host, spec, reussies, cle_locale)
|
|
|
|
@staticmethod
|
|
def _pve_log_dir():
|
|
"""Répertoire de journaux de CE déploiement, créé au besoin.
|
|
|
|
Même esprit que ~/.erplibre/qemu-install : une session par
|
|
déploiement, un fichier par VM. La vue de progression se referme ; le
|
|
journal reste, et c'est lui qu'on relit quand une étape a cédé."""
|
|
session = os.path.join(
|
|
os.path.expanduser("~/.erplibre/proxmox-deploy"),
|
|
time.strftime("%Y%m%d-%H%M%S"),
|
|
)
|
|
os.makedirs(session, exist_ok=True)
|
|
return session
|
|
|
|
@staticmethod
|
|
def _pve_write_log(session, nom, spec, cmds, code, sortie, duree):
|
|
"""Écrit le journal d'UNE VM et rend son chemin.
|
|
|
|
Les commandes AVANT leur sortie : c'est ce qui rend l'étape rejouable
|
|
à la main, et c'est ainsi que les pannes de ce module ont été
|
|
diagnostiquées."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
chemin = os.path.join(session, f"{nom}.log")
|
|
hote = (spec.get("host") or {}).get("target", "?")
|
|
vm = next((v for v in spec.get("vms") or [] if v["name"] == nom), {})
|
|
entete = [
|
|
"=" * 64,
|
|
" ERPLibre — création d'une VM sur Proxmox VE",
|
|
f" Date : {time.strftime('%Y-%m-%d %H:%M:%S')}",
|
|
f" VM : {nom} VMID {vm.get('vmid', '?')}",
|
|
f" Hôte : {hote}",
|
|
f" Stockage : {spec.get('storage')} "
|
|
f"{t('bridge')} : {spec.get('bridge')}",
|
|
f" Adresse : {(vm.get('ipconfig') or '').replace('ip=', '')}",
|
|
f" Ressources: {vm.get('vcpus', '?')} vCPU "
|
|
f"{vm.get('ram', '?')} Mo {vm.get('disk', '?')}",
|
|
"=" * 64,
|
|
"",
|
|
"---- commandes ----",
|
|
]
|
|
entete += [f" {c}" for c in cmds]
|
|
propre = pve.collapse_progress(pve.strip_ssh_noise(sortie or ""))
|
|
entete += ["", "---- sortie ----", propre.rstrip(), ""]
|
|
entete += [
|
|
(
|
|
f"---- fin : code {code}, {duree:.0f} s ----"
|
|
if isinstance(duree, (int, float))
|
|
else f"---- fin : code {code} ----"
|
|
)
|
|
]
|
|
with open(chemin, "w", encoding="utf-8") as fh:
|
|
fh.write("\n".join(entete) + "\n")
|
|
return chemin
|
|
|
|
def _pve_confirm_spec(self, host, spec):
|
|
"""Récapitulatif puis confirmation, dans le TERMINAL.
|
|
|
|
L'écran a montré le plan, mais c'est ici que ça devient réel — et sur
|
|
une machine qui n'est pas la nôtre. La ligne dit donc où, quoi, et
|
|
combien, avant le mot de passe sudo que l'hôte va demander."""
|
|
print(f"\n {t('Proxmox host')} : {self._pve_label(host)}")
|
|
print(
|
|
f" {t('storage')} {spec['storage']} "
|
|
f"{t('bridge')} {spec['bridge']} [{spec['res_label']}]"
|
|
)
|
|
for vm in spec["vms"]:
|
|
print(
|
|
f" {vm['name']:32} {t('VMID')} {vm['vmid']} "
|
|
f"{vm['vcpus']} vCPU {vm['ram']} Mo {vm['disk']} "
|
|
f"{(vm.get('ipconfig') or '').replace('ip=', '')}"
|
|
)
|
|
if spec.get("install"):
|
|
print(
|
|
f" ERPLibre : {spec['install'].get('label') or ''}"
|
|
f" ({spec['install'].get('branch')})"
|
|
)
|
|
return self._is_yes_default_yes(
|
|
input(f"\n{t('Deploy this VM now? (Y/n): ')}")
|
|
)
|
|
|
|
def _pve_after_create(self, host, spec, reussies, cle_locale):
|
|
"""Ce qui suit la création : l'adresse, ~/.ssh/config, l'installation.
|
|
|
|
L'alias et non l'IP dans les étapes suivantes : ssh y lit le rebond
|
|
par l'hôte Proxmox, et le suivi d'installation en a besoin pour
|
|
entrer dans une VM qui n'est pas sur notre réseau."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
joignables = []
|
|
for vm in spec["vms"]:
|
|
if vm["name"] not in reussies:
|
|
continue
|
|
ip = pve.ip_from_ipconfig(vm.get("ipconfig") or "")
|
|
if not ip:
|
|
print(f"\n {t('Waiting for the VM address…')} {vm['name']}")
|
|
ip = self._pve_guest_ip(vm["vmid"])
|
|
if not ip:
|
|
print(
|
|
f" ⚠ {vm['name']} : {t('No address yet. Try [6] later.')}"
|
|
)
|
|
continue
|
|
print(f" ✓ {vm['name']} : {ip}")
|
|
if spec.get("add_ssh_config"):
|
|
self._write_ssh_config_entry(
|
|
vm["name"],
|
|
spec.get("user") or "erplibre",
|
|
ip,
|
|
identity_file=self._ssh_private_key(cle_locale),
|
|
proxy_jump=host["target"],
|
|
)
|
|
print(f" ✓ ~/.ssh/config : ssh {vm['name']}")
|
|
joignables.append(vm)
|
|
install = spec.get("install")
|
|
# Le suivi vient du DÉPLOIEMENT, pas de l'installation — même règle
|
|
# qu'en QEMU/KVM. Sans elle, la case « Suivre l'installation » ne
|
|
# commandait rien : décochée, le tableau de bord s'ouvrait quand
|
|
# même ; cochée sans rien à installer, il ne s'ouvrait jamais.
|
|
suivi = spec.get("monitor", True)
|
|
if not joignables or not (install or suivi):
|
|
return
|
|
noms = [vm["name"] for vm in joignables]
|
|
if install:
|
|
print(f" {install.get('label') or ''}")
|
|
# Une commande PAR VM dès qu'elles diffèrent : un Proxmox imbriqué
|
|
# installe son hyperviseur, ses voisines ERPLibre. Une commande
|
|
# unique en aurait imposé une aux deux.
|
|
commun = (install or {}).get("cmd") or ""
|
|
cartes = {
|
|
vm["name"]: (vm.get("install_cmd") or commun) for vm in joignables
|
|
}
|
|
finale = cartes if self._qemu_per_vm(cartes, commun) else commun
|
|
branche = (install or {}).get("branch") or ""
|
|
if suivi:
|
|
# Rien à installer ? La commande distante regarde alors la VM
|
|
# ARRIVER (cloud-init, puis relevé système) : c'est ce que le
|
|
# tableau de bord montre.
|
|
self._qemu_install_erplibre_monitored(
|
|
noms, branche, {n: n for n in noms}, finale
|
|
)
|
|
return
|
|
# Sans suivi mais avec quelque chose à installer : en série, sortie à
|
|
# l'écran. C'est le pendant exact de la voie QEMU/KVM.
|
|
print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…")
|
|
for vm in joignables:
|
|
self._qemu_install_erplibre_vm(
|
|
vm["name"],
|
|
cle_locale,
|
|
branche,
|
|
pve.ip_from_ipconfig(vm.get("ipconfig") or "") or vm["name"],
|
|
vm.get("install_cmd") or commun,
|
|
False,
|
|
)
|
|
|
|
def _pve_deploy_prompts(self, dry_run=False):
|
|
"""Déploie une VM SUR l'hôte Proxmox choisi, par questions.
|
|
|
|
Le catalogue d'images est celui du dépôt (le même que QEMU/KVM) : c'est
|
|
une connaissance locale, indépendante de l'hyperviseur. Tout le reste
|
|
part sur l'hôte — téléchargement compris, puisque c'est là que le
|
|
disque sera écrit.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
host = self._pve_host()
|
|
if not host:
|
|
return
|
|
mod = self._qemu_import_module()
|
|
distro = self._qemu_prompt_distro()
|
|
version = self._qemu_prompt_version(distro)
|
|
arch = "amd64"
|
|
nom = (
|
|
input(t("VM name (default: erplibre-<distro>): ")).strip()
|
|
or f"erplibre-{distro}"
|
|
)
|
|
memoire = (
|
|
self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096
|
|
)
|
|
vcpus = (
|
|
self._qemu_ask_cpu(t("vCPU, blank = 2"), 2, os.cpu_count() or 2)
|
|
or 2
|
|
)
|
|
disque = input(t("Disk size (default 32G): ")).strip() or "32G"
|
|
|
|
code, _v = mod.DISTROS[distro][0][version][:2]
|
|
url = mod.image_url(distro, code, arch, version)
|
|
image = mod.default_image_name(distro, code, arch, version)
|
|
|
|
# Stockage et pont : demandés à l'HÔTE, jamais devinés. « local-lvm »
|
|
# n'existe pas partout, et un pont inventé fait échouer « qm create ».
|
|
_c, out = self._pve_show("pvesm status --content images", quiet=True)
|
|
stockages = pve.parse_storages(out)
|
|
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
|
|
ponts = pve.parse_bridges(out)
|
|
_c, cfg_reseau = self._pve_show(
|
|
"cat /etc/network/interfaces", quiet=True
|
|
)
|
|
infos_ponts = pve.parse_bridge_config(cfg_reseau)
|
|
stockage = pve.pick_storage(stockages)
|
|
pont = pve.pick_bridge(ponts)
|
|
if not stockage:
|
|
print(f"\n ✗ {t('No storage able to hold a VM disk.')}")
|
|
return
|
|
if not pont and not dry_run:
|
|
pont = self._pve_offer_bridge()
|
|
if not pont:
|
|
return
|
|
_c, cfg_reseau = self._pve_show(
|
|
"cat /etc/network/interfaces", quiet=True
|
|
)
|
|
infos_ponts = pve.parse_bridge_config(cfg_reseau)
|
|
elif not pont:
|
|
pont = pve.INTERNAL_BRIDGE
|
|
# Le VMID D'ABORD : l'adresse d'un pont interne s'en déduit, et
|
|
# l'afficher avant de l'avoir choisi ne pouvait pas marcher.
|
|
vmid = pve.next_vmid(self._pve_vms())
|
|
ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid)
|
|
print(
|
|
f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})"
|
|
)
|
|
print(f" {t('bridge')} : {pont}")
|
|
print(f" {t('address')} {ipconfig}")
|
|
print(f" VMID : {vmid}")
|
|
|
|
cle_locale = self._qemu_default_ssh_key()
|
|
spec = {
|
|
"name": nom,
|
|
"memory": memoire,
|
|
"vcpus": vcpus,
|
|
"disk": disque,
|
|
"storage": stockage,
|
|
"bridge": pont,
|
|
"image": image,
|
|
"user": "erplibre",
|
|
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
|
"start": True,
|
|
# DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont
|
|
# interne : là, aucun serveur DHCP ne répondrait et la VM
|
|
# resterait muette.
|
|
"ipconfig": ipconfig,
|
|
}
|
|
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
|
vmid, spec
|
|
)
|
|
if dry_run:
|
|
print(f"\n── {t('Would run on')} {host['target']} ──")
|
|
print(f" # {t('SSH key ->')} {spec['sshkey_path']}")
|
|
for cmd in etapes:
|
|
print(f" {cmd}")
|
|
return
|
|
if not self._is_yes_default_yes(
|
|
input(f"\n{t('Deploy this VM now? (Y/n): ')}")
|
|
):
|
|
print(t("Cancelled."))
|
|
return
|
|
if cle_locale and not self._pve_push_key(cle_locale):
|
|
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
|
|
spec.pop("sshkey_path", None)
|
|
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
|
vmid, spec
|
|
)
|
|
for cmd in etapes:
|
|
code, _out = self._pve_show(cmd, timeout=1800)
|
|
if code:
|
|
print(f"\n ✗ {t('Step failed, stopping here.')}")
|
|
return
|
|
# Adresse fixe : c'est nous qui l'avons donnée, inutile de la
|
|
# chercher. La découverte ne sert qu'au DHCP.
|
|
ip = pve.ip_from_ipconfig(ipconfig)
|
|
if ip:
|
|
print(f"\n {t('address given at creation:')} {ip}")
|
|
else:
|
|
print(f"\n {t('Waiting for the VM address…')}")
|
|
ip = self._pve_guest_ip(vmid)
|
|
if not ip:
|
|
print(f" ⚠ {t('No address yet. Try [6] later.')}")
|
|
return
|
|
print(f" ✓ {nom} : {ip}")
|
|
# Entrée ~/.ssh/config avec l'hôte Proxmox en REBOND : c'est ce qui
|
|
# rend la VM joignable d'ici, et c'est aussi ce qui permet au suivi
|
|
# d'installation d'y entrer (il reçoit l'alias, pas l'IP).
|
|
self._write_ssh_config_entry(
|
|
nom,
|
|
"erplibre",
|
|
ip,
|
|
identity_file=self._ssh_private_key(cle_locale),
|
|
proxy_jump=host["target"],
|
|
)
|
|
print(f" ✓ ~/.ssh/config : ssh {nom}")
|
|
if self._is_yes_default_yes(
|
|
input(f"\n{t('Install ERPLibre on it? (Y/n): ')}")
|
|
):
|
|
branch = self._qemu_pick_branch()
|
|
label, cmd = self._qemu_pick_install_profile(distro)
|
|
print(f" {label}")
|
|
# L'ALIAS, pas l'IP : ssh y lit le ProxyJump de ~/.ssh/config.
|
|
self._qemu_install_erplibre_monitored(
|
|
[nom], branch, {nom: nom}, cmd
|
|
)
|
|
|
|
def _pve_ssh_config(self):
|
|
"""Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte
|
|
Proxmox en ProxyJump — sans quoi ces VM ne sont joignables d'ici que
|
|
si leur réseau est routé jusqu'à nous."""
|
|
host = self._pve_host()
|
|
if not host:
|
|
return
|
|
vms = [v for v in self._pve_vms() if v["status"] == "running"]
|
|
if not vms:
|
|
print(f"\n{t('No running VM on this Proxmox host.')}")
|
|
return
|
|
cle = self._ssh_private_key(self._qemu_default_ssh_key())
|
|
for vm in vms:
|
|
ip = self._pve_guest_ip(vm["vmid"], attente=0)
|
|
if not ip:
|
|
print(f" ⚠ {vm['name']} : {t('no address, skipped')}")
|
|
continue
|
|
self._write_ssh_config_entry(
|
|
vm["name"],
|
|
"erplibre",
|
|
ip,
|
|
identity_file=cle,
|
|
proxy_jump=host["target"],
|
|
)
|
|
print(
|
|
f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})"
|
|
)
|
|
|
|
def _pve_test_vm(self):
|
|
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
|
|
|
|
Même chose que pour QEMU, à ceci près que l'adresse vient de l'hôte
|
|
Proxmox et non de libvirt — et qu'elle n'est joignable d'ici que si son
|
|
réseau l'est. On le dit plutôt que d'ouvrir une page vide.
|
|
"""
|
|
vm = self._pve_pick_vm()
|
|
if not vm:
|
|
return
|
|
ip = self._pve_guest_ip(vm["vmid"], attente=30)
|
|
if not ip:
|
|
print(f"\n ⚠ {t('No address for this VM.')}")
|
|
return
|
|
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
|
|
print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}")
|
|
print(
|
|
f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}"
|
|
)
|
|
return
|
|
navigateur = self._qemu_choose_cli_browser()
|
|
if not navigateur:
|
|
return
|
|
url = f"http://{ip}:8069"
|
|
print(f"→ {navigateur} {url}")
|
|
os.system(f"{navigateur} {shlex.quote(url)}")
|
|
|
|
def _pve_example(self):
|
|
"""Exemple de séquence, sans rien exécuter : de quoi voir ce que
|
|
l'outil enverrait sur l'hôte."""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
spec = {
|
|
"name": "demo-vm",
|
|
"memory": 4096,
|
|
"vcpus": 2,
|
|
"disk": "32G",
|
|
"storage": "local-lvm",
|
|
"bridge": "vmbr0",
|
|
"image": "debian-13-genericcloud-amd64.qcow2",
|
|
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
|
}
|
|
print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──")
|
|
print(
|
|
f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}"
|
|
)
|
|
for cmd in pve.create_cmds(101, spec):
|
|
print(f" {cmd}")
|
|
|
|
def _pve_stats(self):
|
|
"""État de l'hôte et de ses VM, en une page."""
|
|
host = self._pve_host()
|
|
if not host:
|
|
return
|
|
print(f"\n══ {t('Proxmox host:')} {self._pve_label(host)} ══")
|
|
for titre, cmd in (
|
|
(t("uptime"), "uptime"),
|
|
(t("memory"), "free -h | head -2"),
|
|
(t("storages"), "pvesm status"),
|
|
):
|
|
code, out = self._pve_show(cmd, quiet=True)
|
|
print(f"\n── {titre} ──")
|
|
print((out or "").rstrip() if code == 0 else f" ⚠ {out.strip()}")
|
|
self._pve_list()
|
|
|
|
def prompt_execute_proxmox(self):
|
|
"""Sous-menu Proxmox VE : l'équivalent du menu QEMU/KVM, mais sur un
|
|
hôte DISTANT. La première question est donc « lequel ? » — et la
|
|
réponse est retenue pour toute la session."""
|
|
print(f"🤖 {t('Deploy a virtual machine on Proxmox VE!')}")
|
|
if not self._pve_host():
|
|
return False
|
|
choices = [
|
|
{"section": t("Deployment")},
|
|
{"prompt_description": t("Deploy a VM on the Proxmox host")},
|
|
{
|
|
"prompt_description": t(
|
|
"Preview a deployment (dry-run, nothing sent)"
|
|
)
|
|
},
|
|
{"prompt_description": t("Download a cloud image on the host")},
|
|
{
|
|
"prompt_description": t(
|
|
"Reopen install monitoring (last run / history)"
|
|
)
|
|
},
|
|
{"section": t("Manage")},
|
|
{"prompt_description": t("List VMs (qm list)")},
|
|
{"prompt_description": t("Show a VM IP address")},
|
|
{"prompt_description": t("Open the console on a VM")},
|
|
{"prompt_description": t("Resize a VM disk")},
|
|
{"prompt_description": t("Delete VM(s)")},
|
|
{"prompt_description": t("Clean up (orphan disks)")},
|
|
{
|
|
"prompt_description": t(
|
|
"Test a VM (open Odoo in a CLI browser)"
|
|
)
|
|
},
|
|
{"prompt_description": t("Statistics (host and VMs)")},
|
|
{
|
|
"prompt_description": t(
|
|
"SSH configuration (~/.ssh/config, ProxyJump)"
|
|
)
|
|
},
|
|
{
|
|
"prompt_description": t(
|
|
"Remote desktop tunnel (VNC/RDP over SSH)"
|
|
)
|
|
},
|
|
{
|
|
"prompt_description": t(
|
|
"Android emulator (start, tunnel, scrcpy)"
|
|
)
|
|
},
|
|
{"section": t("Catalog")},
|
|
{"prompt_description": t("List available images and their specs")},
|
|
{"prompt_description": t("Proxmox - example sequence (dry-run)")},
|
|
{"section": t("Host")},
|
|
{"prompt_description": t("Change the Proxmox host")},
|
|
]
|
|
help_info = self.fill_help_info(choices)
|
|
while True:
|
|
hote = self._pve_host(ask=False)
|
|
print(f"\n {t('Proxmox host:')} {self._pve_label(hote) or '-'}")
|
|
status = click.prompt(help_info)
|
|
print()
|
|
if status == "0":
|
|
return False
|
|
elif status == "1":
|
|
self._pve_deploy()
|
|
elif status == "2":
|
|
self._pve_deploy(dry_run=True)
|
|
elif status == "3":
|
|
self._pve_fetch_image()
|
|
elif status == "4":
|
|
self._qemu_reopen_monitor()
|
|
elif status == "5":
|
|
self._pve_list()
|
|
elif status == "6":
|
|
self._pve_vm_ip()
|
|
elif status == "7":
|
|
self._pve_console()
|
|
elif status == "8":
|
|
self._pve_resize()
|
|
elif status == "9":
|
|
self._pve_delete()
|
|
elif status == "10":
|
|
self._pve_cleanup()
|
|
elif status == "11":
|
|
self._pve_test_vm()
|
|
elif status == "12":
|
|
self._pve_stats()
|
|
elif status == "13":
|
|
self._pve_ssh_config()
|
|
elif status == "14":
|
|
# Les VM Proxmox sont dans ~/.ssh/config (entrée 13) : le
|
|
# tunnel du menu QEMU les y trouve, rebond compris.
|
|
self._qemu_tunnel_menu()
|
|
elif status == "15":
|
|
self._qemu_emulator_menu()
|
|
elif status == "16":
|
|
self._qemu_list_images()
|
|
elif status == "17":
|
|
self._pve_example()
|
|
elif status == "18":
|
|
self._pve_forget_host()
|
|
self._pve_pick_host()
|
|
else:
|
|
print(t("Command not found !"))
|
|
|
|
def _pve_fetch_image(self):
|
|
"""Télécharge une image cloud SUR l'hôte Proxmox.
|
|
|
|
Là et pas ici : c'est sur l'hôte que le disque sera écrit, et faire
|
|
descendre 325 Mio chez soi pour les renvoyer doublerait le transfert.
|
|
"""
|
|
from script.proxmox import proxmox_deploy as pve
|
|
|
|
mod = self._qemu_import_module()
|
|
distro = self._qemu_prompt_distro()
|
|
version = self._qemu_prompt_version(distro)
|
|
code = mod.DISTROS[distro][0][version][0]
|
|
url = mod.image_url(distro, code, "amd64", version)
|
|
nom = mod.default_image_name(distro, code, "amd64", version)
|
|
print(f"\n {nom}\n {url}")
|
|
self._pve_show(pve.image_fetch_cmd(url, nom), timeout=1800)
|