[ADD] proxmox : déployer des VM sur un hôte Proxmox distant

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
This commit is contained in:
Mathieu Benoit 2026-08-23 05:54:38 -04:00
parent 9cb954749c
commit 01b77dfa80
9 changed files with 2526 additions and 12 deletions

View file

@ -0,0 +1,188 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# Deploying VMs on a Proxmox VE host
<!-- [fr] -->
# Déployer des VM sur un hôte Proxmox VE
<!-- [en] -->
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`.
<!-- [fr] -->
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`.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.
<!-- [en] -->
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).
<!-- [fr] -->
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).
<!-- [common] -->
```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'
```
<!-- [en] -->
## 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 '<whole command>'` and not `sudo <command>`: 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.
<!-- [fr] -->
## 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 '<toute la commande>'` et non `sudo <commande>` : 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é.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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é.
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.
<!-- [common] -->
```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
```
<!-- [en] -->
## 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.
<!-- [fr] -->
## 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.

View file

@ -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 '<toute la commande>'` et non `sudo <commande>` : 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.

91
script/proxmox/README.md Normal file
View file

@ -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 '<whole command>'` and not `sudo <command>`: 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.

View file

@ -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."

View file

@ -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 '<tout>' » et non « sudo <tout> » : 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 <id> 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 <id> ».
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

View file

