From 63534e0473564777e25eb078c667478aa94f3836 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH] [ADD] qemu: deploy ERPLibre VMs from cloud images MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploys Ubuntu, Debian, Fedora and Arch cloud images through libvirt, on amd64, arm64 and s390x. Handles the image download with mirror fallback, the cloud-init seed, UEFI without Secure Boot, and the host setup. --- FR --- Déploie des images cloud Ubuntu, Debian, Fedora et Arch via libvirt, en amd64, arm64 et s390x. Gère le téléchargement avec repli sur miroir, le seed cloud-init, l'UEFI sans Secure Boot et la préparation de l'hôte. Assisted-by: Claude Opus 4.8 --- script/qemu/README.base.md | 476 +++++++++ script/qemu/README.fr.md | 270 +++++ script/qemu/README.md | 259 +++++ script/qemu/deploy_qemu.py | 2024 ++++++++++++++++++++++++++++++++++++ 4 files changed, 3029 insertions(+) create mode 100644 script/qemu/README.base.md create mode 100644 script/qemu/README.fr.md create mode 100644 script/qemu/README.md create mode 100755 script/qemu/deploy_qemu.py diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md new file mode 100644 index 0000000..e93815f --- /dev/null +++ b/script/qemu/README.base.md @@ -0,0 +1,476 @@ + + + + + + +# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora) + +`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud +image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the +distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the +release with `--version`; run `--list-images` to see the full catalogue with +minimum specs. It: + +1. **Downloads the cloud image by itself** (cached, no double download). +2. Converts it to a dedicated qcow2 working disk and resizes it. +3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init). +4. Runs `virt-install` importing the disk + the seed as a CD-ROM. +5. Waits for the DHCP lease and prints the SSH command. + + +# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora) + +`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image +cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez +la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et +la version avec `--version` ; `--list-images` affiche tout le catalogue avec +les specs minimales. Il : + +1. **Télécharge lui-même l'image cloud** (mise en cache, sans double + téléchargement). +2. La convertit en un disque de travail qcow2 dédié et le redimensionne. +3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init). +4. Lance `virt-install` en important le disque + le seed en CD-ROM. +5. Attend le bail DHCP et affiche la commande SSH. + + +## Prerequisites + +- A host with KVM available (bare-metal or nested virtualization enabled). +- `sudo` rights (the deployment writes to `/var/lib/libvirt/images` and drives + libvirt). + +## Installation + +The script **auto-installs the missing pieces it needs**: on first run it +detects your package manager (apt / dnf / pacman / zypper / brew), lists the +missing components (the client tools, **plus the libvirt daemon and the QEMU +system emulator**), asks for confirmation, installs them with `sudo`, then +enables and starts `libvirtd`. Use `-y` to accept automatically or +`--no-install-deps` to disable this behaviour. + +To install everything manually on Ubuntu/Debian (recommended full KVM stack): + + +## Prérequis + +- Un hôte disposant de KVM (bare-metal ou virtualisation imbriquée activée). +- Les droits `sudo` (le déploiement écrit dans `/var/lib/libvirt/images` et + pilote libvirt). + +## Installation + +Le script **installe automatiquement les composants manquants** : au premier +lancement, il détecte votre gestionnaire de paquets (apt / dnf / pacman / +zypper / brew), liste les composants absents (les outils clients, **ainsi que +le démon libvirt et l'émulateur QEMU système**), demande confirmation, les +installe avec `sudo`, puis active et démarre `libvirtd`. Utilisez `-y` pour +accepter automatiquement ou `--no-install-deps` pour désactiver ce +comportement. + +Pour tout installer manuellement sur Ubuntu/Debian (pile KVM complète +recommandée) : + + +```bash +sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \ + libvirt-daemon-system qemu-system-x86 +sudo systemctl enable --now libvirtd +sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous +``` + + +`libvirt-daemon-system` provides the `libvirtd` daemon (and the +`/var/run/libvirt/libvirt-sock` socket) and `qemu-system-x86` the emulator — +without them `virt-install` fails with *"Failed to connect socket to +'/var/run/libvirt/libvirt-sock'"*. The script installs and starts them for +you; this manual command is only needed if you prefer to prepare the host +yourself or run with `--no-install-deps`. + +## Usage + +Simplest form — the image is downloaded automatically (path derived from +`--version`, cached in `/var/lib/libvirt/images/iso`): + + +`libvirt-daemon-system` fournit le démon `libvirtd` (et le socket +`/var/run/libvirt/libvirt-sock`) et `qemu-system-x86` l'émulateur — sans eux +`virt-install` échoue avec *« Failed to connect socket to +'/var/run/libvirt/libvirt-sock' »*. Le script les installe et les démarre pour +vous ; cette commande manuelle n'est utile que si vous préférez préparer +l'hôte vous-même ou utiliser `--no-install-deps`. + +## Utilisation + +Forme la plus simple — l'image est téléchargée automatiquement (chemin déduit +de `--version`, mis en cache dans `/var/lib/libvirt/images/iso`) : + + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub +``` + + +Download (and verify) an image without creating a VM: + + +Télécharger (et vérifier) une image sans créer de VM : + + +```bash +sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify +``` + + +Deploy with an interactive password instead of an SSH key: + + +Déployer avec un mot de passe interactif au lieu d'une clé SSH : + + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password +``` + + +Larger VM (8 GB RAM, 8 vCPU, 120 GB disk), overwriting an existing disk: + + +VM plus grande (8 Go RAM, 8 vCPU, disque 120 Go), en écrasant un disque +existant : + + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force +``` + + +Preview what would happen, without doing anything (no sudo, no download): + + +Prévisualiser ce qui serait fait, sans rien exécuter (sans sudo, sans +téléchargement) : + + +```bash +./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run +``` + + +Non-interactive deployment (accept dependency install automatically): + + +Déploiement non interactif (accepte automatiquement l'installation des +dépendances) : + + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub -y +``` + + +Supported Ubuntu versions: `20.04`, `22.04`, `24.04` (default), `24.10`, +`25.04`, `25.10`. Provide an explicit image path as a positional argument to +override the automatic download location. + +## After deployment + + +Versions Ubuntu supportées : `20.04`, `22.04`, `24.04` (défaut), `24.10`, +`25.04`, `25.10`. Fournissez un chemin d'image en argument positionnel pour +surcharger l'emplacement de téléchargement automatique. + +## Après le déploiement + + +```bash +virsh list --all +virsh console test-vm # Ctrl+] to quit / pour quitter +virsh domifaddr test-vm --source lease # find the IP / trouver l'IP +ssh erplibre@ +``` + + +The default user is `erplibre` (change it with `--user`). + +## Via the TODO menu + +The script is integrated into the interactive assistant. Run `make todo` (or +`./script/todo/todo.py`), then go to **Execute → Deploy → QEMU/KVM - Deploy an +Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, +download an image, list VMs and show a VM IP address — the menu asks for the +parameters and builds the command for you. + +## Main options + +- `--distro` — `ubuntu` (default), `debian` or `fedora`. +- `--version` — release for the distro (default: the distro's default). +- `--list-images` — print all distros/versions and their specs, then exit. +- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`). +- `--download-only` — download the image then exit (no VM). +- `--name` — VM name (required for deployment). +- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory` + and `--disk-size` default to the **minimum required by the chosen version** + (libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian + → 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2. +- `--ssh-key`, `--ask-password`, `--password-hash` — authentication. +- `-y` / `--assume-yes` — auto-accept dependency installation. +- `--no-install-deps` — never auto-install dependencies. +- `--dry-run` — show the commands without executing anything. +- `--force` — overwrite the existing working qcow2 disk. + +Run `./script/qemu/deploy_qemu.py --help` for the full list. + + +L'utilisateur par défaut est `erplibre` (modifiable avec `--user`). + +## Via le menu TODO + +Le script est intégré à l'assistant interactif. Lancez `make todo` (ou +`./script/todo/todo.py`), puis allez dans **Execute → Deploy → QEMU/KVM - +Deploy an Ubuntu VM (libvirt)**. De là, vous pouvez déployer une VM, +prévisualiser un dry-run, télécharger une image, lister les VM et afficher +l'IP d'une VM — le menu demande les paramètres et construit la commande pour +vous. + +## Principales options + +- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. +- `--version` — version de la distro (défaut : celle par défaut de la distro). +- `--list-images` — affiche toutes les distros/versions et leurs specs. +- `--image-dir` — répertoire de cache des images (défaut + `/var/lib/libvirt/images/iso`). +- `--download-only` — télécharge l'image puis quitte (sans VM). +- `--name` — nom de la VM (requis pour le déploiement). +- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis, + `--memory` et `--disk-size` prennent le **minimum requis par la version** + choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ → + 3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus` + vaut 2 par défaut. +- `--ssh-key`, `--ask-password`, `--password-hash` — authentification. +- `-y` / `--assume-yes` — accepte automatiquement l'installation des + dépendances. +- `--no-install-deps` — n'installe jamais les dépendances automatiquement. +- `--dry-run` — affiche les commandes sans rien exécuter. +- `--force` — écrase le disque de travail qcow2 existant. + +Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. + + +## Managing VMs + +List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images` +is kept unless you delete it): + + +## Gestion des VM + +Lister, arrêter et supprimer les VM (le disque qcow2 sous +`/var/lib/libvirt/images` est conservé tant que vous ne le supprimez pas) : + + +```bash +sudo virsh list --all # toutes les VM et leur état / all VMs and state +sudo virsh shutdown # arrêt propre ACPI / graceful shutdown +sudo virsh destroy # arrêt forcé / force off (pull the plug) +sudo virsh undefine # supprime la définition / remove definition +sudo virsh domifaddr # adresse IP de la VM / VM IP address +``` + + +`destroy` only powers the VM off (disk kept); `undefine` removes its +definition. To fully recreate a VM with the same name, `destroy` + `undefine` +it first, or redeploy with `--force`. + +## SSH access from another machine (ProxyJump) + +With the default NAT network the VM is reachable **only from the KVM host**. +To reach it from another machine **without changing the network**, use the +host as a jump host (it already reaches the VM). Get the VM IP with +`sudo virsh domifaddr `, then from the other machine: + + +`destroy` ne fait qu'éteindre la VM (disque conservé) ; `undefine` supprime sa +définition. Pour recréer proprement une VM du même nom, faites `destroy` + +`undefine` d'abord, ou redéployez avec `--force`. + +## Accès SSH depuis une autre machine (ProxyJump) + +Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte +KVM**. Pour l'atteindre depuis une autre machine **sans toucher au réseau**, +utilisez l'hôte comme rebond (il joint déjà la VM). Récupérez l'IP de la VM +avec `sudo virsh domifaddr `, puis depuis l'autre machine : + + +```bash +# Rebond SSH vers la VM / jump through the KVM host +ssh -J user@ erplibre@ + +# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069 +ssh -L 8069::8069 user@ +``` + + +To make it permanent, add this to `~/.ssh/config` on the other machine (then +just `ssh myvm`): + + +Pour le rendre permanent, ajoutez ceci à `~/.ssh/config` sur l'autre machine +(ensuite `ssh myvm` suffit) : + + +```text +Host myvm + HostName # ex. 192.168.122.50 (reseau NAT) + User erplibre + ProxyJump user@ # IP LAN de l'hote KVM +``` + + +This works over Wi-Fi and needs no VM shutdown — the simplest option for +personal access. Prefer a bridge (below) if the VM must be a full server +exposed on the LAN. + +## QEMU inside QEMU (nested) & exposing the VM via a bridge + +If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only +when **nested virtualization** is enabled on the outer/physical host and the +middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host +(the first command must be non-empty): + + +Ça marche en Wi-Fi et sans arrêter la VM — l'option la plus simple pour un +accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur +à part entière exposé sur le LAN. + +## QEMU dans QEMU (imbriqué) & exposer la VM via un pont + +Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne +fonctionne que si la **virtualisation imbriquée** est activée sur l'hôte +physique et que la VM intermédiaire utilise le mode CPU `host-passthrough`. +Vérifiez depuis l'hôte KVM (la première commande doit être non vide) : + + +```bash +grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible +# Sur l'hote PHYSIQUE / on the PHYSICAL host: +cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1 +cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1 +``` + + +To enable nesting on the physical host (Intel shown; use `kvm_amd` on AMD), +then recreate the middle VM with `host-passthrough`: + + +Pour activer l'imbrication sur l'hôte physique (Intel montré ; `kvm_amd` sur +AMD), puis recréer la VM intermédiaire en `host-passthrough` : + + +```bash +echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot +``` + + +If nesting is unavailable, QEMU still runs via software emulation (TCG) — it +works but is slow. + +### Bridge for external access + +A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN, +reachable by any machine. On the KVM host, create a bridge `br0` over the +physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu +server) — replace `enp3s0` with your interface: + + +Si l'imbrication est indisponible, QEMU tourne quand même en émulation +logicielle (TCG) — ça marche mais c'est lent. + +### Pont pour l'accès externe + +Une VM en NAT est isolée ; une VM **pontée** obtient une IP directement sur le +LAN, joignable par n'importe quelle machine. Sur l'hôte KVM, créez un pont +`br0` sur la carte physique (**filaire uniquement** — le Wi-Fi ne se ponte +pas). netplan (Ubuntu serveur) — remplacez `enp3s0` par votre interface : + + +```yaml +# /etc/netplan/01-br0.yaml +network: + version: 2 + renderer: networkd + ethernets: + enp3s0: {dhcp4: no, dhcp6: no} + bridges: + br0: + interfaces: [enp3s0] + dhcp4: yes + parameters: {stp: false, forward-delay: 0} +``` + + +Apply safely (auto-reverts if you lose the connection) and verify — or use +NetworkManager (Ubuntu desktop): + + +Appliquez avec filet de sécurité (annulation auto en cas de coupure) et +vérifiez — ou via NetworkManager (Ubuntu bureau) : + + +```bash +# netplan +sudo netplan try && sudo netplan apply +ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP + +# NetworkManager (alternative) +nmcli con add type bridge ifname br0 con-name br0 +nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port +nmcli con modify br0 ipv4.method auto +nmcli con down "Wired connection 1" ; nmcli con up br0 +``` + + +Then attach the VM to the bridge — **either at creation**: + + +Rattachez ensuite la VM au pont — **soit à la création** : + + +```bash +sudo ./script/qemu/deploy_qemu.py --name --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force +``` + + +**or by editing a VM already created**: stop it, replace its `` +block (`type='network'` / `` → `type='bridge'` / +``), then start it again: + + +**soit par édition d'une VM déjà créée** : arrêtez-la, remplacez son bloc +`` (`type='network'` / `` → +`type='bridge'` / ``), puis redémarrez-la : + + +```bash +sudo virsh shutdown +sudo virsh edit # mettre l'interface en bridge=br0 +sudo virsh start +sudo virsh domifaddr # nouvelle IP LAN / new LAN IP +``` + + +The VM now gets a LAN IP from your router, reachable by other machines. From +the Internet you additionally need a port-forward on your router (or a VPN); +in a nested setup the outer host must also forward/expose the middle VM. + + +La VM obtient maintenant une IP LAN de votre routeur, joignable par les autres +machines. Depuis Internet, il faut en plus une redirection de port sur votre +routeur (ou un VPN) ; en configuration imbriquée, l'hôte externe doit aussi +rediriger/exposer la VM intermédiaire. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md new file mode 100644 index 0000000..22088fd --- /dev/null +++ b/script/qemu/README.fr.md @@ -0,0 +1,270 @@ + +# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora) + +`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image +cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez +la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et +la version avec `--version` ; `--list-images` affiche tout le catalogue avec +les specs minimales. Il : + +1. **Télécharge lui-même l'image cloud** (mise en cache, sans double + téléchargement). +2. La convertit en un disque de travail qcow2 dédié et le redimensionne. +3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init). +4. Lance `virt-install` en important le disque + le seed en CD-ROM. +5. Attend le bail DHCP et affiche la commande SSH. + +## Prérequis + +- Un hôte disposant de KVM (bare-metal ou virtualisation imbriquée activée). +- Les droits `sudo` (le déploiement écrit dans `/var/lib/libvirt/images` et + pilote libvirt). + +## Installation + +Le script **installe automatiquement les composants manquants** : au premier +lancement, il détecte votre gestionnaire de paquets (apt / dnf / pacman / +zypper / brew), liste les composants absents (les outils clients, **ainsi que +le démon libvirt et l'émulateur QEMU système**), demande confirmation, les +installe avec `sudo`, puis active et démarre `libvirtd`. Utilisez `-y` pour +accepter automatiquement ou `--no-install-deps` pour désactiver ce +comportement. + +Pour tout installer manuellement sur Ubuntu/Debian (pile KVM complète +recommandée) : + +```bash +sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \ + libvirt-daemon-system qemu-system-x86 +sudo systemctl enable --now libvirtd +sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous +``` + +`libvirt-daemon-system` fournit le démon `libvirtd` (et le socket +`/var/run/libvirt/libvirt-sock`) et `qemu-system-x86` l'émulateur — sans eux +`virt-install` échoue avec *« Failed to connect socket to +'/var/run/libvirt/libvirt-sock' »*. Le script les installe et les démarre pour +vous ; cette commande manuelle n'est utile que si vous préférez préparer +l'hôte vous-même ou utiliser `--no-install-deps`. + +## Utilisation + +Forme la plus simple — l'image est téléchargée automatiquement (chemin déduit +de `--version`, mis en cache dans `/var/lib/libvirt/images/iso`) : + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub +``` + +Télécharger (et vérifier) une image sans créer de VM : + +```bash +sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify +``` + +Déployer avec un mot de passe interactif au lieu d'une clé SSH : + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password +``` + +VM plus grande (8 Go RAM, 8 vCPU, disque 120 Go), en écrasant un disque +existant : + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force +``` + +Prévisualiser ce qui serait fait, sans rien exécuter (sans sudo, sans +téléchargement) : + +```bash +./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run +``` + +Déploiement non interactif (accepte automatiquement l'installation des +dépendances) : + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub -y +``` + +Versions Ubuntu supportées : `20.04`, `22.04`, `24.04` (défaut), `24.10`, +`25.04`, `25.10`. Fournissez un chemin d'image en argument positionnel pour +surcharger l'emplacement de téléchargement automatique. + +## Après le déploiement + +```bash +virsh list --all +virsh console test-vm # Ctrl+] to quit / pour quitter +virsh domifaddr test-vm --source lease # find the IP / trouver l'IP +ssh erplibre@ +``` + +L'utilisateur par défaut est `erplibre` (modifiable avec `--user`). + +## Via le menu TODO + +Le script est intégré à l'assistant interactif. Lancez `make todo` (ou +`./script/todo/todo.py`), puis allez dans **Execute → Deploy → QEMU/KVM - +Deploy an Ubuntu VM (libvirt)**. De là, vous pouvez déployer une VM, +prévisualiser un dry-run, télécharger une image, lister les VM et afficher +l'IP d'une VM — le menu demande les paramètres et construit la commande pour +vous. + +## Principales options + +- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. +- `--version` — version de la distro (défaut : celle par défaut de la distro). +- `--list-images` — affiche toutes les distros/versions et leurs specs. +- `--image-dir` — répertoire de cache des images (défaut + `/var/lib/libvirt/images/iso`). +- `--download-only` — télécharge l'image puis quitte (sans VM). +- `--name` — nom de la VM (requis pour le déploiement). +- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis, + `--memory` et `--disk-size` prennent le **minimum requis par la version** + choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ → + 3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus` + vaut 2 par défaut. +- `--ssh-key`, `--ask-password`, `--password-hash` — authentification. +- `-y` / `--assume-yes` — accepte automatiquement l'installation des + dépendances. +- `--no-install-deps` — n'installe jamais les dépendances automatiquement. +- `--dry-run` — affiche les commandes sans rien exécuter. +- `--force` — écrase le disque de travail qcow2 existant. + +Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. + +## Gestion des VM + +Lister, arrêter et supprimer les VM (le disque qcow2 sous +`/var/lib/libvirt/images` est conservé tant que vous ne le supprimez pas) : + +```bash +sudo virsh list --all # toutes les VM et leur état / all VMs and state +sudo virsh shutdown # arrêt propre ACPI / graceful shutdown +sudo virsh destroy # arrêt forcé / force off (pull the plug) +sudo virsh undefine # supprime la définition / remove definition +sudo virsh domifaddr # adresse IP de la VM / VM IP address +``` + +`destroy` ne fait qu'éteindre la VM (disque conservé) ; `undefine` supprime sa +définition. Pour recréer proprement une VM du même nom, faites `destroy` + +`undefine` d'abord, ou redéployez avec `--force`. + +## Accès SSH depuis une autre machine (ProxyJump) + +Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte +KVM**. Pour l'atteindre depuis une autre machine **sans toucher au réseau**, +utilisez l'hôte comme rebond (il joint déjà la VM). Récupérez l'IP de la VM +avec `sudo virsh domifaddr `, puis depuis l'autre machine : + +```bash +# Rebond SSH vers la VM / jump through the KVM host +ssh -J user@ erplibre@ + +# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069 +ssh -L 8069::8069 user@ +``` + +Pour le rendre permanent, ajoutez ceci à `~/.ssh/config` sur l'autre machine +(ensuite `ssh myvm` suffit) : + +```text +Host myvm + HostName # ex. 192.168.122.50 (reseau NAT) + User erplibre + ProxyJump user@ # IP LAN de l'hote KVM +``` + +Ça marche en Wi-Fi et sans arrêter la VM — l'option la plus simple pour un +accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur +à part entière exposé sur le LAN. + +## QEMU dans QEMU (imbriqué) & exposer la VM via un pont + +Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne +fonctionne que si la **virtualisation imbriquée** est activée sur l'hôte +physique et que la VM intermédiaire utilise le mode CPU `host-passthrough`. +Vérifiez depuis l'hôte KVM (la première commande doit être non vide) : + +```bash +grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible +# Sur l'hote PHYSIQUE / on the PHYSICAL host: +cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1 +cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1 +``` + +Pour activer l'imbrication sur l'hôte physique (Intel montré ; `kvm_amd` sur +AMD), puis recréer la VM intermédiaire en `host-passthrough` : + +```bash +echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot +``` + +Si l'imbrication est indisponible, QEMU tourne quand même en émulation +logicielle (TCG) — ça marche mais c'est lent. + +### Pont pour l'accès externe + +Une VM en NAT est isolée ; une VM **pontée** obtient une IP directement sur le +LAN, joignable par n'importe quelle machine. Sur l'hôte KVM, créez un pont +`br0` sur la carte physique (**filaire uniquement** — le Wi-Fi ne se ponte +pas). netplan (Ubuntu serveur) — remplacez `enp3s0` par votre interface : + +```yaml +# /etc/netplan/01-br0.yaml +network: + version: 2 + renderer: networkd + ethernets: + enp3s0: {dhcp4: no, dhcp6: no} + bridges: + br0: + interfaces: [enp3s0] + dhcp4: yes + parameters: {stp: false, forward-delay: 0} +``` + +Appliquez avec filet de sécurité (annulation auto en cas de coupure) et +vérifiez — ou via NetworkManager (Ubuntu bureau) : + +```bash +# netplan +sudo netplan try && sudo netplan apply +ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP + +# NetworkManager (alternative) +nmcli con add type bridge ifname br0 con-name br0 +nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port +nmcli con modify br0 ipv4.method auto +nmcli con down "Wired connection 1" ; nmcli con up br0 +``` + +Rattachez ensuite la VM au pont — **soit à la création** : + +```bash +sudo ./script/qemu/deploy_qemu.py --name --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force +``` + +**soit par édition d'une VM déjà créée** : arrêtez-la, remplacez son bloc +`` (`type='network'` / `` → +`type='bridge'` / ``), puis redémarrez-la : + +```bash +sudo virsh shutdown +sudo virsh edit # mettre l'interface en bridge=br0 +sudo virsh start +sudo virsh domifaddr # nouvelle IP LAN / new LAN IP +``` + +La VM obtient maintenant une IP LAN de votre routeur, joignable par les autres +machines. Depuis Internet, il faut en plus une redirection de port sur votre +routeur (ou un VPN) ; en configuration imbriquée, l'hôte externe doit aussi +rediriger/exposer la VM intermédiaire. \ No newline at end of file diff --git a/script/qemu/README.md b/script/qemu/README.md new file mode 100644 index 0000000..7675cab --- /dev/null +++ b/script/qemu/README.md @@ -0,0 +1,259 @@ + +# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora) + +`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud +image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the +distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the +release with `--version`; run `--list-images` to see the full catalogue with +minimum specs. It: + +1. **Downloads the cloud image by itself** (cached, no double download). +2. Converts it to a dedicated qcow2 working disk and resizes it. +3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init). +4. Runs `virt-install` importing the disk + the seed as a CD-ROM. +5. Waits for the DHCP lease and prints the SSH command. + +## Prerequisites + +- A host with KVM available (bare-metal or nested virtualization enabled). +- `sudo` rights (the deployment writes to `/var/lib/libvirt/images` and drives + libvirt). + +## Installation + +The script **auto-installs the missing pieces it needs**: on first run it +detects your package manager (apt / dnf / pacman / zypper / brew), lists the +missing components (the client tools, **plus the libvirt daemon and the QEMU +system emulator**), asks for confirmation, installs them with `sudo`, then +enables and starts `libvirtd`. Use `-y` to accept automatically or +`--no-install-deps` to disable this behaviour. + +To install everything manually on Ubuntu/Debian (recommended full KVM stack): + +```bash +sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \ + libvirt-daemon-system qemu-system-x86 +sudo systemctl enable --now libvirtd +sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous +``` + +`libvirt-daemon-system` provides the `libvirtd` daemon (and the +`/var/run/libvirt/libvirt-sock` socket) and `qemu-system-x86` the emulator — +without them `virt-install` fails with *"Failed to connect socket to +'/var/run/libvirt/libvirt-sock'"*. The script installs and starts them for +you; this manual command is only needed if you prefer to prepare the host +yourself or run with `--no-install-deps`. + +## Usage + +Simplest form — the image is downloaded automatically (path derived from +`--version`, cached in `/var/lib/libvirt/images/iso`): + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub +``` + +Download (and verify) an image without creating a VM: + +```bash +sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify +``` + +Deploy with an interactive password instead of an SSH key: + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password +``` + +Larger VM (8 GB RAM, 8 vCPU, 120 GB disk), overwriting an existing disk: + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force +``` + +Preview what would happen, without doing anything (no sudo, no download): + +```bash +./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run +``` + +Non-interactive deployment (accept dependency install automatically): + +```bash +sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub -y +``` + +Supported Ubuntu versions: `20.04`, `22.04`, `24.04` (default), `24.10`, +`25.04`, `25.10`. Provide an explicit image path as a positional argument to +override the automatic download location. + +## After deployment + +```bash +virsh list --all +virsh console test-vm # Ctrl+] to quit / pour quitter +virsh domifaddr test-vm --source lease # find the IP / trouver l'IP +ssh erplibre@ +``` + +The default user is `erplibre` (change it with `--user`). + +## Via the TODO menu + +The script is integrated into the interactive assistant. Run `make todo` (or +`./script/todo/todo.py`), then go to **Execute → Deploy → QEMU/KVM - Deploy an +Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, +download an image, list VMs and show a VM IP address — the menu asks for the +parameters and builds the command for you. + +## Main options + +- `--distro` — `ubuntu` (default), `debian` or `fedora`. +- `--version` — release for the distro (default: the distro's default). +- `--list-images` — print all distros/versions and their specs, then exit. +- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`). +- `--download-only` — download the image then exit (no VM). +- `--name` — VM name (required for deployment). +- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory` + and `--disk-size` default to the **minimum required by the chosen version** + (libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian + → 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2. +- `--ssh-key`, `--ask-password`, `--password-hash` — authentication. +- `-y` / `--assume-yes` — auto-accept dependency installation. +- `--no-install-deps` — never auto-install dependencies. +- `--dry-run` — show the commands without executing anything. +- `--force` — overwrite the existing working qcow2 disk. + +Run `./script/qemu/deploy_qemu.py --help` for the full list. + +## Managing VMs + +List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images` +is kept unless you delete it): + +```bash +sudo virsh list --all # toutes les VM et leur état / all VMs and state +sudo virsh shutdown # arrêt propre ACPI / graceful shutdown +sudo virsh destroy # arrêt forcé / force off (pull the plug) +sudo virsh undefine # supprime la définition / remove definition +sudo virsh domifaddr # adresse IP de la VM / VM IP address +``` + +`destroy` only powers the VM off (disk kept); `undefine` removes its +definition. To fully recreate a VM with the same name, `destroy` + `undefine` +it first, or redeploy with `--force`. + +## SSH access from another machine (ProxyJump) + +With the default NAT network the VM is reachable **only from the KVM host**. +To reach it from another machine **without changing the network**, use the +host as a jump host (it already reaches the VM). Get the VM IP with +`sudo virsh domifaddr `, then from the other machine: + +```bash +# Rebond SSH vers la VM / jump through the KVM host +ssh -J user@ erplibre@ + +# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069 +ssh -L 8069::8069 user@ +``` + +To make it permanent, add this to `~/.ssh/config` on the other machine (then +just `ssh myvm`): + +```text +Host myvm + HostName # ex. 192.168.122.50 (reseau NAT) + User erplibre + ProxyJump user@ # IP LAN de l'hote KVM +``` + +This works over Wi-Fi and needs no VM shutdown — the simplest option for +personal access. Prefer a bridge (below) if the VM must be a full server +exposed on the LAN. + +## QEMU inside QEMU (nested) & exposing the VM via a bridge + +If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only +when **nested virtualization** is enabled on the outer/physical host and the +middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host +(the first command must be non-empty): + +```bash +grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible +# Sur l'hote PHYSIQUE / on the PHYSICAL host: +cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1 +cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1 +``` + +To enable nesting on the physical host (Intel shown; use `kvm_amd` on AMD), +then recreate the middle VM with `host-passthrough`: + +```bash +echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot +``` + +If nesting is unavailable, QEMU still runs via software emulation (TCG) — it +works but is slow. + +### Bridge for external access + +A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN, +reachable by any machine. On the KVM host, create a bridge `br0` over the +physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu +server) — replace `enp3s0` with your interface: + +```yaml +# /etc/netplan/01-br0.yaml +network: + version: 2 + renderer: networkd + ethernets: + enp3s0: {dhcp4: no, dhcp6: no} + bridges: + br0: + interfaces: [enp3s0] + dhcp4: yes + parameters: {stp: false, forward-delay: 0} +``` + +Apply safely (auto-reverts if you lose the connection) and verify — or use +NetworkManager (Ubuntu desktop): + +```bash +# netplan +sudo netplan try && sudo netplan apply +ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP + +# NetworkManager (alternative) +nmcli con add type bridge ifname br0 con-name br0 +nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port +nmcli con modify br0 ipv4.method auto +nmcli con down "Wired connection 1" ; nmcli con up br0 +``` + +Then attach the VM to the bridge — **either at creation**: + +```bash +sudo ./script/qemu/deploy_qemu.py --name --version 24.04 \ + --ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force +``` + +**or by editing a VM already created**: stop it, replace its `` +block (`type='network'` / `` → `type='bridge'` / +``), then start it again: + +```bash +sudo virsh shutdown +sudo virsh edit # mettre l'interface en bridge=br0 +sudo virsh start +sudo virsh domifaddr # nouvelle IP LAN / new LAN IP +``` + +The VM now gets a LAN IP from your router, reachable by other machines. From +the Internet you additionally need a port-forward on your router (or a VPN); +in a nested setup the outer host must also forward/expose the middle VM. diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py new file mode 100755 index 0000000..cd0f502 --- /dev/null +++ b/script/qemu/deploy_qemu.py @@ -0,0 +1,2024 @@ +#!/usr/bin/env python3 +"""Déploiement rapide de VM Linux (Ubuntu/Debian/Fedora) via cloud-image + qemu-img + cloud-init + virt-install. + +Choisissez la distribution avec --distro (ubuntu par défaut, debian, fedora) +et la version avec --version. « --list-images » affiche tout le catalogue et +les specs minimales. Reprend le workflow des notes : + 1. Télécharge l'image cloud (si absente du cache -> pas de double téléchargement). + 2. Convertit/copie l'image en un qcow2 de travail dédié à la VM. + 3. Redimensionne le disque virtuel. + 4. Génère user-data / meta-data et construit le seed.iso (cidata). + 5. Lance virt-install en important le disque + le seed en CD-ROM. + +Exemples +-------- + # Le plus simple : image téléchargée automatiquement (chemin déduit de + # --distro/--version, mis en cache dans /var/lib/libvirt/images/iso) et + # outils manquants installés après confirmation. + sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \\ + --ssh-key ~/.ssh/id_ed25519.pub + + # Debian 12 / Fedora 42 (mêmes options, --distro change la source d'image) + sudo ./script/qemu/deploy_qemu.py --distro debian --version 12 \\ + --name deb12 --ssh-key ~/.ssh/id_ed25519.pub + sudo ./script/qemu/deploy_qemu.py --distro fedora --version 42 \\ + --name fed42 --ssh-key ~/.ssh/id_ed25519.pub + + # Voir tout le catalogue (distros, versions, specs minimales) + ./script/qemu/deploy_qemu.py --list-images + + # Télécharger (et vérifier) une image, sans créer de VM + sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify + + # Déploiement minimal en fournissant explicitement le chemin d'image + sudo ./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img \\ + --name test-vm --ssh-key ~/.ssh/id_ed25519.pub + + # Reproduction fidèle des notes (8 Go RAM, 8 vCPU, mot de passe demandé) + sudo ./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img \\ + --name test-vm --memory 8192 --vcpus 8 --disk-size 120G --ask-password + + # Voir ce qui serait fait, sans rien exécuter + ./qemu-deploy.py /var/lib/libvirt/images/iso/noble.img --name test-vm --dry-run + + sudo ./script/qemu/deploy_qemu.py /var/lib/libvirt/images/iso/noble.img --name test-vm --memory 8192 --vcpus 8 --disk-size 120G --ask-password --force +""" +from __future__ import annotations + +import argparse +import getpass +import grp +import hashlib +import os +import re +import shutil +import socket +import subprocess +import sys +import tempfile +import textwrap +import time +import urllib.error +import urllib.request +import warnings +from pathlib import Path + +# --------------------------------------------------------------------------- # +# Registre des distributions : version -> (code, --osinfo, RAM min Mo, disque). +# Le « code » est le nom de code (Ubuntu/Debian) ou le numéro de release +# (Fedora). RAM/disque minimaux proviennent de libosinfo (osinfo-db) : ce sont +# les seuils sous lesquels virt-install avertit. Ils servent de valeurs PAR +# DÉFAUT — la VM démarre au plus juste sans gaspiller la RAM de l'hôte. +# --codename / --osinfo / --memory / --disk-size surchargent au besoin. +# --------------------------------------------------------------------------- # +# NB : les versions intermédiaires (non-LTS) sont RETIRÉES du miroir +# cloud-images une fois EOL (leur /current/ renvoie 404). On ne garde donc +# que les LTS + les intermédiaires encore publiées. À réviser au fil du temps. +# Disque MINIMUM = 20G partout : un ERPLibre + Odoo installé occupe ~11G, et +# l'installation (caches pip, sync repo, sources Odoo) en consomme plus en +# transitoire. Un VM à 10G tombait « Poetry installation error » (disque +# plein). Le qcow2 est CREUX (sparse) : une taille virtuelle plus grande ne +# consomme rien tant qu'elle n'est pas remplie -> 20G sans surcoût réel. +UBUNTU_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "20.04": ("focal", "ubuntu20.04", 2048, "20G"), + "22.04": ("jammy", "ubuntu22.04", 2048, "20G"), + "24.04": ("noble", "ubuntu24.04", 3072, "20G"), + "25.10": ("questing", "ubuntu25.10", 3072, "20G"), + "26.04": ("resolute", "ubuntu26.04", 3072, "20G"), +} +DEBIAN_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "11": ("bullseye", "debian11", 1024, "20G"), + "12": ("bookworm", "debian12", 1024, "20G"), + "13": ("trixie", "debian13", 1024, "20G"), +} +FEDORA_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "41": ("41", "fedora41", 2048, "20G"), + "42": ("42", "fedora42", 2048, "20G"), + "43": ("43", "fedora43", 2048, "20G"), + "44": ("44", "fedora44", 2048, "20G"), +} +# Arch est en rolling release : une seule « version » (latest). +ARCH_VERSIONS: dict[str, tuple[str, str, int, str]] = { + "latest": ("latest", "archlinux", 1024, "20G"), +} + +# distro -> (table des versions, version par défaut). +DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = { + "ubuntu": (UBUNTU_VERSIONS, "24.04"), + "debian": (DEBIAN_VERSIONS, "12"), + "fedora": (FEDORA_VERSIONS, "42"), + "arch": (ARCH_VERSIONS, "latest"), +} + +# Traduction de l'arch générique (amd64/arm64) vers le nom propre à la distro. +# s390x s'écrit « s390x » partout (identité), donc aucune entrée n'est requise. +ARCH_ALIASES: dict[str, dict[str, str]] = { + "fedora": {"amd64": "x86_64", "arm64": "aarch64"}, + "arch": {"amd64": "x86_64", "arm64": "aarch64"}, +} + +# Architectures non-x86 nécessitant une machine/un amorçage spécifiques (voir +# virt_install). Émulées en TCG (pas de KVM), donc LENTES, quand elles +# diffèrent de l'architecture de l'hôte. +NON_X86_ARCHES: tuple[str, ...] = ("arm64", "s390x") + +# Distros publiant des images cloud par architecture (vérifié juillet 2026) : +# - s390x (IBM Z) : Ubuntu seulement (Debian/Fedora : 404 ; Arch : x86/arm). +# - arm64/aarch64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud officielle +# aarch64 sur geo.mirror.pkgbuild.com). +S390X_DISTROS: tuple[str, ...] = ("ubuntu",) +ARM64_DISTROS: tuple[str, ...] = ("ubuntu", "debian", "fedora") +# arch générique -> distros la publiant (pour valider --arch tôt). +ARCH_DISTRO_SUPPORT: dict[str, tuple[str, ...]] = { + "s390x": S390X_DISTROS, + "arm64": ARM64_DISTROS, +} + + +def host_arch() -> str: + """Architecture de l'hôte en jeton générique (amd64/arm64/s390x).""" + try: + machine = os.uname().machine + except (AttributeError, OSError): + machine = "" + return { + "x86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + "s390x": "s390x", + }.get(machine, "amd64") + + +ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest" + +CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" +# Debian : cloud.debian.org est un redirecteur qui, selon le réseau, peut +# renvoyer vers un miroir injoignable. On essaie donc plusieurs bases dans +# l'ordre (le premier miroir qui répond gagne). Les deux acc.umu.se sont des +# miroirs Debian officiels de repli. +DEBIAN_CLOUD_BASES: tuple[str, ...] = ( + "https://cloud.debian.org/images/cloud", + "https://gemmei.ftp.acc.umu.se/cdimage/cloud", + "https://laotzu.ftp.acc.umu.se/cdimage/cloud", +) +FEDORA_BASE = "https://download.fedoraproject.org/pub/fedora/linux/releases" +# Serveur MAÎTRE (pas de redirection MirrorManager) : repli fiable quand le +# redirecteur envoie sur un miroir incomplet (fréquent en déploiement +# PARALLÈLE : chaque requête peut tomber sur un miroir différent/désynchronisé). +FEDORA_BASE_MASTER = "https://dl.fedoraproject.org/pub/fedora/linux/releases" + +# Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir / +# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute +# façon sous sudo (virt-install). Surchargez avec --image-dir au besoin. +DEFAULT_IMAGE_DIR = Path("/var/lib/libvirt/images/iso") + +# Emplacements de la base osinfo-db (détection d'un --osinfo connu). +OSINFO_DB_DIRS: tuple[str, ...] = ( + "/usr/share/osinfo/os", + "/usr/local/share/osinfo/os", + os.path.expanduser("~/.local/share/osinfo/os"), +) + + +def distro_arch(distro: str, arch: str) -> str: + """Nom d'architecture attendu par la distro (Fedora utilise x86_64).""" + return ARCH_ALIASES.get(distro, {}).get(arch, arch) + + +def image_url(distro: str, code: str, arch: str, version: str) -> str: + """URL directe (primaire) de l'image cloud (Ubuntu/Debian).""" + return image_candidates(distro, code, arch, version, dry_run=True)[0] + + +def image_candidates( + distro: str, code: str, arch: str, version: str, dry_run: bool = False +) -> list[str]: + """Liste ordonnée d'URL candidates pour l'image cloud. Plusieurs miroirs + pour Debian ; une seule URL pour Ubuntu/Fedora.""" + a = distro_arch(distro, arch) + if distro == "ubuntu": + # /releases//release/ : présent pour toutes les versions + # publiées (redirige vers l'image datée), contrairement à /current/ + # qui disparaît quand une version intermédiaire devient EOL. + return [ + f"{CLOUD_IMG_BASE}/releases/{version}/release/" + f"ubuntu-{version}-server-cloudimg-{a}.img" + ] + if distro == "debian": + return [ + f"{base}/{code}/latest/debian-{version}-genericcloud-{a}.qcow2" + for base in DEBIAN_CLOUD_BASES + ] + if distro == "fedora": + return [resolve_fedora_url(version, arch, dry_run)] + if distro == "arch": + # Rolling release : image « latest » officielle (cloud-init inclus). + return [f"{ARCH_CLOUD_BASE}/Arch-Linux-{a}-cloudimg.qcow2"] + raise ValueError(f"URL indisponible pour la distro {distro!r}") + + +def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str: + """Résout l'URL du qcow2 « Fedora Cloud Base Generic » depuis l'index + HTML des releases (Fedora ne publie pas de lien « latest »). ROBUSTE : + plusieurs tentatives sur le redirecteur puis repli sur le serveur maître + (dl.fedoraproject.org) — sinon, en déploiement parallèle, une requête + tombée sur un miroir incomplet faisait échouer la VM (« image introuvable ») + alors que l'image existe bel et bien.""" + a = distro_arch("fedora", arch) + pattern = re.compile( + rf"Fedora-Cloud-Base-Generic-{version}-[0-9.]+\.{a}\.qcow2" + ) + if dry_run: + index = f"{FEDORA_BASE}/{version}/Cloud/{a}/images/" + return index + f"Fedora-Cloud-Base-Generic-{version}-.{a}.qcow2" + # On essaie le redirecteur (2 fois : chaque requête peut viser un miroir + # différent) puis le serveur maître (toujours complet). + bases = [FEDORA_BASE, FEDORA_BASE, FEDORA_BASE_MASTER] + last_err = "" + for base in bases: + index = f"{base}/{version}/Cloud/{a}/images/" + try: + with urllib.request.urlopen( + index, timeout=30 + ) as resp: # noqa: S310 + html = resp.read().decode(errors="replace") + except Exception as exc: # pragma: no cover - dépend du réseau + last_err = str(exc) + continue + names = sorted(set(pattern.findall(html))) + if names: + return index + names[-1] + sys.exit( + "Aucune image « Fedora-Cloud-Base-Generic » trouvée pour " + f"Fedora {version} ({a}) après plusieurs miroirs" + + (f" (dernière erreur : {last_err})" if last_err else "") + ) + + +def default_image_name(distro: str, code: str, arch: str, version: str) -> str: + """Nom de fichier local pour le cache d'image.""" + a = distro_arch(distro, arch) + if distro == "ubuntu": + return f"ubuntu-{version}-server-cloudimg-{a}.img" + if distro == "debian": + return f"debian-{version}-genericcloud-{a}.qcow2" + if distro == "arch": + return f"arch-linux-{a}-cloudimg.qcow2" + return f"fedora-cloud-{version}-{a}.qcow2" + + +def osinfo_known(short_id: str) -> bool: + """Vrai si l'id osinfo (ex. « ubuntu26.04 ») figure dans osinfo-db.""" + needle = f"{short_id}" + for base in OSINFO_DB_DIRS: + if not os.path.isdir(base): + continue + for root, _dirs, files in os.walk(base): + for fn in files: + if not fn.endswith(".xml"): + continue + try: + with open( + os.path.join(root, fn), + encoding="utf-8", + errors="replace", + ) as fh: + if needle in fh.read(): + return True + except OSError: + continue + return False + + +def osinfo_arg(osinfo: str, distro: str = "") -> str: + """Valeur --osinfo résiliente à un osinfo-db périmé. Si l'id est connu on + l'utilise ; sinon on retombe sur le DERNIER osinfo connu de la même distro + (meilleures perfs que « generic », pas d'avertissement) ; en dernier + recours, détection best-effort. Utile pour une version plus récente que la + base locale (ex. ubuntu26.04, fedora43+).""" + if "=" in osinfo or "," in osinfo: + return osinfo # forme avancée déjà fournie par l'utilisateur + if osinfo_known(osinfo): + return osinfo + # Repli 1 : dernier osinfo connu de la même distro (ordre de la table). + versions = DISTROS.get(distro, ({}, ""))[0] + known = [v[1] for v in versions.values() if osinfo_known(v[1])] + if known: + fallback = known[-1] + print( + f" osinfo « {osinfo} » inconnu (osinfo-db) — repli sur " + f"« {fallback} » (proche). « sudo apt upgrade osinfo-db » pour " + "l'entrée exacte." + ) + return fallback + # Repli 2 : détection best-effort (évite l'échec, peut donner generic). + print( + f" osinfo « {osinfo} » inconnu de la base locale (osinfo-db) — repli " + "sur la détection auto (detect=on,require=off).\n" + " Astuce : « sudo apt upgrade osinfo-db » pour des métadonnées à jour." + ) + return "detect=on,require=off" + + +def list_images() -> None: + """Affiche toutes les distros/versions et leurs specs (--list-images).""" + print("Images cloud disponibles (distro / version / specs) :\n") + for distro, (versions, default) in DISTROS.items(): + print(f" {distro} (défaut : {default})") + for v, (code, osinfo, ram, disk) in versions.items(): + star = "*" if v == default else " " + note = ( + "" + if osinfo_known(osinfo) + else " [osinfo local absent → auto]" + ) + print( + f" {star} {v:<7} {code:<10} osinfo={osinfo:<12} " + f"RAM≥{ram}Mo disque≥{disk}{note}" + ) + print() + print("Exemple : deploy_qemu.py --distro debian --version 12 --name vm1") + + +# --------------------------------------------------------------------------- # +# Utilitaires d'exécution +# --------------------------------------------------------------------------- # +class Runner: + """Exécute (ou affiche, en dry-run) les commandes, avec sudo au besoin.""" + + def __init__(self, use_sudo: bool, dry_run: bool) -> None: + self.use_sudo = use_sudo + self.dry_run = dry_run + + def run( + self, cmd: list[str], *, privileged: bool = False, check: bool = True + ) -> None: + if privileged and self.use_sudo: + cmd = ["sudo", *cmd] + printable = " ".join(cmd) + if self.dry_run: + print(f" [dry-run] {printable}") + return + print(f" $ {printable}") + try: + subprocess.run(cmd, check=check) + except subprocess.CalledProcessError as exc: + # Sortie propre plutôt qu'une trace Python illisible. + sys.exit( + f"\nÉchec de la commande (code {exc.returncode}) :\n" + f" {printable}" + ) + + +def need_tool(name: str) -> None: + if shutil.which(name) is None: + sys.exit(f"Erreur : outil requis introuvable dans le PATH : {name!r}") + + +# --------------------------------------------------------------------------- # +# Détection et installation des dépendances système +# --------------------------------------------------------------------------- # +# Outils indispensables au déploiement complet (le mode --download-only n'en +# requiert aucun). +# URI libvirt visée par TOUS les clients (virsh, virt-install). À ne jamais +# laisser implicite : pour un utilisateur non root, libvirt choisit +# « qemu:///session », où le réseau « default » N'EXISTE PAS — virt-install +# échoue alors sur « --network network=default » de façon incompréhensible. +# Appartenir au groupe libvirt donne le DROIT d'accéder à qemu:///system mais +# ne change PAS l'URI par défaut : il faut l'imposer. +LIBVIRT_URI = "qemu:///system" + +REQUIRED_TOOLS: tuple[str, ...] = ("qemu-img", "virt-install", "virsh") +# Pour construire le seed cloud-init il faut AU MOINS un de ces outils. +SEED_TOOLS: tuple[str, ...] = ("cloud-localds", "genisoimage") + +# outil -> nom de paquet, selon le gestionnaire de paquets détecté. +TOOL_PACKAGES: dict[str, dict[str, str]] = { + "apt": { + "qemu-img": "qemu-utils", + "virt-install": "virtinst", + "virsh": "libvirt-clients", + "cloud-localds": "cloud-image-utils", + "genisoimage": "genisoimage", + "openssl": "openssl", + }, + "dnf": { + "qemu-img": "qemu-img", + "virt-install": "virt-install", + "virsh": "libvirt-client", + "cloud-localds": "cloud-utils", + "genisoimage": "genisoimage", + "openssl": "openssl", + }, + "pacman": { + "qemu-img": "qemu-img", + "virt-install": "virt-install", + "virsh": "libvirt", + "cloud-localds": "cloud-image-utils", + "genisoimage": "cdrtools", + "openssl": "openssl", + }, + "zypper": { + "qemu-img": "qemu-tools", + "virt-install": "virt-install", + "virsh": "libvirt-client", + "cloud-localds": "cloud-utils-cloud-localds", + "genisoimage": "genisoimage", + "openssl": "openssl", + }, + "brew": { + "qemu-img": "qemu", + "virt-install": "virt-manager", + "virsh": "libvirt", + "cloud-localds": "cdrtools", + "genisoimage": "cdrtools", + "openssl": "openssl", + }, +} + +# Paquets fournissant le démon libvirt + l'émulateur QEMU système. Ils sont +# INDISPENSABLES pour exécuter la VM : virsh/virt-install (clients) seuls ne +# créent pas le socket /var/run/libvirt/libvirt-sock. +# On inclut le firmware UEFI (OVMF/edk2) : le boot par défaut est UEFI +# (indispensable pour Debian 13+ ; les images récentes n'ont plus de BIOS). +DAEMON_PACKAGES: dict[str, list[str]] = { + "apt": ["libvirt-daemon-system", "qemu-system-x86", "ovmf"], + "dnf": ["libvirt-daemon-kvm", "qemu-kvm", "edk2-ovmf"], + "pacman": ["libvirt", "qemu-desktop", "dnsmasq", "edk2-ovmf"], + "zypper": [ + "libvirt-daemon", + "libvirt-daemon-qemu", + "qemu-kvm", + "qemu-ovmf-x86_64", + ], + "brew": [], +} + +# Émulateurs QEMU système pour architectures non-x86, requis pour --arch +# sur un hôte d'architecture différente (émulation TCG). Par arch puis +# par gestionnaire de paquets. NB apt : qemu-system-misc ne contient PAS s390x +# (alpha/avr/… seulement) -> paquet dédié qemu-system-s390x ; qemu-system-arm +# fournit qemu-system-aarch64 ; l'arm64 exige aussi le firmware UEFI AAVMF. +EMULATOR_PACKAGES: dict[str, dict[str, list[str]]] = { + "s390x": { + "apt": ["qemu-system-s390x"], + "dnf": ["qemu-system-s390x"], + "pacman": ["qemu-emulators-full"], + "zypper": ["qemu-s390"], + "brew": [], + }, + "arm64": { + "apt": ["qemu-system-arm", "qemu-efi-aarch64"], + "dnf": ["qemu-system-aarch64", "edk2-aarch64"], + "pacman": ["qemu-emulators-full", "edk2-aarch64"], + "zypper": ["qemu-arm", "qemu-uefi-aarch64"], + "brew": [], + }, +} + +# Binaire émulateur par arch (détection de présence). +EMULATOR_BINARY: dict[str, str] = { + "s390x": "qemu-system-s390x", + "arm64": "qemu-system-aarch64", +} + +# Firmwares UEFI AAVMF possibles (arm64) : au moins un doit exister. +AARCH64_FIRMWARE_PATHS: tuple[str, ...] = ( + "/usr/share/AAVMF/AAVMF_CODE.fd", + "/usr/share/AAVMF/AAVMF_CODE.no-secboot.fd", + "/usr/share/edk2/aarch64/QEMU_EFI.fd", + "/usr/share/edk2/aarch64/QEMU_EFI-silent.fd", + "/usr/share/qemu-efi-aarch64/QEMU_EFI.fd", + "/usr/share/edk2-armvirt/aarch64/QEMU_EFI.fd", +) + +# Gestionnaires de paquets, dans l'ordre de préférence : +# (clé TOOL_PACKAGES, binaire à détecter, commande d'installation, sudo, refresh) +PKG_MANAGERS: tuple[ + tuple[str, str, list[str], bool, list[str] | None], ... +] = ( + ( + "apt", + "apt-get", + ["apt-get", "install", "-y"], + True, + ["apt-get", "update"], + ), + ("dnf", "dnf", ["dnf", "install", "-y"], True, None), + ( + "pacman", + "pacman", + ["pacman", "-S", "--needed", "--noconfirm"], + True, + None, + ), + ( + "zypper", + "zypper", + ["zypper", "--non-interactive", "install"], + True, + None, + ), + ("brew", "brew", ["brew", "install"], False, None), +) + + +def detect_pkg_manager() -> ( + tuple[str, list[str], bool, list[str] | None] | None +): + """Retourne (clé, cmd d'install, use_sudo, cmd de refresh) ou None.""" + for key, binary, install_cmd, use_sudo, refresh in PKG_MANAGERS: + if shutil.which(binary): + return key, install_cmd, use_sudo, refresh + return None + + +def missing_tools() -> list[str]: + """Binaires requis absents du PATH (dont un outil de seed si aucun présent).""" + missing = [t for t in REQUIRED_TOOLS if shutil.which(t) is None] + if not any(shutil.which(t) for t in SEED_TOOLS): + missing.append(SEED_TOOLS[0]) # on installera cloud-localds + return missing + + +def prompt_yes_no(question: str, default: bool = True) -> bool: + """Question oui/non. Lit /dev/tty pour rester visible même si stdout est + redirigé (cas d'un lancement depuis le menu todo).""" + suffix = " [O/n] " if default else " [o/N] " + prompt = question + suffix + try: + with open("/dev/tty", "r+") as tty: + tty.write(prompt) + tty.flush() + ans = tty.readline().strip().lower() + except OSError: + try: + ans = input(prompt).strip().lower() + except EOFError: + return default + if not ans: + return default + return ans in ("o", "oui", "y", "yes") + + +def daemon_missing() -> bool: + """Vrai si le démon libvirt OU l'émulateur QEMU système est absent.""" + libvirtd = shutil.which("libvirtd") or any( + os.path.exists(p) for p in ("/usr/sbin/libvirtd", "/usr/bin/libvirtd") + ) + emulator = ( + shutil.which("qemu-system-x86_64") + or shutil.which("qemu-system-x86") + or shutil.which("kvm") + ) + return not (libvirtd and emulator) + + +def libvirt_ready(use_sudo: bool) -> bool: + """Vrai si l'hyperviseur qemu:///system répond (démon libvirt démarré).""" + if shutil.which("virsh") is None: + return False + cmd = (["sudo"] if use_sudo else []) + [ + "virsh", + "-c", + "qemu:///system", + "version", + ] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + return res.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + +def ensure_libvirt_service(runner: Runner) -> None: + """S'assure que le démon libvirt tourne ; sinon le démarre puis vérifie.""" + if libvirt_ready(runner.use_sudo): + return + if shutil.which("systemctl"): + print( + " Démarrage du démon libvirt" + " (systemctl enable --now libvirtd)…" + ) + runner.run( + ["systemctl", "enable", "--now", "libvirtd"], + privileged=True, + check=False, + ) + runner.run( + ["systemctl", "start", "libvirtd.socket"], + privileged=True, + check=False, + ) + # Le socket peut mettre un instant à apparaître. + for _ in range(10): + if libvirt_ready(runner.use_sudo): + return + time.sleep(1) + sys.exit( + "Erreur : impossible de se connecter à l'hyperviseur libvirt" + " (/var/run/libvirt/libvirt-sock).\n" + " Le démon libvirtd n'est pas démarré. Essayez :\n" + " sudo systemctl enable --now libvirtd\n" + " puis vérifiez : sudo virsh -c qemu:///system version" + ) + + +def invoking_user() -> str: + """Utilisateur réel derrière l'appel, même sous sudo.""" + return os.environ.get("SUDO_USER") or getpass.getuser() + + +def ensure_libvirt_group(runner: Runner) -> bool: + """Ajoute l'utilisateur au groupe libvirt. Renvoie True s'il l'était déjà. + + SANS ce groupe, les clients libvirt d'un utilisateur non root retombent sur + « qemu:///session », où le réseau « default » N'EXISTE PAS : virt-install + échoue alors sur « --network network=default » alors que tous les paquets + sont pourtant installés. C'est le trou qui manquait au profil + « ERPLibre Déploiement » : il installait QEMU sans jamais rendre + qemu:///system accessible. + """ + user = invoking_user() + if user == "root": + return True + lst_group = [] + for group in ("libvirt", "kvm"): + try: + grp.getgrnam(group) + except KeyError: + continue # groupe absent sur cette distro + try: + if user in grp.getgrnam(group).gr_mem: + continue + except KeyError: + pass + lst_group.append(group) + if not lst_group: + print(f" Utilisateur « {user} » déjà dans les groupes libvirt/kvm.") + return True + for group in lst_group: + print(f" Ajout de « {user} » au groupe « {group} »…") + runner.run( + ["usermod", "-aG", group, user], privileged=True, check=False + ) + return False + + +def kernel_modules_stale() -> str: + """Renvoie un message si les modules du noyau EN COURS ont disparu. + + Sur une distro roulante, « make install_os » met le noyau à jour et le + gestionnaire de paquets SUPPRIME /lib/modules/. Tant qu'on + n'a pas redémarré, « modprobe bridge » échoue et libvirt ne peut pas créer + virbr0 : « Unable to create bridge virbr0: Package not installed ». Aucun + paquet ne corrige ça, seul un redémarrage le fait — d'où ce diagnostic. + """ + if not shutil.which("uname"): + return "" + running = os.uname().release + path_module = f"/lib/modules/{running}" + if os.path.isdir(path_module): + return "" + return ( + f"Le noyau en cours ({running}) n'a plus ses modules " + f"({path_module} absent) : il a été mis à jour depuis le démarrage.\n" + " libvirt ne pourra pas créer le pont virbr0 tant que la machine\n" + " n'aura pas REDÉMARRÉ. Redémarrez, puis relancez --setup-host." + ) + + +def ensure_ssh_key(runner: Runner) -> None: + """Garantit que l'utilisateur possède une clé publique SSH. + + Sans clé, cloud-init ne peut en injecter aucune dans la VM créée : elle + démarre sans accès SSH et l'orchestrateur ne peut plus vérifier son état. + On en génère donc une (ed25519, sans passphrase) quand il n'y en a pas. + + La clé doit appartenir à l'UTILISATEUR, pas à root : sous sudo, « ~ » + désigne /root et la clé y serait inutilisable. + """ + user = invoking_user() + home = os.path.expanduser(f"~{user}") + ssh_dir = os.path.join(home, ".ssh") + for name in ("id_ed25519.pub", "id_rsa.pub"): + path_pub = os.path.join(ssh_dir, name) + if os.path.exists(path_pub): + print(f" Clé SSH déjà présente : {path_pub}") + return + + path_key = os.path.join(ssh_dir, "id_ed25519") + print(f" Génération d'une clé SSH ed25519 pour « {user} »…") + # sudo -u : on exécute EN TANT QUE l'utilisateur, pour que la clé et le + # répertoire lui appartiennent même quand --setup-host tourne sous sudo. + prefix = ( + ["sudo", "-u", user] if os.geteuid() == 0 and user != "root" else [] + ) + runner.run(prefix + ["mkdir", "-p", "-m", "700", ssh_dir], check=False) + runner.run( + prefix + + [ + "ssh-keygen", + "-t", + "ed25519", + "-N", + "", + "-q", + "-f", + path_key, + "-C", + f"erplibre-deploy@{socket.gethostname()}", + ], + check=False, + ) + if not runner.dry_run and os.path.exists(f"{path_key}.pub"): + print(f" Clé créée : {path_key}.pub") + + +def schedule_reboot(runner: Runner) -> None: + """Programme un redémarrage DIFFÉRÉ et détaché de la session courante. + + Un « systemctl reboot » immédiat tuerait le SSH de l'installation, et + l'orchestrateur compterait la VM en échec alors que tout s'est bien passé. + On laisse donc quelques secondes pour que la commande distante rende la + main proprement. + """ + if shutil.which("systemd-run"): + runner.run( + ["systemd-run", "--on-active=5", "systemctl", "reboot"], + privileged=True, + check=False, + ) + return + runner.run(["shutdown", "-r", "+1"], privileged=True, check=False) + + +def setup_host( + runner: Runner, + assume_yes: bool, + no_install: bool, + reboot_if_needed: bool = False, +) -> None: + """Prépare l'hôte à faire tourner des VM : paquets, démon, groupe, réseau. + + Point d'entrée unique du profil d'installation « ERPLibre Déploiement ». + Il réutilise les mêmes fonctions que le déploiement, donc les noms de + paquets restent définis à UN SEUL endroit (TOOL_PACKAGES / + DAEMON_PACKAGES) et restent valides pour apt, dnf, pacman, zypper et brew. + """ + print("\n== Préparation de l'hôte pour QEMU/libvirt ==") + # ensure_tools installe les clients ET, si le démon manque, les paquets de + # DAEMON_PACKAGES (qemu système + libvirt + firmware UEFI), puis démarre le + # service. ensure_emulator ne sert QU'À l'émulation croisée (arm64 sur x86) + # et n'a pas de clé pour l'architecture hôte. + ensure_tools(runner, assume_yes, no_install, force_daemon=True) + ensure_libvirt_service(runner) + already_in_group = ensure_libvirt_group(runner) + ensure_ssh_key(runner) + # Diagnostiqué AVANT le réseau : sans les modules du noyau en cours, le + # pont virbr0 est impossible et l'erreur de virsh est indéchiffrable. + stale = kernel_modules_stale() + if stale: + print(f"\n⚠ {stale}") + ensure_network("default", runner) + + if runner.dry_run: + print("\n[dry-run] Rien n'a été modifié, vérification ignorée.") + return + + print("\n== Vérification ==") + ok = libvirt_ready(runner.use_sudo) + print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}") + active, autostart = network_state("default", runner.use_sudo) + print( + f" réseau libvirt « default » : " + f"{'actif' if active else 'INACTIF'}" + f" / {'autostart' if autostart else 'PAS autostart'}" + ) + if not active and stale: + # Le réseau est déjà « autostart » : après le redémarrage, libvirt le + # monte tout seul avec les modules du nouveau noyau. Un seul reboot + # suffit donc à rendre l'hôte utilisable, sans repasser par ici. + if reboot_if_needed: + print( + "\n↻ Redémarrage programmé (dans quelques secondes) : c'est la" + " SEULE façon de retrouver les modules du noyau.\n" + " Au retour, le réseau « default » démarrera seul" + " (autostart déjà actif)." + ) + schedule_reboot(runner) + return + sys.exit(f"Erreur : l'hôte n'est pas prêt.\n {stale}") + + if not (ok and active): + sys.exit( + "Erreur : l'hôte n'est pas prêt.\n" + + (f" {stale}\n" if stale else "") + + " Sinon vérifiez :\n" + " sudo systemctl enable --now libvirtd\n" + " sudo virsh -c qemu:///system net-start default" + ) + if not already_in_group: + print( + f"\n⚠ « {invoking_user()} » vient d'être ajouté au groupe libvirt." + " Les groupes ne s'appliquent qu'aux NOUVELLES sessions :" + " reconnectez-vous (ou « newgrp libvirt »), sinon virt-install" + " continuera d'utiliser qemu:///session et le réseau « default »" + " restera introuvable." + ) + print("\nHôte prêt.") + + +def ensure_tools( + runner: Runner, + assume_yes: bool, + no_install: bool, + force_daemon: bool = False, +) -> None: + """Vérifie outils, démon libvirt et émulateur ; installe/démarre ce qui + manque, puis vérifie la connexion à l'hyperviseur. + + `force_daemon` réinstalle DAEMON_PACKAGES même quand le démon répond déjà. + Vécu sur Arch : libvirt était présent (posé par un ancien one-liner qui ne + listait pas dnsmasq), donc daemon_missing() renvoyait False et dnsmasq + n'était jamais installé -> « Failed to start network default ». Les + gestionnaires de paquets ignorent ce qui est déjà là : c'est bon marché. + """ + missing = missing_tools() + need_daemon = daemon_missing() or force_daemon + + # Tout est là et l'hyperviseur répond : rien à faire. + if not missing and not need_daemon and libvirt_ready(runner.use_sudo): + return + + pm = detect_pkg_manager() + if pm is None: + sys.exit( + " Gestionnaire de paquets non reconnu " + "(apt/dnf/pacman/zypper/brew).\n" + " Installez manuellement : " + ", ".join(missing) + ) + pm_key, install_cmd, use_sudo, refresh = pm + + pkg_map = TOOL_PACKAGES[pm_key] + packages = [pkg_map.get(t, t) for t in missing] + if need_daemon: + packages += DAEMON_PACKAGES.get(pm_key, []) + packages = list(dict.fromkeys(packages)) # dédoublonne, ordre gardé + + if packages: + label = list(missing) + if need_daemon: + label.append("démon libvirt / émulateur QEMU") + print(" Composants manquants : " + ", ".join(label)) + + if no_install: + sys.exit( + " Installation automatique désactivée (--no-install-deps).\n" + " Installez manuellement : " + " ".join(packages) + ) + + full_cmd = install_cmd + packages + printable = " ".join(full_cmd) + if use_sudo and runner.use_sudo: + printable = "sudo " + printable + + print(f" Gestionnaire détecté : {pm_key}") + print(f" Commande d'installation : {printable}") + + if not assume_yes and not prompt_yes_no( + " Installer ces dépendances maintenant ?" + ): + sys.exit( + " Installation refusée.\n Commande manuelle : " + printable + ) + + if refresh: + runner.run(refresh, privileged=use_sudo, check=False) + runner.run(full_cmd, privileged=use_sudo) + + still = missing_tools() + # Repli : si seul l'outil de seed manque encore (le nom du paquet + # cloud-localds varie selon la distro), tente genisoimage. + if still == [SEED_TOOLS[0]]: + alt = pkg_map.get("genisoimage", "genisoimage") + print(f" Repli sur {alt} (autre outil de seed cloud-init)…") + runner.run(install_cmd + [alt], privileged=use_sudo, check=False) + still = missing_tools() + if still: + sys.exit( + " Outils toujours absents après installation : " + + ", ".join(still) + + f"\n Essayez manuellement : {printable}" + ) + print(" Dépendances installées avec succès.") + + # Démarre le démon (si nécessaire) et vérifie l'accès à l'hyperviseur. + ensure_libvirt_service(runner) + + +def emulator_ready(arch: str) -> bool: + """Vrai si l'émulateur (et, pour arm64, un firmware UEFI AAVMF) est là.""" + if shutil.which(EMULATOR_BINARY[arch]) is None: + return False + if arch == "arm64": + return any(os.path.exists(p) for p in AARCH64_FIRMWARE_PATHS) + return True + + +def ensure_emulator( + arch: str, runner: Runner, assume_yes: bool, no_install: bool +) -> None: + """Vérifie l'émulateur QEMU système pour `arch` (et le firmware UEFI pour + arm64), requis quand on émule une architecture différente de l'hôte ; + l'installe au besoin via le gestionnaire de paquets.""" + binary = EMULATOR_BINARY[arch] + if emulator_ready(arch): + return + pm = detect_pkg_manager() + if pm is None: + sys.exit( + f" Émulateur {arch} introuvable ({binary}) et gestionnaire de " + "paquets non reconnu.\n Installez-le manuellement." + ) + pm_key, install_cmd, use_sudo, refresh = pm + packages = EMULATOR_PACKAGES.get(arch, {}).get(pm_key, []) + if not packages: + sys.exit( + f" Émulateur {arch} introuvable ({binary}) : installez le paquet " + "QEMU système correspondant de votre distribution " + "(+ firmware UEFI pour arm64)." + ) + full_cmd = install_cmd + packages + printable = ("sudo " if use_sudo and runner.use_sudo else "") + " ".join( + full_cmd + ) + print(f" Émulateur {arch} manquant ({binary}).") + print(f" Commande d'installation : {printable}") + if no_install: + sys.exit( + " Installation automatique désactivée (--no-install-deps).\n" + " Installez manuellement : " + printable + ) + if not assume_yes and not prompt_yes_no( + f" Installer l'émulateur {arch} maintenant ?" + ): + sys.exit(" Installation refusée.\n Commande manuelle : " + printable) + if refresh: + runner.run(refresh, privileged=use_sudo, check=False) + runner.run(full_cmd, privileged=use_sudo) + if not emulator_ready(arch): + sys.exit( + f" Émulateur {arch} ({binary}) ou firmware toujours absent après " + f"installation.\n Essayez manuellement : {printable}" + ) + print(f" Émulateur {arch} installé avec succès.") + + +# --------------------------------------------------------------------------- # +# Étapes +# --------------------------------------------------------------------------- # +# Délai (s) par opération réseau : au-delà, on abandonne le miroir courant. +# Sans lui, un miroir injoignable ferait pendre le téléchargement à l'infini. +DOWNLOAD_TIMEOUT = 30 + + +def _download_one(url: str, tmp: Path, timeout: int) -> None: + """Télécharge url -> tmp en streaming, avec timeout et barre de %. + Lève une exception en cas d'échec réseau (miroir suivant à essayer).""" + is_tty = sys.stdout.isatty() + last_pct = -1 + req = urllib.request.Request( + url, headers={"User-Agent": "erplibre-qemu-deploy"} + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: # noqa: S310 + total = int(resp.headers.get("Content-Length", 0) or 0) + done = 0 + with open(tmp, "wb") as fh: + while True: + chunk = resp.read(1 << 16) + if not chunk: + break + fh.write(chunk) + done += len(chunk) + if total <= 0: + continue + pct = min(100, done * 100 // total) + if pct == last_pct: + continue + last_pct = pct + # TTY : une ligne réécrite (\r) ; sinon (capturé par le menu + # todo) au plus 101 lignes, jamais de flot infini. + if is_tty: + print(f"\r {pct:3d}%", end="", flush=True) + else: + print(f" {pct:3d}%", flush=True) + if is_tty: + print() + # Vérifie la COMPLÉTUDE : si le serveur a annoncé une taille et qu'on a + # reçu moins (connexion coupée), le .part est TRONQUÉ. Sans ce contrôle, + # il était validé comme « complet » -> qcow2 valide mais VIDE (juste + # l'en-tête) -> VM qui ne boote pas, et cache empoisonné réutilisé ensuite. + if total > 0 and done < total: + raise OSError( + f"téléchargement incomplet : {done}/{total} octets reçus " + "(connexion interrompue)" + ) + + +def download_image( + urls, dest: Path, dry_run: bool, timeout: int = DOWNLOAD_TIMEOUT +) -> None: + """Télécharge l'image (essaie chaque miroir dans l'ordre) si absente du + cache. Échoue proprement — jamais de blocage infini — grâce au timeout.""" + if isinstance(urls, str): + urls = [urls] + if dest.exists() and dest.stat().st_size > 0: + size_mb = dest.stat().st_size / 1024 / 1024 + print( + f" Image déjà présente ({size_mb:.0f} Mo), téléchargement" + f" ignoré : {dest}", + flush=True, + ) + return + if dry_run: + print(f" [dry-run] téléchargement {urls[0]} -> {dest}", flush=True) + return + + try: + dest.parent.mkdir(parents=True, exist_ok=True) + except PermissionError: + sys.exit( + f"\nPermission refusée pour écrire dans {dest.parent}.\n" + " Relancez avec sudo, ou choisissez --image-dir vers un dossier" + " accessible en écriture." + ) + tmp = dest.with_suffix(dest.suffix + ".part") + errors = [] + had_404 = False + for i, url in enumerate(urls, 1): + tag = "" if len(urls) == 1 else f" (miroir {i}/{len(urls)})" + print(f" Téléchargement{tag} : {url}", flush=True) + try: + _download_one(url, tmp, timeout) + tmp.replace(dest) + return + except urllib.error.HTTPError as exc: # image absente sur ce miroir + tmp.unlink(missing_ok=True) + had_404 = had_404 or exc.code == 404 + print(f"\n Échec : HTTP {exc.code}", flush=True) + errors.append(f"{url} -> HTTP {exc.code}") + except Exception as exc: # réseau/timeout : miroir suivant + tmp.unlink(missing_ok=True) + print(f"\n Échec : {exc}", flush=True) + errors.append(f"{url} -> {exc}") + hint = ( + "\n Image introuvable (404) : cette version est probablement EOL et" + " a été retirée du miroir. Choisissez une version LTS encore" + " supportée (voir --list-images)." + if had_404 + else "\n Vérifiez la connectivité (IPv6 ?), réessayez plus tard, ou" + " fournissez un chemin d'image local en argument positionnel." + ) + # Chemin visé + commande prête à copier pour reprendre le téléchargement + # manuellement (reprise si un .part existe), puis relancer le déploiement + # (l'image complète en cache sera réutilisée). curl -C - reprend, -f + # échoue proprement sur une erreur HTTP. + resume = ( + f"\n Destination : {dest}" + f"\n Reprendre le téléchargement manuellement :" + f"\n sudo curl -fL -C - -o {dest} \\\n {urls[0]}" + "\n puis relancez le déploiement (l'image en cache sera réutilisée)." + ) + sys.exit( + "\nÉchec du téléchargement depuis tous les miroirs :\n " + + "\n ".join(errors) + + hint + + resume + ) + + +def verify_sha256(url: str, image: Path, dry_run: bool) -> None: + """Vérifie l'empreinte via le SHA256SUMS publié dans le même répertoire.""" + if dry_run: + print(" [dry-run] vérification SHA256 ignorée") + return + sums_url = url.rsplit("/", 1)[0] + "/SHA256SUMS" + filename = url.rsplit("/", 1)[1] + print(f" Vérification SHA256 via {sums_url}") + try: + with urllib.request.urlopen( # noqa: S310 + sums_url, timeout=DOWNLOAD_TIMEOUT + ) as resp: + sums = resp.read().decode() + except Exception as exc: # pragma: no cover + sys.exit(f"Impossible de récupérer SHA256SUMS : {exc}") + + expected = next( + ( + line.split()[0] + for line in sums.splitlines() + if line.strip().endswith(filename) + ), + None, + ) + if expected is None: + sys.exit(f"Empreinte introuvable pour {filename} dans SHA256SUMS") + + h = hashlib.sha256() + with image.open("rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + if h.hexdigest() != expected: + image.unlink( + missing_ok=True + ) # évite la réutilisation du cache corrompu + sys.exit( + f"SHA256 NON conforme ! Image supprimée : {image}\n" + f" attendu : {expected}\n obtenu : {h.hexdigest()}" + ) + print(" SHA256 conforme.") + + +def hash_password(plain: str) -> str: + """Hash SHA-512 crypt ($6$...) via crypt (< 3.13) ou openssl en repli.""" + try: + with warnings.catch_warnings(): + # crypt est déprécié (retiré en 3.13) : on masque l'avertissement, + # le repli openssl couvre les versions récentes de Python. + warnings.simplefilter("ignore", DeprecationWarning) + import crypt + + return crypt.crypt(plain, crypt.mksalt(crypt.METHOD_SHA512)) + except (ImportError, AttributeError): + need_tool("openssl") + out = subprocess.run( + ["openssl", "passwd", "-6", "-stdin"], + input=plain + "\n", + capture_output=True, + text=True, + check=True, + ) + return out.stdout.strip() + + +def build_cloud_config( + args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str] +) -> str: + """Construit le contenu #cloud-config (user-data).""" + lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"] + + user_block = [ + "users:", + f" - name: {args.user}", + " sudo: ALL=(ALL) NOPASSWD:ALL", + # « sudo » existe sur Debian ET Ubuntu ; « admin » n'existe PAS sur + # Debian -> useradd -G ...,admin échoue et l'utilisateur n'est jamais + # créé (login/clé SSH KO). C'était la cause du « Debian ne marche pas ». + " groups: users, sudo", + " shell: /bin/bash", + " lock_passwd: false" if pw_hash else " lock_passwd: true", + ] + if pw_hash: + user_block.append(f' passwd: "{pw_hash}"') + if ssh_keys: + user_block.append(" ssh_authorized_keys:") + user_block += [f" - {k}" for k in ssh_keys] + lines += user_block + + lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}") + lines.append(f"locale: {args.locale}") + lines += [ + "keyboard:", + f" layout: {args.keyboard_layout}", + f" variant: {args.keyboard_variant}", + ] + # apt update/upgrade désactivés par défaut : sur un réseau lent/instable + # ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH + # est déjà présent dans les images cloud ; on l'active via runcmd sans apt. + # --apt-update réactive apt update (+ upgrade, sauf --no-upgrade). + do_update = args.apt_update + do_upgrade = args.apt_update and not args.no_upgrade + lines.append(f"package_update: {'true' if do_update else 'false'}") + lines.append(f"package_upgrade: {'true' if do_upgrade else 'false'}") + + # On n'installe AUCUN paquet via cloud-init : cela exige le réseau au 1er + # boot et peut bloquer longtemps (dnf/apt/pacman lents sur réseau lent) — + # cloud-init reste alors « running » et tout ce qui suit attend. sshd est + # DÉJÀ présent dans toutes les images cloud (Ubuntu/Debian/Fedora/Arch) ; + # on l'active seulement (runcmd). Les outils nécessaires (curl/git/make…) + # sont installés — avec dépôts optimisés — par le bootstrap d'installation. + packages = list(dict.fromkeys(args.package)) # seulement --package + if packages: + lines.append("packages:") + lines += [f" - {p}" for p in packages] + # Active et démarre SSH quel que soit le nom du service (ssh sur + # Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter + # sans SSH accessible. + lines += [ + "runcmd:", + " - systemctl enable --now ssh 2>/dev/null" + " || systemctl enable --now sshd 2>/dev/null || true", + # qemu-guest-agent : installé + activé APRÈS sshd (donc SSH reste + # disponible tout de suite, sans attendre le réseau). Tout est + # « || true » : si l'installation échoue (réseau lent/absent), le boot + # n'est pas bloqué. La plupart des images cloud l'incluent déjà. + # Rafraîchit d'abord l'index (les images cloud n'ont PAS de listes apt + # -> sinon « Unable to locate package »), puis installe. timeout : un + # miroir lent ne bloque JAMAIS cloud-init. Non fatal (|| true). + # Sous-shell ( ) et NON accolades { } : « { » est un indicateur YAML. + " - (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq" + " || true; timeout 300 apt-get install -y qemu-guest-agent)) ||" + " (command -v dnf >/dev/null && timeout 300 dnf install -y" + " qemu-guest-agent) || (command -v pacman >/dev/null && timeout 300" + " pacman -Sy --noconfirm qemu-guest-agent) || true", + # Autorise guest-exec (bloqué par défaut sur certaines distros). + # On VIDE la liste de blocage (block-rpcs/blacklist) plutôt que + # d'utiliser allow-rpcs (liste BLANCHE : casserait les autres RPC). + # Les deux clés couvrent les versions anciennes (blacklist) et + # récentes (block-rpcs) de qemu-ga. + " - mkdir -p /etc/qemu && printf '[general]\\nblock-rpcs=\\n" + "blacklist=\\n' > /etc/qemu/qemu-ga.conf || true", + # Fedora/RHEL bloquent guest-exec via /etc/sysconfig/qemu-ga. + # « test -f » et NON « [ -f … ] » : en YAML, « - [ » démarre une + # séquence en flux -> user-data invalide -> cloud-init rejeté (aucun + # utilisateur créé, VM figée, login console impossible). + " - test -f /etc/sysconfig/qemu-ga && sed -i" + " 's/^BLACKLIST_RPC=.*/BLACKLIST_RPC=/'" + " /etc/sysconfig/qemu-ga || true", + " - systemctl enable qemu-guest-agent 2>/dev/null" + " || systemctl enable qemu-ga 2>/dev/null || true", + # restart (et non enable --now) : recharge la config si l'agent était + # déjà démarré par l'image cloud. + " - systemctl restart qemu-guest-agent 2>/dev/null" + " || systemctl restart qemu-ga 2>/dev/null || true", + ] + return "\n".join(lines) + "\n" + + +# network-config (cloud-init v2) : DHCP sur toute interface « e* ». Les images +# Debian genericcloud ne configurent pas toujours le réseau sans ça (le NIC +# reste down -> pas d'IP), contrairement à Ubuntu. Inoffensif pour Ubuntu. +# La clé est « eth0 » car le renderer cloud-init d'Arch utilise la CLÉ comme +# nom d'interface (en ignorant « match ») et l'image Arch nomme son NIC eth0 ; +# Debian/Fedora/Ubuntu utilisent bien « match: name: e* » (leur en*). +NETWORK_CONFIG = ( + "version: 2\n" + "ethernets:\n" + " eth0:\n" + " match:\n" + ' name: "e*"\n' + " dhcp4: true\n" + " dhcp6: false\n" +) + + +def build_seed( + cloud_cfg: str, hostname: str, seed_dest: Path, runner: Runner +) -> None: + """Génère le seed.iso (cidata) et le copie vers seed_dest.""" + meta_data = f"instance-id: {hostname}\nlocal-hostname: {hostname}\n" + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + ud = tmp_path / "user-data" + md = tmp_path / "meta-data" + nc = tmp_path / "network-config" + local_iso = tmp_path / "seed.iso" + + if runner.dry_run: + print(" [dry-run] user-data qui serait généré :") + print(textwrap.indent(cloud_cfg, " ")) + print(" [dry-run] network-config :") + print(textwrap.indent(NETWORK_CONFIG, " ")) + else: + ud.write_text(cloud_cfg) + md.write_text(meta_data) + nc.write_text(NETWORK_CONFIG) + + if runner.dry_run or shutil.which("cloud-localds"): + runner.run( + [ + "cloud-localds", + "--network-config", + str(nc), + str(local_iso), + str(ud), + str(md), + ] + ) + else: + need_tool("genisoimage") + runner.run( + [ + "genisoimage", + "-output", + str(local_iso), + "-volid", + "cidata", + "-joliet", + "-rock", + str(ud), + str(md), + str(nc), + ] + ) + + ( + seed_dest.parent.mkdir(parents=True, exist_ok=True) + if not runner.dry_run + else None + ) + runner.run(["cp", str(local_iso), str(seed_dest)], privileged=True) + + +def prepare_disk( + image: Path, disk: Path, size: str, runner: Runner, force: bool +) -> None: + """Convertit l'image cloud en qcow2 de travail puis redimensionne.""" + if disk.exists() and not force: + sys.exit( + f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser." + ) + runner.run( + [ + "qemu-img", + "convert", + "-f", + "qcow2", + "-O", + "qcow2", + str(image), + str(disk), + ], + privileged=True, + ) + runner.run(["qemu-img", "resize", str(disk), size], privileged=True) + + +def network_name(network_arg: str) -> str | None: + """Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc.""" + for part in network_arg.split(","): + if part.strip().startswith("network="): + return part.split("=", 1)[1].strip() + return None + + +def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]: + """(actif, autostart) d'un réseau libvirt, via « virsh net-info ».""" + cmd = (["sudo"] if use_sudo else []) + [ + "virsh", + "-c", + LIBVIRT_URI, + "net-info", + name, + ] + try: + res = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError): + return (False, False) + active = bool(re.search(r"Active:\s*yes", res.stdout, re.IGNORECASE)) + autostart = bool(re.search(r"Autostart:\s*yes", res.stdout, re.IGNORECASE)) + return (active, autostart) + + +def ensure_network(name: str | None, runner: Runner) -> None: + """Active le réseau libvirt si besoin. On vérifie d'abord son état pour + éviter le faux « error: network is already active » de virsh quand il + tourne déjà (message purement bruyant, sans conséquence).""" + if not name: + return + if runner.dry_run: + print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire") + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-start", name], + privileged=True, + check=False, + ) + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-autostart", name], + privileged=True, + check=False, + ) + return + active, autostart = network_state(name, runner.use_sudo) + if active and autostart: + print(f" Réseau libvirt '{name}' déjà actif.") + return + print(f" Configuration du réseau libvirt '{name}'…") + if not active: + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-start", name], + privileged=True, + check=False, + ) + if not autostart: + runner.run( + ["virsh", "-c", LIBVIRT_URI, "net-autostart", name], + privileged=True, + check=False, + ) + + +def virt_install( + args: argparse.Namespace, + disk: Path, + seed: Path, + osinfo: str, + runner: Runner, +) -> None: + # Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte. + emulated = args.arch != host_arch() + # s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non + # ttyS0. Ailleurs (x86/arm64), console série classique. + console_target = "sclp" if args.arch == "s390x" else "serial" + cmd = [ + "virt-install", + # Sans --connect, un utilisateur non root vise qemu:///session : le + # réseau « default » n'y existe pas et la création échoue même quand + # tous les paquets sont installés et le réseau actif côté système. + "--connect", + LIBVIRT_URI, + "--name", + args.name, + "--memory", + str(args.memory), + "--vcpus", + str(args.vcpus), + "--import", + "--disk", + f"path={disk},format=qcow2,bus=virtio", + # Seed cloud-init attaché comme DISQUE virtio en lecture seule (et non + # en CD-ROM) : le pilote virtio-blk est dans l'initramfs, donc le + # volume « cidata » est visible dès init-local et cloud-init le lit. + # En CD-ROM, l'initramfs Debian ne charge pas sr_mod à temps -> le + # seed n'est pas vu et rien ne s'applique (Ubuntu, lui, tolère le CD). + # Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt. + "--disk", + f"path={seed},readonly=on,bus=virtio", + "--osinfo", + osinfo, + "--network", + args.network, + "--graphics", + args.graphics, + "--console", + f"pty,target_type={console_target}", + # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à + # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après + # un redimensionnement de disque). Inoffensif si l'agent est absent. + "--channel", + "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", + ] + if args.arch == "s390x": + # s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le + # disque (ni BIOS ni UEFI/OVMF -> aucun --boot). + cmd += ["--arch", "s390x", "--machine", "s390-ccw-virtio"] + elif args.arch == "arm64": + # arm64/aarch64 : machine « virt » + UEFI (firmware AAVMF). Les images + # cloud arm64 n'ont pas de BIOS -> UEFI obligatoire. Secure Boot + # DÉSACTIVÉ (comme x86) : sinon libvirt sélectionne l'AAVMF « secure » + # à clés Microsoft enrôlées et la VM RESTE FIGÉE au firmware (aucun + # boot, aucune IP). secure-boot=no -> AAVMF non-SB, boot OK. + cmd += [ + "--arch", + "aarch64", + "--machine", + "virt", + "--boot", + "uefi,firmware.feature0.name=secure-boot," + "firmware.feature0.enabled=no", + ] + elif not args.bios: + # Boot UEFI par défaut (x86) : Debian 13 (trixie) et les images cloud + # récentes n'embarquent plus le chargeur BIOS/GRUB-pc et partent en + # boucle « Booting... » en SeaBIOS. UEFI (OVMF) fonctionne pour + # Ubuntu/Debian/Fedora. --bios force l'ancien BIOS si OVMF est absent. + # Secure Boot DÉSACTIVÉ : le chargeur d'Arch (GRUB) n'est pas signé et + # OVMF Secure Boot le refuse (« Access Denied » -> pas de boot). + # Ubuntu/Debian/Fedora bootent aussi sans Secure Boot. + cmd += [ + "--boot", + "uefi,firmware.feature0.name=secure-boot," + "firmware.feature0.enabled=no", + ] + if emulated: + # Architecture différente de l'hôte -> émulation logicielle TCG + # (pas de KVM). LENT. + cmd += ["--virt-type", "qemu"] + if not args.attach_console: + cmd.append("--noautoconsole") + # virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous + # sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python + # déverse un « Logging error » (le pavé « Fetched capabilities … »). On + # force un cache/HOME ÉCRIVABLE via « env VAR=… » (traverse sudo) pour + # que le journal s'écrive silencieusement. + # Chemin propre à l'UID : un répertoire partagé finit créé par root lors + # d'un premier passage sous sudo, puis devient illisible pour l'utilisateur + # (« Error setting up logfile: No write access to … /virt-manager »). + cache_dir = f"/var/tmp/erplibre-virtinst-{os.getuid()}" + try: + os.makedirs(cache_dir, mode=0o700, exist_ok=True) + except OSError: + pass + log_env = [ + "env", + f"XDG_CACHE_HOME={cache_dir}", + f"HOME={cache_dir}", + ] + runner.run(log_env + cmd, privileged=True) + + +def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool: + """Vrai si le port SSH répond (distingue le bail actif du bail périmé).""" + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + +def wait_for_ip(name: str, use_sudo: bool, timeout: int) -> str | None: + """Interroge les baux DHCP libvirt jusqu'à obtenir l'IPv4 de la VM. Une VM + peut avoir PLUSIEURS baux (l'image demande d'abord une IP avec le hostname + par défaut « ubuntu », puis cloud-init fixe le vrai hostname -> 2e bail) : + on renvoie une IP JOIGNABLE (sshd up), sinon la plus récente, jamais + aveuglément la 1re (souvent le bail précoce périmé, « No route to host »). + """ + base = (["sudo"] if use_sudo else []) + [ + "virsh", + "domifaddr", + name, + "--source", + "lease", + ] + deadline = time.time() + timeout + ips: list[str] = [] + while time.time() < deadline: + res = subprocess.run(base, capture_output=True, text=True) + ips = re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout) + for ip in ips: + if _ip_reachable(ip): + return ip + time.sleep(3) + return ips[-1] if ips else None + + +def ssh_command(user: str, ip: str, has_key: bool) -> str: + """Commande SSH adaptée : clé -> connexion simple ; mot de passe seul -> + force le mot de passe pour éviter « Too many authentication failures ».""" + if has_key: + return f"ssh {user}@{ip}" + return ( + f"ssh -o IdentitiesOnly=yes -o PreferredAuthentications=password " + f"{user}@{ip}" + ) + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def build_parser() -> argparse.ArgumentParser: + versions_help = "\n".join( + f" {distro:<7} {default:<7} (défaut) versions : " + + ", ".join(versions) + for distro, (versions, default) in DISTROS.items() + ) + p = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description=__doc__, + epilog="Distros et versions (--distro / --version), specs via " + "--list-images :\n" + versions_help, + ) + p.add_argument( + "image_path", + type=Path, + nargs="?", + default=None, + help="Chemin de cache de l'image cloud. Optionnel : si absent, il est " + "déduit de --distro/--version/--codename + --arch dans --image-dir. " + "Si le fichier existe, il n'est PAS re-téléchargé.", + ) + + g_img = p.add_argument_group("Image") + g_img.add_argument( + "--distro", + default="ubuntu", + choices=DISTROS, + help="Distribution : ubuntu, debian ou fedora (défaut : ubuntu).", + ) + g_img.add_argument( + "--version", + default=None, + help="Version de la distro (défaut : la version par défaut de la " + "distro). Voir --list-images pour la liste complète.", + ) + g_img.add_argument( + "--codename", + help="Force le nom de code / la release (surcharge --version).", + ) + g_img.add_argument( + "--arch", + default="amd64", + help="Architecture de l'image (défaut : amd64). amd64/x86_64, " + "arm64/aarch64 (Ubuntu/Debian/Fedora) ou s390x (Ubuntu). Toute arch " + "différente de l'hôte est ÉMULÉE (TCG, lente).", + ) + g_img.add_argument( + "--image-dir", + type=Path, + default=DEFAULT_IMAGE_DIR, + help="Répertoire de cache des images cloud quand image_path est " + f"omis (défaut : {DEFAULT_IMAGE_DIR}).", + ) + g_img.add_argument( + "--verify", + action="store_true", + help="Vérifie l'empreinte SHA256 après téléchargement (recommandé).", + ) + + g_vm = p.add_argument_group("VM") + g_vm.add_argument( + "--name", + help="Nom de la VM (virsh). Requis pour déployer ; inutile avec " + "--download-only.", + ) + g_vm.add_argument( + "--hostname", help="Nom d'hôte interne (défaut : --name)." + ) + g_vm.add_argument( + "--memory", + type=int, + default=None, + help="RAM en Mo (défaut : minimum requis par la version choisie, " + "voir --list-images).", + ) + g_vm.add_argument( + "--vcpus", type=int, default=2, help="Nombre de vCPU (défaut : 2)." + ) + g_vm.add_argument( + "--disk-size", + default=None, + help="Taille du disque virtuel, ex. 120G (défaut : minimum requis " + "par la version choisie, voir --list-images).", + ) + g_vm.add_argument( + "--disk-dir", + type=Path, + default=Path("/var/lib/libvirt/images"), + help="Répertoire du qcow2 de travail (défaut : /var/lib/libvirt/images).", + ) + g_vm.add_argument( + "--seed-dir", + type=Path, + default=Path("/var/lib/libvirt/images/iso"), + help="Répertoire du seed.iso (défaut : .../images/iso).", + ) + g_vm.add_argument( + "--network", + default="network=default,model=virtio", + help="Argument --network de virt-install.", + ) + g_vm.add_argument( + "--graphics", + default="none", + help="Argument --graphics (défaut : none).", + ) + g_vm.add_argument( + "--osinfo", help="Force la valeur --osinfo (sinon déduite)." + ) + g_vm.add_argument( + "--attach-console", + action="store_true", + help="Attache la console série (sinon --noautoconsole).", + ) + g_vm.add_argument( + "--bios", + action="store_true", + help="Force l'amorçage BIOS hérité au lieu d'UEFI (par défaut UEFI ; " + "n'utiliser que si le firmware OVMF est absent).", + ) + + g_cloud = p.add_argument_group("cloud-init") + g_cloud.add_argument( + "--user", + default="erplibre", + help="Utilisateur créé (défaut : erplibre).", + ) + g_cloud.add_argument( + "--password", help="Mot de passe en clair (déconseillé : visible)." + ) + g_cloud.add_argument( + "--ask-password", + action="store_true", + help="Demande le mot de passe de façon interactive (sûr).", + ) + g_cloud.add_argument( + "--password-hash", + help="Empreinte $6$... déjà calculée (openssl passwd -6).", + ) + g_cloud.add_argument( + "--ssh-key", + action="append", + default=[], + metavar="FICHIER", + help="Fichier de clé publique SSH à injecter (répétable).", + ) + g_cloud.add_argument( + "--locale", + default="fr_CA.UTF-8", + help="Locale (défaut : fr_CA.UTF-8).", + ) + g_cloud.add_argument( + "--keyboard-layout", + default="ca", + help="Disposition clavier (défaut : ca).", + ) + g_cloud.add_argument( + "--keyboard-variant", + default="multix", + help="Variante clavier (défaut : multix).", + ) + g_cloud.add_argument( + "--package", + action="append", + default=[], + metavar="PKG", + help="Paquet APT additionnel (répétable).", + ) + g_cloud.add_argument( + "--no-upgrade", + action="store_true", + help="N'exécute pas package_upgrade au premier boot.", + ) + g_cloud.add_argument( + "--apt-update", + action="store_true", + help="Exécute « apt update » au 1er boot (package_update). Désactivé " + "par défaut : évite que cloud-init se bloque sur un miroir lent/" + "injoignable (SSH est déjà présent dans les images cloud).", + ) + + g_run = p.add_argument_group("Exécution") + g_run.add_argument( + "--no-sudo", + action="store_true", + help="N'utilise pas sudo pour les étapes privilégiées.", + ) + g_run.add_argument( + "--force", + action="store_true", + help="Écrase le qcow2 de travail existant.", + ) + g_run.add_argument( + "--no-wait-ip", + action="store_true", + help="N'attend pas l'attribution de l'IP à la fin.", + ) + g_run.add_argument( + "--ip-timeout", + type=int, + default=90, + metavar="SEC", + help="Délai max d'attente de l'IP DHCP (défaut : 90 s).", + ) + g_run.add_argument( + "--dry-run", + action="store_true", + help="Affiche les commandes et le user-data sans rien exécuter.", + ) + g_run.add_argument( + "--download-only", + action="store_true", + help="Télécharge (et vérifie si --verify) l'image cloud puis quitte, " + "sans créer de VM. --name n'est pas requis.", + ) + g_run.add_argument( + "-y", + "--assume-yes", + action="store_true", + help="Accepte automatiquement l'installation des dépendances manquantes.", + ) + g_run.add_argument( + "--no-install-deps", + action="store_true", + help="N'installe jamais les dépendances manquantes (échoue si absentes).", + ) + g_run.add_argument( + "--setup-host", + action="store_true", + help="Prépare l'hôte (paquets QEMU/libvirt, démon, groupe libvirt, " + "réseau default) puis quitte. Ne déploie aucune VM.", + ) + g_run.add_argument( + "--reboot-if-needed", + action="store_true", + help="Avec --setup-host : redémarre si le noyau a été mis à jour " + "depuis le démarrage (sinon libvirt ne peut pas créer virbr0).", + ) + g_run.add_argument( + "--list-images", + action="store_true", + help="Liste les distros/versions disponibles et leurs specs, " + "puis quitte.", + ) + return p + + +def resolve_password(args: argparse.Namespace) -> str | None: + if args.password_hash: + return args.password_hash + if args.ask_password: + pw = getpass.getpass("Mot de passe pour l'utilisateur : ") + if pw != getpass.getpass("Confirmer : "): + sys.exit("Les mots de passe ne correspondent pas.") + return hash_password(pw) + if args.password: + return hash_password(args.password) + return None + + +def load_ssh_keys(paths: list[str]) -> list[str]: + keys: list[str] = [] + for path in paths: + p = Path(path).expanduser() + if not p.exists(): + sys.exit(f"Clé SSH introuvable : {p}") + keys.append(p.read_text().strip()) + return keys + + +def main() -> None: + # Sortie ligne par ligne même quand stdout est un tube (menu todo) : sinon + # les en-têtes restent bufferisés et le déploiement paraît « gelé ». + try: + sys.stdout.reconfigure(line_buffering=True) + except (AttributeError, ValueError): + pass + + args = build_parser().parse_args() + + if args.list_images: + list_images() + return + + # Préparation de l'hôte : aucune image, aucune distro cible, aucun --name. + # Traité AVANT la résolution de version/image pour rester utilisable seul. + if args.setup_host: + setup_host( + Runner( + use_sudo=not args.no_sudo and os.geteuid() != 0, + dry_run=args.dry_run, + ), + args.assume_yes, + args.no_install_deps, + args.reboot_if_needed, + ) + return + + versions, default_version = DISTROS[args.distro] + if args.version is None: + args.version = default_version + if args.version not in versions: + sys.exit( + f"Version {args.version!r} inconnue pour {args.distro}. " + f"Choix : {', '.join(versions)} (voir --list-images)." + ) + # Certaines architectures ne sont publiées que par une partie des distros + # (voir ARCH_DISTRO_SUPPORT) : on valide tôt plutôt qu'échouer au download. + supported = ARCH_DISTRO_SUPPORT.get(args.arch) + if supported is not None and args.distro not in supported: + sys.exit( + f"Architecture {args.arch} indisponible pour {args.distro!r} : " + f"images cloud publiées seulement pour {', '.join(supported)}." + ) + code, default_osinfo, min_ram, min_disk = versions[args.version] + if args.codename: + code = args.codename + osinfo = args.osinfo or default_osinfo + # Dimensionnement par défaut = minimum requis par la version (libosinfo). + if args.memory is None: + args.memory = min_ram + if args.disk_size is None: + args.disk_size = min_disk + # Un nombre nu (« 30 ») serait pris pour des OCTETS par qemu-img et ferait + # échouer le resize : on suppose des Go par défaut (« 30 » -> « 30G »). + if re.fullmatch(r"\d+", str(args.disk_size)): + args.disk_size = f"{args.disk_size}G" + urls = image_candidates( + args.distro, code, args.arch, args.version, args.dry_run + ) + url = urls[0] + # --verify s'appuie sur un SHA256SUMS style Ubuntu ; Debian/Fedora + # publient des sommes dans un autre format -> on saute proprement. + do_verify = args.verify and args.distro == "ubuntu" + if args.verify and not do_verify: + print( + f" Note : --verify n'est pris en charge que pour ubuntu " + f"(ignoré pour {args.distro})." + ) + + # Chemin de l'image : déduit automatiquement si non fourni. + if args.image_path is None: + args.image_path = args.image_dir / default_image_name( + args.distro, code, args.arch, args.version + ) + + runner = Runner( + use_sudo=not args.no_sudo and os.geteuid() != 0, dry_run=args.dry_run + ) + + # -- Mode téléchargement seul : aucun outil ni VM requis. -------------- + if args.download_only: + print( + f"\n== Téléchargement image cloud " + f"({args.distro} {args.version} / {code}) ==" + ) + print(f" Destination : {args.image_path}") + download_image(urls, args.image_path, args.dry_run) + if do_verify: + verify_sha256(url, args.image_path, args.dry_run) + print("\nTerminé (téléchargement seul).") + return + + # -- Déploiement complet ----------------------------------------------- + if not args.name: + sys.exit( + "Erreur : --name est requis pour déployer une VM " + "(ou utilisez --download-only)." + ) + args.hostname = args.hostname or args.name + + pw_hash = resolve_password(args) + ssh_keys = load_ssh_keys(args.ssh_key) + if not pw_hash and not ssh_keys: + print( + "ATTENTION : ni mot de passe ni clé SSH -> connexion impossible à la VM.\n" + " Ajoutez --ssh-key, --ask-password ou --password-hash.\n", + file=sys.stderr, + ) + + disk = args.disk_dir / f"{args.name}.qcow2" + seed = args.seed_dir / f"{args.name}-seed.iso" + + if not args.dry_run: + ensure_tools(runner, args.assume_yes, args.no_install_deps) + # Émulation requise si l'arch diffère de l'hôte : installe l'émulateur + # QEMU système adéquat (+ firmware UEFI pour arm64) et prévient de la + # lenteur. + if args.arch in EMULATOR_BINARY and args.arch != host_arch(): + ensure_emulator( + args.arch, runner, args.assume_yes, args.no_install_deps + ) + print( + f" Note : {args.arch} est ÉMULÉ (TCG) sur cet hôte " + f"({host_arch()}) — le boot et l'installation seront " + "nettement plus lents que l'architecture native." + ) + + print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==") + download_image(urls, args.image_path, args.dry_run) + if do_verify: + verify_sha256(url, args.image_path, args.dry_run) + + print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") + prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) + + print(f"\n== 4/5 Seed cloud-init {seed} ==") + cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) + build_seed(cloud_cfg, args.hostname, seed, runner) + + resolved_osinfo = osinfo_arg(osinfo, args.distro) + print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") + ensure_network(network_name(args.network), runner) + virt_install(args, disk, seed, resolved_osinfo, runner) + + has_key = bool(ssh_keys) + print("\nTerminé. Suivi :") + print(" virsh list --all") + print(f" virsh console {args.name} # Ctrl+] pour quitter") + + if args.dry_run: + print("\n Connexion SSH (IP attribuée au 1er boot) :") + print(f" {ssh_command(args.user, '', has_key)}") + return + if args.no_wait_ip: + print( + f"\n Récupérer l'IP : virsh domifaddr {args.name} --source lease" + ) + return + + print(f"\n Attente de l'IP (bail DHCP, max {args.ip_timeout} s)…") + ip = wait_for_ip(args.name, runner.use_sudo, args.ip_timeout) + if ip: + print(f" IP : {ip}") + print(" Connexion SSH :") + print(f" {ssh_command(args.user, ip, has_key)}") + else: + print(" IP non obtenue dans le délai (cloud-init encore en cours ?).") + print(f" Réessayez : virsh domifaddr {args.name} --source lease") + + +if __name__ == "__main__": + main()