[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() {
|
fix_efi_fallback() {
|
||||||
local esp="${PVE_ESP:-/boot/efi}"
|
local esp="${PVE_ESP:-/boot/efi}"
|
||||||
local secours="${esp}/EFI/BOOT"
|
local secours="${esp}/EFI/BOOT"
|
||||||
[ -d "${secours}" ] || return 0
|
# sudo sur CHAQUE lecture. /boot/efi est une vfat montée « umask=077 » :
|
||||||
[ -e "${secours}/grub.cfg" ] && return 0
|
# root seul y entre, et un « [ -d ] » non privilégié y répond FAUX. Ce
|
||||||
local stub="" candidat=""
|
# correctif ne faisait donc RIEN, en silence, et la VM retombait sur
|
||||||
for candidat in "${esp}"/EFI/*/grub.cfg; do
|
# « grub> » au redémarrage suivant — vécu deux fois. Le glob du shell est
|
||||||
[ -e "${candidat}" ] || continue
|
# aveugle pour la même raison : il faut énumérer avec sudo.
|
||||||
case "${candidat}" in
|
sudo test -d "${secours}" || return 0
|
||||||
*/EFI/BOOT/grub.cfg) continue ;;
|
sudo test -e "${secours}/grub.cfg" && return 0
|
||||||
esac
|
local stub=""
|
||||||
stub="${candidat}"
|
# « || true » : quand aucun stub n'existe, grep ne trouve rien et rend 1
|
||||||
break
|
# — avec « set -o pipefail », l'affectation échoue et le script s'arrête
|
||||||
done
|
# 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
|
if [ -z "${stub}" ]; then
|
||||||
say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \
|
say "${Yellow}⚠${Color_Off} aucun grub.cfg à recopier sous ${esp} :" \
|
||||||
"vérifier l'amorçage avant de redémarrer."
|
"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)"
|
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"prompt_description": t(
|
||||||
|
"Proxmox VE - Deploy a VM on a remote host"
|
||||||
|
)
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"prompt_description": t(
|
"prompt_description": t(
|
||||||
"Deploy - Install NTFY notification server"
|
"Deploy - Install NTFY notification server"
|
||||||
|
|
@ -966,6 +971,8 @@ class TODO:
|
||||||
elif status == "5":
|
elif status == "5":
|
||||||
self.prompt_execute_qemu()
|
self.prompt_execute_qemu()
|
||||||
elif status == "6":
|
elif status == "6":
|
||||||
|
self.prompt_execute_proxmox()
|
||||||
|
elif status == "7":
|
||||||
self._deploy_ntfy_server()
|
self._deploy_ntfy_server()
|
||||||
else:
|
else:
|
||||||
print(t("Command not found !"))
|
print(t("Command not found !"))
|
||||||
|
|
@ -1021,6 +1028,756 @@ class TODO:
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
# QEMU / KVM (libvirt) VM deployment
|
# 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):
|
def _qemu_script_path(self):
|
||||||
"""Chemin absolu vers script/qemu/deploy_qemu.py."""
|
"""Chemin absolu vers script/qemu/deploy_qemu.py."""
|
||||||
path = os.path.join(
|
path = os.path.join(
|
||||||
|
|
@ -1284,6 +2041,120 @@ class TODO:
|
||||||
print(f"⚠ {t('virsh still missing; a reboot may be required.')}")
|
print(f"⚠ {t('virsh still missing; a reboot may be required.')}")
|
||||||
return False
|
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):
|
def prompt_execute_qemu(self):
|
||||||
print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}")
|
print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}")
|
||||||
script_path = self._qemu_script_path()
|
script_path = self._qemu_script_path()
|
||||||
|
|
@ -5081,6 +5952,19 @@ class TODO:
|
||||||
return m.group(1)
|
return m.group(1)
|
||||||
return None
|
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):
|
def _qemu_vm_ip(self, name, timeout=600):
|
||||||
"""IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname
|
"""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
|
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)",
|
"fr": "Suivre le démarrage des VM (sans installation)",
|
||||||
"en": "Watch the VMs start (no install)",
|
"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)": {
|
"Proxmox VE hypervisor (no Odoo)": {
|
||||||
"fr": "Hyperviseur Proxmox VE (sans Odoo)",
|
"fr": "Hyperviseur Proxmox VE (sans Odoo)",
|
||||||
"en": "Proxmox VE hypervisor (no Odoo)",
|
"en": "Proxmox VE hypervisor (no Odoo)",
|
||||||
|
|
|
||||||
|
|
@ -293,11 +293,27 @@ class TestTheRealBundle(unittest.TestCase):
|
||||||
" ./mobile/install_mobile_dev.sh"
|
" ./mobile/install_mobile_dev.sh"
|
||||||
)
|
)
|
||||||
cls.repos = MOBILE / "dist" / "repos"
|
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(
|
raise unittest.SkipTest(
|
||||||
"dépôt mobile présent mais pas compilé :"
|
"dépôt mobile présent mais pas compilé :"
|
||||||
" ./mobile/compile_and_run.sh (ou npm run build)"
|
" ./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):
|
def test_the_transfer_is_coherent(self):
|
||||||
rep = cbt.check(MOBILE, REPO)
|
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