@ -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 <nic>, needs console)')}")
if input(t("Choice: ")).strip() != "1":
print(f"\n {t('To bridge the LAN, on the host:')}")
print(" auto vmbr0")
print(" iface vmbr0 inet static")
print(" address <ip-de-l-hôte>/24")
print(" gateway <passerelle>")
print(" bridge-ports <interface>")
print(f" ⚠ {t('This moves the host address: do it from a console.')}")
return ""
_c, sortie = self._pve_show(
"ip -o -4 route show default", quiet=True
)
uplink = ""
parts = (sortie or "").split()
if "dev" in parts:
uplink = parts[parts.index("dev") + 1]
print(f" {t('uplink for NAT')} : {uplink or t('none')}")
for cmd in pve.bridge_setup_cmds(uplink=uplink):
code, _o = self._pve_show(cmd, timeout=120)
if code:
print(f" ✗ {t('Step failed, stopping here.')}")
return ""
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
ponts = pve.parse_bridges(out)
if pve.INTERNAL_BRIDGE not in ponts:
print(f" ✗ {t('The bridge did not come up.')}")
return ""
print(f" ✓ {pve.INTERNAL_BRIDGE}")
return pve.INTERNAL_BRIDGE
def _pve_deploy(self, dry_run=False):
"""Déploie une 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-<distro>): ")
).strip() or f"erplibre-{distro}"
memoire = (
self._qemu_ask_ram(t("RAM in MB, blank = 4096"), 4096) or 4096
)
vcpus = (
self._qemu_ask_cpu(t("vCPU, blank = 2"), 2, os.cpu_count() or 2)
or 2
)
disque = input(t("Disk size (default 32G): ")).strip() or "32G"
code, _v = mod.DISTROS[distro][0][version][:2]
url = mod.image_url(distro, code, arch, version)
image = mod.default_image_name(distro, code, arch, version)
# Stockage et pont : demandés à l'HÔTE, jamais devinés. « local-lvm »
# n'existe pas partout, et un pont inventé fait échouer « qm create ».
_c, out = self._pve_show("pvesm status --content images", quiet=True)
stockages = pve.parse_storages(out)
_c, out = self._pve_show("ip -o link show type bridge", quiet=True)
ponts = pve.parse_bridges(out)
_c, cfg_reseau = self._pve_show(
"cat /etc/network/interfaces", quiet=True
)
infos_ponts = pve.parse_bridge_config(cfg_reseau)
stockage = pve.pick_storage(stockages)
pont = pve.pick_bridge(ponts)
if not stockage:
print(f"\n ✗ {t('No storage able to hold a VM disk.')}")
return
if not pont and not dry_run:
pont = self._pve_offer_bridge()
if not pont:
return
_c, cfg_reseau = self._pve_show(
"cat /etc/network/interfaces", quiet=True
)
infos_ponts = pve.parse_bridge_config(cfg_reseau)
elif not pont:
pont = pve.INTERNAL_BRIDGE
# Le VMID D'ABORD : l'adresse d'un pont interne s'en déduit, et
# l'afficher avant de l'avoir choisi ne pouvait pas marcher.
vmid = pve.next_vmid(self._pve_vms())
ipconfig = pve.ipconfig_for(infos_ponts.get(pont, {}), vmid)
print(f"\n {t('storage')} : {stockage} ({len(stockages)} {t('offered')})")
print(f" {t('bridge')} : {pont}")
print(f" {t('address')} {ipconfig}")
print(f" VMID : {vmid}")
cle_locale = self._qemu_default_ssh_key()
spec = {
"name": nom,
"memory": memoire,
"vcpus": vcpus,
"disk": disque,
"storage": stockage,
"bridge": pont,
"image": image,
"user": "erplibre",
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
"start": True,
# DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont
# interne : là, aucun serveur DHCP ne répondrait et la VM
# resterait muette.
"ipconfig": ipconfig,
}
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
)
if dry_run:
print(f"\n── {t('Would run on')} {host['target']} ──")
print(f" # {t('SSH key ->')} {spec['sshkey_path']}")
for cmd in etapes:
print(f" {cmd}")
return
if not self._is_yes_default_yes(
input(f"\n{t('Deploy this VM now? (Y/n): ')}")
):
print(t("Cancelled."))
return
if cle_locale and not self._pve_push_key(cle_locale):
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
spec.pop("sshkey_path", None)
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
)
for cmd in etapes:
code, _out = self._pve_show(cmd, timeout=1800)
if code:
print(f"\n ✗ {t('Step failed, stopping here.')}")
return
# Adresse fixe : c'est nous qui l'avons donnée, inutile de la
# chercher. La découverte ne sert qu'au DHCP.
ip = pve.ip_from_ipconfig(ipconfig)
if ip:
print(f"\n {t('address given at creation:')} {ip}")
else:
print(f"\n {t('Waiting for the VM address…')}")
ip = self._pve_guest_ip(vmid)
if not ip:
print(f" ⚠ {t('No address yet. Try [6] later.')}")
return
print(f" ✓ {nom} : {ip}")
# Entrée ~/.ssh/config avec l'hôte Proxmox en REBOND : c'est ce qui
# rend la VM joignable d'ici, et c'est aussi ce qui permet au suivi
# d'installation d'y entrer (il reçoit l'alias, pas l'IP).
self._write_ssh_config_entry(
nom,
"erplibre",
ip,
identity_file=self._ssh_private_key(cle_locale),
proxy_jump=host["target"],
)
print(f" ✓ ~/.ssh/config : ssh {nom}")
if self._is_yes_default_yes(
input(f"\n{t('Install ERPLibre on it? (Y/n): ')}")
):
branch = self._qemu_pick_branch()
label, cmd = self._qemu_pick_install_profile(distro)
print(f" {label}")
# L'ALIAS, pas l'IP : ssh y lit le ProxyJump de ~/.ssh/config.
self._qemu_install_erplibre_monitored(
[nom], branch, {nom: nom}, cmd
)
def _pve_ssh_config(self):
"""Écrit une entrée ~/.ssh/config par VM de l'hôte, avec l'hôte
Proxmox en ProxyJump — sans quoi ces VM ne sont joignables d'ici que
si leur réseau est routé jusqu'à nous."""
host = self._pve_host()
if not host:
return
vms = [v for v in self._pve_vms() if v["status"] == "running"]
if not vms:
print(f"\n{t('No running VM on this Proxmox host.')}")
return
cle = self._ssh_private_key(self._qemu_default_ssh_key())
for vm in vms:
ip = self._pve_guest_ip(vm["vmid"], attente=0)
if not ip:
print(f" ⚠ {vm['name']} : {t('no address, skipped')}")
continue
self._write_ssh_config_entry(
vm["name"],
"erplibre",
ip,
identity_file=cle,
proxy_jump=host["target"],
)
print(f" ✓ ssh {vm['name']} ({ip} {t('through')} {host['target']})")
def _pve_test_vm(self):
"""Ouvre Odoo (:8069) d'une VM Proxmox dans un navigateur en ligne.
Même chose que pour QEMU, à ceci près que l'adresse vient de l'hôte
Proxmox et non de libvirt — et qu'elle n'est joignable d'ici que si son
réseau l'est. On le dit plutôt que d'ouvrir une page vide.
"""
vm = self._pve_pick_vm()
if not vm:
return
ip = self._pve_guest_ip(vm["vmid"], attente=30)
if not ip:
print(f"\n ⚠ {t('No address for this VM.')}")
return
if not self._qemu_ip_reachable(ip, port=8069, timeout=3):
print(f"\n ⚠ {ip}:8069 {t('unreachable from here.')}")
print(f" → {t('Use [13] to add a ProxyJump entry, then a tunnel.')}")
return
navigateur = self._qemu_choose_cli_browser()
if not navigateur:
return
url = f"http://{ip}:8069"
print(f"→ {navigateur} {url}")
os.system(f"{navigateur} {shlex.quote(url)}")
def _pve_example(self):
"""Exemple de séquence, sans rien exécuter : de quoi voir ce que
l'outil enverrait sur l'hôte."""
from script.proxmox import proxmox_deploy as pve
spec = {
"name": "demo-vm",
"memory": 4096,
"vcpus": 2,
"disk": "32G",
"storage": "local-lvm",
"bridge": "vmbr0",
"image": "debian-13-genericcloud-amd64.qcow2",
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
}
print(f"\n── {t('Example: demo-vm, Debian 13, on a Proxmox host')} ──")
print(f" {pve.image_fetch_cmd('https://…/debian-13.qcow2', spec['image'])}")
for cmd in pve.create_cmds(101, spec):
print(f" {cmd}")
def _pve_stats(self):
"""État de l'hôte et de ses VM, en une page."""
host = self._pve_host()
if not host:
return
print(f"\n══ {t('Proxmox host:')} {self._pve_label(host)} ══")
for titre, cmd in (
(t("uptime"), "uptime"),
(t("memory"), "free -h | head -2"),
(t("storages"), "pvesm status"),
):
code, out = self._pve_show(cmd, quiet=True)
print(f"\n── {titre} ──")
print((out or "").rstrip() if code == 0 else f" ⚠ {out.strip()}")
self._pve_list()
def _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

View file

@ -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-<distro>): ": {
"fr": "Nom de la VM (défaut : erplibre-<distro>) : ",
"en": "VM name (default: erplibre-<distro>): ",
},
"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 <nic>, needs console)": {
"fr": "le faire moi-même (bridge-ports <nic>, console requise)",
"en": "do it myself (bridge-ports <nic>, 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)",

View file

@ -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)

408
test/test_proxmox_deploy.py Normal file
View file

@ -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: <BROADCAST,UP>\n4: vmbr1@if2: <BROADCAST>\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)