From 01b77dfa80843b17c5e71422517cd3e5720bfcbc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 23 Aug 2026 05:54:38 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20proxmox=20:=20d=C3=A9ployer=20des=20VM?= =?UTF-8?q?=20sur=20un=20h=C3=B4te=20Proxmox=20distant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nouvelle entrée sous QEMU/KVM, avec l'équivalent de ses dix-sept commandes. Toute la différence tient en une phrase : l'hyperviseur est ailleurs. On choisit donc l'hôte — VM QEMU locale, adresse, ou ~/.ssh/config — et on le vérifie : pveversion le prouve, id/sudo décident du privilège, et une clé d'hôte inconnue s'enregistre par ssh-keyscan plutôt qu'en désactivant le contrôle. Quatre pièges trouvés sur un hôte réel. Une Proxmox installée sur Debian n'a aucun pont : on en propose un INTERNE, car ajouter l'interface physique déplace l'adresse de l'hôte et coupe la session — à distance, sans retour. --- EN --- A new entry under QEMU/KVM, with the counterpart of its seventeen commands. The whole difference fits in one sentence: the hypervisor is elsewhere. So the host is chosen — local QEMU VM, address, or ~/.ssh/config — and then checked: pveversion proves it, id/sudo decide about privilege, and an unknown host key is recorded with ssh-keyscan rather than by disabling the check. Four traps found on a real host. A Proxmox installed on Debian has no bridge: we offer an INTERNAL one, because adding the physical NIC moves the host address and cuts the session — remotely, with no way back. Assisted-by: Claude Opus 5 --- script/proxmox/README.base.md | 188 ++++++ script/proxmox/README.fr.md | 92 +++ script/proxmox/README.md | 91 +++ script/proxmox/install_proxmox.sh | 25 +- script/proxmox/proxmox_deploy.py | 517 ++++++++++++++++ script/todo/todo.py | 884 ++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 315 ++++++++++ test/test_mobile_bundle_transfer.py | 18 +- test/test_proxmox_deploy.py | 408 +++++++++++++ 9 files changed, 2526 insertions(+), 12 deletions(-) create mode 100644 script/proxmox/README.base.md create mode 100644 script/proxmox/README.fr.md create mode 100644 script/proxmox/README.md create mode 100644 script/proxmox/proxmox_deploy.py create mode 100644 test/test_proxmox_deploy.py diff --git a/script/proxmox/README.base.md b/script/proxmox/README.base.md new file mode 100644 index 0000000..5c3a871 --- /dev/null +++ b/script/proxmox/README.base.md @@ -0,0 +1,188 @@ + + + + + + +# Deploying VMs on a Proxmox VE host + + +# Déployer des VM sur un hôte Proxmox VE + + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. + + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. diff --git a/script/proxmox/README.fr.md b/script/proxmox/README.fr.md new file mode 100644 index 0000000..465347e --- /dev/null +++ b/script/proxmox/README.fr.md @@ -0,0 +1,92 @@ + +# Déployer des VM sur un hôte Proxmox VE + +Deux choses différentes vivent dans ce répertoire : + +- `install_proxmox.sh` transforme une Debian en hyperviseur Proxmox. Voir + `script/qemu/README.fr.md`, qui documente la distro `proxmox` du catalogue + de déploiement. +- `proxmox_deploy.py` déploie des VM **sur** un tel hôte, depuis + `TODO › Execute › Deploy › Proxmox VE`, juste sous `QEMU/KVM`. + +## Toute la différence : l'hyperviseur est ailleurs + +Avec QEMU/KVM, l'hyperviseur est la machine qui exécute le script. Avec +Proxmox il est ailleurs : la première question est donc **quel hôte** — et la +réponse est retenue pour la session. Trois voies, toutes proposées par le menu : + +1. **Depuis les VM QEMU locales** — une VM `proxmox` déployée ici. Son adresse + vient du bail DHCP, rien à retaper. +2. **Par adresse** — `utilisateur@hôte`, plus un rebond SSH facultatif. +3. **Depuis `~/.ssh/config`** — l'alias porte déjà l'utilisateur, le port et le + ProxyJump ; on ne demande rien d'autre. + +L'hôte choisi est ensuite **vérifié**, pas supposé : `pveversion` prouve que +c'en est un, `id -u` et `sudo -n true` décident s'il faut `sudo`, et une clé +d'hôte inconnue est proposée à l'enregistrement (par `ssh-keyscan`, jamais en +désactivant la vérification — un hyperviseur n'est pas une VM jetable). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Pourquoi SSH et `qm`, pas l'API REST + +L'API demande un jeton ou un ticket à créer et à renouveler. `qm` est la voie +que tout administrateur Proxmox connaît, le dépôt sait déjà gérer des accès SSH +(`~/.ssh/config`, ProxyJump, clés), et les commandes restent lisibles dans le +journal — donc rejouables à la main. C'est ainsi que chaque panne de ce module +a été diagnostiquée. + +`sudo sh -c ''` et non `sudo ` : ces commandes sont +des suites et des redirections. Préfixer par sudo n'élèverait que le premier +mot, et la redirection resterait celle du shell non privilégié. + +## Quatre pièges rencontrés sur un hôte réel + +Une Proxmox installée **sur Debian** n'a aucun `vmbr0` — l'installateur ISO en +crée un, cette procédure non. Or `qm create` exige un pont. + +Le menu propose alors un pont **interne** (`vmbr0`, `10.10.10.1/24`, NAT par la +sortie). Ne jamais ajouter l'interface physique à un pont est un choix : cela +déplace l'adresse de l'hôte et coupe la session SSH en cours — à distance, sans +retour. Pour un pont donnant sur le LAN, la strophe est affichée, à appliquer +depuis une console. + +Sur un pont interne, aucun DHCP ne répond : l'adresse est donc **fixe**, dérivée +du VMID — donc connue avant que la VM ne démarre. La chercher ensuite était +absurde. + +L'image cloud Debian n'embarque pas `qemu-guest-agent`, et Proxmox ne connaît +pas l'adresse d'un invité en DHCP : il ne distribue pas les baux. Le repli est +le voisinage de l'hôte (`ip neigh`), qui ne demande rien à l'invité. + +## Le menu, entrée par entrée + +Les dix-sept entrées de QEMU/KVM ont leur équivalent. Quatre sont le **même +code**, parce que c'est le même travail : rouvrir le suivi d'installation, le +tunnel bureau distant, l'émulateur Android et le catalogue d'images. Elles +atteignent les invités Proxmox par les entrées `~/.ssh/config` que l'entrée 13 +écrit, avec l'hôte Proxmox en ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Vérifié + +Déploiement d'une VM dans une Proxmox qui tourne elle-même dans une VM +libvirt : image téléchargée sur l'hôte, pont interne créé, adresse fixe, +utilisateur et clé par cloud-init, disque redimensionné, `qm start`. Puis +`ssh vm-essai` depuis l'extérieur l'atteint par le rebond — trois niveaux +imbriqués. Redimensionnement `12G → 16G`, effacement avec `--purge`, recherche +d'orphelins : tout contrôlé contre Proxmox VE 9.2.11. \ No newline at end of file diff --git a/script/proxmox/README.md b/script/proxmox/README.md new file mode 100644 index 0000000..aecfbc6 --- /dev/null +++ b/script/proxmox/README.md @@ -0,0 +1,91 @@ + +# Deploying VMs on a Proxmox VE host + +Two different things live in this directory: + +- `install_proxmox.sh` turns a Debian into a Proxmox hypervisor. See + `script/qemu/README.md`, which documents the `proxmox` distro of the + deployment catalog. +- `proxmox_deploy.py` deploys VMs **on** such a host, from + `TODO › Execute › Deploy › Proxmox VE`, right under `QEMU/KVM`. + +## The whole difference: the hypervisor is elsewhere + +With QEMU/KVM, the hypervisor is the machine running the script. With Proxmox +it is somewhere else, so the first question is **which host** — and the answer +is remembered for the session. Three ways, all offered by the menu: + +1. **From the local QEMU VMs** — a `proxmox` VM deployed here. Its address + comes from the DHCP lease, nothing to retype. +2. **By address** — `user@host`, plus an optional SSH jump. +3. **From `~/.ssh/config`** — the alias already carries user, port and + ProxyJump; nothing else is asked. + +The chosen host is then **checked**, not assumed: `pveversion` proves it is a +Proxmox, `id -u` and `sudo -n true` decide whether commands need `sudo`, and an +unknown SSH host key is offered for recording (with `ssh-keyscan`, never by +disabling the check — a hypervisor is not a throwaway VM). + +```bash +# Ce que l'outil envoie, et qu'on peut rejouer à la main : +ssh erplibre@pve1 sudo sh -c 'qm list' +ssh erplibre@pve1 sudo sh -c 'pvesm status --content images' +``` + +## Why SSH and `qm`, not the REST API + +The API needs a token or a ticket to create and renew. `qm` is the path every +Proxmox administrator knows, the repository already manages SSH access +(`~/.ssh/config`, ProxyJump, keys), and the commands stay readable in the log — +so they can be replayed by hand. That is how every failure of this module was +diagnosed. + +`sudo sh -c ''` and not `sudo `: these commands are +sequences and redirections. Prefixing with sudo would elevate only the first +word, and the redirection would still be the unprivileged shell's. + +## Four traps met on a real host + +A Proxmox installed **on Debian** has no `vmbr0` — the ISO installer creates +one, that procedure does not. And `qm create` requires a bridge. + +The menu offers an **internal** bridge (`vmbr0`, `10.10.10.1/24`, NAT through +the uplink). Never adding the physical NIC to a bridge is deliberate: that +moves the host address and cuts the SSH session in progress — remotely, there +is no way back. A LAN-facing bridge is printed as a stanza to apply from a +console. + +On an internal bridge no DHCP answers, so the address is **static**, derived +from the VMID — and therefore known before the VM boots. Looking for it +afterwards was absurd. + +The Debian cloud image does not ship `qemu-guest-agent`, so Proxmox cannot tell +the address of a DHCP guest: it does not hand out the leases. The fallback is +the host's own neighbour table (`ip neigh`), which needs nothing from the guest. + +## The menu, entry by entry + +The seventeen QEMU/KVM entries have their counterpart. Four of them are the +**same code**, because it is the same work: reopening the install monitoring, +the remote desktop tunnel, the Android emulator and the image catalog. They +reach Proxmox guests through the `~/.ssh/config` entries that entry 13 writes, +with the Proxmox host as ProxyJump. + +```text +[1] Déployer une VM [8] Redimensionner un disque [15] Émulateur Android * +[2] Prévisualiser [9] Effacer des VM [16] Catalogue d'images * +[3] Télécharger une image [10] Nettoyer (orphelins) [17] Exemple (dry-run) +[4] Rouvrir le suivi * [11] Tester une VM (Odoo) [18] Changer d'hôte +[5] Lister (qm list) [12] Statistiques +[6] Adresse IP d'une VM [13] Configuration SSH +[7] Console d'une VM [14] Tunnel bureau distant * + * code partagé avec le menu QEMU/KVM +``` + +## Verified + +Deploying a VM inside a Proxmox that itself runs in a libvirt VM: image +downloaded on the host, internal bridge created, static address, cloud-init +user and key, disk resized, `qm start`. Then `ssh vm-essai` from the outside +reaches it through the jump — three nested levels. Resize `12G → 16G`, delete +with `--purge`, orphan scan: all checked against Proxmox VE 9.2.11. diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 9c6cf5e..4bcbd7a 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -426,17 +426,20 @@ cleanup() { fix_efi_fallback() { local esp="${PVE_ESP:-/boot/efi}" local secours="${esp}/EFI/BOOT" - [ -d "${secours}" ] || return 0 - [ -e "${secours}/grub.cfg" ] && return 0 - local stub="" candidat="" - for candidat in "${esp}"/EFI/*/grub.cfg; do - [ -e "${candidat}" ] || continue - case "${candidat}" in - */EFI/BOOT/grub.cfg) continue ;; - esac - stub="${candidat}" - break - done + # sudo sur CHAQUE lecture. /boot/efi est une vfat montée « umask=077 » : + # root seul y entre, et un « [ -d ] » non privilégié y répond FAUX. Ce + # correctif ne faisait donc RIEN, en silence, et la VM retombait sur + # « grub> » au redémarrage suivant — vécu deux fois. Le glob du shell est + # aveugle pour la même raison : il faut énumérer avec sudo. + sudo test -d "${secours}" || return 0 + sudo test -e "${secours}/grub.cfg" && return 0 + local stub="" + # « || true » : quand aucun stub n'existe, grep ne trouve rien et rend 1 + # — avec « set -o pipefail », l'affectation échoue et le script s'arrête + # AVANT d'avoir dit ce qui manque. Attrapé par un test, pas sur la machine + # réelle, où un stub existait et masquait le cas. + stub="$(sudo sh -c "ls ${esp}/EFI/*/grub.cfg 2>/dev/null" \ + | grep -v '/EFI/BOOT/grub.cfg' | head -1 || true)" if [ -z "${stub}" ]; then say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \ "vérifier l'amorçage avant de redémarrer." diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py new file mode 100644 index 0000000..7074579 --- /dev/null +++ b/script/proxmox/proxmox_deploy.py @@ -0,0 +1,517 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déploiement de VM SUR un hôte Proxmox VE, piloté à distance par SSH. + +Différence de nature avec `script/qemu/deploy_qemu.py` : là-bas, l'hyperviseur +est la machine qui exécute le script. Ici, il est AILLEURS — « on n'exécute pas +dessus ». Tout ce que ce module produit part donc sur l'hôte choisi, et rien +n'exige de privilège local. + +Pourquoi SSH et `qm` plutôt que l'API REST : l'API demande un jeton ou un +ticket à créer et à renouveler, quand `qm` est la voie que tout administrateur +Proxmox connaît, et que le dépôt sait déjà gérer des accès SSH (~/.ssh/config, +ProxyJump, clés). Les commandes restent lisibles dans le journal, donc +rejouables à la main — c'est ce qui a permis de diagnostiquer chaque panne de +ce module. + +Découpage voulu : TOUT ce qui construit une commande ou lit une sortie est une +fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. +""" +from __future__ import annotations + +import json +import re +import shlex +import subprocess + +# Réglages par défaut d'une VM Proxmox. Chacun a sa raison : +# +# - virtio-scsi-single : le contrôleur que Proxmox recommande depuis PVE 7, et +# le seul qui donne l'iothread par disque. +# - agent enabled=1 : sans l'agent invité, « qm guest cmd » ne rend aucune +# adresse IP et le menu ne peut pas dire où joindre la VM. +# - serial0 socket + vga serial0 : c'est ce qui rend « qm terminal » utilisable. +# Une console graphique seule obligerait à passer par l'interface web. +# - ostype l26 : Linux 2.6+, ce qui règle les horloges et les pilotes. +DEFAULT_BRIDGE = "vmbr0" +DEFAULT_STORAGE = "" # vide = on choisit d'après « pvesm status » +IMAGE_DIR = "/var/lib/vz/template/iso" +VMID_MIN = 100 + +# Stockages qui savent héberger un disque de VM. « pvesm status » liste aussi +# des stockages de sauvegarde ou d'ISO, où un disque ne peut PAS aller : les +# proposer produirait un « qm set » refusé après le téléchargement de l'image. +DISK_CONTENT = ("images", "rootdir") + + +def ssh_argv(host: dict, remote: str, tty: bool = False) -> list: + """Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox. + + `host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} — + « target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà + l'utilisateur, le port et le ProxyJump. + """ + argv = ["ssh"] + if not tty: + argv += ["-o", "BatchMode=yes"] + argv += ["-o", "ConnectTimeout=10"] + if host.get("port"): + argv += ["-p", str(host["port"])] + if host.get("jump"): + argv += ["-J", host["jump"]] + if tty: + argv.append("-t") + argv += [host["target"], remote] + return argv + + +def wrap_privilege(remote: str, prefix: str) -> str: + """Enveloppe la commande pour qu'elle tourne en root, si nécessaire. + + « sudo sh -c '' » et non « sudo » : les commandes de ce module + sont des SUITES (« mkdir && if … fi », une boucle for, une redirection). + Préfixer par sudo n'élèverait que le premier mot, et la redirection + resterait celle du shell non privilégié — donc « permission denied » sur + /root ou /boot/efi. + """ + if not prefix: + return remote + return "sudo sh -c " + shlex.quote(remote) + + +def run(host: dict, remote: str, timeout: int = 120) -> tuple: + """(code, sortie) de `remote` exécuté sur l'hôte. Ne lève jamais. + + `host["sudo"]` non vide -> la commande passe par sudo : « qm » exige les + privilèges, et l'accès offert par une VM du parc est celui d'`erplibre`. + """ + remote = wrap_privilege(remote, host.get("sudo") or "") + try: + res = subprocess.run( + ssh_argv(host, remote), + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return 255, "timeout" + except (OSError, subprocess.SubprocessError) as exc: + return 255, str(exc) + return res.returncode, (res.stdout or "") + (res.stderr or "") + + +# --------------------------------------------------------------------------- # +# Lecture des sorties de l'hôte — fonctions pures +# --------------------------------------------------------------------------- # +def parse_pveversion(text: str) -> str: + """« pve-manager/9.2.11/f6997e69 (running kernel: 7.0.14-12-pve) » -> 9.2.11. + + Sert de PREUVE que l'hôte est bien un Proxmox : une adresse saisie à la + main peut être n'importe quoi, et la première commande `qm` échouerait + alors sur un message qui ne dit pas pourquoi. + """ + m = re.search(r"pve-manager/(\d[\w.]*)", text or "") + return m.group(1) if m else "" + + +def parse_qm_list(text: str) -> list: + """Sortie de « qm list » -> [{vmid, name, status, mem, disk}]. + + L'en-tête et les lignes vides sont écartés. Les colonnes sont séparées par + des espaces, mais un NOM peut en contenir : on découpe donc par la + GAUCHE (vmid) et par la DROITE (status, mem, bootdisk, pid), et ce qui + reste au milieu est le nom. + """ + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or not parts[0].isdigit(): + continue + vmid = parts[0] + pid = parts[-1] + bootdisk = parts[-2] + mem = parts[-3] + status = parts[-4] + nom = " ".join(parts[1:-4]) + out.append( + { + "vmid": int(vmid), + "name": nom, + "status": status, + "mem": mem, + "disk": bootdisk, + "pid": pid, + } + ) + return out + + +def parse_storages(text: str) -> list: + """Sortie de « pvesm status --content images » -> [{name, type, avail}].""" + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 6 or parts[0] == "Name": + continue + try: + avail = int(parts[5]) + except ValueError: + continue + out.append( + { + "name": parts[0], + "type": parts[1], + "actif": parts[2] == "active", + "avail": avail * 1024, # pvesm compte en Kio + } + ) + return out + + +def parse_bridges(text: str) -> list: + """Sortie de « ip -o link show type bridge » -> ['vmbr0', …].""" + ponts = [] + for ligne in (text or "").splitlines(): + parts = ligne.split(":") + if len(parts) > 1: + nom = parts[1].strip().split("@")[0] + if nom: + ponts.append(nom) + return ponts + + +def parse_guest_ips(text: str) -> list: + """Adresses IPv4 rendues par « qm guest cmd network-get-interfaces ». + + L'agent invité répond du JSON. Les adresses de bouclage sont écartées : la + question posée est « où joindre cette VM », et 127.0.0.1 n'y répond pas. + """ + try: + data = json.loads(text or "") + except (ValueError, TypeError): + return [] + ips = [] + for iface in data if isinstance(data, list) else []: + for addr in iface.get("ip-addresses") or []: + ip = addr.get("ip-address") or "" + if addr.get("ip-address-type") == "ipv4" and not ip.startswith( + "127." + ): + ips.append(ip) + return ips + + +def mac_from_config(text: str) -> str: + """MAC de net0 dans « qm config ». + + C'est le seul lien entre une VM Proxmox et son adresse IP quand l'agent + invité n'est pas là : l'image cloud Debian ne l'embarque PAS, et Proxmox ne + distribue pas les baux lui-même — il ne peut donc pas répondre. + """ + m = re.search( + r"^net0:.*?([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})", + text or "", + re.M, + ) + return m.group(1).lower() if m else "" + + +def ip_from_neigh(text: str, mac: str) -> str: + """Adresse vue par le voisinage de l'hôte (« ip neigh »), pour cette MAC. + + Marche dès que la VM a émis un paquet — un bail DHCP suffit. C'est le + repli quand l'agent invité manque, et il ne demande rien à l'invité. + """ + if not mac: + return "" + cible = mac.lower() + for ligne in (text or "").splitlines(): + if cible in ligne.lower(): + parts = ligne.split() + if parts and re.match(r"^\d+\.\d+\.\d+\.\d+$", parts[0]): + return parts[0] + return "" + + +def next_vmid(existing, mini: int = VMID_MIN) -> int: + """Premier VMID libre à partir de `mini`. + + Proxmox refuse un VMID déjà pris, et le message (« CT/VM 100 already + exists ») arrive APRÈS le téléchargement de l'image : on choisit donc + avant, d'après ce que l'hôte déclare. + """ + pris = {int(v["vmid"]) for v in existing or () if str(v["vmid"]).isdigit()} + vmid = max(mini, VMID_MIN) + while vmid in pris: + vmid += 1 + return vmid + + +def pick_storage(storages, voulu: str = "") -> str: + """Stockage où poser le disque : celui demandé, sinon le plus libre. + + Aucun repli sur un nom devinné (« local-lvm » n'existe pas partout) : sans + stockage utilisable, on rend une chaîne vide et l'appelant le dit. + """ + utiles = [s for s in storages or () if s.get("actif")] + if voulu: + return voulu if any(s["name"] == voulu for s in utiles) else "" + if not utiles: + return "" + return max(utiles, key=lambda s: s.get("avail") or 0)["name"] + + +def pick_bridge(bridges, voulu: str = "") -> str: + """Pont réseau : celui demandé, sinon vmbr0, sinon le premier déclaré.""" + ponts = list(bridges or ()) + if voulu: + return voulu if voulu in ponts else "" + if DEFAULT_BRIDGE in ponts: + return DEFAULT_BRIDGE + return ponts[0] if ponts else "" + + +# --------------------------------------------------------------------------- # +# Construction des commandes — fonctions pures +# --------------------------------------------------------------------------- # +# Réseau interne proposé quand l'hôte n'a AUCUN pont. Choisi pour être sûr : +# un pont sans port physique ne peut pas couper l'accès SSH à l'hôte, alors +# qu'ajouter « bridge-ports enp1s0 » déplace l'adresse et coupe la session en +# cours — sur une machine distante, c'est un aller sans retour. +INTERNAL_BRIDGE = "vmbr0" +INTERNAL_CIDR = "10.10.10.1/24" + + +def parse_bridge_config(text: str) -> dict: + """/etc/network/interfaces -> {pont: {ports, address}}. + + Sert à savoir si un pont donne sur le LAN (il a des ports) ou s'il est + interne (« bridge-ports none ») : les VM du premier prennent leur adresse + en DHCP, celles du second n'en auraient aucune et doivent recevoir une + adresse fixe. + """ + ponts = {} + courant = "" + for ligne in (text or "").splitlines(): + nu = ligne.strip() + m = re.match(r"^iface\s+(\S+)\s", nu) + if m: + courant = m.group(1) + continue + if not courant: + continue + if nu.startswith("bridge-ports") or nu.startswith("bridge_ports"): + ports = nu.split(None, 1)[1].strip() if " " in nu else "" + ponts.setdefault(courant, {})["ports"] = ( + "" if ports in ("none", "") else ports + ) + elif nu.startswith("address"): + ponts.setdefault(courant, {})["address"] = nu.split()[1] + return ponts + + +def bridge_setup_cmds( + nom: str = INTERNAL_BRIDGE, + cidr: str = INTERNAL_CIDR, + uplink: str = "", +) -> list: + """Crée un pont INTERNE, et le masque derrière l'uplink si demandé. + + « bridge-ports none » : aucune interface physique n'est touchée, donc + l'accès à l'hôte survit. Les lignes post-up/post-down de masquerading sont + celles que documente Proxmox pour un hôte à une seule adresse routée : sans + elles les VM se parlent entre elles mais ne sortent pas. + """ + reseau = cidr.rsplit(".", 1)[0] + ".0/" + cidr.split("/")[1] + bloc = [ + "", + f"auto {nom}", + f"iface {nom} inet static", + f" address {cidr}", + " bridge-ports none", + " bridge-stp off", + " bridge-fd 0", + ] + if uplink: + bloc += [ + f" post-up iptables -t nat -A POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + f" post-down iptables -t nat -D POSTROUTING -s '{reseau}'" + f" -o {uplink} -j MASQUERADE", + ] + texte = "\n".join(bloc) + "\n" + cmds = [ + # Idempotent : on n'ajoute la strophe que si le pont n'y est pas déjà. + f"grep -qE '^(auto|iface) {nom}( |$)' /etc/network/interfaces" + f" || printf '%s' {shlex.quote(texte)} >> /etc/network/interfaces", + ] + if uplink: + cmds.append( + "printf 'net.ipv4.ip_forward=1\\n' >" + " /etc/sysctl.d/99-erplibre-nat.conf && sysctl -q -p" + " /etc/sysctl.d/99-erplibre-nat.conf" + ) + # ifup plutôt qu'« ifreload -a » : recharger TOUTE la configuration d'un + # hôte distant peut emporter l'interface qui porte la session. + cmds.append(f"ifup {nom} 2>/dev/null || ifreload -a") + return cmds + + +def ipconfig_for(pont_info: dict, vmid: int) -> str: + """« ip=dhcp » sur un pont qui donne sur le LAN, adresse FIXE sur un pont + interne — où aucun serveur DHCP ne répondrait. + + L'adresse est dérivée du VMID : deux VM déployées à la suite ne peuvent pas + se retrouver avec la même, et le lien entre les deux reste lisible. + """ + info = pont_info or {} + adresse = info.get("address") or "" + if info.get("ports") or not adresse: + return "ip=dhcp" + base, _, masque = adresse.partition("/") + tronc = base.rsplit(".", 1)[0] + hote = 50 + (int(vmid) % 200) + return f"ip={tronc}.{hote}/{masque or '24'},gw={base}" + + +def ip_from_ipconfig(ipconfig: str) -> str: + """Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP. + + Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est + absurde : elle est connue avant que la VM ne démarre. La découverte (agent + invité, voisinage de l'hôte) ne sert qu'au DHCP. + """ + m = re.search(r"ip=(\d+\.\d+\.\d+\.\d+)", ipconfig or "") + return m.group(1) if m else "" + + +def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str: + """Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois. + + C'est là que le disque de la VM sera écrit : faire descendre l'image chez + soi pour la renvoyer ensuite doublerait le transfert. Le test de présence + évite de retélécharger 325 Mio à chaque VM. + """ + cible = f"{repertoire}/{nom}" + return ( + f"mkdir -p {shlex.quote(repertoire)} && " + f"if [ -s {shlex.quote(cible)} ]; then " + f'echo "image déjà présente : {cible}"; else ' + f"wget -q --show-progress -O {shlex.quote(cible)} {shlex.quote(url)}; " + f"fi" + ) + + +def create_cmds(vmid: int, spec: dict) -> list: + """Séquence complète de création d'une VM, dans l'ordre. + + Une liste et non une seule commande : chaque étape est lisible dans le + journal, et un échec nomme celle qui a échoué. C'est le contraire d'un + « qm create » géant dont on ne sait pas quel morceau a cédé. + """ + nom = spec["name"] + stockage = spec["storage"] + image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}" + cmds = [ + # 1. La coquille : processeur, mémoire, réseau, contrôleur, agent. + "qm create {id} --name {nom} --memory {mem} --cores {cpu}" + " --cpu host --ostype l26 --scsihw virtio-scsi-single" + " --net0 virtio,bridge={pont} --agent enabled=1" + " --serial0 socket --vga serial0".format( + id=vmid, + nom=shlex.quote(nom), + mem=int(spec["memory"]), + cpu=int(spec["vcpus"]), + pont=spec["bridge"], + ), + # 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+) + # remplace l'ancien « qm importdisk » en une seule étape et attache + # le disque du même coup. + f"qm set {vmid} --scsi0" + f" {stockage}:0,import-from={shlex.quote(image)},discard=on,ssd=1", + # 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order », + # Proxmox laisse le disque importé hors de la liste et la VM démarre + # sur le réseau. + f"qm set {vmid} --ide2 {stockage}:cloudinit" + f" --boot order=scsi0 --bootdisk scsi0", + ] + # 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur + # l'hôte — « --sshkeys » n'accepte pas la clé en ligne. + ci = ( + f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}" + ) + if spec.get("sshkey_path"): + ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}" + if spec.get("password"): + ci += f" --cipassword {shlex.quote(spec['password'])}" + ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}" + cmds.append(ci) + # 5. La taille. L'image cloud fait 2 Gio : sans agrandissement, il ne reste + # rien pour installer quoi que ce soit. + if spec.get("disk"): + cmds.append(f"qm resize {vmid} scsi0 {spec['disk']}") + if spec.get("start", True): + cmds.append(f"qm start {vmid}") + return cmds + + +def destroy_cmds(vmid: int, purge: bool = True) -> list: + """Arrêt puis suppression. « --purge » retire aussi les disques et les + entrées de sauvegarde : sans lui, le stockage garde des volumes orphelins + que rien ne réclame plus.""" + return [ + f"qm stop {vmid} --skiplock 1 || true", + f"qm destroy {vmid} --purge {1 if purge else 0}" + " --destroy-unreferenced-disks 1", + ] + + +def resize_cmd(vmid: int, taille: str, disque: str = "scsi0") -> str: + """« +10G » agrandit, « 40G » fixe. Proxmox REFUSE de rétrécir un disque — + le dire ici évite de croire à un bug de l'outil.""" + return f"qm resize {vmid} {disque} {taille}" + + +def status_cmd(vmid: int) -> str: + return f"qm status {vmid} --verbose" + + +def guest_ip_cmd(vmid: int) -> str: + return f"qm guest cmd {vmid} network-get-interfaces" + + +def console_cmd(vmid: int) -> str: + """Console série. `qm terminal` demande serial0, que create_cmds pose.""" + return f"qm terminal {vmid}" + + +def orphan_disks_cmd() -> str: + """Volumes de disque qui n'appartiennent à aucune VM déclarée. + + Proxmox ne les efface pas tout seul : un « qm destroy » sans « --purge », + ou une création interrompue, en laisse. On les LISTE, on n'efface rien + sans demander. + """ + return ( + "for s in $(pvesm status --content images | awk 'NR>1 {print $1}'); " + 'do pvesm list "$s" 2>/dev/null; done' + ) + + +def parse_orphans(text: str, vmids) -> list: + """[(volid, taille)] des volumes dont le VMID n'existe plus.""" + connus = {str(v) for v in vmids or ()} + out = [] + for ligne in (text or "").splitlines(): + parts = ligne.split() + if len(parts) < 5 or parts[0] == "Volid": + continue + volid, vmid = parts[0], parts[-1] + if vmid.isdigit() and vmid not in connus: + try: + taille = int(parts[3]) + except ValueError: + taille = 0 + out.append((volid, taille)) + return out diff --git a/script/todo/todo.py b/script/todo/todo.py index ffaeba7..1be5ff9 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -942,6 +942,11 @@ class TODO: "QEMU/KVM - Deploy an Ubuntu VM (libvirt)" ) }, + { + "prompt_description": t( + "Proxmox VE - Deploy a VM on a remote host" + ) + }, { "prompt_description": t( "Deploy - Install NTFY notification server" @@ -966,6 +971,8 @@ class TODO: elif status == "5": self.prompt_execute_qemu() elif status == "6": + self.prompt_execute_proxmox() + elif status == "7": self._deploy_ntfy_server() else: print(t("Command not found !")) @@ -1021,6 +1028,756 @@ class TODO: # ------------------------------------------------------------------ # # 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" + + 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 + ) + + 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: + print(f" ✗ {t('Not a Proxmox host (or unreachable):')}") + premiere = (out or "").strip().splitlines() + print(f" {premiere[0] if premiere else t('no answer')}") + 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}") + 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 ». + """ + from script.proxmox import proxmox_deploy as pve + + 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 , 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 /24") + print(" gateway ") + print(" bridge-ports ") + 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 VM SUR l'hôte Proxmox choisi. + + 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-): ") + ).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 _qemu_script_path(self): """Chemin absolu vers script/qemu/deploy_qemu.py.""" path = os.path.join( @@ -1284,6 +2041,120 @@ class TODO: print(f"⚠ {t('virsh still missing; a reboot may be required.')}") return False + 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) + def prompt_execute_qemu(self): print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}") script_path = self._qemu_script_path() @@ -5081,6 +5952,19 @@ class TODO: return m.group(1) return None + def _qemu_vm_ip_now(self, name): + """IP de la VM d'après le bail DHCP, SANS attendre. + + `_qemu_vm_ip` patiente jusqu'à dix minutes par VM : c'est ce qu'il faut + après un déploiement, et exactement ce qu'il ne faut pas pour AFFICHER + une liste — trois VM figeaient le menu une demi-heure. Ici on lit le + bail une fois, en préférant celui dont le hostname est le nom de la VM. + """ + cands = self._qemu_lease_candidates(name) + if not cands: + return None + return self._qemu_lease_ip_for_host(name, cands) or cands[-1] + def _qemu_vm_ip(self, name, timeout=600): """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname changé au boot) : renvoie en priorité le bail dont le hostname == nom diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 4fd87eb..6327a7a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3147,6 +3147,321 @@ TRANSLATIONS = { "fr": "Suivre le démarrage des VM (sans installation)", "en": "Watch the VMs start (no install)", }, + "Proxmox VE - Deploy a VM on a remote host": { + "fr": "Proxmox VE - Déployer une VM sur un hôte distant", + "en": "Proxmox VE - Deploy a VM on a remote host", + }, + "Deploy a virtual machine on Proxmox VE!": { + "fr": "Déployer une machine virtuelle sur Proxmox VE !", + "en": "Deploy a virtual machine on Proxmox VE!", + }, + "Which Proxmox host?": { + "fr": "Quel hôte Proxmox ?", + "en": "Which Proxmox host?", + }, + "From the local QEMU VMs": { + "fr": "Depuis les VM QEMU locales", + "en": "From the local QEMU VMs", + }, + "Type an address": { + "fr": "Saisir une adresse", + "en": "Type an address", + }, + "Keep": { + "fr": "Garder", + "en": "Keep", + }, + "Address (user@host, default user root): ": { + "fr": "Adresse (utilisateur@hôte, utilisateur root par défaut) : ", + "en": "Address (user@host, default user root): ", + }, + "SSH jump host (blank = none): ": { + "fr": "Rebond SSH (vide = aucun) : ", + "en": "SSH jump host (blank = none): ", + }, + "Local VMs:": { + "fr": "VM locales :", + "en": "Local VMs:", + }, + "No IP for this VM: is it running?": { + "fr": "Pas d'IP pour cette VM : est-elle démarrée ?", + "en": "No IP for this VM: is it running?", + }, + "Checking": { + "fr": "Vérification de", + "en": "Checking", + }, + "Not a Proxmox host (or unreachable):": { + "fr": "Ce n'est pas un hôte Proxmox (ou il est injoignable) :", + "en": "Not a Proxmox host (or unreachable):", + }, + "Check the address, the SSH access and pveversion.": { + "fr": "Vérifier l'adresse, l'accès SSH et pveversion.", + "en": "Check the address, the SSH access and pveversion.", + }, + "Proxmox host:": { + "fr": "Hôte Proxmox :", + "en": "Proxmox host:", + }, + "through": { + "fr": "par", + "en": "through", + }, + "exit code": { + "fr": "code de retour", + "en": "exit code", + }, + "No VM on this Proxmox host.": { + "fr": "Aucune VM sur cet hôte Proxmox.", + "en": "No VM on this Proxmox host.", + }, + "No running VM on this Proxmox host.": { + "fr": "Aucune VM démarrée sur cet hôte Proxmox.", + "en": "No running VM on this Proxmox host.", + }, + "VMs on this host:": { + "fr": "VM de cet hôte :", + "en": "VMs on this host:", + }, + "Selection (number): ": { + "fr": "Sélection (numéro) : ", + "en": "Selection (number): ", + }, + "Deploy a VM on the Proxmox host": { + "fr": "Déployer une VM sur l'hôte Proxmox", + "en": "Deploy a VM on the Proxmox host", + }, + "Preview a deployment (dry-run, nothing sent)": { + "fr": "Prévisualiser un déploiement (dry-run, rien n'est envoyé)", + "en": "Preview a deployment (dry-run, nothing sent)", + }, + "Download a cloud image on the host": { + "fr": "Télécharger une image cloud sur l'hôte", + "en": "Download a cloud image on the host", + }, + "List VMs (qm list)": { + "fr": "Lister les VM (qm list)", + "en": "List VMs (qm list)", + }, + "Clean up (orphan disks)": { + "fr": "Nettoyer (disques orphelins)", + "en": "Clean up (orphan disks)", + }, + "Statistics (host and VMs)": { + "fr": "Statistiques (hôte et VM)", + "en": "Statistics (host and VMs)", + }, + "Remote desktop tunnel (VNC/RDP over SSH)": { + "fr": "Tunnel bureau distant (VNC/RDP par SSH)", + "en": "Remote desktop tunnel (VNC/RDP over SSH)", + }, + "List available images and their specs": { + "fr": "Lister les images disponibles et leurs specs", + "en": "List available images and their specs", + }, + "Proxmox - example sequence (dry-run)": { + "fr": "Proxmox - exemple de séquence (dry-run)", + "en": "Proxmox - example sequence (dry-run)", + }, + "Host": { + "fr": "Hôte", + "en": "Host", + }, + "Change the Proxmox host": { + "fr": "Changer d'hôte Proxmox", + "en": "Change the Proxmox host", + }, + "VM name (default: erplibre-): ": { + "fr": "Nom de la VM (défaut : erplibre-) : ", + "en": "VM name (default: erplibre-): ", + }, + "RAM in MB, blank = 4096": { + "fr": "RAM en Mo, vide = 4096", + "en": "RAM in MB, blank = 4096", + }, + "vCPU, blank = 2": { + "fr": "vCPU, vide = 2", + "en": "vCPU, blank = 2", + }, + "Disk size (default 32G): ": { + "fr": "Taille du disque (défaut 32G) : ", + "en": "Disk size (default 32G): ", + }, + "Size (+10G to add, 40G for a target): ": { + "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", + "en": "Size (+10G to add, 40G for a target): ", + }, + "No storage able to hold a VM disk.": { + "fr": "Aucun stockage capable d'héberger un disque de VM.", + "en": "No storage able to hold a VM disk.", + }, + "No network bridge on this host.": { + "fr": "Aucun pont réseau sur cet hôte.", + "en": "No network bridge on this host.", + }, + "offered": { + "fr": "proposés", + "en": "offered", + }, + "Would run on": { + "fr": "Serait exécuté sur", + "en": "Would run on", + }, + "SSH key ->": { + "fr": "clé SSH ->", + "en": "SSH key ->", + }, + "Deploy this VM now? (Y/n): ": { + "fr": "Déployer cette VM maintenant ? (O/n) : ", + "en": "Deploy this VM now? (Y/n): ", + }, + "SSH key unreadable:": { + "fr": "Clé SSH illisible :", + "en": "SSH key unreadable:", + }, + "SSH key not pushed: password login only.": { + "fr": "Clé SSH non transmise : connexion par mot de passe seulement.", + "en": "SSH key not pushed: password login only.", + }, + "Step failed, stopping here.": { + "fr": "Étape en échec, on s'arrête ici.", + "en": "Step failed, stopping here.", + }, + "Waiting for the VM address…": { + "fr": "Attente de l'adresse de la VM…", + "en": "Waiting for the VM address…", + }, + "No address yet. Try [6] later.": { + "fr": "Pas encore d'adresse. Réessayer avec [6] plus tard.", + "en": "No address yet. Try [6] later.", + }, + "Install ERPLibre on it? (Y/n): ": { + "fr": "Y installer ERPLibre ? (O/n) : ", + "en": "Install ERPLibre on it? (Y/n): ", + }, + "No address from the guest agent.": { + "fr": "Aucune adresse rendue par l'agent invité.", + "en": "No address from the guest agent.", + }, + "Is qemu-guest-agent installed and the VM started?": { + "fr": "qemu-guest-agent est-il installé et la VM démarrée ?", + "en": "Is qemu-guest-agent installed and the VM started?", + }, + "Ctrl+O to quit the serial console.": { + "fr": "Ctrl+O pour quitter la console série.", + "en": "Ctrl+O to quit the serial console.", + }, + "Proxmox can only GROW a disk, never shrink it.": { + "fr": "Proxmox ne sait qu'AGRANDIR un disque, jamais le rétrécir.", + "en": "Proxmox can only GROW a disk, never shrink it.", + }, + "This also destroys their disks and backups.": { + "fr": "Cela détruit aussi leurs disques et leurs sauvegardes.", + "en": "This also destroys their disks and backups.", + }, + "Nothing orphaned.": { + "fr": "Rien d'orphelin.", + "en": "Nothing orphaned.", + }, + "Orphan disks:": { + "fr": "Disques orphelins :", + "en": "Orphan disks:", + }, + "Free them?": { + "fr": "Les libérer ?", + "en": "Free them?", + }, + "no address, skipped": { + "fr": "pas d'adresse, ignorée", + "en": "no address, skipped", + }, + "unreachable from here.": { + "fr": "injoignable d'ici.", + "en": "unreachable from here.", + }, + "Use [13] to add a ProxyJump entry, then a tunnel.": { + "fr": "Utiliser [13] pour l'entrée ProxyJump, puis un tunnel.", + "en": "Use [13] to add a ProxyJump entry, then a tunnel.", + }, + "Example: demo-vm, Debian 13, on a Proxmox host": { + "fr": "Exemple : demo-vm, Debian 13, sur un hôte Proxmox", + "en": "Example: demo-vm, Debian 13, on a Proxmox host", + }, + "storages": { + "fr": "stockages", + "en": "storages", + }, + "memory": { + "fr": "mémoire", + "en": "memory", + }, + "No address for this VM.": { + "fr": "Aucune adresse pour cette VM.", + "en": "No address for this VM.", + }, + "A static address is visible right after creation.": { + "fr": "Une adresse fixe est connue dès la création.", + "en": "A static address is visible right after creation.", + }, + "address given at creation:": { + "fr": "adresse donnée à la création :", + "en": "address given at creation:", + }, + "qm create needs one. Two ways:": { + "fr": "« qm create » en exige un. Deux voies :", + "en": "qm create needs one. Two ways:", + }, + "create an internal": {"fr": "créer un pont interne", "en": "create an internal"}, + "touches no physical NIC": { + "fr": "ne touche à aucune interface physique", + "en": "touches no physical NIC", + }, + "do it myself (bridge-ports , needs console)": { + "fr": "le faire moi-même (bridge-ports , console requise)", + "en": "do it myself (bridge-ports , needs console)", + }, + "To bridge the LAN, on the host:": { + "fr": "Pour ponter le LAN, sur l'hôte :", + "en": "To bridge the LAN, on the host:", + }, + "This moves the host address: do it from a console.": { + "fr": "Cela déplace l'adresse de l'hôte : à faire depuis une console.", + "en": "This moves the host address: do it from a console.", + }, + "uplink for NAT": {"fr": "sortie pour le NAT", "en": "uplink for NAT"}, + "none": {"fr": "aucune", "en": "none"}, + "The bridge did not come up.": { + "fr": "Le pont n'est pas monté.", + "en": "The bridge did not come up.", + }, + "SSH does not know this host key yet.": { + "fr": "ssh ne connaît pas encore la clé de cet hôte.", + "en": "SSH does not know this host key yet.", + }, + "Would record:": { + "fr": "Enregistrerait :", + "en": "Would record:", + }, + "Record it?": { + "fr": "L'enregistrer ?", + "en": "Record it?", + }, + "No host key obtained.": { + "fr": "Aucune clé d'hôte obtenue.", + "en": "No host key obtained.", + }, + "key(s) recorded in ~/.ssh/known_hosts": { + "fr": "clé(s) enregistrée(s) dans ~/.ssh/known_hosts", + "en": "key(s) recorded in ~/.ssh/known_hosts", + }, + "qm needs root: no root, and sudo asks for a password.": { + "fr": "qm exige root : ni root, ni sudo sans mot de passe.", + "en": "qm needs root: no root, and sudo asks for a password.", + }, + "Connect as root@, or allow NOPASSWD sudo.": { + "fr": "Se connecter en root@, ou autoriser sudo sans mot de passe.", + "en": "Connect as root@, or allow NOPASSWD sudo.", + }, "Proxmox VE hypervisor (no Odoo)": { "fr": "Hyperviseur Proxmox VE (sans Odoo)", "en": "Proxmox VE hypervisor (no Odoo)", diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py index f877e33..7193670 100644 --- a/test/test_mobile_bundle_transfer.py +++ b/test/test_mobile_bundle_transfer.py @@ -293,11 +293,27 @@ class TestTheRealBundle(unittest.TestCase): " ./mobile/install_mobile_dev.sh" ) cls.repos = MOBILE / "dist" / "repos" - if not (cls.repos / "manifest.json").is_file(): + manifeste = cls.repos / "manifest.json" + if not manifeste.is_file(): raise unittest.SkipTest( "dépôt mobile présent mais pas compilé :" " ./mobile/compile_and_run.sh (ou npm run build)" ) + # Manifeste PRÉSENT mais VIDE : l'application a été compilée sans le + # transfert des dépôts. C'est un choix légitime, pas une régression — + # et le distinguer importe, car ces tests échouaient alors sur + # « aucun dépôt à vérifier », ce qui se lit comme une panne du + # transfert. Vu le 23 août 2026 sur un build de 07:55 : manifeste à + # zéro entrée, aucun pack. + try: + entrees = json.loads(manifeste.read_text()) + except (OSError, ValueError) as exc: + raise unittest.SkipTest(f"manifeste illisible : {exc}") + if not entrees: + raise unittest.SkipTest( + "compilé SANS les dépôts (manifeste vide) :" + " relancer ./mobile/compile_and_run.sh pour les inclure" + ) def test_the_transfer_is_coherent(self): rep = cbt.check(MOBILE, REPO) diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py new file mode 100644 index 0000000..0cf92c6 --- /dev/null +++ b/test/test_proxmox_deploy.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Déployer sur un hôte Proxmox : l'hyperviseur est AILLEURS. + +Toute la différence avec QEMU/KVM tient là. Rien ne s'exécute sur la machine +locale : il faut d'abord savoir OÙ, puis tout envoyer par SSH. Ces tests +gardent ce qui a été appris contre un hôte réel (Proxmox VE 9.2.11 dans une VM +libvirt), une panne après l'autre : + +- « qm » exige root. La voie « VM QEMU locale » ne donne que l'accès + d'erplibre : il faut sudo, et l'enrober AUTOUR de toute la commande — les + commandes de ce module sont des suites et des redirections, et « sudo cmd » + n'élèverait que le premier mot. +- Une Proxmox installée SUR Debian n'a AUCUN pont. On en propose un INTERNE : + ajouter l'interface physique à un pont déplace l'adresse de l'hôte et coupe + la session SSH — à distance, c'est sans retour. +- Sur un pont interne, aucun DHCP ne répond : l'adresse doit être fixe, et + elle est alors connue AVANT le démarrage. La chercher ensuite était absurde. +- L'agent invité n'est pas dans l'image cloud Debian : le voisinage de l'hôte + (« ip neigh ») est le seul repli, et il a trouvé l'adresse là où l'agent + répondait « not running ». +""" + +import shlex +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.proxmox import proxmox_deploy as pve # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + +# Sorties RÉELLES relevées sur l'hôte d'essai. +PVEVERSION = ( + "pve-manager/9.2.11/f6997e698c7933ea (running kernel: 7.0.14-12-pve)" +) +QM_LIST = """ VMID NAME STATUS MEM(MB) BOOTDISK(GB) PID + 100 vm-essai running 2048 16.00 2726 + 101 avec un espace stopped 4096 32.00 0 +""" +PVESM = """Name Type Status Total Used Available % +local dir active 32815812 6873084 24559348 20.94% +sauvegarde dir inactive 99999999 0 99999999 0.00% +""" +NEIGH = """192.168.123.1 dev enp1s0 lladdr 52:54:00:cd:73:ef REACHABLE +10.10.10.150 dev vmbr0 lladdr bc:24:11:93:da:22 REACHABLE +""" +QM_CONFIG = """boot: order=scsi0 +memory: 2048 +net0: virtio=BC:24:11:93:DA:22,bridge=vmbr0 +scsi0: local:100/vm-100-disk-0.raw,discard=on,size=16G,ssd=1 +""" +INTERFACES = """auto lo +iface lo inet loopback + +iface enp1s0 inet manual + +auto vmbr0 +iface vmbr0 inet static + address 10.10.10.1/24 + bridge-ports none + bridge-stp off + +auto vmbr1 +iface vmbr1 inet manual + bridge-ports enp2s0 +""" + + +class TestLectureDesSorties(unittest.TestCase): + def test_the_version_proves_it_is_a_proxmox(self): + """Une adresse saisie à la main peut être n'importe quelle machine : + sans cette preuve, la première commande « qm » échouerait sur un + « command not found » qui n'explique rien.""" + self.assertEqual("9.2.11", pve.parse_pveversion(PVEVERSION)) + self.assertEqual( + "", pve.parse_pveversion("bash: pveversion: not found") + ) + + def test_a_vm_name_with_spaces_is_read_whole(self): + """« qm list » sépare par des espaces, et un nom peut en contenir : on + découpe par les deux bouts, le milieu est le nom.""" + vms = pve.parse_qm_list(QM_LIST) + self.assertEqual([100, 101], [v["vmid"] for v in vms]) + self.assertEqual("avec un espace", vms[1]["name"]) + self.assertEqual("running", vms[0]["status"]) + + def test_the_header_is_not_a_vm(self): + self.assertEqual([], pve.parse_qm_list(" VMID NAME STATUS\n")) + self.assertEqual([], pve.parse_qm_list("")) + + def test_only_active_storages_count_and_kib_become_bytes(self): + st = pve.parse_storages(PVESM) + self.assertEqual(["local", "sauvegarde"], [s["name"] for s in st]) + self.assertTrue(st[0]["actif"]) + self.assertFalse(st[1]["actif"]) + self.assertEqual(24559348 * 1024, st[0]["avail"]) + + def test_bridges_are_read_without_their_at_suffix(self): + texte = "3: vmbr0: \n4: vmbr1@if2: \n" + self.assertEqual(["vmbr0", "vmbr1"], pve.parse_bridges(texte)) + + def test_the_guest_agent_answer_drops_loopback_and_ipv6(self): + json_txt = ( + '[{"name":"lo","ip-addresses":[{"ip-address-type":"ipv4",' + '"ip-address":"127.0.0.1"}]},{"name":"eth0","ip-addresses":[' + '{"ip-address-type":"ipv4","ip-address":"10.10.10.150"},' + '{"ip-address-type":"ipv6","ip-address":"fe80::1"}]}]' + ) + self.assertEqual(["10.10.10.150"], pve.parse_guest_ips(json_txt)) + + def test_a_missing_agent_is_not_a_crash(self): + """Sa réponse n'est pas du JSON : « QEMU guest agent is not running ».""" + self.assertEqual( + [], pve.parse_guest_ips("QEMU guest agent is not running") + ) + + def test_the_mac_links_a_vm_to_its_address(self): + """Le seul lien quand l'agent manque, et l'image cloud Debian ne + l'embarque pas.""" + mac = pve.mac_from_config(QM_CONFIG) + self.assertEqual("bc:24:11:93:da:22", mac) + self.assertEqual("10.10.10.150", pve.ip_from_neigh(NEIGH, mac)) + + def test_an_unknown_mac_finds_nothing(self): + self.assertEqual("", pve.ip_from_neigh(NEIGH, "de:ad:be:ef:00:00")) + self.assertEqual("", pve.ip_from_neigh(NEIGH, "")) + + def test_a_lan_bridge_and_an_internal_one_are_told_apart(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("", ponts["vmbr0"]["ports"]) + self.assertEqual("10.10.10.1/24", ponts["vmbr0"]["address"]) + self.assertEqual("enp2s0", ponts["vmbr1"]["ports"]) + + def test_orphans_are_the_volumes_no_vm_claims(self): + liste = ( + "Volid Format Type Size VMID\n" + "local:100/vm-100-disk-0.raw raw images 17179869184 100\n" + "local:999/vm-999-disk-0.raw raw images 8589934592 999\n" + ) + orph = pve.parse_orphans(liste, [100]) + self.assertEqual(1, len(orph)) + self.assertIn("999", orph[0][0]) + + +class TestLesChoix(unittest.TestCase): + def test_the_vmid_skips_the_taken_ones(self): + """Proxmox refuse un VMID pris, et le dit APRÈS le téléchargement de + l'image : on choisit donc avant, d'après ce que l'hôte déclare.""" + self.assertEqual( + 102, pve.next_vmid([{"vmid": 100}, {"vmid": 101}, {"vmid": 103}]) + ) + self.assertEqual(100, pve.next_vmid([])) + + def test_the_storage_is_the_freest_active_one(self): + st = pve.parse_storages(PVESM) + self.assertEqual("local", pve.pick_storage(st)) + + def test_an_unknown_storage_is_refused_not_guessed(self): + """« local-lvm » n'existe pas partout : un repli deviné ferait échouer + « qm set » après le téléchargement de l'image.""" + self.assertEqual( + "", pve.pick_storage(pve.parse_storages(PVESM), "nas") + ) + + def test_vmbr0_wins_when_it_exists(self): + self.assertEqual("vmbr0", pve.pick_bridge(["vmbr9", "vmbr0"])) + self.assertEqual("br-lan", pve.pick_bridge(["br-lan"])) + self.assertEqual("", pve.pick_bridge([])) + + +class TestLesCommandes(unittest.TestCase): + def _spec(self, **extra): + base = { + "name": "vm-essai", + "memory": 2048, + "vcpus": 2, + "disk": "12G", + "storage": "local", + "bridge": "vmbr0", + "image": "debian-13-genericcloud-amd64.qcow2", + "sshkey_path": "/root/.ssh/erplibre-deploy.pub", + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + } + base.update(extra) + return base + + def test_the_sequence_is_in_the_order_proxmox_needs(self): + cmds = pve.create_cmds(100, self._spec()) + joint = "\n".join(cmds) + self.assertTrue(cmds[0].startswith("qm create 100")) + self.assertIn("import-from=", joint) + self.assertIn(":cloudinit", joint) + self.assertIn("--boot order=scsi0", joint) + self.assertIn("qm resize 100 scsi0 12G", joint) + self.assertTrue(cmds[-1].endswith("qm start 100")) + + def test_the_agent_and_the_serial_console_are_asked_for(self): + """Sans agent, aucune adresse ; sans serial0, « qm terminal » est + inutilisable et il ne reste que l'interface web.""" + cmd = pve.create_cmds(100, self._spec())[0] + self.assertIn("--agent enabled=1", cmd) + self.assertIn("--serial0 socket", cmd) + + def test_a_name_with_a_space_cannot_break_the_command(self): + """Le nom vient d'une saisie : découpée par le shell, elle doit rester + UN argument. « rm » ne doit jamais devenir une commande.""" + mechant = "vm essai; rm -rf /" + cmds = pve.create_cmds(100, self._spec(name=mechant)) + args = shlex.split(cmds[0]) + self.assertIn(mechant, args) + self.assertNotIn("rm", args) + + def test_destroy_stops_first_and_purges(self): + cmds = pve.destroy_cmds(100) + self.assertIn("qm stop 100", cmds[0]) + self.assertIn("--purge 1", cmds[1]) + + def test_the_image_is_fetched_once_on_the_host(self): + cmd = pve.image_fetch_cmd("https://x/deb.qcow2", "deb.qcow2") + self.assertIn("if [ -s", cmd) + self.assertIn("wget", cmd) + + def test_the_internal_bridge_never_touches_a_physical_nic(self): + """Le point le plus important de ce module : ajouter l'interface au + pont déplace l'adresse de l'hôte et coupe la session SSH — à distance, + sans retour.""" + cmds = pve.bridge_setup_cmds(uplink="enp1s0") + joint = "\n".join(cmds) + self.assertIn("bridge-ports none", joint) + self.assertNotIn("bridge-ports enp1s0", joint) + self.assertIn("MASQUERADE", joint) + self.assertIn("ip_forward", joint) + + def test_the_bridge_stanza_is_added_only_once(self): + cmds = pve.bridge_setup_cmds() + self.assertIn("grep -qE", cmds[0]) + self.assertIn("||", cmds[0]) + + def test_an_internal_bridge_gets_a_static_address(self): + """Aucun DHCP n'y répondrait : la VM resterait muette.""" + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual( + "ip=10.10.10.150/24,gw=10.10.10.1", + pve.ipconfig_for(ponts["vmbr0"], 100), + ) + + def test_a_lan_bridge_gets_dhcp(self): + ponts = pve.parse_bridge_config(INTERFACES) + self.assertEqual("ip=dhcp", pve.ipconfig_for(ponts["vmbr1"], 100)) + + def test_two_vms_do_not_share_an_address(self): + ponts = pve.parse_bridge_config(INTERFACES) + a = pve.ipconfig_for(ponts["vmbr0"], 100) + b = pve.ipconfig_for(ponts["vmbr0"], 101) + self.assertNotEqual(a, b) + + def test_a_static_address_is_known_before_boot(self): + self.assertEqual( + "10.10.10.150", + pve.ip_from_ipconfig("ip=10.10.10.150/24,gw=10.10.10.1"), + ) + self.assertEqual("", pve.ip_from_ipconfig("ip=dhcp")) + + +class TestLePrivilege(unittest.TestCase): + def test_the_whole_command_is_wrapped_not_just_its_first_word(self): + """« sudo mkdir && if … fi » n'élèverait que le mkdir, et la + redirection resterait celle du shell non privilégié : « permission + denied » sur /root ou /boot/efi.""" + compose = "mkdir -p /root/.ssh && printf x > /root/.ssh/k" + enrobe = pve.wrap_privilege(compose, "sudo ") + self.assertTrue(enrobe.startswith("sudo sh -c ")) + self.assertIn("printf x > /root/.ssh/k", enrobe) + + def test_without_sudo_the_command_is_untouched(self): + self.assertEqual("qm list", pve.wrap_privilege("qm list", "")) + + def test_ssh_never_asks_a_question_it_cannot_show(self): + """BatchMode : une invite de mot de passe dans un menu bloquerait sans + rien afficher.""" + argv = pve.ssh_argv({"target": "root@h"}, "qm list") + self.assertIn("BatchMode=yes", argv) + self.assertIn("ConnectTimeout=10", " ".join(argv)) + + def test_the_jump_and_the_port_travel(self): + argv = pve.ssh_argv( + {"target": "root@h", "jump": "rebond", "port": "2222"}, "x" + ) + self.assertIn("-J", argv) + self.assertIn("rebond", argv) + self.assertIn("-p", argv) + self.assertIn("2222", argv) + + def test_a_console_gets_a_tty_and_no_batchmode(self): + argv = pve.ssh_argv({"target": "root@h"}, "qm terminal 100", tty=True) + self.assertIn("-t", argv) + self.assertNotIn("BatchMode=yes", argv) + + +class TestChoixDeLHote(unittest.TestCase): + """La question propre à Proxmox : sur QUELLE machine ?""" + + def _todo(self): + todo = TODO.__new__(TODO) + todo._pve_remember_host = lambda h: None + return todo + + def test_an_unknown_host_key_is_recognised(self): + for texte in ( + "Host key verification failed.", + "The authenticity of host '10.0.0.1' can't be established.", + "No ED25519 host key is known for 10.0.0.1", + ): + self.assertTrue(TODO._pve_hostkey_missing(texte), texte) + self.assertFalse(TODO._pve_hostkey_missing("Permission denied")) + + def _confirm(self, reponses): + """reponses : [(code, sortie)] pour chaque appel à pve.run.""" + it = iter(reponses) + with mock.patch.object( + pve, "run", side_effect=lambda *a, **k: next(it) + ): + import contextlib + import io + + out = io.StringIO() + with contextlib.redirect_stdout(out): + host = self._todo()._pve_confirm_host( + {"target": "erplibre@10.0.0.5", "jump": ""} + ) + return host, out.getvalue() + + def test_a_non_proxmox_host_is_refused_with_what_was_seen(self): + host, sortie = self._confirm([(127, "bash: pveversion: not found")]) + self.assertIsNone(host) + self.assertIn("pveversion", sortie) + + def test_a_non_root_access_gets_sudo(self): + """C'est le cas de la voie « VM QEMU locale » : cloud-init crée + erplibre, pas root.""" + host, _s = self._confirm([(0, PVEVERSION), (0, "1000\n"), (0, "")]) + self.assertEqual("sudo ", host["sudo"]) + self.assertEqual("9.2.11", host["version"]) + + def test_root_needs_no_sudo(self): + host, _s = self._confirm([(0, PVEVERSION), (0, "0\n")]) + self.assertEqual("", host["sudo"]) + + def test_without_root_nor_passwordless_sudo_it_stops(self): + """Un sudo qui réclame un mot de passe bloquerait chaque commande du + menu sur une invite que personne ne voit.""" + host, sortie = self._confirm( + [ + (0, PVEVERSION), + (0, "1000\n"), + (1, "sudo: a password is required"), + ] + ) + self.assertIsNone(host) + self.assertIn("root", sortie) + + +class TestLeMenu(unittest.TestCase): + def test_proxmox_sits_right_under_qemu_in_the_deploy_menu(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + i_qemu = src.index('"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"') + i_pve = src.index('"Proxmox VE - Deploy a VM on a remote host"') + i_ntfy = src.index('"Deploy - Install NTFY notification server"') + self.assertLess(i_qemu, i_pve) + self.assertLess(i_pve, i_ntfy) + + def test_the_dispatch_follows_the_list(self): + src = open("script/todo/todo.py", encoding="utf-8").read() + self.assertIn( + 'elif status == "6":\n self.prompt_execute_proxmox()', + src, + ) + self.assertIn( + 'elif status == "7":\n self._deploy_ntfy_server()', + src, + ) + + def test_every_qemu_entry_has_its_proxmox_counterpart(self): + """L'équivalent des dix-sept commandes, plus le choix de l'hôte.""" + src = open("script/todo/todo.py", encoding="utf-8").read() + debut = src.index(" def prompt_execute_proxmox(self):") + bloc = src[debut : src.index(" def _pve_fetch_image(self):")] + for n in range(1, 19): + self.assertIn(f'elif status == "{n}":', bloc, f"entrée {n}") + + def test_the_script_is_valid_python(self): + res = subprocess.run( + [ + sys.executable, + "-c", + "import ast;ast.parse(open('script/proxmox/proxmox_deploy.py',encoding='utf-8').read())", + ], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + +if __name__ == "__main__": + unittest.main(verbosity=1)