[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:
parent
9cb954749c
commit
01b77dfa80
9 changed files with 2526 additions and 12 deletions
188
script/proxmox/README.base.md
Normal file
188
script/proxmox/README.base.md
Normal 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.
|
||||
92
script/proxmox/README.fr.md
Normal file
92
script/proxmox/README.fr.md
Normal 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
91
script/proxmox/README.md
Normal 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.
|
||||
|
|
@ -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."
|
||||
|
|
|
|||
517
script/proxmox/proxmox_deploy.py
Normal file
517
script/proxmox/proxmox_deploy.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
408
test/test_proxmox_deploy.py
Normal 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)
|
||||
Loading…
Reference in a new issue