From 63534e0473564777e25eb078c667478aa94f3836 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 01/10] [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() From dd989551923ec3e7f05b3991fab91a6aca3c98d9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 02/10] [IMP] install: support Fedora, Debian, Ubuntu and Arch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Fedora dependency installer, rewrites the Arch one on pacman rather than an AUR helper absent from cloud images, and repairs the Debian path: apt lock timeout, PostGIS best-effort, Ubuntu 26.04, Node 22. --- FR --- Ajoute un installeur de dépendances Fedora, réécrit celui d'Arch sur pacman plutôt qu'un assistant AUR absent des images cloud, et répare le chemin Debian : attente du verrou apt, PostGIS best-effort, Ubuntu 26.04, Node 22. Assisted-by: Claude Opus 4.8 Assisted-by: Claude Opus 5 --- script/install/install_arch_linux.sh | 158 +++++++++++++------ script/install/install_debian_dependency.sh | 96 +++++++---- script/install/install_dev.sh | 9 +- script/install/install_fedora_dependency.sh | 129 +++++++++++++++ script/install/install_locally.sh | 6 + script/manifest/update_manifest_local_dev.sh | 17 +- script/version/update_env_version.py | 79 +++++++++- 7 files changed, 410 insertions(+), 84 deletions(-) create mode 100755 script/install/install_fedora_dependency.sh diff --git a/script/install/install_arch_linux.sh b/script/install/install_arch_linux.sh index a350f96..3289b17 100755 --- a/script/install/install_arch_linux.sh +++ b/script/install/install_arch_linux.sh @@ -1,68 +1,126 @@ #!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Dépendances système ERPLibre pour Arch Linux (pacman). Réécrit pour les +# images cloud Arch : l'ancien script utilisait « yay » (helper AUR ABSENT +# d'une image cloud et qui refuse de tourner en root) et n'installait jamais +# « base-devel » -> pas de compilateur C -> échec de la compilation de Python +# par pyenv. On passe tout par pacman (dépôts officiels) ; l'AUR (wkhtmltopdf) +# est best-effort et ne bloque pas. -install_package() { - local package_name=$1 +. ./env_var.sh - # Check package is already installed - if pacman -Qs "$package_name" >/dev/null; then - echo "$package_name is already installed." - else - echo "Installation of package $package_name..." - yes | yay -S "$package_name" - fi -} +EL_USER=${USER} -# Odoo installation -install_package postgis -install_package postgresql -install_package mariadb -install_package libev -install_package wkhtmltopdf -install_package freetds +# pacman résilient : --needed saute ce qui est déjà là, --noconfirm en non +# interactif. +PAC="sudo pacman -S --needed --noconfirm" +# Mise à jour COMPLÈTE obligatoire : Arch est en rolling release et NE SUPPORTE +# PAS les mises à jour partielles. Sur une image cloud dont la glibc est +# ancienne, un « pacman -S » lie le binaire à une glibc plus +# récente que celle installée -> « /usr/bin/postgres: GLIBC_2.44 not found ». +# « -Syu » met à jour glibc (et tout le système) pour rester cohérent. +sudo pacman -Syu --noconfirm || true -echo "Need password to create symbolic link, create postgres user and install npm :" -sudo ln -fs /usr/lib/libldap.so /usr/lib/libldap_r.so +#-------------------------------------------------- +# Outils de compilation — CRITIQUE (build Python via pyenv, extensions Python) +# base-devel fournit gcc, make, patch, pkgconf, fakeroot, etc. +#-------------------------------------------------- +echo -e "\n---- Groupe base-devel (compilateur C, make…) ----" +${PAC} base-devel +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "pacman base-devel installation error." + exit 1 +fi +#-------------------------------------------------- +# Dépendances de build pyenv (compilation de CPython) + outils de base +#-------------------------------------------------- +echo -e "\n---- Dépendances pyenv (compilation Python) + outils ----" +${PAC} git wget curl openssl zlib xz tk bzip2 readline sqlite libffi +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "pacman pyenv dependencies installation error." + exit 1 +fi + +#-------------------------------------------------- +# PostgreSQL (+ PostGIS) — Arch n'initialise pas le cluster automatiquement +#-------------------------------------------------- +echo -e "\n---- Install PostgreSQL Server ----" +${PAC} postgresql +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "pacman postgresql installation error." + exit 1 +fi +# Initialisation du cluster (chemin Arch : /var/lib/postgres/data). +if [ ! -f /var/lib/postgres/data/PG_VERSION ]; then + echo -e "\n---- Initialisation du cluster PostgreSQL ----" + sudo -u postgres initdb --locale=C.UTF-8 --encoding=UTF8 \ + -D /var/lib/postgres/data || true +fi +sudo systemctl enable --now postgresql 2>/dev/null || true +# PostGIS : optionnel (géospatial), ne bloque pas. +${PAC} postgis || echo "PostGIS non installé (optionnel)." + +echo -e "\n---- Creating the ERPLibre PostgreSQL User ----" sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true -echo -e "\n---- Update NPM ----" -install_package npm +#-------------------------------------------------- +# Dépendances Odoo / ERPLibre (extensions Python, bindings) +#-------------------------------------------------- +echo -e "\n---- Installing arch dependency ----" +# best-effort paquet par paquet : pacman refuse TOUTE la transaction si UN +# seul nom est inconnu (pas de --skip-unavailable). Ces deps ne sont pas +# critiques pour le build Python -> on ne bloque pas sur un nom absent. +for _pkg in libxslt libzip libldap libsasl cmake parallel swig portaudio \ + cups xmlsec freetds libev mariadb-libs shfmt; do + ${PAC} "${_pkg}" || echo " ${_pkg} : non installé (optionnel), on continue." +done +# libldap_r : Odoo/python-ldap cherche parfois libldap_r.so (supprimé des +# versions récentes d'OpenLDAP) -> lien vers libldap.so. +sudo ln -fs /usr/lib/libldap.so /usr/lib/libldap_r.so 2>/dev/null || true -sudo npm install npm@latest -g +#-------------------------------------------------- +# Node.js + npm (rtlcss, less) +#-------------------------------------------------- +echo -e "\n---- Installing nodeJS NPM and rtlcss ----" +${PAC} nodejs npm retVal=$? if [[ $retVal -ne 0 ]]; then - echo "npm install npm lastest installation error." - exit 1 -fi -sudo npm install -g rtlcss -retVal=$? -if [[ $retVal -ne 0 ]]; then - echo "npm install rtlcss installation error." - exit 1 -fi -sudo npm install -g less -retVal=$? -if [[ $retVal -ne 0 ]]; then - echo "npm install less installation error." + echo "pacman nodejs installation error." exit 1 fi +sudo npm install -g rtlcss less || echo "npm rtlcss/less: erreur (optionnel)." echo -e "\n---- Test tool ----" -npm install -retVal=$? -if [[ $retVal -ne 0 ]]; then - echo "npm install prettier + plugin-xml installation error." - exit 1 +npm install || echo "npm install (prettier/plugin-xml): erreur (optionnel)." + +# Pour erplibre_devops. +${PAC} sshpass || echo "sshpass non installé (optionnel)." + +#-------------------------------------------------- +# nginx (optionnel) +#-------------------------------------------------- +if [ "${EL_INSTALL_NGINX}" = "True" ]; then + echo -e "\n---- Installing nginx ----" + ${PAC} nginx || echo "nginx: erreur (optionnel)." fi -# TODO install nginx - -# ERPLibre installation -install_package cmake -install_package parallel -install_package tk -install_package shfmt - -# For erplibre_devops -install_package sshpass +#-------------------------------------------------- +# wkhtmltopdf (optionnel) — uniquement dans l'AUR sur Arch (pas de paquet +# officiel). Sur une image cloud sans helper AUR, on saute proprement. +#-------------------------------------------------- +if [ "${EL_INSTALL_WKHTMLTOPDF}" = "True" ]; then + if ! command -v wkhtmltopdf >/dev/null 2>&1; then + echo "wkhtmltopdf : disponible seulement via l'AUR sur Arch, ignoré" + echo " (installez-le manuellement avec un helper AUR si nécessaire)." + else + echo -e "\n---- Already installed wkhtml ----" + fi +fi +echo -e "\n---- Arch Linux dependency installation done ----" diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index adbc2d5..cc56946 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -5,6 +5,11 @@ EL_USER=${USER} #EL_INSTALL_WKHTMLTOPDF="True" +# apt-get qui ATTEND le verrou (jusqu'à 10 min) : sur une image cloud fraîche, +# cloud-init / unattended-upgrades tiennent souvent le verrou apt au 1er boot +# (« Could not get lock » -> échec de l'install). DPkg::Lock::Timeout patiente. +APT_GET="sudo apt-get -o DPkg::Lock::Timeout=600" + ## ### WKHTMLTOPDF download links ## === Ubuntu Focal x64 === (for other distributions please replace these two links, @@ -15,22 +20,30 @@ UBUNTU_VERSION=$(lsb_release -rs) DEBIAN_VERSION=$(lsb_release -cs) OS=$(lsb_release -si) if [[ "${OS}" == "Ubuntu" ]]; then - if [ "25.10" == "${UBUNTU_VERSION}" ] || [ "25.04" == "${UBUNTU_VERSION}" ] || [ "24.04" == "${UBUNTU_VERSION}" ] || [ "24.10" == "${UBUNTU_VERSION}" ] || [ "23.10" == "${UBUNTU_VERSION}" ] || [ "23.04" == "${UBUNTU_VERSION}" ] || [ "22.10" == "${UBUNTU_VERSION}" ] || [ "22.04" == "${UBUNTU_VERSION}" ]; then - WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb - elif [ "20.04" == "${UBUNTU_VERSION}" ]; then + if [ "20.04" == "${UBUNTU_VERSION}" ]; then WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb elif [ "18.04" == "${UBUNTU_VERSION}" ]; then WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.bionic_amd64.deb + else + # 22.04+ (jusqu'à 26.04 et au-delà) : wkhtmltopdf ne publie pas de build + # par version ; le .deb « jammy » est le plus récent et fonctionne. Un + # « else » (au lieu d'énumérer les versions) évite une URL VIDE sur une + # version récente (26.04) -> gdebi appelé sans fichier -> échec. + WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb fi elif [[ "${OS}" == "Linuxmint" ]]; then if [ "22.3" == "${UBUNTU_VERSION}" ]; then WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb fi elif [[ "${OS}" == "Debian" ]]; then - if [ "bookworm" == "${DEBIAN_VERSION}" ]; then - WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb - else + if [ "bullseye" == "${DEBIAN_VERSION}" ]; then WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb + else + # bookworm (12), trixie (13) et au-delà : wkhtmltopdf ne publie pas de + # build au-delà de « bookworm » -> on prend bookworm (le plus récent). + # Le build « bullseye » (Debian 11) échouait à s'installer sur trixie + # (gdebi : dépendances incompatibles). + WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb fi elif [[ "${OS}" == *"Ubuntu"* ]]; then echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04" @@ -55,25 +68,30 @@ echo -e "\n---- Update Server ----" if [ "18.04" == "${UBUNTU_VERSION}" ]; then # add-apt-repository can install add-apt-repository Ubuntu 18.x - sudo apt-get install software-properties-common curl -y + ${APT_GET} install software-properties-common curl -y # universe package is for Ubuntu 18.x sudo add-apt-repository universe # libpng12-0 dependency for wkhtmltopdf sudo add-apt-repository "deb http://mirrors.kernel.org/ubuntu/ xenial main" - sudo apt-get update - sudo apt-get upgrade -y + ${APT_GET} update + ${APT_GET} upgrade -y fi #-------------------------------------------------- # Install PostgreSQL Server #-------------------------------------------------- echo -e "\n---- Install PostgreSQL Server ----" -sudo apt-get install postgresql libpq-dev postgis -y +${APT_GET} install postgresql postgresql-contrib libpq-dev -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get install postgresql installation error." exit 1 fi +# PostGIS : optionnel (géospatial). Le nom du paquet « postgis » n'existe pas +# sur toutes les versions (Ubuntu 24.04) -> best-effort, ne bloque pas. +${APT_GET} install postgis -y \ + || ${APT_GET} install postgresql-postgis -y \ + || echo "PostGIS non installé (optionnel)." echo -e "\n---- Creating the ERPLibre PostgreSQL User ----" sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true @@ -82,20 +100,27 @@ sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true # Install Dependencies #-------------------------------------------------- echo -e "\n--- Installing debian dependency --" -sudo apt-get install git build-essential wget libxslt-dev libzip-dev libldap2-dev libsasl2-dev gdebi-core libffi-dev libbz2-dev parallel pysassc swig cmake portaudio19-dev libcups2-dev shfmt xmlsec1 -y +${APT_GET} install git build-essential wget libxslt-dev libzip-dev libldap2-dev libsasl2-dev gdebi-core libffi-dev libbz2-dev parallel pysassc swig cmake portaudio19-dev libcups2-dev xmlsec1 -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get debian tool installation error." exit 1 fi -sudo apt-get install libmariadbd-dev freetds-dev -y +# shfmt : ABSENT des dépôts Ubuntu < 22.04. Il était dans le lot critique +# ci-dessus -> un seul paquet introuvable faisait échouer TOUT l'apt-get +# (donc pas de build-essential/gcc -> pyenv ne pouvait plus compiler Python). +# C'est un simple formateur shell (dev), non requis pour exécuter ERPLibre : +# on l'installe SÉPARÉMENT et en best-effort (jamais fatal). +${APT_GET} install shfmt -y \ + || echo "shfmt indisponible dans les dépôts (Ubuntu < 22.04 ?) — ignoré." +${APT_GET} install libmariadbd-dev freetds-dev -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get libmariadb installation error." exit 1 fi if [ "18.04" == "${UBUNTU_VERSION}" ]; then - sudo apt-get install libpng12-0 -y + ${APT_GET} install libpng12-0 -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get libpng installation error." @@ -103,14 +128,14 @@ if [ "18.04" == "${UBUNTU_VERSION}" ]; then fi fi # Dependencies for pyenv -sudo apt-get install make libssl-dev zlib1g-dev libreadline-dev libsqlite3-dev curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev liblzma-dev -y +${APT_GET} install make libssl-dev zlib1g-dev libreadline-dev libsqlite3-dev curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev liblzma-dev -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get pyenv dependencies installation error." exit 1 fi # Dependencies for selenium -sudo apt-get install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y +${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y retVal=$? if [[ $retVal -ne 0 ]]; then echo "apt-get selenium dependencies installation error." @@ -118,8 +143,8 @@ if [[ $retVal -ne 0 ]]; then fi echo -e "\n---- Installing nodeJS NPM and rtlcss for LTR support ----" -sudo apt-get update -sudo apt-get install -y ca-certificates curl gnupg +${APT_GET} update +${APT_GET} install -y ca-certificates curl gnupg sudo mkdir -p /etc/apt/keyrings curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg @@ -132,8 +157,8 @@ else fi echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list -sudo apt-get update -sudo apt-get install nodejs -y +${APT_GET} update +${APT_GET} install nodejs -y sudo npm install npm@latest -g retVal=$? @@ -177,21 +202,36 @@ fi #-------------------------------------------------- # Install Wkhtmltopdf if needed #-------------------------------------------------- -if [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then +if [ "$(uname -m)" != "x86_64" ]; then + # WKHTMLTOX_X64 pointe vers un .deb amd64 : sur toute autre architecture + # (s390x, arm64/aarch64…) gdebi échouerait. On saute proprement plutôt que + # d'avorter tout l'install (wkhtmltopdf est optionnel). + echo "wkhtmltopdf : pas de build pour $(uname -m), ignoré (optionnel)." +elif [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then echo -e "\n---- Installing wkhtml ----" INSTALLED=$(dpkg -s wkhtmltox | grep installed) if [ "" == "${INSTALLED}" ]; then echo -e "\n---- Install wkhtml and place shortcuts on correct place ----" _url=${WKHTMLTOX_X64} - sudo wget ${_url} - sudo gdebi --n $(basename ${_url}) - retVal=$? - if [[ $retVal -ne 0 ]]; then - echo "gdebi install wkhtmltopdf installation error." - exit 1 + if [ -z "${_url}" ]; then + # Aucune URL (version non mappée) : wkhtmltopdf est OPTIONNEL, on saute + # proprement plutôt que d'appeler gdebi sans fichier (« Usage: gdebi »). + echo "wkhtmltopdf : aucune URL pour cette version, ignoré (optionnel)." + else + sudo wget ${_url} + sudo gdebi --n $(basename ${_url}) + retVal=$? + if [[ $retVal -ne 0 ]]; then + # wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian + # 13 (trixie) le .deb dépendait de libssl1.1 (absent) et « exit 1 » + # faisait échouer TOUT install_os -> Odoo jamais installé. On avertit + # et on continue (comme Arch qui poursuit sans wkhtmltopdf). + echo "wkhtmltopdf : installation échouée, ignoré (optionnel — pas de PDF)." + else + sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin + sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin + fi fi - sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin - sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin else echo -e "\n---- Already installed wkhtml ----" fi diff --git a/script/install/install_dev.sh b/script/install/install_dev.sh index 2220a12..95cc0a3 100755 --- a/script/install/install_dev.sh +++ b/script/install/install_dev.sh @@ -3,11 +3,11 @@ if [[ "${OSTYPE}" == "linux-gnu" ]]; then source /etc/os-release if [[ "${ID}" == "ubuntu" ]]; then - if [[ "${VERSION_ID}" == "18.04" || "${VERSION_ID}" == "20.04" || "${VERSION_ID}" == "22.04" || "${VERSION_ID}" == "22.10" || "${VERSION_ID}" == "23.04" || "${VERSION_ID}" == "23.10" || "${VERSION_ID}" == "24.04" || "${VERSION_ID}" == "25.04" || "${VERSION_ID}" == "25.10" ]]; then + if [[ "${VERSION_ID}" == "18.04" || "${VERSION_ID}" == "20.04" || "${VERSION_ID}" == "22.04" || "${VERSION_ID}" == "22.10" || "${VERSION_ID}" == "23.04" || "${VERSION_ID}" == "23.10" || "${VERSION_ID}" == "24.04" || "${VERSION_ID}" == "25.04" || "${VERSION_ID}" == "25.10" || "${VERSION_ID}" == "26.04" ]]; then echo "\n---- linux-gnu installation process started ----" ./script/install/install_debian_dependency.sh else - echo "Your version is not supported, only support 18.04, 20.04 and 22.04 - 24.04, 25.04, 25.10 : ${VERSION_ID}" + echo "Your version is not supported, only support 18.04, 20.04 and 22.04 - 24.04, 25.04, 25.10, 26.04 : ${VERSION_ID}" fi elif [[ "${ID}" == "linuxmint" ]]; then if [[ "${VERSION_ID}" == "22.3" ]]; then @@ -20,9 +20,12 @@ if [[ "${OSTYPE}" == "linux-gnu" ]]; then ./script/install/install_debian_dependency.sh elif [[ "${ID}" == "arch" ]]; then ./script/install/install_arch_linux.sh + elif [[ "${ID}" == "fedora" || "${ID_LIKE}" == *"fedora"* || "${ID_LIKE}" == *"rhel"* ]]; then + echo "\n---- Fedora installation process started ----" + ./script/install/install_fedora_dependency.sh else ./script/install/install_debian_dependency.sh - echo "Your Linux system is not supported, only support Ubuntu 18.04 or Ubuntu 20.04 or Ubuntu 22.04 - Ubuntu 23.10 - Ubuntu 24.04, Ubuntu 25.04, Ubuntu 25.10 ." + echo "Your Linux system is not supported, only support Ubuntu 18.04 or Ubuntu 20.04 or Ubuntu 22.04 - Ubuntu 23.10 - Ubuntu 24.04, Ubuntu 25.04, Ubuntu 25.10, Debian, Fedora, Arch." fi elif [[ "${OSTYPE}" == "darwin"* ]]; then echo "\n---- Darwin installation process started ----" diff --git a/script/install/install_fedora_dependency.sh b/script/install/install_fedora_dependency.sh new file mode 100755 index 0000000..7fbafa7 --- /dev/null +++ b/script/install/install_fedora_dependency.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Dépendances système ERPLibre pour Fedora (dnf). Équivalent Fedora de +# install_debian_dependency.sh. « --skip-unavailable » tolère un nom de paquet +# absent (dnf5) au lieu d'échouer sur tout le lot. + +. ./env_var.sh + +EL_USER=${USER} + +# dnf résilient : rafraîchit le cache (évite « checksum doesn't match » / +# signature après un cache périmé) et saute les paquets introuvables. +DNF="sudo dnf install -y --refresh --skip-unavailable" + +#-------------------------------------------------- +# Outils de compilation (build Python via pyenv, extensions Python) +#-------------------------------------------------- +echo -e "\n---- Groupe outils de développement ----" +# Groupes par ID (le nom affiché « C Development Tools... » n'est pas matché). +sudo dnf group install -y --skip-unavailable development-tools c-development \ + || sudo dnf install -y gcc gcc-c++ make automake patch + +#-------------------------------------------------- +# PostgreSQL +#-------------------------------------------------- +echo -e "\n---- Install PostgreSQL Server ----" +${DNF} postgresql-server postgresql-contrib libpq-devel +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "dnf install postgresql installation error." + exit 1 +fi +# Initialisation du cluster (Fedora ne le fait pas automatiquement). +if [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then + echo -e "\n---- Initialisation du cluster PostgreSQL ----" + # Nettoie un init partiel et FORCE une locale valide : les images cloud + # Fedora n'ont pas de LANG défini -> « initdb: invalid locale settings ». + sudo rm -rf /var/lib/pgsql/data + sudo PGSETUP_INITDB_OPTIONS="--locale=C.UTF-8 --encoding=UTF8" \ + postgresql-setup --initdb || true +fi +sudo systemctl enable --now postgresql 2>/dev/null || true +# PostGIS : optionnel (géospatial), ne bloque pas. +${DNF} postgis || echo "PostGIS non installé (optionnel)." + +echo -e "\n---- Creating the ERPLibre PostgreSQL User ----" +sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true + +#-------------------------------------------------- +# Dépendances de build (extensions Python, Odoo) +#-------------------------------------------------- +echo -e "\n--- Installing fedora dependency --" +# git-daemon : sur Fedora la sous-commande « git daemon » N'EST PAS dans le +# paquet « git » de base (contrairement à Debian/Ubuntu/Arch). ERPLibre sert +# son manifeste via un « git daemon » local (git://127.0.0.1:9418/) pendant +# « repo sync » -> sans ce paquet : « git: 'daemon' is not a git command » +# puis « Connection refused » et l'échec de la synchro du manifeste. +${DNF} \ + git git-daemon wget libxslt-devel libzip-devel openldap-devel \ + cyrus-sasl-devel \ + libffi-devel bzip2-devel parallel swig cmake portaudio-devel \ + cups-devel xmlsec1 xmlsec1-openssl mariadb-connector-c-devel freetds-devel +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "dnf fedora tool installation error." + exit 1 +fi + +# Dépendances de build pour pyenv (compilation de CPython) — CRITIQUE. +echo -e "\n---- Dépendances pyenv (compilation Python) ----" +${DNF} \ + make gcc zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel \ + openssl-devel tk-devel libffi-devel xz-devel patch findutils +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "dnf pyenv dependencies installation error." + exit 1 +fi + +# Dépendances selenium / bindings. +${DNF} \ + cairo-devel python3-devel pkgconf-pkg-config gobject-introspection-devel \ + libXt-devel || echo "Dépendances selenium partielles (optionnel)." + +#-------------------------------------------------- +# Node.js + npm (rtlcss, less) +#-------------------------------------------------- +echo -e "\n---- Installing nodeJS NPM and rtlcss ----" +${DNF} nodejs npm +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "dnf nodejs installation error." + exit 1 +fi +sudo npm install -g rtlcss less || echo "npm rtlcss/less: erreur (optionnel)." + +echo -e "\n---- Test tool ----" +npm install || echo "npm install (prettier/plugin-xml): erreur (optionnel)." +sudo ln -fs /usr/local/bin/lessc /usr/bin/lessc 2>/dev/null || true + +#-------------------------------------------------- +# nginx (optionnel) +#-------------------------------------------------- +if [ "${EL_INSTALL_NGINX}" = "True" ]; then + echo -e "\n---- Installing nginx ----" + ${DNF} nginx || echo "nginx: erreur (optionnel)." +fi + +#-------------------------------------------------- +# wkhtmltopdf (optionnel) — paquet RPM officiel wkhtmltopdf +#-------------------------------------------------- +if [ "${EL_INSTALL_WKHTMLTOPDF}" = "True" ]; then + if ! command -v wkhtmltopdf >/dev/null 2>&1; then + echo -e "\n---- Installing wkhtml (best-effort) ----" + # wkhtmltopdf ne publie plus de build « fedora-* » ; le RPM AlmaLinux 9 + # (EL9) est compatible Fedora (testé sur F42 : dnf résout les deps). + # Repli AlmaLinux 8 au besoin. + _base="https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3" + sudo dnf install -y "${_base}/wkhtmltox-0.12.6.1-3.almalinux9.x86_64.rpm" \ + || sudo dnf install -y "${_base}/wkhtmltox-0.12.6.1-3.almalinux8.x86_64.rpm" \ + || echo "wkhtmltopdf non installé (optionnel)." + else + echo -e "\n---- Already installed wkhtml ----" + fi +fi + +echo -e "\n---- Fedora dependency installation done ----" diff --git a/script/install/install_locally.sh b/script/install/install_locally.sh index 331701c..350b86a 100755 --- a/script/install/install_locally.sh +++ b/script/install/install_locally.sh @@ -96,6 +96,12 @@ if [[ "${EL_PHASE}" != "setup" ]]; then retVal=$? if [[ $retVal -ne 0 ]]; then echo "Poetry installation error with status ${retVal}" + # « -q » masque la CAUSE. On rejoue en « -vvv » (debug) car c'est + # le SEUL niveau où Poetry affiche la sortie des sous-processus + # (git clone/checkout, build pip) — donc l'erreur réelle d'une + # dépendance VCS/build. Capturé dans le log pour diagnostic. + echo "---- Poetry: rejeu -vvv pour diagnostic ----" + poetry install --no-root -vvv 2>&1 || true exit 1 fi fi diff --git a/script/manifest/update_manifest_local_dev.sh b/script/manifest/update_manifest_local_dev.sh index abdc5c3..122978f 100755 --- a/script/manifest/update_manifest_local_dev.sh +++ b/script/manifest/update_manifest_local_dev.sh @@ -12,9 +12,22 @@ fi #EL_MANIFEST_PROD="./default.xml" #EL_MANIFEST_DEV="./manifest/default.dev.xml" -# Update git-repo +# Update git-repo : local git daemon serving the repo over git://127.0.0.1:9418. +# Kill any leftover daemon from a previous (interrupted) run first: otherwise the +# stale server keeps port 9418, the new daemon fails to bind ("Address already in +# use"), and the cleanup kill below fails on an already-dead PID -> script exit 1. +# Match on the stable arguments, not "git daemon": « git daemon » execs into +# « git-daemon » (hyphen, /usr/lib/git-core/git-daemon), so "git daemon" (space) +# never matches the actual running process. +if pkill -f "daemon --base-path=. --export-all" 2>/dev/null; then + sleep 1 # let the kernel release port 9418 before we rebind +fi + git daemon --base-path=. --export-all --reuseaddr --informative-errors ${DAEMON_VERBOSE} & DAEMON_PID=$! +# Always stop the daemon we started, whatever happens next (success or error), +# without ever failing the script if it is already gone. +trap 'kill "${DAEMON_PID}" 2>/dev/null || true' EXIT if [ -L "$EL_MANIFEST_DEV" ]; then MANIFEST_TARGET=$(readlink -f "$EL_MANIFEST_DEV") @@ -34,4 +47,4 @@ fi .venv.erplibre/bin/repo init -u git://127.0.0.1:9418/ -b $(git rev-parse --verify HEAD) -m ${MANIFEST_TARGET} "$@" .venv.erplibre/bin/repo sync -c -j "$JOBS" ${REPO_VERBOSE} -m ${MANIFEST_TARGET} -kill ${DAEMON_PID} +# Daemon cleanup handled by the EXIT trap above (tolerant of an already-dead PID). diff --git a/script/version/update_env_version.py b/script/version/update_env_version.py index ca265b1..def0085 100755 --- a/script/version/update_env_version.py +++ b/script/version/update_env_version.py @@ -14,6 +14,10 @@ import time # erplibre_state is in the same directory; import lazily to avoid hard failure # when the script runs outside the project root (e.g. during bare install). +# Two import styles are needed: when this file is imported as a package module +# the repo root is on sys.path (package import works); when it is executed +# directly as ./script/version/update_env_version.py, sys.path[0] is +# script/version/ and only the sibling import resolves. try: from script.version.erplibre_state import ( get_version_extra, @@ -25,7 +29,18 @@ try: _STATE_AVAILABLE = True except ImportError: - _STATE_AVAILABLE = False + try: + from erplibre_state import ( + get_version_extra, + get_version_installed, + print_state, + set_version_installed, + set_version_switched, + ) + + _STATE_AVAILABLE = True + except ImportError: + _STATE_AVAILABLE = False logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO")) @@ -546,6 +561,61 @@ class Update: self.execute_log.append("Extra modules (CybroOdoo)") return os.system("./script/manifest/update_manifest_local_dev.sh") + def add_extra_to_config_conf(self): + """Append the extra addons (CybroOdoo, ...) to config.conf addons_path. + + generate_config.sh rewrites config.conf from a fixed addons list that + does NOT include the extra modules, so this must run AFTER it (last + writer wins). The paths come from the source-of-truth extra manifest + (projects tagged with the "extra" group). Already-present paths are + skipped, so the call is idempotent.""" + import xml.etree.ElementTree as ET + + ver = self.new_version_odoo # e.g. "18.0" + manifest = os.path.join( + "manifest", f"git_manifest_extra_odoo{ver}.xml" + ) + config_path = "config.conf" + if not os.path.isfile(manifest) or not os.path.isfile(config_path): + _logger.warning( + "Cannot add extra modules to config.conf:" + f" missing {manifest} or {config_path}." + ) + return + home = os.getcwd() + prefix = f"odoo{ver}/addons/" + extra_paths = [] + for project in ET.parse(manifest).getroot().findall("project"): + groups = project.get("groups", "").split(",") + path = project.get("path", "") + if "extra" in groups and path.startswith(prefix): + extra_paths.append(os.path.join(home, path)) + if not extra_paths: + return + + with open(config_path, "r", encoding="utf-8") as f: + lines = f.readlines() + changed = False + for i, line in enumerate(lines): + if not line.startswith("addons_path"): + continue + value = line.rstrip("\n").split("=", 1)[1] if "=" in line else "" + existing = [p.strip() for p in value.split(",") if p.strip()] + missing = [p for p in extra_paths if p not in existing] + if missing: + lines[i] = "addons_path = " + ",".join(existing + missing) + "\n" + changed = True + break + if changed: + with open(config_path, "w", encoding="utf-8") as f: + f.writelines(lines) + _logger.info( + "Added extra addons to config.conf: " + + ", ".join(extra_paths) + ) + else: + _logger.info("Extra addons already present in config.conf.") + def install_system(self): self.execute_log.append(f"System installation") status = os.system("./script/install/install_dev.sh") @@ -779,6 +849,13 @@ def main(): ) os.system("./script/generate_config.sh") + # config.conf : generate_config.sh (ci-dessus) réécrit le fichier à partir + # d'une liste d'addons figée SANS les modules extra ; on ajoute donc les + # chemins extra APRÈS coup (dernier writer). Idempotent, sans effet si + # --with_extra n'est pas demandé. + if update.config.with_extra and exit_code == 0: + update.add_extra_to_config_conf() + return exit_code # TODO ignore this if installation fail From 5e7391252b922f288acbf0d384da62763977258e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 03/10] [ADD] todo: install Textual on demand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TUI screens need Textual. Rather than naming it, the CLI offers to install it for the running interpreter, bounded to the major the screens are written for. --- FR --- Les écrans TUI ont besoin de Textual. Plutôt que de le nommer, le CLI propose de l'installer pour l'interpréteur courant, borné à la majeure pour laquelle les écrans sont écrits. Assisted-by: Claude Opus 4.8 Assisted-by: Claude Opus 5 --- requirement/erplibre_require-ments.txt | 7 ++ script/todo/textual_setup.py | 98 ++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 script/todo/textual_setup.py diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index 16203b1..9016738 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -24,6 +24,13 @@ openai humanize requests urwid +# Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI +# sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux. +textual>=8,<9 +# Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml. +# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc +# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame. +lxml python-dotenv python-dateutil unidecode diff --git a/script/todo/textual_setup.py b/script/todo/textual_setup.py new file mode 100644 index 0000000..eae64b1 --- /dev/null +++ b/script/todo/textual_setup.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Disponibilité de Textual, et installation à la demande. + +Les écrans TUI du CLI TODO (télémétrie, dashboard d'installation, formulaire +de déploiement, reprise de migration) dépendent tous de Textual. Sans lui, ils +se contentaient d'un « Installez textual (pip) » qui laissait l'utilisateur +chercher la bonne commande pour son système. + +`ensure(...)` répond à la question à sa place : Textual est-il là, et sinon +veut-on l'installer maintenant ? + +Module à part, et non une méthode de `TODO` : `todo_upgrade` en a besoin +aussi, et il est importé PAR `todo` — le mettre là créerait un cycle. +""" +from __future__ import annotations + +import importlib +import importlib.util # « import importlib » seul n'expose PAS .util +import subprocess +import sys + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Borne de version, à UN seul endroit. Les écrans TUI du dépôt sont écrits pour +# Textual 8 ; la bibliothèque casse son API entre majeures. « pip install +# textual » sans borne installerait la majeure suivante et casserait les écrans +# sans prévenir, alors même que requirement/erplibre_require-ments.txt la borne. +# Les deux chemins d'installation doivent dire la même chose : ce littéral est +# recopié dans le fichier de requirements, avec un commentaire qui pointe ici. +TEXTUAL_SPEC = "textual>=8,<9" + + +def available() -> bool: + """Textual est-il importable maintenant ?""" + return importlib.util.find_spec("textual") is not None + + +def in_venv() -> bool: + """Vrai si l'interpréteur courant est dans un environnement virtuel.""" + return sys.prefix != getattr(sys, "base_prefix", sys.prefix) + + +def install_command(): + """Commande d'installation adaptée à l'interpréteur QUI TOURNE. + + C'est lui qui devra importer Textual, pas le python du système : viser + `sys.executable` évite d'installer un paquet distribution que le venv ne + verrait jamais. Hors venv, « --user » contourne le refus des + distributions dont l'environnement est « externally managed » (PEP 668). + """ + cmd = [sys.executable, "-m", "pip", "install", TEXTUAL_SPEC] + if not in_venv(): + cmd.insert(4, "--user") + return cmd + + +def ensure(prompt=True, ask=input): + """Textual disponible ? Sinon proposer de l'installer. Renvoie un booléen. + + `prompt=False` se contente de constater — pour les appels qui ne peuvent + pas poser de question. `ask` est injectable pour les tests. + """ + if available(): + return True + print(f"\n⚠ {t('Textual is required for this screen.')}") + if not prompt: + return False + answer = ask(t("Install it now? (Y/n): ")).strip().lower() + if answer and answer not in ("y", "yes", "o", "oui"): + return False + + cmd = install_command() + print(f" {t('Will execute:')} {' '.join(cmd)}") + try: + status = subprocess.run(cmd).returncode + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ {exc}") + return False + + # Un import négatif est mémorisé : sans purge du cache, Textual resterait + # « absent » pour ce processus alors qu'il vient d'être installé. + importlib.invalidate_caches() + if available(): + print(f"✅ {t('Textual is installed.')}") + return True + print(f" ⚠ {t('Installation finished but textual is still missing.')}") + if status: + print(f" {t('pip exited with')} {status}") + print(f" {t('Your distribution may package it as python3-textual.')}") + return False From ba5aa5c97d66ca2eefbe3f479ebea3ffc48b17c2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 04/10] [ADD] todo: dashboard to follow the VM installs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One Textual dashboard per install run: live status, logs, host telemetry, error counts and history. The installs run detached, so closing it does not stop them. --- FR --- Un tableau de bord Textual par exécution : état en direct, logs, télémétrie de l'hôte, comptes d'erreurs et historique. Les installations tournent détachées : le fermer ne les arrête pas. Assisted-by: Claude Opus 4.8 --- script/todo/qemu_install_monitor.py | 1315 +++++++++++++++++++++++++++ 1 file changed, 1315 insertions(+) create mode 100644 script/todo/qemu_install_monitor.py diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py new file mode 100644 index 0000000..f0e16fa --- /dev/null +++ b/script/todo/qemu_install_monitor.py @@ -0,0 +1,1315 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Dashboard Textual : suivi des installations ERPLibre parallèles sur VM. + +Principe « détachable » : chaque installation tourne dans un processus +DÉTACHÉ (setsid) qui écrit un fichier log et, à la fin, un marqueur +« __ERPLIBRE_EXIT__ ». Ce module ne fait que VISUALISER ces fichiers : +quitter le dashboard n'arrête rien, on peut le rouvrir pour ré-attacher. + +- launch_installs(...) : lance les process détachés + écrit un manifeste JSON. +- run_monitor(manifest_path) : ouvre le dashboard Textual sur un manifeste. +""" +from __future__ import annotations + +import asyncio +import json +import os +import shlex +import shutil +import socket +import subprocess +import time +from pathlib import Path + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +EXIT_MARKER = "__ERPLIBRE_EXIT__" +SSH_OPTS = ( + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " + "-o ConnectTimeout=8" +) + + +def session_dir() -> Path: + """Répertoire des logs/manifestes d'installation (créé au besoin).""" + base = Path(os.path.expanduser("~/.erplibre/qemu-install")) + base.mkdir(parents=True, exist_ok=True) + return base + + +def list_install_runs() -> list: + """Runs d'installation passés (chacun a un session.json), TRIÉS du plus + récent au plus ancien. Chaque entrée : dict {label, manifest, mtime, + vms, branch}. Permet de ROUVRIR le suivi d'un run (le dashboard s'étant + fermé sur un bug, on reprend l'analyse).""" + runs = [] + for d in sorted(session_dir().glob("*/"), reverse=True): + manifest = d / "session.json" + if not manifest.is_file(): + continue + try: + data = json.loads(manifest.read_text()) + mtime = manifest.stat().st_mtime + except (OSError, ValueError): + continue + runs.append( + { + "label": d.name.rstrip("/"), + "manifest": str(manifest), + "mtime": mtime, + "vms": data.get("vms", []), + "branch": data.get("branch", ""), + } + ) + runs.sort(key=lambda r: r["mtime"], reverse=True) + return runs + + +def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None: + """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise + la sortie puis écrit le marqueur de fin avec le code de sortie.""" + # Sonde de disponibilité : on attend que sshd réponde ET que cloud-init + # soit TERMINÉ, via des connexions COURTES successives (jusqu'à ~20 min : + # une architecture ÉMULÉE, s390x/arm64 sur hôte x86, boote lentement). + # Au 1er boot, cloud-init régénère les clés d'hôte et REDÉMARRE sshd : une + # session SSH longue (ex. « cloud-init status --wait ») serait alors tuée + # (« Connection closed by remote host », exit 255 — cas Fedora). Chaque + # itération étant une connexion neuve, un redémarrage de sshd ne casse que + # la tentative en cours. On imprime toujours l'état (|| true) pour matcher + # sur le TEXTE, « status: running » n'ayant pas de code de sortie fiable. + ci_probe = ( + "if command -v cloud-init >/dev/null 2>&1; then " + "cloud-init status 2>/dev/null || true; else echo nocloudinit; fi" + ) + log_q = shlex.quote(log_path) + # On écrit un message d'attente + un battement toutes les ~30 s : sinon le + # log reste VIDE pendant tout le boot émulé et paraît « bloqué ». + msg_wait = t("Waiting for the VM to start (boot + cloud-init)") + msg_slow = t("(an emulated architecture can be slow; this is normal)") + msg_ready = t("VM ready - starting the ERPLibre install") + wrapper = ( + f"echo {shlex.quote('== ' + msg_wait + ' ==')} >> {log_q}; " + f"echo {shlex.quote(' ' + msg_slow)} >> {log_q}; " + f"for i in $(seq 1 240); do " + f"st=$(ssh {SSH_OPTS} -o BatchMode=yes erplibre@{ip} " + f"{shlex.quote(ci_probe)} 2>/dev/null); " + f'case "$st" in ' + f"*done*|*disabled*|*error*|*degraded*|*nocloudinit*) break;; " + f"esac; " + f'if [ $((i % 6)) -eq 0 ]; then echo " ... $((i*5))s" >> {log_q}; fi; ' + f"sleep 5; done; " + f"echo {shlex.quote('== ' + msg_ready + ' ==')} >> {log_q}; " + f"ssh {SSH_OPTS} erplibre@{ip} {shlex.quote(remote_cmd)} " + f">> {log_q} 2>&1; " + f'echo "{EXIT_MARKER} $?" >> {log_q}' + ) + # setsid -f : le process survit à la fermeture du menu / du dashboard. + subprocess.Popen( + ["setsid", "-f", "bash", "-c", wrapper], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + + +def _log_header(vm: dict, branch: str, when: str) -> str: + """En-tête du log : date, VM, distribution, version, architecture, branche. + Permet d'identifier l'installation d'un coup d'œil (et de ne jamais laisser + le log vide pendant l'attente du boot).""" + distro = vm.get("distro") or "?" + version = vm.get("version") or "" + arch = vm.get("arch") or "?" + bar = "=" * 64 + return ( + f"{bar}\n" + f" ERPLibre — {t('installation')}\n" + f" Date : {when}\n" + f" VM : {vm['name']}\n" + f" Distribution : {distro} {version}\n" + f" Architecture : {arch}\n" + f" Branche : {branch}\n" + f" IP : {vm['ip']}\n" + f"{bar}\n\n" + ) + + +def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: + """vms : [{name, ip, distro?, version?, arch?}]. Lance chaque install + détachée, écrit un manifeste et retourne son chemin. remote_cmd : script + exécuté dans chaque VM.""" + sdir = session_dir() + stamp = time.strftime("%Y%m%d-%H%M%S") + when = time.strftime("%Y-%m-%d %H:%M:%S") + logdir = sdir / stamp + logdir.mkdir(parents=True, exist_ok=True) + entries = [] + for vm in vms: + log_path = str(logdir / f"{vm['name']}.log") + # En-tête d'emblée (date/distro/version/arch) : le log n'est jamais + # vide, l'utilisateur voit tout de suite QUOI s'installe. + Path(log_path).write_text(_log_header(vm, branch, when)) + _launch_one(vm["ip"], remote_cmd, log_path) + entries.append( + { + "name": vm["name"], + "ip": vm["ip"], + "distro": vm.get("distro"), + "version": vm.get("version"), + "arch": vm.get("arch"), + "log": log_path, + "ssh": f"ssh erplibre@{vm['ip']}", + } + ) + manifest = { + "branch": branch, + "started": time.time(), + "vms": entries, + } + manifest_path = str(logdir / "session.json") + Path(manifest_path).write_text(json.dumps(manifest, indent=2)) + return manifest_path + + +def read_status(log_path: str) -> tuple[str, int | None]: + """(état, code) d'un log : pending / running / done / failed. Ne lit que + la FIN du fichier (le marqueur de sortie est sur la dernière ligne) : lire + tout le log de 30 VM chaque seconde saturait la boucle d'événements du TUI + (lag, interface figée quand l'I/O ralentit).""" + try: + size = os.path.getsize(log_path) + if size == 0: + return "pending", None + with open(log_path, "rb") as fh: + if size > 4096: + fh.seek(-4096, os.SEEK_END) + tail = fh.read().decode(errors="replace") + except OSError: + return "pending", None + if not tail.strip(): + return "pending", None + for line in reversed(tail.splitlines()): + if EXIT_MARKER in line: + try: + code = int(line.split()[-1]) + except ValueError: + code = 1 + return ("done" if code == 0 else "failed"), code + return "running", None + + +# Listes d'ignore reprises de script/test/run_parallel_test.py (erreurs/ +# avertissements connus et bénins) : on réutilise la MÊME logique de détection +# que la suite de tests ERPLibre pour analyser les logs d'installation. +_LST_IGNORE_WARNING = ( + "have the same label:", + "odoo.addons.code_generator.extractor_module_file: Ignore next error about" + " ALTER TABLE DROP CONSTRAINT.", +) +_LST_IGNORE_ERROR = ( + "fetchmail_notify_error_to_sender", + 'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT' + ' "db_backup_db_backup_name_unique"', + 'ERROR: constraint "db_backup_db_backup_name_unique" of relation' + ' "db_backup" does not exist', + 'odoo.sql_db: bad query: ALTER TABLE "db_backup" DROP CONSTRAINT' + ' "db_backup_db_backup_days_to_keep_positive"', + 'ERROR: constraint "db_backup_db_backup_days_to_keep_positive" of relation' + ' "db_backup" does not exist', + "odoo.addons.code_generator.extractor_module_file: Ignore next error about" + " ALTER TABLE DROP CONSTRAINT.", +) + + +def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]: + """(lignes_erreur, lignes_avertissement) d'un log, même détection que + scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les + afficher. Chaque ligne est préfixée de son numéro (1-indexé).""" + try: + text = Path(log_path).read_text(errors="replace") + except OSError: + return [], [] + errs, warns = [], [] + for i, line in enumerate(text.splitlines(), 1): + if EXIT_MARKER in line: + continue + low = line.lower() + if ( + "error" in low + and not any(ig in line for ig in _LST_IGNORE_ERROR) + and len(errs) < cap + ): + errs.append(f"{i}: {line}") + if ( + "warning" in low + and not any(ig in line for ig in _LST_IGNORE_WARNING) + and len(warns) < cap + ): + warns.append(f"{i}: {line}") + return errs, warns + + +def scan_log_errors(log_path: str) -> tuple[int, int]: + """(nb_erreurs, nb_avertissements) dans un log d'installation, en + réutilisant la détection de la suite de tests ERPLibre : sous-chaîne + « error »/« warning » (insensible à la casse) moins les listes d'ignore. + Lit le fichier COMPLET (appelé une seule fois, à la complétion d'une VM). + """ + try: + text = Path(log_path).read_text(errors="replace") + except OSError: + return 0, 0 + nerr = nwarn = 0 + for line in text.splitlines(): + low = line.lower() + if EXIT_MARKER in line: + continue + if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR): + nerr += 1 + if "warning" in low and not any( + ig in line for ig in _LST_IGNORE_WARNING + ): + nwarn += 1 + return nerr, nwarn + + +def _port_open(ip: str, port: int = 8069, timeout: float = 0.5) -> bool: + """Vrai si un TCP connect réussit (l'UI web Odoo écoute sur :8069).""" + if not ip: + return False + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + +def _read_new(path: str, offset: int) -> tuple[str, int]: + """Lit le log à partir de `offset` (lecture incrémentale). Renvoie + (nouveau_texte, nouvel_offset). Bloquant -> à appeler dans un thread.""" + try: + with open(path, "r", errors="replace") as fh: + fh.seek(offset) + data = fh.read() + return data, fh.tell() + except OSError: + return "", offset + + +def _read_tail( + path: str, max_bytes: int = 131072, max_lines: int = 1000 +) -> tuple[str, int]: + """Lit uniquement la FIN du log (dernier `max_bytes`, tronqué à + `max_lines` lignes) et renvoie (texte, TAILLE TOTALE du fichier). Utilisé + au CHANGEMENT de VM : lire+réafficher le fichier ENTIER (offset 0) gelait + l'UI sur les gros logs (250 Ko / milliers de lignes). L'offset renvoyé = + taille totale -> le suivi incrémental (_tick_log) continue depuis la fin. + """ + try: + size = os.path.getsize(path) + with open(path, "rb") as fh: + if size > max_bytes: + fh.seek(-max_bytes, os.SEEK_END) + raw = fh.read() + text = raw.decode(errors="replace") + # Si on a coupé au milieu d'une ligne, jeter la 1re ligne partielle. + if size > max_bytes and "\n" in text: + text = text.split("\n", 1)[1] + lines = text.splitlines() + if len(lines) > max_lines: + lines = lines[-max_lines:] + return "\n".join(lines), size + except OSError: + return "", 0 + + +# --------------------------------------------------------------------------- # +# Télémétrie, historique de durées (ETA), navigateur CLI +# --------------------------------------------------------------------------- # +def _stats_path() -> Path: + """Fichier d'historique persistant des installations. DÉDIÉ dans + .venv.erplibre du dépôt (repli sur ~/.erplibre si le venv est absent).""" + try: + venv = Path(__file__).resolve().parents[2] / ".venv.erplibre" + venv.mkdir(parents=True, exist_ok=True) + return venv / "qemu_install_stats.json" + except OSError: + return session_dir() / "stats.json" + + +def load_stats() -> dict: + try: + return json.loads(_stats_path().read_text()) + except (OSError, ValueError): + return {} + + +def record_duration(distro, version, arch, secs, ok=True) -> None: + """Enregistre une install (distro + version + archi + durée + horodatage) + dans l'historique, pour l'ETA et les moyennes par archi/distro. Garde les + 500 derniers runs. + + `ok=False` conserve la trace d'un ÉCHEC : sans elle aucun taux de réussite + n'est calculable. Les échecs sont exclus des moyennes et de l'ETA (leur + durée ne dit rien du temps d'une install qui aboutit).""" + data = load_stats() + runs = data.setdefault("runs", []) + runs.append( + { + "distro": distro or "?", + "version": version or "?", + "arch": arch or "?", + "seconds": int(secs), + "ts": int(time.time()), + "ok": bool(ok), + } + ) + data["runs"] = runs[-500:] + try: + _stats_path().write_text(json.dumps(data, ensure_ascii=False)) + except OSError: + pass + + +def reset_stats() -> int: + """Vide l'historique. Renvoie le nombre de runs effacés.""" + count = len(all_runs()) + try: + _stats_path().write_text(json.dumps({"runs": []})) + except OSError: + pass + return count + + +def all_runs(stats=None): + """Tous les runs, succès ET échecs.""" + return (stats or load_stats()).get("runs", []) or [] + + +def _runs(stats=None): + """Runs RÉUSSIS seulement : base des moyennes et de l'ETA. + + Les entrées écrites avant l'ajout du champ « ok » n'en ont pas ; elles + étaient forcément des succès (seuls ceux-là étaient enregistrés). + """ + return [r for r in all_runs(stats) if r.get("ok", True)] + + +def stats_summary(stats=None): + """Chiffres globaux de l'historique d'installation. + + Renvoie un dict vide quand rien n'a encore été enregistré, pour que + l'appelant distingue « aucune donnée » de « zéro seconde ».""" + runs = all_runs(stats) + if not runs: + return {} + ok = [r for r in runs if r.get("ok", True)] + secs = sorted(r["seconds"] for r in ok) + lst_ts = [r.get("ts", 0) for r in runs if r.get("ts")] + return { + "total": len(runs), + "ok": len(ok), + "failed": len(runs) - len(ok), + "first_ts": min(lst_ts) if lst_ts else 0, + "last_ts": max(lst_ts) if lst_ts else 0, + "median": secs[len(secs) // 2] if secs else 0, + "min": secs[0] if secs else 0, + "max": secs[-1] if secs else 0, + "total_secs": sum(secs), + } + + +def stats_by(field, stats=None): + """Agrège par « distro », « arch » ou « distro version ». + + Renvoie [(clé, nb_réussis, moyenne_secondes, nb_échecs)] trié du plus + utilisé au moins utilisé.""" + dct = {} + for run in all_runs(stats): + if field == "version": + key = f"{run.get('distro', '?')} {run.get('version', '?')}" + else: + key = run.get(field, "?") + entry = dct.setdefault(key, {"secs": [], "failed": 0}) + if run.get("ok", True): + entry["secs"].append(run["seconds"]) + else: + entry["failed"] += 1 + lst = [ + ( + key, + len(e["secs"]), + int(sum(e["secs"]) / len(e["secs"])) if e["secs"] else 0, + e["failed"], + ) + for key, e in dct.items() + ] + return sorted(lst, key=lambda row: (-(row[1] + row[3]), row[0])) + + +def eta_reference(stats, arch): + """Durée d'install de RÉFÉRENCE (médiane) pour cette archi ; repli toutes + archis confondues. None si aucun historique.""" + runs = _runs(stats) + secs = [r["seconds"] for r in runs if r.get("arch") == arch] + if not secs: + secs = [r["seconds"] for r in runs] + if not secs: + return None + s = sorted(secs) + return s[len(s) // 2] + + +def _avg(field, value, stats=None): + secs = [r["seconds"] for r in _runs(stats) if r.get(field) == value] + return (sum(secs) / len(secs)) if secs else None + + +def avg_by_arch(arch, stats=None): + """(moyenne_secondes, nb_runs) pour cette archi, ou (None, 0).""" + secs = [r["seconds"] for r in _runs(stats) if r.get("arch") == arch] + return (sum(secs) / len(secs), len(secs)) if secs else (None, 0) + + +def avg_by_distro(distro, stats=None): + """(moyenne_secondes, nb_runs) pour cette distro, ou (None, 0).""" + secs = [r["seconds"] for r in _runs(stats) if r.get("distro") == distro] + return (sum(secs) / len(secs), len(secs)) if secs else (None, 0) + + +def avg_by_version(distro, version, stats=None): + """(moyenne_secondes, nb_runs) pour cette (distro, version), ou (None, 0).""" + secs = [ + r["seconds"] + for r in _runs(stats) + if r.get("distro") == distro and r.get("version") == version + ] + return (sum(secs) / len(secs), len(secs)) if secs else (None, 0) + + +def last_run(stats=None): + """Dernier run enregistré (dict) ou None.""" + runs = _runs(stats) + return runs[-1] if runs else None + + +def _fmt_size(nbytes) -> str: + """Octets -> « 1.2G » / « 345M » / « 12K ».""" + if nbytes is None: + return "-" + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)): + if nbytes >= div: + return f"{nbytes / div:.1f}{unit}" + return f"{nbytes // 1024}K" + + +def _fmt_secs(secs) -> str: + """Secondes -> « 45s » / « 12m » / « 1h05 ».""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + return f"{secs // 3600}h{(secs % 3600) // 60:02d}" + + +def vm_disk_path(vm: dict) -> str: + """Chemin du qcow2 de la VM (défaut libvirt si non fourni).""" + return vm.get("disk") or f"/var/lib/libvirt/images/{vm['name']}.qcow2" + + +def disk_actual_size(path: str) -> int | None: + """Taille RÉELLEMENT occupée du qcow2 (creux) via st_blocks.""" + try: + st = os.stat(path) + return int(getattr(st, "st_blocks", 0)) * 512 + except OSError: + return None + + +# Navigateurs web en ligne de commande, par ordre de préférence (rendu JS +# d'abord — utile pour l'UI Odoo — puis navigateurs texte classiques). +CLI_BROWSERS = ("browsh", "carbonyl", "w3m", "links", "elinks", "lynx") + + +def cli_browser() -> str | None: + """Premier navigateur CLI disponible dans le PATH, sinon None.""" + for name in CLI_BROWSERS: + if shutil.which(name): + return name + return None + + +def _os_id() -> str: + """ID de la distribution hôte (/etc/os-release), ex. « ubuntu », « fedora ».""" + try: + for line in open("/etc/os-release", encoding="utf-8"): + if line.startswith("ID="): + return line.split("=", 1)[1].strip().strip('"').lower() + except OSError: + pass + return "" + + +# Navigateurs CLI installables via apt/dnf/pacman (nom de paquet = binaire). +# browsh/carbonyl ne sont pas dans les dépôts standard -> non proposés ici. +INSTALLABLE_BROWSERS = ( + ("w3m", "w3m — léger, rend un peu de HTML"), + ("lynx", "lynx — navigateur texte"), + ("links", "links — texte / graphique"), + ("elinks", "elinks — texte, onglets"), +) + + +def browser_install_command(browser="w3m") -> list | None: + """Commande d'installation du navigateur CLI `browser` adaptée à l'OS hôte : + apt (Ubuntu/Debian), dnf (Fedora), pacman (Arch). None si gestionnaire + inconnu.""" + apt = ["sudo", "apt-get", "install", "-y", browser] + dnf = ["sudo", "dnf", "install", "-y", browser] + pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", browser] + by_id = { + "ubuntu": apt, + "debian": apt, + "linuxmint": apt, + "fedora": dnf, + "arch": pac, + } + cmd = by_id.get(_os_id()) + if cmd: + return cmd + if shutil.which("apt-get"): + return apt + if shutil.which("dnf"): + return dnf + if shutil.which("pacman"): + return pac + return None + + +def virsh_domstates() -> dict: + """{nom: état} de tous les domaines libvirt (« virsh list --all »). Sert à + détecter une VM EN PAUSE ou EFFACÉE pendant le suivi. Un seul appel virsh + pour tout le parc (à interroger à intervalle LENT).""" + try: + res = subprocess.run( + ["sudo", "virsh", "list", "--all"], + capture_output=True, + text=True, + timeout=15, + # LC_ALL=C : sortie en ANGLAIS (« running »/« paused »/« shut off » + # + en-tête « Id Name State ») quelle que soit la locale de l'hôte. + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + return {} + states = {} + for line in res.stdout.splitlines(): + parts = line.split() + # Ignore l'en-tête (« Id Name State ») et le séparateur (« ---- », + # un seul token). Une VM éteinte a « - » en Id : à NE PAS ignorer. + if len(parts) < 3 or parts[0] == "Id": + continue + states[parts[1]] = " ".join(parts[2:]) + return states + + +# --------------------------------------------------------------------------- # +# Dashboard Textual +# --------------------------------------------------------------------------- # +def run_monitor(manifest_path: str, run_app: bool = True): + """Ouvre le dashboard Textual sur un manifeste d'installation. `run_app` + à False renvoie l'instance sans la lancer (tests headless).""" + from textual.app import App, ComposeResult + from textual.containers import Horizontal, Vertical + from textual.screen import ModalScreen + from textual.widgets import DataTable, Footer, Header, RichLog, Static + + manifest = json.loads(Path(manifest_path).read_text()) + started = manifest.get("started", time.time()) + vms = manifest["vms"] + + class ErrorLinesScreen(ModalScreen): + """Petite fenêtre modale : liste les LIGNES d'erreurs/avertissements + d'une VM pour les lire (défilable). Échap / q pour fermer.""" + + BINDINGS = [ + ("escape", "dismiss", "Fermer"), + ("q", "dismiss", "Fermer"), + ] + + def __init__(self, vm_name, errs, warns): + super().__init__() + self._vm = vm_name + self._errs = errs + self._warns = warns + + def compose(self) -> ComposeResult: + with Vertical(id="errbox"): + yield Static( + f" {self._vm} — ⚠ {len(self._errs)} " + f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}" + f" ({t('Esc to close')})", + id="errtitle", + ) + yield RichLog( + id="errlog", highlight=False, markup=False, wrap=True + ) + + def on_mount(self) -> None: + log = self.query_one("#errlog", RichLog) + if self._errs: + log.write(f"── {t('errors').capitalize()} ──") + for line in self._errs: + log.write(line) + if self._warns: + log.write(f"── {t('warnings').capitalize()} ──") + for line in self._warns: + log.write(line) + if not self._errs and not self._warns: + log.write(t("No error detected.")) + + def action_dismiss(self) -> None: + self.dismiss() + + ICON = { + "pending": "⏳", + "running": "⏳", + "done": "✅", + "failed": "❌", + } + + class Monitor(App): + CSS = """ + DataTable { width: 74; height: 1fr; overflow-x: auto; border: solid $accent; } + RichLog { border: solid $accent; } + #telemetry { height: 1; color: $text-muted; } + #stats { height: 1; color: $accent; } + #statsdetail { display: none; height: auto; color: $text-muted; } + #sshbar { height: 2; color: $text-muted; } + ErrorLinesScreen { align: center middle; } + #errbox { + width: 80%; height: 70%; + border: thick $accent; background: $surface; + } + #errtitle { height: 1; color: $accent; text-style: bold; } + #errlog { height: 1fr; border: solid $accent; } + """ + BINDINGS = [ + ("q", "quit", "Quitter (détaché)"), + ("s", "ssh", "SSH"), + ("w", "web", "Web (navigateur CLI)"), + ("f", "follow", "Suivre"), + ("c", "copy_log", "Copier log"), + ("d", "details", "Détails erreurs"), + ("p", "pause_all", "Pause tout"), + ("o", "resume_all", "Reprendre tout"), + ] + + def __init__(self): + super().__init__() + self._offsets = {vm["name"]: 0 for vm in vms} + self._selected = vms[0]["name"] if vms else None + self._follow = True + # Statuts TERMINAUX mémorisés : une VM finie n'est plus relue + # (réduit fortement l'I/O sur un gros parc). Valeur = (état, code, + # durée à la complétion). + self._final = {} + # Cache des cellules AFFICHÉES : on n'appelle update_cell (donc on + # ne re-render) que si la valeur CHANGE -> plus de churn de rendu. + self._cells = {} + # Historique de durées (ETA) + dossier disque à surveiller. + self._stats = load_stats() + self._disk_dir = ( + os.path.dirname(vm_disk_path(vms[0])) if vms else "/" + ) + # État libvirt (running/paused/gone), rafraîchi à intervalle LENT. + self._domstate = {} + # Erreurs détectées dans le log à la complétion : {nom: (err, warn)}. + self._errcount = {} + # Sommaire de stats déplié (clic) ou non. + self._stats_open = False + # VM dont l'UI Odoo (:8069) répond déjà : une fois détectée « up », + # on ne re-teste plus (Odoo ne redescend pas en cours d'install). + self._odoo_up = set() + # Debounce du changement de VM : la sélection défile vite au + # clavier ; on ne recharge le log qu'une fois le curseur STABILISÉ. + self._pending_sel = None + self._sel_timer = None + + @staticmethod + def _fmt(secs): + mm, ss = divmod(int(secs), 60) + return f"{mm:02d}:{ss:02d}" + + def _set_cell(self, table, name, col, value): + key = (name, col) + if self._cells.get(key) == value: + return + self._cells[key] = value + table.update_cell(name, col, value) + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + table = DataTable(id="vms", cursor_type="row") + # Clés de colonnes explicites : update_cell() les référence. + # La colonne « ⚠ » (erreurs détectées) est à GAUCHE d'« État ». + # Largeurs FIXES pour les colonnes courtes -> l'État n'est plus + # tronqué (« ❌ effacée », « ⏸ en pause » lisibles) ; la table + # défile horizontalement (overflow-x) pour les noms de VM longs. + # « # » : numéro de séquence de la VM (première colonne). + table.add_column("#", key="seq", width=3) + table.add_column("VM", key="vm", width=22) + table.add_column("⚠", key="err", width=4) + # width=8 : le plus long libellé restant est « ⏸ pause » (7) — + # « effacée » (10) est remplacé par l'icône 🗑 (voir plus bas). + table.add_column("État", key="state", width=8) + # « Odoo » : l'UI web répond-elle sur :8069 ? (🟢 up / — down) + table.add_column("Odoo", key="odoo", width=6) + table.add_column("Durée", key="elapsed", width=7) + table.add_column("Disque", key="disk", width=8) + for i, vm in enumerate(vms, 1): + table.add_row( + str(i), + vm["name"], + "", + "⏳", + "—", + "--:--", + "-", + key=vm["name"], + ) + # max_lines borne la mémoire/rendu (un install verbeux × 30 VM). + self._log = RichLog( + id="log", highlight=False, markup=False, max_lines=5000 + ) + with Horizontal(): + yield table + yield self._log + # Barre de télémétrie hôte (CPU, disque, ETA parc). + yield Static("", id="telemetry") + # Sommaire de stats en CHIFFRES (cliquable -> détail). + yield Static("", id="stats") + yield Static("", id="statsdetail") + yield Static("", id="sshbar") + yield Footer() + + def on_mount(self) -> None: + # Le NOMBRE de VM figure dans le titre ; le sous-titre suit la + # progression (terminées / total + durée globale). + self.title = ( + f"ERPLibre — {t('install monitoring')} ({len(vms)} VM)" + ) + self.sub_title = f"0/{len(vms)} {t('completed')}" + self._refresh_ssh() + self._load_selected_log(reset=True) + # Table toutes les 2 s (30 lectures de fin de log), suivi du log + # sélectionné toutes les 1 s (une seule lecture incrémentale). + self.set_interval(2.0, self._tick_table) + self.set_interval(1.0, self._tick_log) + # État libvirt (pause / effacée) : check LENT (appel virsh) toutes + # les 10 s ; le tableau applique le cache à chaque tick (2 s). + self.set_interval(10.0, self._tick_domstate) + # Premier relevé domstate immédiat (async -> via worker). + self.run_worker(self._tick_domstate(), exclusive=False) + + # -- helpers -------------------------------------------------------- # + def _vm_by_name(self, name): + return next((v for v in vms if v["name"] == name), None) + + def _refresh_ssh(self): + vm = self._vm_by_name(self._selected) + bar = self.query_one("#sshbar", Static) + if vm: + bar.update( + f" {vm['ssh']} (s = SSH · w = web :8069 · " + "c = copier le log · d = détails erreurs · " + "Maj+glisser = sélectionner)\n" + f" Log : {vm['log']}" + ) + + def _load_selected_log(self, reset=False): + # Au reset (changement de VM), on ne lit QUE LA FIN du log + # (_read_tail) et on cale l'offset sur la taille totale : le suivi + # incrémental (_tick_log) continue depuis la fin. Lire+réafficher + # le fichier ENTIER gelait l'UI sur les gros logs. + vm = self._vm_by_name(self._selected) + if not vm: + return + name = vm["name"] + if reset: + self._log.clear() + text, size = _read_tail(vm["log"]) + self._offsets[name] = size + for line in text.splitlines(): + if EXIT_MARKER not in line: + self._log.write(line) + return + new, off = _read_new(vm["log"], self._offsets.get(name, 0)) + self._offsets[name] = off + for line in new.splitlines(): + if EXIT_MARKER not in line: + self._log.write(line) + + def _collect_tele(self): + """(THREAD) chaîne de télémétrie hôte : CPU % + disque des images.""" + try: + ncpu = os.cpu_count() or 1 + load1 = os.getloadavg()[0] + du = shutil.disk_usage(self._disk_dir) + used_pct = int(du.used / du.total * 100) if du.total else 0 + return ( + f" ⚙ CPU {min(999, int(load1 / ncpu * 100))}% " + f"(charge {load1:.1f}/{ncpu}) " + f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" + f"{_fmt_size(du.total)} ({used_pct}%) · " + f"libre {_fmt_size(du.free)}" + ) + except Exception: + return "" + + def _collect_table(self): + """(THREAD) statut + taille disque de chaque VM + télémétrie. AUCUNE + mise à jour d'UI ici : uniquement des I/O bloquantes déportées.""" + disks, status, errors, odoo = {}, {}, {}, {} + for vm in vms: + name = vm["name"] + disks[name] = _fmt_size(disk_actual_size(vm_disk_path(vm))) + if ( + name not in self._final + and self._domstate.get(name) != "gone" + ): + st = read_status(vm["log"]) + status[name] = st + # À la complétion (succès OU échec), on ANALYSE le log + # complet pour signaler les erreurs — même un « succès » + # peut contenir des erreurs passées inaperçues. + if st[0] in ("done", "failed") and name not in errors: + errors[name] = scan_log_errors(vm["log"]) + # Odoo up ? On ne teste que celles pas encore confirmées up + # et non effacées (test TCP court sur :8069). + if ( + name not in self._odoo_up + and self._domstate.get(name) != "gone" + ): + if _port_open(vm.get("ip"), 8069): + odoo[name] = True + return disks, status, self._collect_tele(), errors, odoo + + async def _tick_table(self): + # I/O (lectures de logs, stat disque, /proc) DÉPORTÉES en thread -> + # la boucle d'événements Textual reste fluide même sous forte + # charge ou disque lent. Les mises à jour d'UI restent sur la boucle. + try: + disks, status, tele, errors, odoo = await asyncio.to_thread( + self._collect_table + ) + except Exception: + return + self._errcount.update(errors) + self._odoo_up.update(odoo) + try: + table = self.query_one("#vms", DataTable) + now = time.time() + remaining = [] + for vm in vms: + name = vm["name"] + self._set_cell(table, name, "disk", disks.get(name, "-")) + # Colonne Odoo : 🟢 dès que :8069 répond, sinon « — ». + self._set_cell( + table, + name, + "odoo", + "🟢" if name in self._odoo_up else "—", + ) + if name in self._final: + continue + ds = self._domstate.get(name) + if ds == "gone": + self._final[name] = ("deleted", None, now - started) + # Icône seule (VM effacée) : évite « ❌ effacée » (10 + # cellules) qui forçait une colonne État large. + self._set_cell(table, name, "state", "🗑") + continue + st = status.get(name) + if st is None: + continue + state, code = st + if state in ("done", "failed"): + elapsed = now - started + self._final[name] = (state, code, elapsed) + # Les échecs sont enregistrés eux aussi (ok=False) : + # sans eux, aucun taux de réussite n'est calculable. + record_duration( + vm.get("distro"), + vm.get("version"), + vm.get("arch"), + elapsed, + ok=state == "done", + ) + self._stats = load_stats() + lbl = "✅" if state == "done" else f"❌ ({code})" + self._set_cell(table, name, "state", lbl) + self._set_cell( + table, name, "elapsed", self._fmt(elapsed) + ) + # Colonne ⚠ (à gauche d'État) : erreurs détectées dans + # le log, y compris pour un « succès ». + self._set_cell( + table, + name, + "err", + self._err_label(self._errcount.get(name)), + ) + elif ds == "paused": + self._set_cell( + table, name, "state", f"⏸ {t('paused')}" + ) + else: + self._set_cell(table, name, "state", ICON[state]) + ref = eta_reference(self._stats, vm.get("arch")) + if ref is not None: + remaining.append(max(0, ref - (now - started))) + done = len(self._final) + eta = ( + f" · ETA ~{_fmt_secs(max(remaining))}" if remaining else "" + ) + # Max de durée : la VM TERMINÉE la plus lente (pire cas). + max_dur = max( + (el for _s, _c, el in self._final.values()), default=0 + ) + maxd = f" · max {self._fmt(max_dur)}" if max_dur else "" + self.sub_title = ( + f"{done}/{len(vms)} {t('completed')} · " + f"{self._fmt(now - started)}{eta}{maxd}" + ) + if tele: + self.query_one("#telemetry", Static).update(tele) + self._update_stats() + except Exception: + pass + + @staticmethod + def _err_label(counts): + """Libellé de la colonne ⚠ : « ⚠N » si erreurs, « ⚡N » si seulement + des avertissements, « ✓ » si log propre.""" + if not counts: + return "" + nerr, nwarn = counts + if nerr: + return f"⚠{nerr}" + if nwarn: + return f"⚡{nwarn}" + return "✓" + + def _stats_counts(self): + """Compte par catégorie (terminées, échecs, erreurs, etc.).""" + done = fail = deleted = err_vms = warn_vms = 0 + for name, (state, _code, _el) in self._final.items(): + if state == "done": + done += 1 + elif state == "failed": + fail += 1 + elif state == "deleted": + deleted += 1 + counts = self._errcount.get(name) + if counts: + if counts[0]: + err_vms += 1 + elif counts[1]: + warn_vms += 1 + running = paused = 0 + for vm in vms: + if vm["name"] in self._final: + continue + if self._domstate.get(vm["name"]) == "paused": + paused += 1 + else: + running += 1 + return { + "total": len(vms), + "done": done, + "fail": fail, + "deleted": deleted, + "running": running, + "paused": paused, + "err_vms": err_vms, + "warn_vms": warn_vms, + } + + def _update_stats(self): + c = self._stats_counts() + line = ( + f" 📊 {c['total']} VM · ✅ {c['done']} · ❌ {c['fail']} · " + f"⏳ {c['running']} · ⏸ {c['paused']} · 🗑 {c['deleted']} · " + f"⚠ {c['err_vms']} · ⚡ {c['warn_vms']} " + f"({t('click to expand')})" + ) + self.query_one("#stats", Static).update(line) + if self._stats_open: + self.query_one("#statsdetail", Static).update( + self._render_stats_detail() + ) + + def _render_stats_detail(self): + """Détail déplié : VM avec erreurs/avertissements + moyennes hist.""" + lines = [] + for name, (state, code, el) in self._final.items(): + counts = self._errcount.get(name) + if counts and (counts[0] or counts[1]): + lines.append( + f" • {name}: ⚠{counts[0]} erreurs, " + f"⚡{counts[1]} avert. ({self._fmt(el)})" + ) + if not lines: + lines.append(f" {t('No error detected.')}") + return "\n".join(lines) + + async def _tick_log(self): + if not self._follow: + return + vm = self._vm_by_name(self._selected) + if not vm: + return + name = vm["name"] + try: + new, off = await asyncio.to_thread( + _read_new, vm["log"], self._offsets.get(name, 0) + ) + except Exception: + return + self._offsets[name] = off + for line in new.splitlines(): + if EXIT_MARKER not in line: + self._log.write(line) + + async def _tick_domstate(self): + # Relevé LENT (subprocess virsh) DÉPORTÉ en thread : ne bloque plus + # la boucle. Une VM absente de « virsh list » est EFFACÉE (« gone »). + try: + states = await asyncio.to_thread(virsh_domstates) + except Exception: + return + for vm in vms: + self._domstate[vm["name"]] = states.get(vm["name"], "gone") + + # -- events --------------------------------------------------------- # + def on_data_table_row_highlighted(self, event) -> None: + # DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du + # curseur. Recharger le log à chaque pas (surtout en maintenant la + # flèche) enchaînait les rechargements -> gros lag. On diffère de + # 0,25 s et on ne charge que la DERNIÈRE VM sélectionnée. + name = event.row_key.value + if not name or name == self._selected: + return + self._pending_sel = name + if self._sel_timer is not None: + self._sel_timer.stop() + self._sel_timer = self.set_timer(0.25, self._apply_pending_sel) + + def _apply_pending_sel(self) -> None: + name = self._pending_sel + self._sel_timer = None + if not name or name == self._selected: + return + self._selected = name + self._refresh_ssh() + self._load_selected_log(reset=True) + + def action_follow(self) -> None: + self._follow = not self._follow + + def action_ssh(self) -> None: + vm = self._vm_by_name(self._selected) + if not vm: + return + with self.suspend(): + os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") + + def action_web(self) -> None: + """Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI + (browsh/carbonyl/w3m/links/elinks/lynx). Surtout utile une fois + l'installation TERMINÉE.""" + vm = self._vm_by_name(self._selected) + if not vm or not vm.get("ip"): + self.notify("Pas d'IP pour cette VM.", title="Web") + return + browser = self._choose_browser() + if not browser: + return + url = f"http://{vm['ip']}:8069" + with self.suspend(): + print(f"→ {browser} {url}") + rc = os.system(f"{browser} {shlex.quote(url)}") + # Diagnostic : sinon le navigateur « clignote » et revient au + # TUI sans qu'on voie l'erreur (souvent Odoo pas démarré). + print(f"\n[{browser}] terminé (code {rc}).") + if rc != 0: + print( + "La page ne s'est peut-être pas affichée : Odoo n'est " + "pas démarré sur :8069, ou réseau/pare-feu. Vérifie que " + "le service Odoo tourne dans la VM (make run / systemd)." + ) + try: + input("Entrée pour revenir au suivi… ") + except EOFError: + pass + + def _choose_browser(self): + """Offre la LISTE des navigateurs CLI installés et laisse choisir + lequel utiliser pour voir la page. Si aucun n'est installé, propose + d'en installer un. Renvoie le binaire choisi, ou None.""" + available = [b for b in CLI_BROWSERS if shutil.which(b)] + if not available: + browser = self._install_cli_browser() + if not browser: + self.notify( + "Aucun navigateur CLI disponible.", + title="Web", + severity="warning", + ) + return browser + with self.suspend(): + print("Quel navigateur utiliser pour voir la page ?") + for i, b in enumerate(available, 1): + print(f" [{i}] {b}{' *' if i == 1 else ''}") + print(" [i] Installer un autre navigateur") + sel = ( + input(f"Choix (numéro, vide = {available[0]}) : ") + .strip() + .lower() + ) + if sel == "i": + return self._install_cli_browser() + if not sel: + return available[0] + try: + idx = int(sel) - 1 + if 0 <= idx < len(available): + return available[idx] + except ValueError: + pass + return available[0] + + def _install_cli_browser(self): + """Demande QUEL navigateur CLI installer (w3m/lynx/links/elinks), + affiche la commande, l'exécute après validation. Renvoie le binaire + désormais disponible, ou None.""" + with self.suspend(): + print("Aucun navigateur CLI installé. Lequel installer ?") + for i, (b, desc) in enumerate(INSTALLABLE_BROWSERS, 1): + print(f" [{i}] {desc}{' *' if i == 1 else ''}") + sel = input("Choix (numéro, vide = w3m) : ").strip() + browser = INSTALLABLE_BROWSERS[0][0] + try: + idx = int(sel) - 1 + if 0 <= idx < len(INSTALLABLE_BROWSERS): + browser = INSTALLABLE_BROWSERS[idx][0] + except ValueError: + pass + cmd = browser_install_command(browser) + if not cmd: + print( + "Gestionnaire de paquets inconnu : installez " + f"« {browser} » manuellement." + ) + input("Entrée… ") + return None + printable = " ".join(cmd) + print(f"Commande : {printable}") + ans = input("Installer maintenant ? (o/N) : ").strip().lower() + if ans not in ("o", "oui", "y", "yes"): + return None + rc = os.system(printable) + print(f"\nInstallation terminée (code {rc}).") + input("Entrée pour continuer… ") + return cli_browser() + + def action_copy_log(self) -> None: + """Copie le log complet de la VM sélectionnée dans le + presse-papiers (OSC 52 ; marche aussi à travers SSH).""" + vm = self._vm_by_name(self._selected) + if not vm: + return + try: + text = Path(vm["log"]).read_text(errors="replace") + except OSError: + return + self.copy_to_clipboard(text) + self.notify( + f"Log de {vm['name']} copié ({len(text)} car.)", + title="Presse-papiers", + ) + + def action_details(self) -> None: + """Ouvre une petite fenêtre avec les LIGNES d'erreurs/avertissements + de la VM sélectionnée (scan à la demande -> toujours à jour).""" + vm = self._vm_by_name(self._selected) + if not vm: + return + errs, warns = scan_log_error_lines(vm["log"]) + self.push_screen(ErrorLinesScreen(vm["name"], errs, warns)) + + def on_click(self, event) -> None: + # Clic sur le sommaire de stats -> déplie / replie le détail. + w = getattr(event, "widget", None) + if w is not None and getattr(w, "id", None) == "stats": + self._stats_open = not self._stats_open + self.query_one("#statsdetail").display = self._stats_open + self._update_stats() + + # -- pause / reprise de tout le parc -------------------------------- # + @staticmethod + def _virsh_bulk(action, names): + for n in names: + try: + subprocess.run( + ["sudo", "virsh", action, n], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + pass + + def action_pause_all(self) -> None: + """Met en PAUSE (virsh suspend) toutes les VM en cours d'exécution. + L'install reprend là où elle en était après « Reprendre ».""" + self.run_worker(self._bulk_worker("suspend"), exclusive=False) + + def action_resume_all(self) -> None: + """REPREND (virsh resume) toutes les VM en pause ; le suivi des + logs continue automatiquement (offsets conservés).""" + self.run_worker(self._bulk_worker("resume"), exclusive=False) + + async def _bulk_worker(self, action): + want = "running" if action == "suspend" else "paused" + targets = [ + vm["name"] + for vm in vms + if self._domstate.get(vm["name"]) == want + ] + if not targets: + self.notify( + t("No running VM to pause.") + if action == "suspend" + else t("No paused VM to resume.") + ) + return + await asyncio.to_thread(self._virsh_bulk, action, targets) + verb = t("paused") if action == "suspend" else t("resumed") + self.notify(f"{len(targets)} VM {verb}.") + # Rafraîchit tout de suite l'état libvirt (pause/reprise visible). + self.run_worker(self._tick_domstate(), exclusive=False) + + app = Monitor() + if run_app: + app.run() + return app From c44ce4b81452f80d4fb225e8f6c55a7f7cb4dafb Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 05/10] [ADD] todo: TUI form to deploy VMs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows every deployment setting at once, with a plan that recomputes on each change and marks the collisions. A collapsible progress view follows. --- FR --- Affiche tous les réglages de déploiement d'un coup, avec un plan qui se recalcule à chaque changement et signale les collisions. Une vue de progression repliable suit. Assisted-by: Claude Opus 4.8 --- script/todo/qemu_deploy_form.py | 854 ++++++++++++++++++++++++++++++++ 1 file changed, 854 insertions(+) create mode 100644 script/todo/qemu_deploy_form.py diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py new file mode 100644 index 0000000..e969f20 --- /dev/null +++ b/script/todo/qemu_deploy_form.py @@ -0,0 +1,854 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Formulaire Textual de déploiement QEMU, et vue de progression. + +Deux interfaces mènent au même déploiement : les invites en ligne de +`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la +« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne +puisse l'être là-bas, et réciproquement. + +- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal. +- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None. +- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le + déploiement, avec copie du log vers le presse-papiers (OSC 52). + +Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel +`virsh` passe par sudo et une invite de mot de passe casserait l'affichage +Textual. Les données coûteuses (domaines existants, branches distantes) sont +préchargées par l'appelant et arrivent dans `ctx`. +""" +from __future__ import annotations + +import os +import time + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# --------------------------------------------------------------------------- # +# Logique pure — aucune dépendance à Textual, testable telle quelle +# --------------------------------------------------------------------------- # +def entry_key(entry) -> tuple: + """Identité stable d'une entrée de catalogue, indépendante de son rang : + les surcharges par VM y survivent quand la sélection change.""" + return (entry["distro"], entry["version"], entry["arch"]) + + +def parse_disk(value): + """« 60 », « 60G », « 1T », « 1,5T » -> « G », ou None si invalide. + Même règle que `TODO._qemu_parse_disk` : tout le reste de la chaîne + raisonne en gigaoctets.""" + txt = str(value).strip().upper().replace(",", ".") + factor = 1 + if txt.endswith("T"): + factor, txt = 1024, txt[:-1] + elif txt.endswith("G"): + txt = txt[:-1] + try: + gigs = int(float(txt) * factor) + except ValueError: + return None + return f"{gigs}G" if gigs > 0 else None + + +def disk_gb(value) -> int: + """« 60G » -> 60 (best effort), pour les totaux.""" + parsed = parse_disk(value) + return int(parsed[:-1]) if parsed else 0 + + +def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None): + """Applique le profil de ressources aux entrées choisies. + + Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur + monte la RAM minimale du catalogue et les vCPU en se bornant aux cœurs de + l'hôte ; « custom » impose les mêmes valeurs à tout le parc, une valeur + absente gardant celle du catalogue.""" + out = [] + for e in entries: + if profile == "custom": + cus = custom or {} + ram = cus.get("ram") or e["ram"] + disk = cus.get("disk") or e["disk"] + vcpus = cus.get("vcpus") or base_vcpus + else: + mult = int(profile) + ram = e["ram"] * mult + disk = e["disk"] + vcpus = min(base_vcpus * mult, host_cpu) + out.append( + { + "name": e["name"], + "distro": e["distro"], + "version": e["version"], + "arch": e["arch"], + "ram": ram, + "disk": disk, + "vcpus": vcpus, + } + ) + return out + + +def apply_overrides(vms, entries, overrides): + """Réapplique les réglages par VM (nom, vCPU, RAM, disque) après un + recalcul du profil. `overrides` est indexé par `entry_key`.""" + for vm, e in zip(vms, entries): + for field, value in (overrides.get(entry_key(e)) or {}).items(): + vm[field] = value + return vms + + +def build_vms(entries, profile, base_vcpus, host_cpu, custom, overrides): + """Catalogue choisi + profil + surcharges -> liste de VM de la spec.""" + return apply_overrides( + apply_profile(entries, profile, base_vcpus, host_cpu, custom), + entries, + overrides, + ) + + +def vm_status(name, domains): + """État d'un nom face à l'existant : ('new'|'exists'|'orphan', message). + + Les deux collisions n'ont pas la même gravité — une VM définie est + ignorée, un qcow2 resté seul fait échouer deploy_qemu, qui refuse + d'écraser sans --force.""" + if name in domains: + return "exists", t("exists - skipped") + if os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2"): + return "orphan", t("orphan disk - will FAIL") + return "new", "" + + +def plan_rows(vms, domains, extra_disk_gb=0): + """Lignes du tableau du plan : une par VM, avec son état.""" + rows = [] + for vm in vms: + state, note = vm_status(vm["name"], domains) + rows.append( + { + "vm": vm, + "state": state, + "note": note, + "disk_gb": disk_gb(vm["disk"]) + extra_disk_gb, + } + ) + return rows + + +def plan_totals(rows): + """Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de + neuf) : (nb, vcpus, ram_mo, disque_go).""" + fresh = [r for r in rows if r["state"] != "exists"] + return ( + len(fresh), + sum(r["vm"]["vcpus"] for r in fresh), + sum(r["vm"]["ram"] for r in fresh), + sum(r["disk_gb"] for r in fresh), + ) + + +def build_spec(vms, domains, form): + """Assemble la spec finale, dans la forme exacte que produit la CLI.""" + known = set(domains) + return { + "res_label": form["res_label"], + "vms": [vm for vm in vms if vm["name"] not in known], + "existing": [vm["name"] for vm in vms if vm["name"] in known], + "ssh_key": form["ssh_key"], + "install": form["install"], + "add_ssh_config": form["add_ssh_config"], + "parallelism": form["parallelism"], + } + + +def fmt_dur(secs) -> str: + mm, ss = divmod(int(secs), 60) + if mm >= 60: + return f"{mm // 60}h{mm % 60:02d}" + return f"{mm}m{ss:02d}" if mm else f"{ss}s" + + +# Au-delà, un OSC 52 est tronqué par certains terminaux (xterm notamment). +# On copie alors la FIN du log — la partie qui porte l'erreur. +CLIP_LIMIT = 100_000 + + +def clip_payload(text, limit=CLIP_LIMIT): + """(texte_à_copier, tronqué?) — on garde la fin, pas le début.""" + if len(text) <= limit: + return text, False + return text[-limit:], True + + +# --------------------------------------------------------------------------- # +# Formulaire Textual +# --------------------------------------------------------------------------- # +def run_deploy_form(ctx, run_app: bool = True): + """Formulaire de déploiement. Renvoie une spec, ou None si annulé. + `run_app=False` renvoie l'instance sans la lancer (tests headless).""" + from textual.app import App, ComposeResult + from textual.containers import Horizontal, Vertical, VerticalScroll + from textual.screen import ModalScreen + from textual.widgets import ( + Button, + Checkbox, + DataTable, + Footer, + Header, + Input, + Label, + RadioButton, + RadioSet, + Select, + SelectionList, + Static, + ) + + catalog = ctx["catalog"] + arches = ctx["arches"] + domains = set(ctx.get("domains") or []) + profiles = ctx.get("install_profiles") or [] + branches = ctx.get("branches") or ["master"] + host_cpu = ctx.get("host_cpu") or 2 + free_ram = ctx.get("free_ram") or 0 + base_vcpus = ctx.get("base_vcpus") or 2 + extra_disk = ctx.get("extra_disk_gb") or 0 + defaults = ctx.get("defaults") or {} + result = {"spec": None} + + AUTO = "__auto__" + + def entry_label(e): + star = " *" if e.get("default") else "" + return ( + f"{e['distro']} {e['version']}{star} [{e['arch']}] " + f"RAM≥{e['ram']}Mo {e['disk']}" + ) + + class EditVMScreen(ModalScreen): + """Réglages d'UNE VM. Un champ vide garde la valeur courante.""" + + BINDINGS = [("escape", "cancel", t("Cancel"))] + + def __init__(self, vm): + super().__init__() + self._vm = vm + + def compose(self) -> ComposeResult: + with Vertical(id="editbox"): + yield Static(f" {self._vm['name']}", id="edittitle") + yield Label(t("Name")) + yield Input(value=self._vm["name"], id="e_name") + yield Label(t("vCPU")) + yield Input(value=str(self._vm["vcpus"]), id="e_vcpus") + yield Label(t("RAM (MB)")) + yield Input(value=str(self._vm["ram"]), id="e_ram") + yield Label(t("Disk")) + yield Input(value=str(self._vm["disk"]), id="e_disk") + with Horizontal(id="editbtns"): + yield Button(t("Apply"), variant="primary", id="e_ok") + yield Button(t("Cancel"), id="e_cancel") + + def on_button_pressed(self, event) -> None: + if event.button.id != "e_ok": + self.dismiss(None) + return + out = {} + name = self.query_one("#e_name", Input).value.strip() + if name: + out["name"] = name + for field, wid in (("vcpus", "#e_vcpus"), ("ram", "#e_ram")): + raw = self.query_one(wid, Input).value.strip() + if raw.isdigit() and int(raw) > 0: + out[field] = int(raw) + disk = parse_disk(self.query_one("#e_disk", Input).value) + if disk: + out["disk"] = disk + self.dismiss(out) + + def action_cancel(self) -> None: + self.dismiss(None) + + class PreviewScreen(ModalScreen): + """Aperçu des commandes qui seraient lancées (aucune exécution).""" + + BINDINGS = [ + ("escape", "close", t("Close")), + ("q", "close", t("Close")), + ] + + def __init__(self, lines): + super().__init__() + self._lines = lines + + def compose(self) -> ComposeResult: + with Vertical(id="prevbox"): + yield Static( + f" {t('Preview (dry-run):')} ({t('Esc to close')})", + id="prevtitle", + ) + yield Static("\n\n".join(self._lines), id="prevbody") + + def action_close(self) -> None: + self.dismiss() + + class DeployForm(App): + CSS = """ + #body { height: 1fr; } + #fields { width: 62; border: solid $accent; overflow-y: auto; } + #right { width: 1fr; } + #plan { height: 1fr; border: solid $accent; } + #totals { height: auto; color: $text-muted; padding: 0 1; } + .grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; } + SelectionList { height: 10; border: solid $panel; } + RadioSet { height: auto; layout: horizontal; } + #reslabel { color: $text-muted; } + EditVMScreen { align: center middle; } + #editbox { + width: 56; height: auto; padding: 1 2; + border: thick $accent; background: $surface; + } + #edittitle { color: $accent; text-style: bold; } + #editbtns { height: auto; padding-top: 1; } + PreviewScreen { align: center middle; } + #prevbox { + width: 90%; height: 70%; padding: 1 2; + border: thick $accent; background: $surface; + } + #prevtitle { height: 1; color: $accent; text-style: bold; } + #prevbody { height: 1fr; overflow-y: auto; } + """ + # Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la + # palette de commandes de Textual, et une lettre seule serait avalée + # par le champ de saisie qui a le focus. + BINDINGS = [ + ("f5", "deploy", t("Deploy")), + ("f2", "edit_vm", t("Edit VM")), + ("f3", "preview", t("Preview")), + ("f6", "select_all", t("All")), + ("f7", "select_main", t("Main versions")), + ("f8", "select_none", t("None")), + ("escape", "cancel", t("Cancel")), + ] + + def __init__(self): + super().__init__() + self.arch = ctx.get("native") or arches[0] + self.profile = "1" + self.custom = {} + self.overrides = {} + self.vms = [] + self.rows = [] + + # -- construction de l'écran ----------------------------------- # + def compose(self) -> ComposeResult: + yield Header() + with Horizontal(id="body"): + with VerticalScroll(id="fields"): + yield Static(t("Architecture"), classes="grouptitle") + with RadioSet(id="f_arch"): + for a in arches: + label = a if a != "all" else t("all archs") + yield RadioButton(label, value=a == self.arch) + yield Static(t("Catalog"), classes="grouptitle") + yield SelectionList(id="f_catalog") + yield Static(t("Resources per VM"), classes="grouptitle") + with RadioSet(id="f_profile"): + for label in ("x1", "x2", "x3", "x4"): + yield RadioButton(label, value=label == "x1") + yield RadioButton(t("custom")) + yield Select( + [(str(c), c) for c in ctx["cpu_presets"]], + prompt=t("vCPU"), + id="f_vcpus", + disabled=True, + ) + yield Select( + [ + (f"{m} ({m // 1024}G)", m) + for m in ctx["ram_presets"] + ], + prompt=t("RAM (MB)"), + id="f_ram", + disabled=True, + ) + yield Select( + [(d, d) for d in ctx["disk_presets"]], + prompt=t("Disk"), + id="f_disk", + disabled=True, + ) + yield Static("ERPLibre", classes="grouptitle") + yield Checkbox( + t("Install ERPLibre"), + value=defaults.get("install", True), + id="f_install", + ) + yield Select( + [(b, b) for b in branches], + value=( + "develop" if "develop" in branches else branches[0] + ), + allow_blank=False, + id="f_branch", + ) + yield Select( + [(lbl, i) for i, (lbl, _c) in enumerate(profiles)], + value=0 if profiles else Select.BLANK, + allow_blank=not profiles, + id="f_profile_install", + ) + yield Checkbox( + t("Production (/opt, confined)"), + value=defaults.get("prod", False), + id="f_prod", + ) + yield Checkbox( + t("Monitoring dashboard"), + value=defaults.get("monitor", True), + id="f_monitor", + ) + yield Static("SSH", classes="grouptitle") + yield Input( + value=ctx.get("ssh_key") or "", + placeholder=t("SSH public key path"), + id="f_key", + ) + yield Checkbox( + t("Add each VM to ~/.ssh/config"), + value=defaults.get("add_ssh_config", True), + id="f_sshcfg", + ) + yield Static(t("Parallelism"), classes="grouptitle") + yield Select( + [(str(n), n) for n in range(1, host_cpu + 1)], + value=min(4, host_cpu), + allow_blank=False, + id="f_par", + ) + with Vertical(id="right"): + yield DataTable(id="plan") + yield Static("", id="totals") + yield Footer() + + def on_mount(self) -> None: + self.title = t("Deploy ERPLibre VM(s)!") + table = self.query_one("#plan", DataTable) + table.cursor_type = "row" + table.add_columns( + t("Name"), + t("Distro"), + t("Version"), + t("Arch"), + "vCPU", + "RAM", + t("Disk"), + t("Status"), + ) + self._reload_catalog(first_load=True) + + # -- catalogue et recalcul ------------------------------------- # + def _entries(self): + return catalog.get(self.arch, []) + + def _reload_catalog(self, first_load=False): + """(Re)charge la liste à cocher. + + RIEN n'est coché d'avance : déployer coûte cher, et une case + pré-cochée ferait créer une VM que personne n'a demandée. Le « * » + marque toujours la version principale, et F7 les coche toutes. + + Après un changement d'architecture, les cases déjà cochées sont + conservées quand l'entrée existe encore — l'identité est + (distro, version, archi), pas le rang dans la liste.""" + widget = self.query_one("#f_catalog", SelectionList) + keep = ( + set() + if first_load + else { + entry_key(self._entries_before[i]) + for i in widget.selected + if i < len(self._entries_before) + } + ) + widget.clear_options() + entries = self._entries() + for i, e in enumerate(entries): + widget.add_option((entry_label(e), i, entry_key(e) in keep)) + self._entries_before = entries + self._recompute() + + def _selected_entries(self): + widget = self.query_one("#f_catalog", SelectionList) + entries = self._entries() + return [entries[i] for i in sorted(widget.selected)] + + def _recompute(self): + entries = self._selected_entries() + self.vms = build_vms( + entries, + self.profile, + base_vcpus, + host_cpu, + self.custom, + self.overrides, + ) + self.rows = plan_rows( + self.vms, + domains, + ( + extra_disk + if self.query_one("#f_install", Checkbox).value + else 0 + ), + ) + self._render_plan() + + def _render_plan(self): + table = self.query_one("#plan", DataTable) + table.clear() + for r in self.rows: + vm = r["vm"] + icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]] + table.add_row( + vm["name"], + vm["distro"], + vm["version"], + vm["arch"], + str(vm["vcpus"]), + f"{vm['ram']}Mo", + f"{r['disk_gb']}G", + f"{icon}{r['note']}", + ) + if not self.rows: + # Rien de coché : un total à zéro n'apprend rien, on dit + # plutôt comment remplir la liste. + self.query_one("#totals", Static).update( + f" {t('Tick what to deploy')} — " + f"{t('F7 main versions · F6 all')}" + ) + return + n, cpus, ram, disk = plan_totals(self.rows) + warn = "" + if free_ram and ram > free_ram: + warn = f" ⚠ {t('> host free RAM')}" + elif cpus > host_cpu: + warn = f" ⚠ {t('> host cores')} ({host_cpu})" + dupes = len({vm["name"] for vm in self.vms}) != len(self.vms) + dup_txt = ( + f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}" + if dupes + else "" + ) + self.query_one("#totals", Static).update( + f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G" + f"{warn}{dup_txt}" + ) + + # -- réactions aux champs -------------------------------------- # + def on_radio_set_changed(self, event) -> None: + if event.radio_set.id == "f_arch": + self.arch = arches[event.radio_set.pressed_index] + self._reload_catalog() + elif event.radio_set.id == "f_profile": + index = event.radio_set.pressed_index + self.profile = "custom" if index == 4 else str(index + 1) + custom = self.profile == "custom" + for wid in ("#f_vcpus", "#f_ram", "#f_disk"): + self.query_one(wid, Select).disabled = not custom + self._recompute() + + def on_selection_list_selected_changed(self, event) -> None: + self._recompute() + + def on_select_changed(self, event) -> None: + mapping = {"f_vcpus": "vcpus", "f_ram": "ram", "f_disk": "disk"} + field = mapping.get(event.select.id) + if field: + if event.value is not Select.BLANK: + self.custom[field] = event.value + self._recompute() + + def on_checkbox_changed(self, event) -> None: + if event.checkbox.id == "f_install": + self._recompute() # le disque annoncé inclut le +5 G ERPLibre + + # -- actions ---------------------------------------------------- # + def action_select_all(self) -> None: + self.query_one("#f_catalog", SelectionList).select_all() + + def action_select_none(self) -> None: + self.query_one("#f_catalog", SelectionList).deselect_all() + + def action_select_main(self) -> None: + """Une VM par distro : la version marquée par défaut.""" + widget = self.query_one("#f_catalog", SelectionList) + widget.deselect_all() + for i, e in enumerate(self._entries()): + if e.get("default"): + widget.select(i) + + def action_edit_vm(self) -> None: + table = self.query_one("#plan", DataTable) + index = table.cursor_row + if not (0 <= index < len(self.vms)): + return + entries = self._selected_entries() + key = entry_key(entries[index]) + + def apply(changes): + if changes: + self.overrides.setdefault(key, {}).update(changes) + self._recompute() + + self.push_screen(EditVMScreen(dict(self.vms[index])), apply) + + def _form_values(self): + install = None + if self.query_one("#f_install", Checkbox).value and profiles: + index = self.query_one("#f_profile_install", Select).value + label, cmd = profiles[index if isinstance(index, int) else 0] + install = { + "branch": self.query_one("#f_branch", Select).value, + "prod": self.query_one("#f_prod", Checkbox).value, + "label": label, + "cmd": cmd, + "monitor": self.query_one("#f_monitor", Checkbox).value, + } + key = self.query_one("#f_key", Input).value.strip() + return { + "res_label": ( + t("custom") + if self.profile == "custom" + else f"x{self.profile}" + ), + "ssh_key": os.path.expanduser(key) if key else "", + "install": install, + "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, + "parallelism": self.query_one("#f_par", Select).value, + } + + def action_preview(self) -> None: + spec = build_spec(self.vms, domains, self._form_values()) + build = ctx.get("build_command") + if not build: + return + lines = [build(vm, spec, True) for vm in spec["vms"]] + self.push_screen(PreviewScreen(lines or [t("Nothing selected.")])) + + def action_deploy(self) -> None: + if not self.vms: + self.notify(t("Nothing selected."), severity="warning") + return + spec = build_spec(self.vms, domains, self._form_values()) + if not spec["vms"]: + self.notify( + t("Nothing to create - every VM already exists."), + severity="warning", + ) + return + orphans = [r for r in self.rows if r["state"] == "orphan"] + if orphans and not getattr(self, "_orphan_ack", False): + # Un qcow2 orphelin fait échouer deploy_qemu : on prévient une + # première fois, F5 à nouveau vaut confirmation. + self._orphan_ack = True + self.notify( + t("orphan disk - will FAIL") + + f" ({len(orphans)}) — " + + t("press F5 again to confirm"), + severity="error", + timeout=10, + ) + return + result["spec"] = spec + self.exit() + + def action_cancel(self) -> None: + self.exit() + + app = DeployForm() + # Exposé pour les tests headless (run_app=False), qui pilotent l'app + # eux-mêmes et ont besoin de lire la spec produite. + app._result = result + if not run_app: + return app + app.run() + return result["spec"] + + +# --------------------------------------------------------------------------- # +# Vue de progression : un bloc repliable par VM +# --------------------------------------------------------------------------- # +def run_deploy_progress(jobs, parallelism, run_app: bool = True): + """Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par + VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app. + + Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit + — et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire.""" + import subprocess + import threading + + from textual.app import App, ComposeResult + from textual.containers import Vertical, VerticalScroll + from textual.widgets import ( + Button, + Collapsible, + Footer, + Header, + RichLog, + Static, + ) + + results = [] + + def slug(name): + """Identifiant de widget : Textual n'accepte ni point ni tiret en + tête, et les noms de VM en contiennent.""" + return "vm_" + "".join(c if c.isalnum() else "_" for c in name) + + class Progress(App): + CSS = """ + #blocks { height: 1fr; } + RichLog { height: 14; border: solid $panel; } + #summary { height: auto; color: $accent; padding: 0 1; } + #hint { height: auto; color: $text-muted; padding: 0 1; } + """ + BINDINGS = [ + ("c", "copy_current", t("Copy log")), + ("C", "copy_all", t("Copy all logs")), + ("q", "quit", t("Quit")), + ] + + def __init__(self): + super().__init__() + self._out = {name: "" for _jid, name, _p in jobs} + self._done = 0 + self._t0 = time.time() + self._slots = threading.Semaphore(max(1, parallelism)) + + def compose(self) -> ComposeResult: + yield Header() + with VerticalScroll(id="blocks"): + for jid, name, _parts in jobs: + with Collapsible( + title=f"⏳ [{jid}] {name}", + collapsed=False, + id=slug(name), + ): + yield RichLog( + id=f"log_{slug(name)}", + highlight=False, + markup=False, + wrap=True, + ) + with Vertical(): + yield Static("", id="summary") + yield Static( + f" {t('c copy log · C copy all · q quit')}", id="hint" + ) + yield Button(t("Copy all logs"), id="copyall") + yield Footer() + + def on_mount(self) -> None: + self.title = t("Deploying") + self._refresh_summary() + for jid, name, parts in jobs: + self.run_job(jid, name, parts) + + def _refresh_summary(self): + self.query_one("#summary", Static).update( + f" {self._done}/{len(jobs)} — " + f"{fmt_dur(time.time() - self._t0)}" + ) + + # `thread=True` : subprocess.run est bloquant ; le faire dans un + # thread garde la boucle d'événements Textual fluide. Le sémaphore + # borne les déploiements SIMULTANÉS — sans lui, demander « 4 en + # parallèle » en lancerait autant que de VM. + def run_job(self, jid, name, parts): + def _job() -> None: + with self._slots: + t0 = time.time() + try: + res = subprocess.run( + parts, capture_output=True, text=True + ) + rc = res.returncode + out = (res.stdout or "") + (res.stderr or "") + except (OSError, subprocess.SubprocessError) as exc: + rc, out = 1, str(exc) + self.call_from_thread( + self._finish, jid, name, rc, out, time.time() - t0 + ) + + self.run_worker(_job, thread=True, group="deploy", exclusive=False) + + def _finish(self, jid, name, rc, out, secs): + self._out[name] = out + results.append((name, rc, out, secs)) + self._done += 1 + log = self.query_one(f"#log_{slug(name)}", RichLog) + for line in out.strip().splitlines(): + log.write(line) + block = self.query_one(f"#{slug(name)}", Collapsible) + mark = "✅" if rc == 0 else "❌" + block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + ( + "" if rc == 0 else f" · rc={rc}" + ) + # Un succès se replie (il n'y a plus rien à y lire) ; un échec + # reste ouvert. + block.collapsed = rc == 0 + self._refresh_summary() + + # -- presse-papiers (OSC 52 : traverse SSH) --------------------- # + def _copy(self, text, what): + payload, cut = clip_payload(text) + if not payload.strip(): + self.notify(t("Nothing to copy."), severity="warning") + return + self.copy_to_clipboard(payload) + note = f"{what} — {len(payload)} {t('chars')}" + if cut: + note += f" ({t('tail only, log was truncated')})" + self.notify( + note + "\n" + t("Needs an OSC 52 capable terminal."), + title=t("Clipboard"), + timeout=8, + ) + + def action_copy_current(self) -> None: + focused = self.focused + for _jid, name, _p in jobs: + node = focused + while node is not None: + if getattr(node, "id", None) == slug(name): + self._copy(self._out[name], name) + return + node = node.parent + self.action_copy_all() + + def action_copy_all(self) -> None: + blob = "\n".join( + f"───── {name} ─────\n{self._out[name]}" + for _jid, name, _p in jobs + ) + self._copy(blob, t("all logs")) + + def on_button_pressed(self, event) -> None: + if event.button.id == "copyall": + self.action_copy_all() + + app = Progress() + app._results = results # lecture par les tests headless + if not run_app: + return app + app.run() + return results From 7d4922bf097cf494ff560821084d49288c8e4bd3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 06/10] [ADD] todo: QEMU/KVM menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy, list, test, resize, delete and clean up VMs. Adds the SSH configuration with recursive ProxyJump, port forwarding, and registration of the QEMU hosts in virt-manager. --- FR --- Déployer, lister, tester, redimensionner, supprimer et nettoyer des VM. Ajoute la configuration SSH avec ProxyJump récursif, la redirection de port et l'enregistrement des hôtes QEMU dans virt-manager. Assisted-by: Claude Opus 4.8 --- script/todo/todo.json | 6 + script/todo/todo.py | 5578 +++++++++++++++++++++++++++++++++----- script/todo/todo_i18n.py | 2136 +++++++++++++-- 3 files changed, 6830 insertions(+), 890 deletions(-) diff --git a/script/todo/todo.json b/script/todo/todo.json index 5a052f9..acf06bc 100644 --- a/script/todo/todo.json +++ b/script/todo/todo.json @@ -61,5 +61,11 @@ "prompt_description_key": "Generate git patch to /tmp", "bash_command": "PATCH=\"/tmp/patch_$(date +%Y%m%d_%H%M%S).patch\"; git -C \"$(pwd)\" diff HEAD > \"$PATCH\" && printf \"\\n✓ Patch created: %s\\n\\n=== Guide to apply the patch ===\\n Check compatibility : git apply --check %s\\n Apply (git) : git apply %s\\n Apply (patch) : patch -p1 < %s\\n Revert (git) : git apply -R %s\\n\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\"" } + ], + "qemu_from_makefile": [ + { + "prompt_description_key": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", + "bash_command": "./script/qemu/deploy_qemu.py --name demo-vm --version 24.04 --dry-run" + } ] } diff --git a/script/todo/todo.py b/script/todo/todo.py index cedf8bf..214118d 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2,12 +2,15 @@ # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +import ast import configparser import datetime import getpass import json import logging import os +import re +import shlex import shutil import subprocess import sys @@ -24,7 +27,6 @@ from script.config import config_file from script.execute import execute from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager -from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.version_manager import get_odoo_version ERROR_LOG_PATH = ".erplibre.error.txt" @@ -115,100 +117,34 @@ class TODO: set_lang("en") print(t("Language changed to: English")) - def run(self): - with open(self.config_file.get_logo_ascii_file_path()) as my_file: - print(my_file.read()) - self._ask_language() - print(t("Opening TODO ...")) - print(f"🤖 {t('=> Enter your choice by number and press Enter!')}") - help_info = f"""{t("Command:")} -[1] {t("Execute")} -[2] {t("Install")} -[3] {t("Question")} -[4] {t("Fork - Open TODO in a new tab")} -[0] {t("Quit")} -""" - while True: - try: - status = click.prompt(help_info) - except NameError: - print("Do") - print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") - sys.exit(1) - except ImportError: - print("Do") - print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") - sys.exit(1) - except click.exceptions.Abort: - sys.exit(0) - print() - if status == "0": - break - elif status == "1": - self.prompt_execute() - elif status == "2": - self.prompt_install() - elif status == "3": - self.execute_prompt_ia() - elif status == "4": - # cmd = ( - # f"gnome-terminal --tab -- bash -c 'source" - # f" ./{VENV_ERPLIBRE}/bin/activate;make todo'" - # ) - cmd = "make todo" - self.execute.exec_command_live(cmd, source_erplibre=True) - # elif status == "3" or status == "install": - # print("install") - else: - print(t("Command not found !")) - - print(status) - # manipuler() - - def execute_prompt_ia(self): - while True: - help_info = f"""{t("Command:")} -[0] {t("Back")} -{t("Write your question ")}""" - status = click.prompt(help_info) - print() - if status == "0": - return - kp = self.kdbx_manager.get_kdbx() - if not kp: - return - config_name = self.config_file.get_config_value( - ["kdbx_config", "openai", "kdbx_key"] - ) - entry = kp.find_entries_by_title(config_name, first=True) - - client = openai.OpenAI(api_key=entry.password) - prompt_update = status - completion = client.chat.completions.create( - model="gpt-4o", - messages=[{"role": "user", "content": prompt_update}], - ) - - print(completion.choices[0].message.content) - print() - def prompt_execute(self): - help_info = f"""{t("Command:")} -[1] {t("Automation - Demonstration of developed features")} -[2] {t("Code - Developer tools")} -[3] {t("Config - Configuration file management")} -[4] {t("Database - Database tools")} -[5] {t("Doc - Documentation search")} -[6] {t("Git - Git tools")} -[7] {t("GPT code - AI assistant tools")} -[8] {t("Language - Change language / Changer la langue")} -[9] {t("Network - Network tools")} -[10] {t("Process - Execution tools")} -[11] {t("Run - Execute and install an instance")} -[12] {t("Security - Dependency security audit")} -[13] {t("Test - Test an Odoo module")} -[14] {t("Update - Update all developed staging source code")} -[15] {t("Deploy - Deploy ERPLibre locally")} + help_info = f"""{self._menu_header()} + +── {t("Development")} ── +[1] {t("Code - Developer tools")} +[2] {t("Config - Configuration file management")} +[3] {t("Run - Execute and install an instance")} +[4] {t("Test - Test an Odoo module")} +[5] {t("Process - Execution tools")} + +── {t("Data")} ── +[6] {t("Database - Database tools")} + +── {t("Sources & documentation")} ── +[7] {t("Git - Git tools")} +[8] {t("Doc - Documentation search")} + +── {t("AI & automation")} ── +[9] {t("GPT code - AI assistant tools")} +[10] {t("Automation - Demonstration of developed features")} + +── {t("Deployment, network & security")} ── +[11] {t("Deploy - Deploy ERPLibre locally")} +[12] {t("Network - Network tools")} +[13] {t("Security - Dependency security audit")} + +── {t("Preferences")} ── +[14] {t("Language - Change language / Changer la langue")} [0] {t("Back")} """ while True: @@ -217,246 +153,64 @@ class TODO: if status == "0": return elif status == "1": - status = self.prompt_execute_function() - if status is not False: - return - elif status == "2": status = self.prompt_execute_code() if status is not False: return - elif status == "3": + elif status == "2": status = self.prompt_execute_config() if status is not False: return - elif status == "4": - status = self.prompt_execute_database() - if status is not False: - return - elif status == "5": - status = self.prompt_execute_doc() - if status is not False: - return - elif status == "6": - status = self.prompt_execute_git() - if status is not False: - return - elif status == "7": - status = self.prompt_execute_gpt_code() - if status is not False: - return - elif status == "8": - status = self._change_language() - if status is not False: - return - elif status == "9": - status = self.prompt_execute_network() - if status is not False: - return - elif status == "10": - status = self.prompt_execute_process() - if status is not False: - return - elif status == "11": + elif status == "3": status = self.prompt_execute_instance() if status is not False: return - elif status == "12": - status = self.prompt_execute_security() - if status is not False: - return - elif status == "13": + elif status == "4": status = self.prompt_execute_test() if status is not False: return - elif status == "14": - status = self.prompt_execute_update() + elif status == "5": + status = self.prompt_execute_process() if status is not False: return - elif status == "15": + elif status == "6": + status = self.prompt_execute_database() + if status is not False: + return + elif status == "7": + status = self.prompt_execute_git() + if status is not False: + return + elif status == "8": + status = self.prompt_execute_doc() + if status is not False: + return + elif status == "9": + status = self.prompt_execute_gpt_code() + if status is not False: + return + elif status == "10": + status = self.prompt_execute_function() + if status is not False: + return + elif status == "11": status = self.prompt_execute_deploy() if status is not False: return + elif status == "12": + status = self.prompt_execute_network() + if status is not False: + return + elif status == "13": + status = self.prompt_execute_security() + if status is not False: + return + elif status == "14": + status = self._change_language() + if status is not False: + return else: print(t("Command not found !")) - def prompt_install(self): - print("Detect first installation from code source.") - - first_installation_input = ( - input( - "💬 First system installation? This will process system installation" - " before (Y/N): " - ) - .strip() - .lower() - ) - if first_installation_input == "y": - cmd = "./script/version/update_env_version.py --install" - self.execute.exec_command_live(cmd, source_erplibre=True) - print("Wait after OS installation before continue.") - - # First detect pycharm, need to be open before installation and close to increase speed - has_pycharm = False - has_pycharm_community = False - result = subprocess.run( - ["which", "pycharm"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - if result.returncode == 0: - has_pycharm = True - else: - result = subprocess.run( - ["which", "pycharm-community"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - ) - has_pycharm_community = result.returncode == 0 - if (has_pycharm or has_pycharm_community) and not os.path.exists( - ".idea" - ): - pycharm_configuration_input = ( - input("💬 Open Pycharm? (Y/N): ").strip().lower() - ) - if pycharm_configuration_input == "y": - pycharm_bin = "pycharm" if has_pycharm else "pycharm-community" - - cmd = f"cd {os.getcwd()} && {pycharm_bin} ./" - self.execute.exec_command_live( - cmd, - source_erplibre=False, - single_source_erplibre=False, - new_window=True, - ) - print( - "👹 WAIT and Close Pycharm when processing is done before continue" - " this guide." - ) - # TODO detect last version supported - # cmd_intern = "./script/install/install_erplibre.sh" - # TODO maybe update q to only install erplibre from install_locally - # TODO problem installing with q, the script depend on odoo - key_i = 0 - commands_begin = { - "q": ( - "q", - "q: ERPLibre only with system python without Odoo", - "./script/install/install_erplibre.sh", - ), - "w": ( - "w", - "w: Install all Odoo version with ERPLibre", - "make install_odoo_all_version", - ), - "m": ( - "m", - "m: ERPLibre with mobile home", - "./mobile/install_and_run.sh", - ), - "0": ( - "0", - f"0: {t('Quit')}", - ), - } - commands_end = {} - versions, installed_versions, odoo_installed_version = ( - get_odoo_version() - ) - - for version_info in versions[::-1]: - key_i += 1 - key_s = str(key_i) - label = f"{key_s}: Odoo {version_info.get('odoo_version')}" - - odoo_version = f"odoo{version_info.get('odoo_version')}" - if odoo_version in installed_versions: - label += " - Installed" - if odoo_version == odoo_installed_version: - label += " - Actual" - if version_info.get("Default"): - label += " - Default" - if version_info.get("is_deprecated"): - label += " - Deprecated" - erplibre_version = version_info.get("erplibre_version") - commands_begin[key_s] = ( - key_s, - label, - f"./script/version/update_env_version.py --erplibre_version {erplibre_version} --install_dev", - ) - - # Add final command - install_commands = {**commands_begin, **commands_end} - - # Show command - odoo_version_input = "" - while odoo_version_input not in install_commands: - if odoo_version_input: - print( - f"{t('Error, cannot understand value')} '{odoo_version_input}'" - ) - str_input_dyn_odoo_version = ( - f"💬 {t('Choose a version:')}\n\t" - + "\n\t".join([a[1] for a in install_commands.values()]) - + f"\n{t('Select: ')}" - ) - odoo_version_input = ( - input(str_input_dyn_odoo_version).strip().lower() - ) - - if odoo_version_input == "0": - return - - cmd_intern = install_commands.get(odoo_version_input)[2] - - # For numbered version selections, offer extra modules sub-menu - if odoo_version_input.isdigit(): - extra_choices = { - "1": ( - "1", - f"1: {t('Standard install (without extra modules)')}", - ), - "2": ( - "2", - f"2: {t('Install with extra modules (CybroOdoo - large, slow)')}", - ), - "0": ("0", f"0: {t('Back')}"), - } - extra_input = "" - while extra_input not in extra_choices: - if extra_input: - print( - f"{t('Error, cannot understand value')} '{extra_input}'" - ) - str_extra = ( - f"💬 {t('Install type:')}\n\t" - + "\n\t".join([a[1] for a in extra_choices.values()]) - + f"\n{t('Select: ')}" - ) - extra_input = input(str_extra).strip() - if extra_input == "0": - return - if extra_input == "2": - cmd_intern = cmd_intern + " --with_extra" - - print(f"{t('Will execute:')}\n{cmd_intern}") - - # TODO use external script to detect terminal to use on system - # TODO check script open_terminal_code_generator.sh - # cmd_extern = f"gnome-terminal -- bash -c '{cmd_intern};bash'" - try: - subprocess.run( - cmd_intern, shell=True, executable="/bin/bash", check=True - ) - except subprocess.CalledProcessError as e: - print( - f"{t('The Bash script failed with return code')} {e.returncode}." - ) - print("Wait after installation and open projects by terminal.") - print("make open_terminal") - self.restart_script(str(e)) - def execute_from_configuration( self, instance, exec_run_db=False, ignore_makefile=False ): @@ -511,16 +265,51 @@ class TODO: if callback: callback(instance) + # Étiquettes du fil d'Ariane par méthode de menu. Le fil est dérivé de la + # pile d'appels (aucune méthode de menu à modifier). Labels courts et + # stables, pensés pour être copiés afin de situer précisément un menu. + _MENU_LABELS = { + "run": "TODO", + "prompt_execute": "Execute", + "execute_prompt_ia": "Question", + "prompt_install": "Install", + "prompt_execute_function": "Automation", + "prompt_execute_code": "Code", + "prompt_execute_config": "Config", + "prompt_execute_database": "Database", + "prompt_execute_doc": "Doc", + "prompt_execute_git": "Git", + "prompt_execute_git_local_server": "Git local server", + "prompt_execute_gpt_code": "GPT code", + "prompt_execute_process": "Process", + "prompt_execute_instance": "Run", + "prompt_execute_rtk": "RTK", + "prompt_execute_update": "Update", + "prompt_execute_deploy": "Deploy", + "prompt_execute_deploy_ssh": "SSH", + "prompt_execute_qemu": "QEMU/KVM", + "prompt_configuration": "Configuration", + } + def fill_help_info(self, choices): - help_info = t("Command:") + "\n" + # Une entrée {"section": "..."} affiche un titre de section SANS + # consommer de numéro : la numérotation reste continue sur les vraies + # commandes (compatible avec les elif codés en dur des menus). + help_info = self._menu_header() + "\n" help_end = f"[0] {t('Back')}\n" - for i, instance in enumerate(choices): + n = 0 + for instance in choices: + section = instance.get("section") + if section: + help_info += f"\n── {section} ──\n" + continue + n += 1 desc_key = instance.get("prompt_description_key") if desc_key: desc = t(desc_key) else: desc = instance["prompt_description"] - help_info += f"[{i + 1}] " + desc + "\n" + help_info += f"[{n}] " + desc + "\n" help_info += help_end return help_info @@ -649,19 +438,21 @@ class TODO: def prompt_execute_deploy(self): print(f"🤖 {t('Deploy ERPLibre to a local directory!')}") choices = [ + {"section": t("Local")}, {"prompt_description": t("Clone ERPLibre locally (git clone)")}, {"prompt_description": t("Configure sshfs")}, - {"prompt_description": t("SSH - Check connection")}, - {"prompt_description": t("SSH - Sync files (rsync)")}, - {"prompt_description": t("SSH - Install ERPLibre")}, - {"prompt_description": t("SSH - Start Odoo")}, - {"prompt_description": t("SSH - Stop Odoo")}, - {"prompt_description": t("SSH - Restart Odoo")}, - {"prompt_description": t("SSH - Service status")}, - {"prompt_description": t("SSH - View logs")}, - {"prompt_description": t("SSH - Run make target")}, - {"prompt_description": t("SSH - Install systemd service")}, - {"prompt_description": t("SSH - Configure nginx + SSL")}, + { + "prompt_description": t( + "SSH port forwarding (open Odoo in the browser)" + ) + }, + {"section": t("Remote & services")}, + {"prompt_description": t("SSH (remote host)...")}, + { + "prompt_description": t( + "QEMU/KVM - Deploy an Ubuntu VM (libvirt)" + ) + }, { "prompt_description": t( "Deploy - Install NTFY notification server" @@ -680,32 +471,4528 @@ class TODO: elif status == "2": self._configure_sshfs() elif status == "3": - self._deploy_ssh_check() + self._deploy_port_forward() elif status == "4": - self._deploy_ssh_push() + self.prompt_execute_deploy_ssh() elif status == "5": - self._deploy_ssh_install() + self.prompt_execute_qemu() elif status == "6": - self._deploy_ssh_run() - elif status == "7": - self._deploy_ssh_stop() - elif status == "8": - self._deploy_ssh_restart() - elif status == "9": - self._deploy_ssh_status() - elif status == "10": - self._deploy_ssh_logs() - elif status == "11": - self._deploy_ssh_make() - elif status == "12": - self._deploy_ssh_install_systemd() - elif status == "13": - self._deploy_ssh_install_nginx() - elif status == "14": self._deploy_ntfy_server() else: print(t("Command not found !")) + def prompt_execute_deploy_ssh(self): + """Sous-menu : opérations de déploiement sur un hôte distant via SSH.""" + print(f"🤖 {t('Deploy ERPLibre to a remote host over SSH!')}") + choices = [ + {"prompt_description": t("SSH - Check connection")}, + {"prompt_description": t("SSH - Sync files (rsync)")}, + {"prompt_description": t("SSH - Install ERPLibre")}, + {"prompt_description": t("SSH - Start Odoo")}, + {"prompt_description": t("SSH - Stop Odoo")}, + {"prompt_description": t("SSH - Restart Odoo")}, + {"prompt_description": t("SSH - Service status")}, + {"prompt_description": t("SSH - View logs")}, + {"prompt_description": t("SSH - Run make target")}, + {"prompt_description": t("SSH - Install systemd service")}, + {"prompt_description": t("SSH - Configure nginx + SSL")}, + ] + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._deploy_ssh_check() + elif status == "2": + self._deploy_ssh_push() + elif status == "3": + self._deploy_ssh_install() + elif status == "4": + self._deploy_ssh_run() + elif status == "5": + self._deploy_ssh_stop() + elif status == "6": + self._deploy_ssh_restart() + elif status == "7": + self._deploy_ssh_status() + elif status == "8": + self._deploy_ssh_logs() + elif status == "9": + self._deploy_ssh_make() + elif status == "10": + self._deploy_ssh_install_systemd() + elif status == "11": + self._deploy_ssh_install_nginx() + else: + print(t("Command not found !")) + + # ------------------------------------------------------------------ # + # QEMU / KVM (libvirt) VM deployment + # ------------------------------------------------------------------ # + def _qemu_script_path(self): + """Chemin absolu vers script/qemu/deploy_qemu.py.""" + path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "qemu", + "deploy_qemu.py", + ) + return os.path.realpath(path) + + def _qemu_default_ssh_key(self): + """Première clé publique SSH trouvée dans ~/.ssh, sinon ''.""" + for name in ("id_ed25519.pub", "id_rsa.pub"): + path = os.path.expanduser(f"~/.ssh/{name}") + if os.path.exists(path): + return path + return "" + + # distro -> (versions affichées, version par défaut). Source de vérité = + # deploy_qemu.py ; ceci ne sert qu'au sélecteur interactif. + _QEMU_DISTROS = { + "ubuntu": ( + ["20.04", "22.04", "24.04", "25.10", "26.04"], + "24.04", + ), + "debian": (["11", "12", "13"], "12"), + "fedora": (["41", "42", "43", "44"], "42"), + "arch": (["latest"], "latest"), + } + + def _qemu_prompt_distro(self): + """Demande la distribution (défaut : ubuntu).""" + distros = list(self._QEMU_DISTROS) + print(f"\n{t('Distribution:')}") + for i, d in enumerate(distros, 1): + print(f" [{i}] {d}") + sel = input(t("Choice (number or name, default: ubuntu): ")).strip() + if not sel: + return "ubuntu" + try: + idx = int(sel) - 1 + if 0 <= idx < len(distros): + return distros[idx] + except ValueError: + if sel in distros: + return sel + print(t("Invalid selection, using ubuntu")) + return "ubuntu" + + def _qemu_prompt_version(self, distro): + """Demande la version pour la distro (défaut = version par défaut).""" + versions, default = self._QEMU_DISTROS.get(distro, ([], "")) + print(f"\n{t('Version for')} {distro.capitalize()} :") + for i, v in enumerate(versions, 1): + suffix = " *" if v == default else "" + stat = self._qemu_stat_avg("version", v, distro) + print(f" [{i}] {v}{suffix}{stat}") + sel = input( + f"{t('Choice (number or version, blank = default):')} " + ).strip() + if not sel: + return default + try: + idx = int(sel) - 1 + if 0 <= idx < len(versions): + return versions[idx] + except ValueError: + if sel in versions: + return sel + print(f"{t('Invalid selection, using')} {default}") + return default + + # Distros publiant des images cloud par architecture (cohérent avec + # S390X_DISTROS / ARM64_DISTROS de deploy_qemu.py). amd64 : toutes. + _QEMU_S390X_DISTROS = ("ubuntu",) + _QEMU_ARM64_DISTROS = ("ubuntu", "debian", "fedora") + # Alias distro pour l'affichage (jeton générique -> nom courant). + _QEMU_ARCH_ALIAS = {"amd64": "x86_64", "arm64": "aarch64"} + + @staticmethod + def _native_arch(): + """Architecture native de l'hôte, en jeton de deploy_qemu.py + (amd64/arm64/s390x). Défaut amd64 si indéterminée.""" + try: + machine = os.uname().machine + except (AttributeError, OSError): + machine = "" + return { + "x86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + "s390x": "s390x", + }.get(machine, "amd64") + + def _qemu_arch_distros(self, arch): + """Distros supportant `arch` (None = toutes, cas amd64).""" + if arch == "s390x": + return self._QEMU_S390X_DISTROS + if arch == "arm64": + return self._QEMU_ARM64_DISTROS + return None + + def _qemu_last_run_line(self): + """Ligne « dernière install » (distro version [arch] en durée), depuis + l'historique (.venv.erplibre) ; '' si aucune donnée.""" + try: + from script.todo import qemu_install_monitor as mon + + r = mon.last_run() + if r: + return ( + f" ℹ {t('Last install:')} {r.get('distro')} " + f"{r.get('version')} [{r.get('arch')}] — " + f"{mon._fmt_secs(r.get('seconds', 0))}" + ) + except Exception: + pass + return "" + + def _qemu_stat_avg(self, field, value, distro=None): + """Suffixe « · ~5m moy (3) » : durée d'install MOYENNE historique pour + cette archi/distro/version (fichier .venv.erplibre), ou '' si aucune + donnée. Pour field='version', `distro` est requis.""" + try: + from script.todo import qemu_install_monitor as mon + + if field == "arch": + secs, n = mon.avg_by_arch(value) + elif field == "version": + secs, n = mon.avg_by_version(distro, value) + else: + secs, n = mon.avg_by_distro(value) + if secs: + return f" · ~{mon._fmt_secs(secs)} {t('avg')} ({n})" + except Exception: + pass + return "" + + def _qemu_ask_arch(self, opts, native, allow_all=False): + """Affiche les architectures `opts` (natif marqué d'un *) et renvoie le + choix. Si `allow_all`, propose aussi [all] = toutes les archis (renvoie + « all »). Toute arch non native est ÉMULÉE (TCG, lente).""" + print(f"\n{t('Architecture:')}") + for i, a in enumerate(opts, 1): + alias = self._QEMU_ARCH_ALIAS.get(a) + label = f"{a} ({alias})" if alias else a + if a == native: + label += f" — {t('native')} *" + elif a == "s390x": + label += f" ({t('IBM Z — emulated, slow; Ubuntu only')})" + elif a == "arm64": + label += f" ({t('ARM 64-bit — emulated, slow')})" + else: + label += f" ({t('emulated, slow')})" + print(f" [{i}] {label}{self._qemu_stat_avg('arch', a)}") + if allow_all: + print(f" [all] {t('All supported architectures')}") + sel = ( + input(f"{t('Choice (number or name, blank = native):')} ") + .strip() + .lower() + ) + if not sel: + return native + if allow_all and sel in ("all", "*"): + note = t("(includes emulated architectures — some VMs are slow)") + print(f"⚠ {note}") + return "all" + chosen = None + for i, a in enumerate(opts, 1): + if sel in (str(i), a, self._QEMU_ARCH_ALIAS.get(a)): + chosen = a + break + if chosen is None: + print(f"{t('Invalid selection, using')} {native}") + return native + if chosen != native: + warn = t( + "This architecture is emulated (TCG): boot and install are" + " much slower than the native one." + ) + print(f"⚠ {warn}") + return chosen + + def _qemu_prompt_infra_arch(self): + """Architecture du parc (défaut : native de l'hôte, marquée d'un *). + Toute arch non native est émulée ; le catalogue est ensuite restreint + aux distros publiant cette arch.""" + native = self._native_arch() + opts = ["amd64", "arm64", "s390x"] + if native not in opts: # hôte exotique : garder le natif en tête + opts.insert(0, native) + return self._qemu_ask_arch(opts, native, allow_all=True) + + def _qemu_list_images(self): + """Affiche la liste des distros/versions et leurs specs.""" + cmd = f"{self._qemu_script_path()} --list-images" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_ensure_tools(self): + """virsh absent : proposer l'installation plutôt que de laisser + chaque commande échouer sur « sudo: virsh: command not found ». + + deploy_qemu.py --setup-host connaît les paquets de chaque + distribution ; on ne devine donc rien ici, on le délègue.""" + if shutil.which("virsh"): + return True + print(f"\n⚠ {t('virsh is missing: libvirt is not installed here.')}") + print(f" {t('Every VM command will fail until it is.')}") + if not self._is_yes_default_yes( + input(t("Install the QEMU/libvirt tools now? (Y/n): ")) + ): + return False + cmd = f"sudo {self._QEMU_QEMU_PKGS}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + if shutil.which("virsh"): + print(f"✅ {t('libvirt is available.')}") + return True + # Sur une distribution à noyau roulant, --setup-host peut demander un + # redémarrage avant que les modules soient chargeables. + print(f"⚠ {t('virsh still missing; a reboot may be required.')}") + return False + + def prompt_execute_qemu(self): + print(f"🤖 {t('Deploy a QEMU/KVM virtual machine (libvirt)!')}") + script_path = self._qemu_script_path() + if not os.path.isfile(script_path): + print(f"{t('QEMU deploy script not found: ')}{script_path}") + return False + self._qemu_ensure_tools() + choices = [ + {"section": t("Deployment")}, + {"prompt_description": t("Deploy VM(s) (one or many)")}, + { + "prompt_description": t( + "Preview a deployment (dry-run, no sudo)" + ) + }, + {"prompt_description": t("Download a cloud image only")}, + {"section": t("Manage")}, + {"prompt_description": t("List VMs (virsh list --all)")}, + {"prompt_description": t("Show a VM IP address")}, + {"prompt_description": t("Open the console on a VM")}, + {"prompt_description": t("Resize a VM disk")}, + {"prompt_description": t("Delete VM(s)")}, + {"prompt_description": t("Clean up QEMU (orphan files)")}, + { + "prompt_description": t( + "Test a VM (open Odoo in a CLI browser)" + ) + }, + { + "prompt_description": t( + "Reopen install monitoring (last run / history)" + ) + }, + {"prompt_description": t("Statistics (installs, durations, VMs)")}, + { + "prompt_description": t( + "SSH configuration (~/.ssh/config, ProxyJump)" + ) + }, + {"section": t("Catalog")}, + {"prompt_description": t("List available images and specs")}, + ] + config_entries = self.config_file.get_config("qemu_from_makefile") + if config_entries: + choices.extend(config_entries) + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._qemu_deploy(dry_run=False) + elif status == "2": + self._qemu_deploy(dry_run=True) + elif status == "3": + self._qemu_download_image() + elif status == "4": + self._qemu_list_vms(ask_advanced=True) + elif status == "5": + self._qemu_show_ip() + elif status == "6": + self._qemu_console() + elif status == "7": + self._qemu_resize_disk() + elif status == "8": + self._qemu_delete_vm() + elif status == "9": + self._qemu_cleanup() + elif status == "10": + self._qemu_test_vm() + elif status == "11": + self._qemu_reopen_monitor() + elif status == "12": + self._qemu_stats() + elif status == "13": + self._qemu_ssh_config_menu() + elif status == "14": + self._qemu_list_images() + else: + cmd_no_found = True + try: + int_cmd = int(status) + # Ignore les entrées de section pour mapper le numéro + # affiché sur la bonne commande (config incluse). + real = [c for c in choices if not c.get("section")] + if 0 < int_cmd <= len(real): + cmd_no_found = False + self.execute_from_configuration(real[int_cmd - 1]) + except ValueError: + pass + if cmd_no_found: + print(t("Command not found !")) + + # Compte créé par cloud-init dans les VM déployées ici. Sert de défaut + # quand ~/.ssh/config ne déclare aucun `User` pour l'hôte adopté. + QEMU_VM_USER = "erplibre" + + # Profondeur d'exploration par défaut : hôte -> VM -> VM imbriquée. Le + # profil « ERPLibre Déploiement (+ QEMU + dev) » installe QEMU DANS la VM, + # donc un parc à deux niveaux est le cas courant. + _QEMU_SSH_DEPTH = 2 + # Sonde exécutée SUR une machine. Première ligne « LIBVIRTyes|no », + # puis un couple « nomip » par VM. Une seule connexion SSH par + # niveau plutôt qu'une par VM ; le bail dnsmasq peut manquer, d'où le + # repli sur l'agent invité. + # + # La première ligne est indispensable : sans virsh, la boucle ne tourne + # simplement pas et la sonde sortirait VIDE avec un code 0 — impossible + # alors de distinguer « pas de QEMU ici » de « QEMU présent, aucune VM ». + _QEMU_SSH_PROBE = ( + "if ! command -v virsh >/dev/null 2>&1; then " + "printf 'LIBVIRT\\tno\\n'; exit 0; fi; " + "vms=$(sudo virsh list --all --name 2>/dev/null) || " + "{ printf 'LIBVIRT\\tno\\n'; exit 0; }; " + "printf 'LIBVIRT\\tyes\\n'; " + "for n in $vms; do " + 'ip=$(sudo virsh domifaddr "$n" --source lease 2>/dev/null ' + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); " + 'if [ -z "$ip" ]; then ' + 'ip=$(sudo virsh domifaddr "$n" --source agent 2>/dev/null ' + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' " + "| grep -v '^127\\.' | head -1); fi; " + 'printf "%s\\t%s\\n" "$n" "$ip"; done' + ) + + def _qemu_ssh_pick_roots(self): + """D'où partir pour configurer ~/.ssh/config. Renvoie une liste de + racines [{alias, ip|None}], ou [] pour renoncer. + + Trois provenances, parce que « la machine à configurer » n'est pas + toujours une VM d'ici : elle peut être un hôte déjà connu de + ~/.ssh/config, ou une adresse qu'on vient d'obtenir.""" + print(f"\n{t('Where should the machines come from?')}") + print(f" [1] {t('Local QEMU VMs (virsh)')} *") + print(f" [2] {t('Hosts from ~/.ssh/config')}") + print(f" [3] {t('Type a host or an IP')}") + print(f" [0] {t('Back')}") + answer = input(t("Choice (0-3, default 1): ")).strip() + if answer == "0": + return [] + + if answer == "2": + hosts = self._ssh_config_hosts() + if not hosts: + print(f" {t('~/.ssh/config holds no host.')}") + return [] + for i, name in enumerate(hosts, 1): + print(f" [{i}] {name}") + raw = input( + t("Which hosts? (numbers, comma-separated; blank = all): ") + ).strip() + chosen = ( + hosts if not raw else self._parse_index_selection(raw, hosts) + ) + # Déjà dans ~/.ssh/config : leur adresse y est, rien à réécrire. + # Le `User` déclaré est repris tel quel : ces hôtes ne sont pas + # forcément des VM ERPLibre, et leurs invitées suivent la même + # convention que leur parent. + return [ + { + "alias": name, + "ip": None, + "user": self._ssh_config_user(name), + } + for name in chosen or hosts + ] + + if answer == "3": + target = input(f"{t('Host or IP:')} ").strip() + if not target: + return [] + if target in self._ssh_config_hosts(): + return [ + { + "alias": target, + "ip": None, + "user": self._ssh_config_user(target), + } + ] + # « utilisateur@hôte » est accepté : c'est la forme qu'on tape + # naturellement, et elle évite une question de plus. + user, _, address = target.rpartition("@") + # Une IP brute n'est pas un alias : on lui en donne un, sinon ni + # le ProxyJump des enfants ni virt-manager n'auraient de nom. + default_alias = "qemu-" + address.replace(".", "-") + alias = ( + input( + f"{t('Name for ~/.ssh/config')} ({default_alias}): " + ).strip() + or default_alias + ) + if not user: + user = ( + input(f"{t('User')} ({self.QEMU_VM_USER}): ").strip() + or self.QEMU_VM_USER + ) + return [{"alias": alias, "ip": address, "user": user}] + + names = self._qemu_pick_domains() + if not names: + return [] + ip_map = self._qemu_resolve_ips(names, timeout=60) + roots = [] + for name in names: + ip = ip_map.get(name) + if not ip: + print(f" ⏭ {name}: {t('no IP')}") + continue + roots.append({"alias": name, "ip": ip}) + return roots + + def _qemu_ssh_config_menu(self): + """Écrit les entrées ~/.ssh/config du parc QEMU. + + Un seul flux : les deux anciennes entrées (« VM locales » et + « imbriquées, récursif ») écrivaient la même chose et ne différaient + que par la profondeur — c'est donc une question, pas un menu.""" + print(f"🔑 {t('SSH configuration for QEMU VMs')}") + roots = self._qemu_ssh_pick_roots() + if not roots: + return + raw = input( + f"{t('Depth (1 = these machines only, default:')}" + f" {self._QEMU_SSH_DEPTH}): " + ).strip() + try: + max_depth = max(1, int(raw)) if raw else self._QEMU_SSH_DEPTH + except ValueError: + max_depth = self._QEMU_SSH_DEPTH + # Aucune question sur la clé ici : tant que rien n'a échoué, elle + # serait prématurée. Elle est posée à la première identité refusée. + self._qemu_ssh_walk(roots, max_depth) + + def _ssh_ensure_key(self): + """Chemin de la clé PUBLIQUE, générée si aucune n'existe. + + Sans clé, ssh-copy-id n'a rien à déployer. On en crée une ed25519 sans + passphrase — le même choix que `deploy_qemu.ensure_ssh_key`, pour que + les VM créées et celles adoptées ici partagent la même clé.""" + existing = self._qemu_default_ssh_key() + if existing: + return existing + path = os.path.expanduser("~/.ssh/id_ed25519") + os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) + print(f"🔑 {t('Generating an ed25519 SSH key')}: {path}") + try: + res = subprocess.run( + ["ssh-keygen", "-t", "ed25519", "-N", "", "-f", path], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ {t('Cannot generate the key')}: {exc}") + return "" + if res.returncode != 0: + print(f" ⚠ {t('Cannot generate the key')}: {res.stderr.strip()}") + return "" + return f"{path}.pub" + + @staticmethod + def _ssh_key_accepted(alias): + """Vrai si la connexion par CLÉ passe déjà (aucun mot de passe). + + `PasswordAuthentication=no` est le point clé : sans lui, ssh + basculerait sur le mot de passe et on croirait la clé installée.""" + try: + res = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "PasswordAuthentication=no", + "-o", + "ConnectTimeout=10", + alias, + "true", + ], + capture_output=True, + timeout=45, + ) + return res.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + def _ssh_deploy_keys(self, aliases): + """Déploie la clé publique sur les hôtes qui ne l'ont pas encore. + + ssh-copy-id passe par ssh, donc par ~/.ssh/config : le ProxyJump d'une + VM imbriquée s'applique tout seul. Le mot de passe est demandé + directement dans le terminal (pas de capture de la sortie), sinon + l'invite serait invisible.""" + if not aliases: + return + pub = self._ssh_ensure_key() + if not pub: + return + print( + f"\n🔑 {t('Deploying the key on')} {len(aliases)} " + f"{self._plural(t('host'), len(aliases))} ({pub})" + ) + n_ok = n_skip = n_fail = 0 + for alias in aliases: + if self._ssh_key_accepted(alias): + print(f" · {alias}: {t('key already accepted')}") + n_skip += 1 + continue + print(f" ⤴ ssh-copy-id {alias}") + try: + res = subprocess.run( + ["ssh-copy-id", "-i", pub, alias], timeout=180 + ) + ok = res.returncode == 0 + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ {exc}") + ok = False + if ok: + n_ok += 1 + else: + n_fail += 1 + print( + f" {n_ok} {t('deployed')} · {n_skip} {t('already there')} · " + f"{n_fail} {t('failed')}" + ) + + # Connexions de virt-manager : stockées dans GSettings, pas dans un + # fichier. Le schéma est le même depuis des années (virt-manager 5.1 + # inclus) ; on LIT d'abord, et on n'écrit que si la lecture a marché. + _VIRT_MANAGER_SCHEMA = "org.virt-manager.virt-manager.connections" + # Schéma RELOCATABLE d'UNE connexion : il porte son nom affiché. + _VIRT_MANAGER_CONN_SCHEMA = "org.virt-manager.virt-manager.connection" + + @staticmethod + def _virt_manager_conn_path(uri): + """Chemin dconf des réglages d'une connexion. + + virt-manager ne fait aucun échappement : il retire simplement TOUS + les « / » de l'URI et s'en sert comme segment unique + (virtManager/config.py, _make_perconn_key). Le « :», le « @» et le + « + » restent donc tels quels.""" + return f"/org/virt-manager/virt-manager/conns/{uri.replace('/', '')}/" + + def _virt_manager_set_label(self, uri, label): + """Fixe le nom affiché d'une connexion. Sans lui, virt-manager + fabrique un libellé à partir de l'URI, où l'imbrication se lit mal.""" + target = ( + f"{self._VIRT_MANAGER_CONN_SCHEMA}:" + f"{self._virt_manager_conn_path(uri)}" + ) + try: + res = subprocess.run( + ["gsettings", "set", target, "pretty-name", label], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return False + return res.returncode == 0 + + def _virt_manager_uris(self): + """URI déjà connues de virt-manager, ou None s'il n'est pas là.""" + if not shutil.which("virt-manager") or not shutil.which("gsettings"): + return None + try: + res = subprocess.run( + ["gsettings", "get", self._VIRT_MANAGER_SCHEMA, "uris"], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + # GSettings rend du littéral Python : ['a', 'b'] ou @as []. + raw = res.stdout.strip() + if raw.startswith("@as "): + raw = raw[4:].strip() + try: + value = ast.literal_eval(raw) + except (ValueError, SyntaxError): + return None + return [str(item) for item in value] if isinstance(value, list) else [] + + def _virt_manager_add(self, uris): + """Ajoute les URI manquantes à virt-manager. Renvoie le nb ajouté.""" + current = self._virt_manager_uris() + if current is None: + return 0 + missing = [uri for uri in uris if uri not in current] + if not missing: + print(f" · {t('virt-manager: every connection already there')}") + return 0 + merged = current + missing + literal = "[" + ", ".join(f"'{uri}'" for uri in merged) + "]" + try: + res = subprocess.run( + [ + "gsettings", + "set", + self._VIRT_MANAGER_SCHEMA, + "uris", + literal, + ], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ virt-manager: {exc}") + return 0 + if res.returncode != 0: + print(f" ⚠ virt-manager: {res.stderr.strip()}") + return 0 + for uri in missing: + print(f" ✅ virt-manager: {uri}") + return len(missing) + + def _virt_manager_offer(self, hosts): + """Propose d'ajouter à virt-manager les machines qui font tourner + libvirt, pour piloter leurs VM depuis l'interface graphique locale. + + On passe par l'ALIAS SSH et non par l'IP : le transport qemu+ssh + utilise le binaire ssh, donc ~/.ssh/config — l'alias porte déjà + l'adresse ET le ProxyJump, ce qu'une IP brute ne saurait pas faire + pour une VM imbriquée. `hosts` = [(alias_chaîné, compte)], de sorte + que l'imbrication se lise aussi dans l'interface graphique et que le + compte soit celui de ~/.ssh/config, pas un défaut supposé.""" + if self._virt_manager_uris() is None: + return + labels = { + f"qemu+ssh://{user or self.QEMU_VM_USER}@{alias}/system": alias + for alias, user in hosts + } + uris = ["qemu:///system"] + list(labels) + print(f"\n🖥 {t('virt-manager detected')}") + for uri in uris: + print(f" {uri}") + if not self._is_yes_default_yes( + input(t("Add the missing connections to virt-manager? (Y/n): ")) + ): + return + # Le nom affiché est posé AVANT l'ajout à la liste : virt-manager le + # lit au moment d'afficher la connexion, pas à l'inscription. + for uri, label in labels.items(): + self._virt_manager_set_label(uri, label) + added = self._virt_manager_add(uris) + if added: + # Le NOM est relu à chaud (virt-manager écoute /pretty-name), + # mais la liste des connexions est lue au démarrage : une + # nouvelle entrée n'apparaît qu'au prochain lancement. + note = t("Restart virt-manager to see the new connections") + print(f" ℹ️ {note} ({t('the names apply live')})") + + # Signatures d'un refus d'AUTHENTIFICATION dans la sortie de ssh, par + # opposition à un hôte éteint ou introuvable. C'est la distinction qui + # décide s'il vaut la peine de parler de clé SSH. + _SSH_AUTH_ERRORS = ( + "permission denied", + "too many authentication failures", + "no such identity", + "host key verification failed", + "publickey", + ) + + @classmethod + def _ssh_error_kind(cls, stderr): + """« auth » si ssh a refusé l'identité, « net » sinon. + + Un hôte éteint et une clé absente produisent tous deux « injoignable » + alors qu'ils n'appellent pas du tout la même réponse.""" + text = (stderr or "").lower() + if any(marker in text for marker in cls._SSH_AUTH_ERRORS): + return "auth" + return "net" + + def _qemu_ssh_retry_with_key(self, alias, message): + """ssh a refusé l'identité : proposer la clé, puis resonder une fois. + + Posée ICI et pas au début : tant que rien n'échoue, la question est + prématurée — et si l'accès passe déjà par une clé d'agent ou un autre + mécanisme, elle n'aurait jamais lieu d'être.""" + print(f"\n 🔒 {alias}: {t('SSH refused the identity.')}") + print(f" {message}") + pub = self._qemu_default_ssh_key() + if pub: + print(f" {t('Existing key:')} {pub}") + question = t("Deploy it on this host (ssh-copy-id)? (Y/n): ") + else: + print(f" {t('No SSH key in ~/.ssh.')}") + question = t("Create one and deploy it? (Y/n): ") + if not self._is_yes_default_yes(input(f" {question}")): + return "auth", message + if not self._ssh_ensure_key(): + return "auth", message + self._ssh_deploy_keys([alias]) + return self._qemu_ssh_probe_remote(alias) + + def _qemu_ssh_probe_remote(self, alias): + """Sonde `alias`. Renvoie (statut, données) : + + ("ok", [(nom, ip)]) libvirt répond, voici ses VM + ("nolibvirt", []) joignable, mais pas de QEMU + ("auth", message) ssh a refusé l'identité + ("net", message) injoignable (éteint, DNS, port fermé…) + + Passe par « ssh », donc par le bloc ~/.ssh/config qu'on vient + d'écrire : le ProxyJump du parent s'applique tout seul et la même + sonde marche à n'importe quelle profondeur. + + « libvirt présent » et « a des VM » sont deux choses distinctes : une + machine avec QEMU mais sans VM mérite quand même sa connexion + virt-manager, une machine sans QEMU n'en veut aucune.""" + cmd = [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + alias, + self._QEMU_SSH_PROBE, + ] + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=90 + ) + except subprocess.TimeoutExpired: + return "net", t("timed out") + except (OSError, subprocess.SubprocessError) as exc: + return "net", str(exc) + if res.returncode != 0: + detail = (res.stderr or "").strip().splitlines() + message = detail[-1] if detail else f"exit {res.returncode}" + return self._ssh_error_kind(res.stderr), message + has_libvirt = False + found = [] + for line in res.stdout.splitlines(): + parts = line.strip().split("\t") + if len(parts) != 2 or not parts[0]: + continue + if parts[0] == "LIBVIRT": + has_libvirt = parts[1].strip() == "yes" + continue + found.append((parts[0], parts[1].strip())) + return ("ok" if has_libvirt else "nolibvirt"), found + + def _qemu_ssh_walk(self, roots, max_depth): + """Descend le parc depuis `roots` et écrit un ProxyJump par niveau. + + Une VM du profil « Déploiement » héberge elle-même des VM : celles-ci + n'ont pas d'IP joignable depuis l'hôte, seulement depuis leur parent. + ProxyJump enchaîne les sauts, et la chaîne se construit d'elle-même + puisque le parent est déjà dans ~/.ssh/config quand on écrit l'enfant. + + Une racine sans IP est un hôte DÉJÀ décrit dans ~/.ssh/config : son + adresse y est, on ne la réécrit pas, on part simplement de lui. + """ + # Clé existante s'il y en a une : elle va dans IdentityFile. Si une + # clé est créée plus tard, en réaction à un refus, les entrées + # suivantes la reprendront. + identity = self._ssh_private_key(self._qemu_default_ssh_key()) + + entries = [] # un enregistrement par machine écrite + taken = set() # tous les noms d'hôte déjà attribués + chain_of = {} # alias -> nom chaîné « parent+enfant » + user_of = {} # alias -> compte de connexion + hosts_libvirt = [] # machines qui font tourner QEMU + frontier = [] + for root in roots: + alias, ip = root["alias"], root.get("ip") + # Le compte vient de ~/.ssh/config quand il y est déclaré : un + # hôte adopté n'est pas forcément une VM ERPLibre. + user_of[alias] = root.get("user") or self.QEMU_VM_USER + if ip: + self._write_ssh_config_entry( + alias, user_of[alias], ip, identity_file=identity + ) + entries.append( + { + "names": [alias], + "ip": ip, + "parent": None, + "user": user_of[alias], + } + ) + taken.add(alias) + chain_of[alias] = alias + frontier.append(alias) + + # `max_depth` compte les NIVEAUX de machines, racines comprises : une + # profondeur de 1 s'arrête donc ici, sans rien sonder. + for depth in range(1, max_depth): + if not frontier: + break + print( + f"\n🔎 {t('Level')} {depth + 1} — " + f"{len(frontier)} {t('machines to probe')}" + ) + next_frontier = [] + for parent in frontier: + status, found = self._qemu_ssh_probe_remote(parent) + if status == "auth": + # C'EST ici qu'une clé manquante se manifeste, pas avant : + # on ne parle d'identité qu'une fois l'identité refusée. + status, found = self._qemu_ssh_retry_with_key( + parent, found + ) + # Une clé a pu naître de cet échange : les entrées + # écrites ensuite doivent la nommer. + identity = ( + self._ssh_private_key(self._qemu_default_ssh_key()) + or identity + ) + if status in ("auth", "net"): + label = ( + t("access refused") + if status == "auth" + else t("unreachable") + ) + print(f" ⏭ {parent}: {label} — {found}") + continue + has_libvirt = status == "ok" + if not has_libvirt: + print(f" · {parent}: {t('no QEMU/libvirt here')}") + continue + # Une machine avec QEMU vaut sa connexion virt-manager, même + # sans VM : c'est là qu'on pourra en créer. + hosts_libvirt.append(parent) + if not found: + print(f" · {parent}: {t('QEMU present, no VM')}") + continue + for child, ip in found: + if not ip: + print(f" ⏭ {parent} › {child}: {t('no IP')}") + continue + # UN SEUL nom, le nom CHAÎNÉ : il dit où vit la VM et ne + # peut heurter aucune autre machine. Y ajouter le nom + # court ne ferait que répéter la fin de la chaîne. + chain = f"{chain_of[parent]}+{child}" + if chain in taken: + continue # déjà vu (cycle) + # L'invitée hérite du compte de son parent : elle a été + # créée par lui, avec la même convention. + user_of[chain] = user_of[parent] + self._write_ssh_config_entry( + chain, + user_of[chain], + ip, + proxy_jump=parent, + identity_file=identity, + ) + entries.append( + { + "names": [chain], + "ip": ip, + "parent": parent, + "user": user_of[chain], + } + ) + taken.add(chain) + chain_of[chain] = chain + next_frontier.append(chain) + frontier = next_frontier + + print(f"\n── {t('SSH hosts written')} ──") + for item in entries: + via = f" ({t('via')} {item['parent']})" if item["parent"] else "" + print( + f" ssh {' '.join(item['names']):<40}" + f" {item['user']}@{item['ip']}{via}" + ) + + # Les machines qui hébergent QEMU sont celles qui valent d'être + # ajoutées à virt-manager : c'est de là qu'on pilote leurs invitées. + # On y présente le nom CHAÎNÉ, pour que l'imbrication se lise aussi + # dans l'interface graphique et pas seulement dans ~/.ssh/config. + self._virt_manager_offer( + [ + (chain_of.get(alias, alias), user_of.get(alias, "")) + for alias in hosts_libvirt + ] + ) + + def _qemu_stats(self): + """Statistiques d'utilisation de QEMU, et remise à zéro. + + Tout vient de l'historique tenu par le moniteur d'installation + (.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant. + """ + # Cet écran ne lit que des fichiers : il n'a pas besoin de Textual, + # contrairement au dashboard du même module. Un échec d'import est + # donc un vrai problème de module, pas une dépendance manquante. + try: + from script.todo import qemu_install_monitor as mon + except ImportError as exc: + print(f"{t('Command failed: ')}{exc}") + return + + while True: + summary = mon.stats_summary() + print(f"\n📊 {t('QEMU statistics')}") + if not summary: + print(f" {t('No installation recorded yet.')}") + else: + rate = 100 * summary["ok"] // max(summary["total"], 1) + print(f"\n── {t('Installations')} ──") + print( + f" {t('Total'):<18}: {summary['total']}" + f" ({summary['ok']} {t('succeeded')}," + f" {summary['failed']} {t('failed')} — {rate} %)" + ) + if summary["first_ts"]: + days = max( + 1, + (summary["last_ts"] - summary["first_ts"]) // 86400, + ) + print( + f" {t('Period'):<18}:" + f" {self._qemu_stamp(summary['first_ts'])}" + f" → {self._qemu_stamp(summary['last_ts'])}" + f" ({days} {t('days')})" + ) + print( + f" {t('Median duration'):<18}:" + f" {mon._fmt_secs(summary['median'])}" + f" ({t('min')} {mon._fmt_secs(summary['min'])} ·" + f" {t('max')} {mon._fmt_secs(summary['max'])})" + ) + print( + f" {t('Cumulated time'):<18}:" + f" {mon._fmt_secs(summary['total_secs'])}" + ) + for field, title in ( + ("distro", t("By distribution")), + ("version", t("By version")), + ("arch", t("By architecture")), + ): + rows = mon.stats_by(field) + if not rows: + continue + print(f"\n── {title} ──") + for key, count, avg, failed in rows[:8]: + # Un groupe sans aucun succès n'a pas de moyenne : « — » + # plutôt qu'un « ~0s » trompeur. + moy = f"~{mon._fmt_secs(avg)}" if count else "—" + fail = ( + f" ⚠ {failed} {self._plural(t('failure'), failed)}" + if failed + else "" + ) + print(f" {key:<22} {count:>3} × {moy:<8}{fail}") + + self._qemu_stats_vms(mon) + print(f"\n [r] {t('Reset the statistics')}") + print(f" [0] {t('Back')}") + answer = input(f"💬 {t('Your choice')} : ").strip().lower() + if answer in ("", "0"): + return + if answer == "r": + if not summary: + print(f" {t('Nothing to reset.')}") + continue + confirm = input( + f" {t('Erase')} {summary['total']}" + f" {t('recorded runs')}? (y/N): " + ).strip() + if self._is_yes(confirm): + count = mon.reset_stats() + print(f" ✅ {count} {t('runs erased')}.") + else: + print(f" {t('Cancelled.')}") + + @staticmethod + def _qemu_stamp(ts): + """Horodatage court « 2026-08-01 ».""" + try: + return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + except (OSError, OverflowError, ValueError): + return "?" + + def _qemu_stats_vms(self, mon): + """Machines virtuelles actuelles : nombre, états, place disque.""" + try: + states = mon.virsh_domstates() + except Exception: + return + if not states: + return + running = sum(1 for s in states.values() if s == "running") + total_bytes = 0 + counted = 0 + for name in states: + try: + # vm_disk_path attend un dict ; le chemin par défaut de libvirt + # se déduit du seul nom. + size = mon.disk_actual_size(mon.vm_disk_path({"name": name})) + except Exception: + size = None + if size: + total_bytes += size + counted += 1 + print(f"\n── {t('Virtual machines')} ──") + print( + f" {t('Defined'):<18}: {len(states)}" + f" ({running} {t('running')}," + f" {len(states) - running} {t('stopped')})" + ) + if counted: + print( + f" {t('Disk used'):<18}:" + f" {mon._fmt_size(total_bytes)}" + f" ({counted} {self._plural(t('image'), counted)})" + ) + + def _qemu_download_image(self): + script_path = self._qemu_script_path() + distro = self._qemu_prompt_distro() + version = self._qemu_prompt_version(distro) + ans = input(t("Verify SHA256 after download? (y/N): ")) + parts = [ + "sudo", + script_path, + "--download-only", + "--distro", + distro, + "--version", + version, + ] + if self._is_yes(ans): + parts.append("--verify") + cmd = " ".join(shlex.quote(p) for p in parts) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_list_vms(self, ask_advanced=False): + cmd = "sudo virsh list --all" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + if not ask_advanced: + return + # Menu contextuel : infos avancées, ou changer l'état de VM. + print(f"\n{t('What do you want to do?')}") + print(f" [1] {t('Advanced info (vCPU, RAM, disk)')}") + print(f" [2] {t('Change the state of one or more VMs')}") + print(f" [{t('Enter')}] {t('Nothing')}") + choice = input(t("Choice: ")).strip() + if choice == "1": + self._qemu_list_vms_advanced() + elif choice == "2": + self._qemu_change_state() + + def _qemu_change_state(self): + """Démarre (« ouvrir ») ou éteint (« fermer ») une liste de VM saisie + séparée par des virgules, avec DOUBLE validation.""" + names = self._qemu_list_domains() + if not names: + print(f"\n{t('No VM found.')}") + return + print(f"\n{t('Available VMs:')} {', '.join(names)}") + raw = input(t("VMs to change (comma-separated): ")).strip() + targets = [n.strip() for n in raw.split(",") if n.strip()] + if not targets: + print(t("Nothing selected.")) + return + # Résoudre les ID -> noms et valider l'existence. + resolved, unknown = [], [] + known = set(names) + for tgt in targets: + real = self._qemu_domname(tgt) + (resolved if real in known else unknown).append(real) + if unknown: + print(f"{t('Unknown VM(s):')} {', '.join(unknown)}") + return + # Choix de l'état cible : ouvrir (démarrer) ou fermer (éteindre). + print(f"\n{t('Target state:')}") + print(f" [1] {t('Open (start)')}") + print(f" [2] {t('Close (shut down)')}") + st = input(t("Choice: ")).strip() + if st == "1": + action, verb = "start", t("start") + elif st == "2": + action, verb = "shutdown", t("shut down") + else: + print(t("Cancelled.")) + return + # DOUBLE validation avant d'appliquer. + summary = f"{verb} -> {', '.join(resolved)}" + if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")): + print(t("Cancelled.")) + return + if not self._is_yes(input(t("Confirm for real? (y/N): "))): + print(t("Cancelled.")) + return + for real in resolved: + cmd = f"sudo virsh {action} {shlex.quote(real)}" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + @staticmethod + def _qemu_dominfo(name): + """(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0).""" + try: + res = subprocess.run( + ["sudo", "virsh", "dominfo", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return 0, 0 + vcpus, mem = 0, 0 + for line in res.stdout.splitlines(): + if line.startswith("CPU(s):"): + try: + vcpus = int(line.split(":", 1)[1].strip()) + except ValueError: + pass + elif line.startswith("Max memory:"): + # « 4194304 KiB » + try: + mem = int(line.split(":", 1)[1].split()[0]) + except (ValueError, IndexError): + pass + return vcpus, mem + + @staticmethod + def _qemu_disk_sizes(disk): + """(taille virtuelle, taille réelle sur disque) en octets, via + qemu-img info -U (lit même VM allumée). (0, 0) si échec.""" + try: + res = subprocess.run( + ["sudo", "qemu-img", "info", "-U", "--output=json", disk], + capture_output=True, + text=True, + timeout=20, + ) + data = json.loads(res.stdout) + return ( + int(data.get("virtual-size", 0)), + int(data.get("actual-size", 0)), + ) + except (OSError, subprocess.SubprocessError, ValueError): + return 0, 0 + + def _qemu_list_vms_advanced(self): + """Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel + + réel), plus l'espace total disponible du stockage des images.""" + names = self._qemu_list_domains() + if not names: + print(f"\n{t('No VM found.')}") + return + g = 1 << 30 + header = ( + f"\n{'VM':<28} {'État':<10} {'vCPU':>4} {'RAM':>8} " + f"{'Disque':>9} {'Réel':>9}" + ) + print(header) + print("─" * len(header.strip())) + disk_dirs = set() + for name in names: + state = self._qemu_domstate(name) or "?" + vcpus, mem_kib = self._qemu_dominfo(name) + disk = self._qemu_main_disk(name) + virt, actual = self._qemu_disk_sizes(disk) if disk else (0, 0) + if disk: + disk_dirs.add(os.path.dirname(disk)) + ram_g = (mem_kib * 1024) / g if mem_kib else 0 + print( + f"{name:<28.28} {state:<10.10} {vcpus:>4} " + f"{ram_g:>7.1f}G {virt / g:>8.1f}G {actual / g:>8.1f}G" + ) + # Espace total disponible sur le(s) stockage(s) des disques. + for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]: + try: + usage = shutil.disk_usage(d) + except OSError: + continue + print( + f"\n{t('Storage')} {d} : " + f"{usage.free / g:.1f}G {t('free')} / " + f"{usage.total / g:.1f}G {t('total')} " + f"({usage.used / g:.1f}G {t('used')})" + ) + + def _qemu_show_ip(self): + # Affiche d'abord les VM (avec leur ID) pour que l'utilisateur sache + # quel nom/ID saisir, puis demande lequel (ou « all » pour toutes). + self._qemu_list_vms() + print() + name = input(t("VM name or ID (or 'all'): ")).strip() + if not name: + print(t("VM name is required!")) + return + if name.lower() in ("all", "tous", "*"): + targets = self._qemu_list_domains() + if not targets: + print(t("No VM found.")) + return + else: + targets = [name] + for tgt in targets: + cmd = f"sudo virsh domifaddr {shlex.quote(tgt)} --source lease" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_console(self): + # Liste les VM, demande laquelle, rappelle comment quitter (Ctrl+]) + # puis ouvre la console série interactive. + self._qemu_list_vms() + print() + name = input(t("VM name or ID: ")).strip() + if not name: + print(t("VM name is required!")) + return + print(f"\n💡 {t('To leave the console, press Ctrl+] (then Enter).')}") + print( + f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}" + ) + cmd = f"sudo virsh console {shlex.quote(name)}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_test_vm(self): + """Teste une VM : résout son IP puis ouvre Odoo (:8069) dans un + navigateur web EN LIGNE DE COMMANDE choisi par l'utilisateur.""" + self._qemu_list_vms() + print() + name = input(t("VM name or ID: ")).strip() + if not name: + print(t("VM name is required!")) + return + real = self._qemu_domname(name) + if not self._qemu_domain_exists(real): + print(f"{real}: {t('VM not found.')}") + return + print(f"\n{t('Resolving VM IP...')}") + ip = self._qemu_vm_ip(real, timeout=120) + if not ip: + print(t("No IP found for this VM.")) + return + browser = self._qemu_choose_cli_browser() + if not browser: + return + url = f"http://{ip}:8069" + print(f"→ {browser} {url}") + # os.system (et NON exec_command_live) : un navigateur texte a besoin + # du VRAI TTY interactif. exec_command_live redirige la sortie dans un + # tube -> le navigateur ne fait qu'imprimer sans réagir au clavier. + rc = os.system(f"{browser} {shlex.quote(url)}") + if rc != 0: + msg = t( + "Page may not have loaded: Odoo not started on :8069, " + "or network/firewall." + ) + print(f"⚠ {msg}") + + def _qemu_reopen_monitor(self): + """Rouvre le suivi d'installation (dashboard) sur un run PASSÉ : le + dernier par défaut, ou un choix dans l'historique. Utile quand le + dashboard s'est fermé et qu'on veut reprendre l'analyse.""" + from script.todo import qemu_install_monitor as mon + + runs = mon.list_install_runs() + if not runs: + print(t("No install run found in history.")) + return + print(f"\n{t('Install runs (most recent first):')}") + for i, r in enumerate(runs, 1): + names = ", ".join(v.get("name", "?") for v in r["vms"]) + star = " *" if i == 1 else "" + print( + f" [{i}] {r['label']} — {len(r['vms'])} VM{star}\n" + f" {names}" + ) + sel = input(t("Choice (number, blank = last): ")).strip() + run = runs[0] + if sel: + try: + idx = int(sel) - 1 + if 0 <= idx < len(runs): + run = runs[idx] + else: + print(t("Invalid selection.")) + return + except ValueError: + print(t("Invalid selection.")) + return + try: + mon.run_monitor(run["manifest"]) + except ImportError: + from script.todo import textual_setup + + if textual_setup.ensure(): + mon.run_monitor(run["manifest"]) + except Exception as exc: + print(f"{t('Command failed: ')}{exc}") + + def _qemu_choose_cli_browser(self): + """Offre la LISTE des navigateurs CLI installés, plus une option pour + en INSTALLER un autre, et renvoie celui choisi, sinon None.""" + from script.todo.qemu_install_monitor import CLI_BROWSERS + + available = [b for b in CLI_BROWSERS if shutil.which(b)] + if not available: + return self._qemu_install_cli_browser() + print(f"\n{t('Which browser to view the page?')}") + for i, b in enumerate(available, 1): + print(f" [{i}] {b}{' *' if i == 1 else ''}") + print(f" [i] {t('Install another browser')}") + sel = input(t("Choice (number, blank = first): ")).strip().lower() + if sel == "i": + return self._qemu_install_cli_browser() + if not sel: + return available[0] + try: + idx = int(sel) - 1 + if 0 <= idx < len(available): + return available[idx] + except ValueError: + pass + return available[0] + + def _qemu_install_cli_browser(self): + """Demande QUEL navigateur CLI installer, affiche la commande adaptée + à l'OS, l'exécute après validation. Renvoie le binaire installé ou + None.""" + from script.todo.qemu_install_monitor import ( + INSTALLABLE_BROWSERS, + browser_install_command, + ) + + print(f"\n{t('Which browser to install?')}") + for i, (b, desc) in enumerate(INSTALLABLE_BROWSERS, 1): + print(f" [{i}] {desc}{' *' if i == 1 else ''}") + sel = input(t("Choice (number, blank = w3m): ")).strip() + browser = INSTALLABLE_BROWSERS[0][0] + try: + idx = int(sel) - 1 + if 0 <= idx < len(INSTALLABLE_BROWSERS): + browser = INSTALLABLE_BROWSERS[idx][0] + except ValueError: + pass + cmd = browser_install_command(browser) + if not cmd: + print(t("Unknown package manager; install it manually.")) + return None + printable = " ".join(cmd) + print(f"{t('Command:')} {printable}") + if not self._is_yes(input(t("Install now? (y/N): "))): + return None + os.system(printable) + return browser if shutil.which(browser) else None + + # ------------------------------------------------------------------ # + # Redimensionnement du disque d'une VM + # ------------------------------------------------------------------ # + @staticmethod + def _qemu_c_env(): + """Environnement forçant LC_ALL=C : la sortie des outils (virsh, + sgdisk, resize2fs, dumpe2fs…) reste en ANGLAIS quelle que soit la + locale de l'hôte. Sinon « running » devient « en cours d'exécution » + (fr) et les comparaisons/parsing d'état cassent.""" + env = dict(os.environ) + env["LC_ALL"] = "C" + env["LANG"] = "C" + return env + + @staticmethod + def _qemu_domstate(name): + """État libvirt de la VM (« running », « shut off », …) ou ''.""" + try: + res = subprocess.run( + ["sudo", "virsh", "domstate", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout.strip() if res.returncode == 0 else "" + + @staticmethod + def _qemu_domname(name): + """Nom canonique de la VM (si on a fourni un ID numérique, le + résout ; sinon renvoie tel quel). Utile car un ID disparaît une + fois la VM éteinte.""" + if not str(name).isdigit(): + return name + try: + res = subprocess.run( + ["sudo", "virsh", "domname", str(name)], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return name + out = res.stdout.strip() + return out if res.returncode == 0 and out else name + + def _qemu_shutdown_wait(self, name, timeout=120): + """Arrête la VM par SIGNAL (ACPI power-button, puis agent invité) et + attend qu'elle soit « shut off » en affichant le temps restant du + timeout. Si l'arrêt gracieux traîne, propose un arrêt forcé (destroy). + Renvoie True si la VM est bien éteinte.""" + name = self._qemu_domname(name) + if self._qemu_domstate(name) == "shut off": + return True + # --mode acpi,agent : envoie le SIGNAL d'extinction (bouton ACPI) puis + # tente l'agent invité si présent — plus fiable qu'un arrêt brutal. + cmd = f"sudo virsh shutdown {shlex.quote(name)} --mode acpi,agent" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print( + f"{t('Waiting for the VM to shut down...')} " + f"({t('timeout')}: {timeout} s)" + ) + deadline = time.time() + timeout + while time.time() < deadline: + if self._qemu_domstate(name) == "shut off": + # Efface la ligne de compte à rebours puis confirme. + print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}") + return True + remaining = int(deadline - time.time()) + print( + f"\r ⏳ {t('shutting down')}… " + f"{remaining:>3d} s {t('remaining')}", + end="", + flush=True, + ) + time.sleep(2) + print() # newline après le compte à rebours + # Arrêt gracieux trop long : proposer un arrêt forcé. + if self._is_yes( + input( + t( + "Graceful shutdown timed out. Force off (destroy)? " + "(y/N): " + ) + ) + ): + cmd = f"sudo virsh destroy {shlex.quote(name)}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + time.sleep(2) + return self._qemu_domstate(name) == "shut off" + return False + + @staticmethod + def _qemu_main_disk(name): + """Chemin du disque PRINCIPAL (qcow2) de la VM via domblklist. On + ignore le seed cloud-init (…-seed.iso, en lecture seule).""" + try: + res = subprocess.run( + ["sudo", "virsh", "domblklist", name, "--details"], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return None + disks = [] + for line in res.stdout.splitlines(): + parts = line.split() + # Colonnes : Type Device Target Source + if len(parts) >= 4 and parts[1] == "disk" and parts[3] != "-": + disks.append(parts[3]) + # Le disque de travail est le .qcow2 (le seed est un .iso). + for d in disks: + if d.endswith(".qcow2"): + return d + return disks[0] if disks else None + + @staticmethod + def _qemu_disk_virtual_bytes(disk): + """Taille VIRTUELLE (octets) du disque via « qemu-img info --json ».""" + try: + # -U (--force-share) : lit même si la VM tourne (libvirt tient le + # lock d'écriture). Sans ça : « Failed to get shared write lock ». + res = subprocess.run( + ["sudo", "qemu-img", "info", "-U", "--output=json", disk], + capture_output=True, + text=True, + timeout=20, + ) + data = json.loads(res.stdout) + return int(data.get("virtual-size", 0)) + except (OSError, subprocess.SubprocessError, ValueError): + return 0 + + def _qemu_resize_disk(self): + """Redimensionne le disque d'une VM : affiche l'espace actuel, demande + +NG / -NG / taille cible, applique (à chaud si possible pour agrandir), + puis propose d'étendre le système de fichiers invité.""" + self._qemu_list_vms() + print() + name = input(t("VM name to resize: ")).strip() + if not name: + print(t("VM name is required!")) + return + if not self._qemu_domain_exists(name): + print(f"{name}: {t('VM not found.')}") + return + # Résout tout de suite le NOM canonique (VM encore allumée -> l'ID est + # résoluble). Après extinction, un ID numérique disparaît : « virsh + # start 32 » échouerait. On travaille désormais avec le nom. + name = self._qemu_domname(name) + disk = self._qemu_main_disk(name) + if not disk: + print(t("Main disk not found for this VM.")) + return + + # 1) Espace actuel (virtuel + réel) + df invité si joignable. + print(f"\n{t('Current disk:')} {disk}") + # -U : lecture sûre même VM allumée (sinon « shared write lock »). + self.execute.exec_command_live( + f"sudo qemu-img info -U {shlex.quote(disk)}", source_erplibre=False + ) + cur_bytes = self._qemu_disk_virtual_bytes(disk) + cur_gb = cur_bytes / (1 << 30) + state = self._qemu_domstate(name) + if cur_bytes <= 0: + print(t("Could not read current disk size; aborting.")) + print(f"{t('VM state:')} {state or '?'}") + return + print(f"{t('Current virtual size:')} {cur_gb:.1f} G") + print(f"{t('VM state:')} {state or '?'}") + + # Espace HÔTE : le qcow2 est creux (sparse), donc on PEUT fixer une + # taille virtuelle plus grande que l'espace réel — mais si la VM la + # remplit, l'hôte tombe à court. Max « soutenable » ≈ taille réelle + # actuelle + espace libre de l'hôte. On l'AFFICHE (avertissement, pas + # de blocage) pour guider le choix. + g = 1 << 30 + _virt, actual = self._qemu_disk_sizes(disk) + try: + free = shutil.disk_usage(os.path.dirname(disk)).free + except OSError: + free = 0 + max_safe_gb = (actual + free) / g if free else 0 + if max_safe_gb: + print( + f"{t('Host free space:')} {free / g:.1f} G · " + f"{t('max sustainable total (before host full):')} " + f"~{max_safe_gb:.1f} G" + ) + + # 2) Nouvelle taille : +NG (agrandir), -NG (réduire) ou NG (cible). + guide = t( + "Enter +NG to grow, -NG to shrink, or NG for a target size " + "(e.g. +20G, -10G, 60G)." + ) + print(f"\n{guide}") + raw = input(t("Resize: ")).strip().upper().replace("G", "") + try: + if raw.startswith("+"): + new_gb = cur_gb + float(raw[1:]) + elif raw.startswith("-"): + new_gb = cur_gb - float(raw[1:]) + else: + new_gb = float(raw) + except ValueError: + print(t("Invalid size.")) + return + if new_gb <= 0: + print(t("Invalid size.")) + return + new_gb = round(new_gb, 1) + if abs(new_gb - cur_gb) < 0.05: + print(t("No change.")) + return + shrink = new_gb < cur_gb + print(f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G") + # Avertissement (NON bloquant) : agrandir au-delà de ce que l'hôte + # peut soutenir -> surallocation, l'hôte se remplira si la VM utilise + # tout l'espace. + if not shrink and max_safe_gb and new_gb > max_safe_gb: + over = new_gb - max_safe_gb + msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over + msg2 = ( + t( + "The qcow2 is thin: fine until the VM fills it, then the " + "host disk runs out. Max sustainable: ~%.1f G." + ) + % max_safe_gb + ) + print(f"⚠ {msg1}") + print(f" {msg2}") + + # 3) Application selon agrandir/réduire et l'état de la VM. + was_shut_down = False # la VM a-t-elle été éteinte pour l'occasion ? + cmd = ( + None # commande d'AGRANDISSEMENT (la réduction a son propre flux) + ) + if shrink: + # DANGER : qcow2 --shrink ne réduit PAS le FS invité -> perte de + # données si le FS dépasse la cible. VM éteinte obligatoire. + danger = t( + "SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. " + "Data beyond the new size is LOST. Shrink the guest FS FIRST, " + "and only then shrink here." + ) + print(f"⚠ {danger}") + if state != "shut off": + if not self._is_yes( + input( + t( + "The VM must be off. Shut it down and retry? " + "(y/N): " + ) + ) + ): + print(t("Cancelled.")) + return + if not self._qemu_shutdown_wait(name): + print(t("VM is still not off; aborting.")) + return + state = "shut off" + was_shut_down = True + if not self._is_yes( + input(t("Type y to confirm you understand the risk (y/N): ")) + ): + print(t("Cancelled.")) + return + # Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec + # sauvegarde optionnelle restaurée en cas d'échec). + if not self._qemu_safe_shrink(name, disk, new_gb): + self._qemu_offer_start(name, was_shut_down) + return + elif state == "running": + # Agrandissement À CHAUD : le disque virtuel grossit, le FS invité + # devra être étendu ensuite. + cmd = ( + f"sudo virsh blockresize {shlex.quote(name)} " + f"{shlex.quote(disk)} {new_gb:g}G" + ) + else: + cmd = f"sudo qemu-img resize {shlex.quote(disk)} {new_gb:g}G" + + # 4) Agrandissement : exécuter la commande + proposer d'étendre le FS. + if cmd is not None: + print(f"{t('Will execute:')} {cmd}") + if self.execute.exec_command_live(cmd, source_erplibre=False) != 0: + print(f"❌ {t('Resize failed (see error above).')}") + return + print(f"✅ {t('Virtual disk resized.')}") + if self._is_yes( + input(t("Grow the guest filesystem now (over SSH)? (y/N): ")) + ): + self._qemu_grow_guest_fs(name) + + # 5) La VM a été éteinte pour l'opération : proposer de la redémarrer + # (l'utilisateur peut ainsi TESTER avant de décider du backup). + self._qemu_offer_start(name, was_shut_down) + + # 6) Réduction réussie AVEC sauvegarde : proposer de l'effacer une fois + # la VM testée (défaut : NON -> on garde le backup par prudence). + bak = getattr(self, "_shrink_backup", None) + if shrink and bak and os.path.exists(bak): + print(f"\n{t('A disk backup was kept:')} {bak}") + if self._is_yes(input(t("Delete this backup now? (y/N): "))): + subprocess.run(["sudo", "rm", "-f", bak], check=False) + print(t("Backup deleted.")) + else: + print(t("Backup kept (delete later via Clean up QEMU).")) + self._shrink_backup = None + + def _qemu_offer_start(self, name, was_shut_down): + """Si la VM a été éteinte pour l'opération, le noter et proposer de la + redémarrer (sinon ne rien demander).""" + if not was_shut_down: + return + print(f"\nℹ {t('The VM was shut down for the resize.')}") + if self._is_yes(input(t("Start the VM now? (y/N): "))): + # `name` est déjà le nom canonique : « virsh start » + # échouerait car l'ID disparaît quand la VM est éteinte. + cmd = f"sudo virsh start {shlex.quote(name)}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + # Outils requis pour la réduction sûre (tous « base » : e2fsprogs, gdisk, + # util-linux, qemu-utils) — PAS libguestfs (souvent cassé : appliance + # supermin sans noyau dans /boot). + _SHRINK_TOOLS = ( + "qemu-nbd", + "e2fsck", + "resize2fs", + "sgdisk", + "partprobe", + "lsblk", + "dumpe2fs", + "blockdev", + ) + _SECT = 512 + _MiB = 1024 * 1024 + + def _qemu_safe_shrink(self, name, disk, new_gb): + """Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs + + sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition, + puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE + .bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de + corruption. ext2/3/4 uniquement. Renvoie True si réduit.""" + import math + + missing = [b for b in self._SHRINK_TOOLS if not shutil.which(b)] + if missing: + print( + f"{t('Missing tools for safe shrink:')} {', '.join(missing)}" + ) + return False + target = int(round(new_gb * (1 << 30))) + # Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas + # d'échec, et de tester la VM avant de la supprimer (proposé à la fin). + self._shrink_backup = None + bak = None + if self._is_yes_default_yes( + input(t("Back up the disk before shrinking? (Y/n): ")) + ): + bak = f"{disk}.bak" + print(f"\n{t('Backing up the disk before shrinking…')}") + if ( + subprocess.run( + [ + "sudo", + "cp", + "--reflink=auto", + "--sparse=always", + disk, + bak, + ] + ).returncode + != 0 + ): + print(t("Backup failed; aborting.")) + return False + else: + print( + f"⚠ {t('No backup: a failure could leave the disk broken.')}" + ) + subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False) + dev = None + try: + dev = self._qemu_nbd_connect(disk) + if not dev: + print(t("Could not attach the disk (nbd); aborting.")) + return self._qemu_shrink_revert(bak, disk, changed=False) + part, start, fstype = self._qemu_root_part(dev) + if not part: + print(t("Could not detect the partition to shrink; aborting.")) + return self._qemu_shrink_revert(bak, disk, changed=False) + if not fstype.startswith("ext"): + print( + f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}" + f" ({fstype})" + ) + return self._qemu_shrink_revert(bak, disk, changed=False) + n = self._qemu_part_number(dev, part) + info = self._qemu_part_info(dev, n) + # fsck AVANT toute opération. + subprocess.run(["sudo", "e2fsck", "-f", "-y", part], check=False) + bs = self._qemu_fs_blocksize(part) + # Cibles (octets), en gardant 2 Mio pour la GPT de secours + marge. + part_start_b = start * self._SECT + max_fs_b = target - part_start_b - 4 * self._MiB + if max_fs_b <= 0: + print(t("Target size too small for this layout; aborting.")) + return self._qemu_shrink_revert(bak, disk, changed=False) + min_blocks = self._qemu_fs_min_blocks(part) + if min_blocks and min_blocks * bs > max_fs_b: + print(t("Not enough used-space margin to shrink; aborting.")) + return self._qemu_shrink_revert(bak, disk, changed=False) + fs_target_mib = max_fs_b // self._MiB + print( + f"\n{t('Shrinking guest ext filesystem')} {part} " + f"-> {fs_target_mib} MiB…" + ) + if ( + subprocess.run( + ["sudo", "resize2fs", part, f"{fs_target_mib}M"] + ).returncode + != 0 + ): + print(t("resize2fs failed; reverting.")) + return self._qemu_shrink_revert(bak, disk, changed=True) + # Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée. + fs_bytes = self._qemu_fs_blocks(part) * bs + new_end = start + int( + math.ceil((fs_bytes + self._MiB) / self._SECT) + ) + new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048 + if (new_end + 34) * self._SECT > target: + print(t("Internal size check failed; reverting.")) + return self._qemu_shrink_revert(bak, disk, changed=True) + # Réécrit la partition (mêmes type/UUID/nom -> PARTUUID préservé). + print(f"{t('Shrinking the partition…')} ({part})") + subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False) + rc = subprocess.run( + [ + "sudo", + "sgdisk", + "-n", + f"{n}:{start}:{new_end}", + "-t", + f"{n}:{info['type']}", + "-u", + f"{n}:{info['uuid']}", + "-c", + f"{n}:{info['name']}", + dev, + ] + ).returncode + if rc != 0: + print(t("Partition rewrite failed; reverting.")) + return self._qemu_shrink_revert(bak, disk, changed=True) + subprocess.run( + ["sudo", "partprobe", dev], check=False, capture_output=True + ) + # Détache puis tronque le conteneur qcow2. + self._qemu_nbd_disconnect(dev) + dev = None + print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G") + if ( + subprocess.run( + [ + "sudo", + "qemu-img", + "resize", + "--shrink", + disk, + f"{new_gb:g}G", + ] + ).returncode + != 0 + ): + print(t("Container shrink failed; reverting.")) + return self._qemu_shrink_revert(bak, disk, changed=True) + # Répare la GPT de secours (fin du disque) + fsck final. + dev = self._qemu_nbd_connect(disk) + if dev: + subprocess.run(["sudo", "sgdisk", "-e", dev], check=False) + subprocess.run( + ["sudo", "partprobe", dev], + check=False, + capture_output=True, + ) + p2 = self._qemu_root_part(dev)[0] + if p2: + subprocess.run( + ["sudo", "e2fsck", "-f", "-y", p2], check=False + ) + self._qemu_nbd_disconnect(dev) + dev = None + self._shrink_backup = bak # proposé à la suppression après le boot + if bak: + print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}") + else: + print(f"✅ {t('Disk safely shrunk.')}") + return True + finally: + if dev: + self._qemu_nbd_disconnect(dev) + + def _qemu_shrink_revert(self, bak, disk, changed): + """Restaure le disque depuis la sauvegarde si on l'a modifié (changed) + et qu'une sauvegarde existe ; sinon retire la sauvegarde inutile. + Renvoie False (la réduction a échoué).""" + if changed and bak: + print(t("Restoring the original disk from backup…")) + subprocess.run(["sudo", "mv", "-f", bak, disk], check=False) + elif changed and not bak: + print( + f"⚠ {t('No backup to restore; run fsck on the disk before use.')}" + ) + elif bak: + subprocess.run(["sudo", "rm", "-f", bak], check=False) + return False + + @staticmethod + def _qemu_nbd_connect(disk): + """Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None. + Attend que les sous-périphériques de partition (nbdNpM) APPARAISSENT + (sinon lsblk/resize2fs ne voient rien juste après le connect).""" + for i in range(16): + dev = f"/dev/nbd{i}" + # /sys/block/nbdN/pid absent => device libre. + if os.path.exists(f"/sys/block/nbd{i}/pid"): + continue + rc = subprocess.run( + ["sudo", "qemu-nbd", "-c", dev, disk], + capture_output=True, + text=True, + ).returncode + if rc != 0: + continue + base = f"nbd{i}" + for _ in range(15): + subprocess.run( + ["sudo", "partprobe", dev], + check=False, + capture_output=True, + ) + time.sleep(1) + if any( + os.path.exists(f"/sys/class/block/{base}p{n}") + for n in range(1, 32) + ): + break + return dev + return None + + @staticmethod + def _qemu_nbd_disconnect(dev): + subprocess.run(["sudo", "qemu-nbd", "-d", dev], check=False) + time.sleep(1) + + @staticmethod + def _qemu_root_part(dev): + """(partition la plus grosse, secteur de début, type FS) du disque nbd. + (None, 0, '') si introuvable. Format lsblk -P (paires) : robuste aux + colonnes VIDES — juste après le connect, FSTYPE peut être vide, et un + parsing positionnel décalait/ignorait alors toutes les partitions.""" + import re + + try: + res = subprocess.run( + ["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None, 0, "" + best, best_sz, best_fs = None, -1, "" + for line in res.stdout.splitlines(): + d = dict(re.findall(r'(\w+)="([^"]*)"', line)) + if d.get("TYPE") != "part": + continue + try: + size = int(d.get("SIZE") or 0) + except ValueError: + size = 0 + if size > best_sz: + best, best_sz, best_fs = ( + d.get("NAME"), + size, + d.get("FSTYPE", ""), + ) + if not best: + return None, 0, "" + part = f"/dev/{best}" + try: + start = int(open(f"/sys/class/block/{best}/start").read().strip()) + except OSError: + start = 0 + if not best_fs: + # FSTYPE pas encore en cache : sonder directement avec blkid. + best_fs = subprocess.run( + ["sudo", "blkid", "-o", "value", "-s", "TYPE", part], + capture_output=True, + text=True, + ).stdout.strip() + return part, start, best_fs + + @staticmethod + def _qemu_part_number(dev, part): + """Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1.""" + return part[len(dev) :].lstrip("p") + + @staticmethod + def _qemu_part_info(dev, n): + """{type, uuid, name} d'une partition via « sgdisk -i ».""" + info = {"type": "", "uuid": "", "name": ""} + res = subprocess.run( + ["sudo", "sgdisk", "-i", n, dev], + capture_output=True, + text=True, + env=TODO._qemu_c_env(), + ) + for line in res.stdout.splitlines(): + low = line.lower() + if low.startswith("partition guid code"): + info["type"] = line.split(":", 1)[1].split()[0] + elif low.startswith("partition unique guid"): + info["uuid"] = line.split(":", 1)[1].strip() + elif low.startswith("partition name"): + info["name"] = line.split(":", 1)[1].strip().strip("'") + return info + + @staticmethod + def _qemu_fs_blocksize(part): + res = subprocess.run( + ["sudo", "dumpe2fs", "-h", part], + capture_output=True, + text=True, + env=TODO._qemu_c_env(), + ) + for line in res.stdout.splitlines(): + if line.startswith("Block size:"): + try: + return int(line.split(":", 1)[1].strip()) + except ValueError: + pass + return 4096 + + @staticmethod + def _qemu_fs_blocks(part): + res = subprocess.run( + ["sudo", "dumpe2fs", "-h", part], + capture_output=True, + text=True, + env=TODO._qemu_c_env(), + ) + for line in res.stdout.splitlines(): + if line.startswith("Block count:"): + try: + return int(line.split(":", 1)[1].strip()) + except ValueError: + pass + return 0 + + @staticmethod + def _qemu_fs_min_blocks(part): + """Taille minimale (blocs) du FS via « resize2fs -P ».""" + res = subprocess.run( + ["sudo", "resize2fs", "-P", part], + capture_output=True, + text=True, + env=TODO._qemu_c_env(), + ) + for tok in res.stdout.replace(":", " ").split(): + if tok.isdigit(): + return int(tok) + return 0 + + # Commande d'extension du FS racine (partition + FS) réutilisée par SSH + # et par le repli console série. + _GROW_FS_REMOTE = ( + "set -e; " + "root=$(findmnt -no SOURCE /); " + 'dev=$(lsblk -no PKNAME "$root" | head -1); ' + "part=$(echo \"$root\" | grep -oE '[0-9]+$'); " + "sudo growpart /dev/$dev $part || true; " + "fstype=$(findmnt -no FSTYPE /); " + 'case "$fstype" in ' + 'ext*) sudo resize2fs "$root";; ' + "xfs) sudo xfs_growfs /;; " + "btrfs) sudo btrfs filesystem resize max /;; " + "esac; " + "df -h /" + ) + + def _qemu_grow_guest_fs(self, name): + """Étend la partition racine + le FS invité. Essaie SSH (IP résolue + avec BATTEMENT, le boot émulé étant lent) ; en cas d'absence d'IP ou + d'échec SSH, propose le repli par CONSOLE SÉRIE (commande à coller).""" + remote = self._GROW_FS_REMOTE + real = self._qemu_domname(name) + # 1) SSH : IP résolue avec BATTEMENT (parallèle, boot émulé lent) + # plutôt qu'un simple timeout court qui abandonnait trop tôt. + ip = self._qemu_resolve_ips([real], timeout=300).get(real) + if ip: + opts = ( + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " + "-o ConnectTimeout=15" + ) + cmd = f"ssh {opts} erplibre@{ip} {shlex.quote(remote)}" + print(f"{t('Will execute:')} {cmd}") + if self.execute.exec_command_live(cmd, source_erplibre=False) == 0: + return + print(f"⚠ {t('SSH grow failed; trying the guest agent.')}") + else: + print(t("No IP; trying the guest agent (no network).")) + # 2) Agent invité (virtio, SANS réseau) — nécessite qemu-guest-agent + # dans la VM (installé au déploiement) + guest-exec autorisé. + res = self._qemu_guest_exec(real, remote) + if res is not None: + rc, out = res + if out.strip(): + print(out.rstrip()) + if rc == 0: + print(f"✅ {t('Guest filesystem grown via guest agent.')}") + return + print( + f"⚠ {t('Guest agent grow failed; falling back to console.')}" + ) + else: + print( + t("Guest agent unavailable; falling back to serial console.") + ) + # 3) Console série (commande prête à coller, login interactif). + self._qemu_grow_via_console(real, remote) + + def _qemu_guest_exec(self, name, script, wait=180): + """Exécute `script` (sh -c) DANS la VM via l'AGENT INVITÉ (canal + virtio, sans réseau). Renvoie (code_sortie, sortie) ou None si l'agent + est indisponible / guest-exec refusé.""" + import base64 + + def agent(payload): + try: + res = subprocess.run( + [ + "sudo", + "virsh", + "qemu-agent-command", + name, + json.dumps(payload), + ], + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.SubprocessError): + return None + if res.returncode != 0: + return None + try: + return json.loads(res.stdout).get("return") + except ValueError: + return None + + if agent({"execute": "guest-ping"}) is None: + return None + start = agent( + { + "execute": "guest-exec", + "arguments": { + "path": "/bin/sh", + "arg": ["-c", script], + "capture-output": True, + }, + } + ) + if not start or "pid" not in start: + return None + pid = start["pid"] + deadline = time.time() + wait + print(t("Running via guest agent (no network)…")) + while time.time() < deadline: + st = agent( + {"execute": "guest-exec-status", "arguments": {"pid": pid}} + ) + if st and st.get("exited"): + out = "" + for k in ("out-data", "err-data"): + if st.get(k): + try: + out += base64.b64decode(st[k]).decode( + errors="replace" + ) + except Exception: + pass + return st.get("exitcode", 0), out + time.sleep(2) + return None + + def _qemu_grow_via_console(self, name, remote): + """Repli console série : affiche la commande prête à coller puis ouvre + la console (login interactif erplibre/erplibre — pas d'automatisation + fiable de la saisie).""" + print(f"\n{t('Serial console fallback. Log in, then paste:')}") + print(f"\n {remote}\n") + print(f"💡 {t('To leave the console, press Ctrl+] (then Enter).')}") + print( + f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}" + ) + if not self._is_yes(input(t("Open the serial console now? (y/N): "))): + return + cmd = f"sudo virsh console {shlex.quote(name)}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + @staticmethod + def _ssh_private_key(pub_path): + """Clé PRIVÉE correspondant à une clé publique, ou '' si introuvable. + C'est elle que réclame IdentityFile ; donner le « .pub » ferait + échouer l'authentification.""" + if not pub_path: + return "" + priv = pub_path[:-4] if pub_path.endswith(".pub") else pub_path + return priv if os.path.exists(os.path.expanduser(priv)) else "" + + @staticmethod + def _ssh_config_drop_hosts(content, names): + """Retire les blocs « Host … » qui déclarent l'un de `names`. + + On découpe en blocs plutôt que de substituer par expression + régulière : une ligne Host peut porter PLUSIEURS noms, et il faut + alors retirer le bloc entier dès qu'un seul de ses noms est repris — + sinon le même nom se retrouverait défini deux fois, et ssh + appliquerait la première définition rencontrée.""" + drop = set(names) + out, block, block_names = [], [], set() + + def flush(): + if block and not (block_names & drop): + out.extend(block) + + for line in content.splitlines(keepends=True): + if re.match(r"^[ \t]*Host[ \t]+", line): + flush() + block = [line] + block_names = set(line.split()[1:]) + elif block: + # Une ligne non indentée et non vide clôt le bloc (Match, + # directive globale…) : elle n'appartient à personne. + if line.strip() and not line[:1].isspace(): + flush() + block, block_names = [], set() + out.append(line) + else: + block.append(line) + else: + out.append(line) + flush() + return "".join(out) + + def _write_ssh_config_entry( + self, host, user, ip, proxy_jump=None, identity_file=None + ): + """Écrit/remplace un bloc « Host » dans ~/.ssh/config. + + `host` peut être une liste de noms : ils partagent alors un seul bloc. + Sert aux VM imbriquées, joignables par leur nom court ET par leur nom + chaîné « parent+enfant », qui montre où elles vivent. + + `proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est + joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout + seul dès que le parent a lui-même le sien. + + `identity_file` : clé PRIVÉE à présenter. Sans elle, ssh propose + toutes les identités de l'agent et un parc un peu fourni déclenche + « Too many authentication failures » avant d'arriver à la bonne.""" + names = [host] if isinstance(host, str) else list(host) + cfg = os.path.expanduser("~/.ssh/config") + os.makedirs(os.path.dirname(cfg), exist_ok=True) + existing = "" + if os.path.exists(cfg): + with open(cfg, encoding="utf-8") as fh: + existing = fh.read() + existing = self._ssh_config_drop_hosts(existing, names).rstrip("\n") + block = ( + f"Host {' '.join(names)}\n" + f" HostName {ip}\n" + f" User {user}\n" + # IP DHCP réutilisées entre VM -> on évite l'erreur de clé d'hôte. + f" StrictHostKeyChecking no\n" + f" UserKnownHostsFile /dev/null\n" + ) + if identity_file: + # IdentitiesOnly : sans lui, IdentityFile s'AJOUTE aux clés de + # l'agent au lieu de les remplacer, et le serveur coupe après + # 5 essais infructueux. + block += ( + f" IdentityFile {identity_file}\n" + f" IdentitiesOnly yes\n" + ) + if proxy_jump: + block += f" ProxyJump {proxy_jump}\n" + content = (existing + "\n\n" + block) if existing else block + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(content) + os.chmod(cfg, 0o600) + print(f"✅ {t('Added to ~/.ssh/config:')} ssh {names[0]}") + + def _qemu_list_domains(self): + """Noms des VM libvirt définies (via virsh).""" + try: + res = subprocess.run( + ["sudo", "virsh", "list", "--all", "--name"], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return [] + return [n for n in res.stdout.split() if n.strip()] + + def _qemu_delete_vm(self): + """Efface une ou plusieurs VM (arrêt + undefine), disques en option.""" + self._qemu_list_vms() + print() + names = self._qemu_list_domains() + if not names: + print(t("No VM found.")) + return + print(f"\n{t('Select VMs to delete:')}") + for i, n in enumerate(names, 1): + print(f" [{i}] {n}") + print(f" [all] {t('select all')}") + raw = input(t("Selection (numbers, or 'all'): ")).strip() + if not raw: + print(t("Nothing selected.")) + return + if raw.lower() in ("all", "*"): + chosen = list(names) + else: + chosen = self._parse_index_selection(raw.lower(), names) + if not chosen: + print(t("Nothing selected.")) + return + + del_disks = self._is_yes( + input(t("Also delete disk images (qcow2 + seed ISO)? (y/N): ")) + ) + + print(f"\n{t('Will delete:')} {', '.join(chosen)}") + if del_disks: + print(f" + {t('disk images and seed ISOs')}") + else: + print(f" ({t('disks kept')})") + if not self._is_yes(input(t("Confirm deletion? (y/N): "))): + print(t("Cancelled.")) + return + + disk_dir = "/var/lib/libvirt/images" + seed_dir = "/var/lib/libvirt/images/iso" + for name in chosen: + q = shlex.quote(name) + # Éteindre si en cours, puis retirer la définition (+ nvram si + # UEFI ; repli sans l'option pour les vieilles versions de virsh). + cmd = ( + f"sudo virsh destroy {q} 2>/dev/null; " + f"sudo virsh undefine {q} --nvram 2>/dev/null " + f"|| sudo virsh undefine {q}" + ) + if del_disks: + disk = shlex.quote(f"{disk_dir}/{name}.qcow2") + seed = shlex.quote(f"{seed_dir}/{name}-seed.iso") + cmd += f"; sudo rm -f {disk} {seed}" + print(f"\n▶ {name}: {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"\n✅ {t('Deletion done.')}") + + @staticmethod + def _human_size(n): + """Octets -> taille lisible (Ko/Mo/Go…).""" + size = float(n) + for unit in ("o", "Ko", "Mo", "Go", "To"): + if size < 1024: + return f"{size:.0f} {unit}" + size /= 1024 + return f"{size:.0f} Po" + + @staticmethod + def _qemu_find_files(directory, pattern): + """(taille, chemin) des fichiers du répertoire (via sudo find).""" + try: + res = subprocess.run( + [ + "sudo", + "find", + directory, + "-maxdepth", + "1", + "-type", + "f", + "-name", + pattern, + "-printf", + "%s\t%p\n", + ], + capture_output=True, + text=True, + timeout=20, + ) + except (OSError, subprocess.SubprocessError): + return [] + out = [] + for line in res.stdout.splitlines(): + if "\t" in line: + size, path = line.split("\t", 1) + out.append((int(size), path)) + return out + + def _cleanup_delete_files(self, title, items, prompt): + """items : [(taille, chemin)]. Liste, confirme, puis « sudo rm -f ».""" + if not items: + return + total = sum(s for s, _ in items) + print( + f"\n{title} — {self._human_size(total)}, " + f"{len(items)} {t('files')} :" + ) + for size, path in sorted(items, key=lambda o: -o[0]): + print(f" {self._human_size(size):>9} {path}") + if not self._is_yes(input(prompt)): + print(t("Cancelled.")) + return + paths = " ".join(shlex.quote(p) for _, p in items) + cmd = f"sudo rm -f {paths}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + + def _qemu_domain_macs(self): + """MACs de toutes les VM définies (pour repérer les baux périmés).""" + macs = set() + for name in self._qemu_list_domains(): + try: + res = subprocess.run( + ["sudo", "virsh", "domiflist", name], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + continue + macs.update( + m.lower() + for m in re.findall( + r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout + ) + ) + return macs + + def _qemu_cleanup(self): + """Repère les restes QEMU orphelins et propose de les effacer.""" + print(f"🧹 {t('Scanning for orphan QEMU files...')}") + disk_dir = "/var/lib/libvirt/images" + seed_dir = "/var/lib/libvirt/images/iso" + nvram_dir = "/var/lib/libvirt/qemu/nvram" + domains = set(self._qemu_list_domains()) + + # 1) Fichiers orphelins : disques / seeds / .part / nvram. + orphans = [] # (taille, chemin, motif) + for size, path in self._qemu_find_files(disk_dir, "*.qcow2"): + if os.path.basename(path)[: -len(".qcow2")] not in domains: + orphans.append((size, path, t("orphan disk"))) + for size, path in self._qemu_find_files(seed_dir, "*-seed.iso"): + if os.path.basename(path)[: -len("-seed.iso")] not in domains: + orphans.append((size, path, t("orphan seed"))) + for size, path in self._qemu_find_files(seed_dir, "*.part"): + orphans.append((size, path, t("partial download"))) + for size, path in self._qemu_find_files(nvram_dir, "*"): + stem = re.sub(r"(_VARS)?\.fd$", "", os.path.basename(path)) + if stem not in domains: + orphans.append((size, path, t("orphan UEFI nvram"))) + # Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak). + for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"): + orphans.append((size, path, t("disk backup (resize)"))) + if orphans: + total = sum(o[0] for o in orphans) + print(f"\n{t('Orphan files:')}") + for size, path, reason in sorted(orphans, key=lambda o: -o[0]): + print(f" {self._human_size(size):>9} {path} [{reason}]") + print( + f"\n {t('Total:')} {self._human_size(total)} " + f"({len(orphans)} {t('files')})" + ) + if self._is_yes(input(t("Delete these orphan files? (y/N): "))): + paths = " ".join(shlex.quote(o[1]) for o in orphans) + cmd = f"sudo rm -f {paths}" + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + else: + print(t("Cancelled.")) + else: + print(f"✅ {t('No orphan files found.')}") + + # 2) Domaines fantômes (définis mais disque manquant). + self._cleanup_ghost_domains() + # 3) Doublons d'images nommées par codename (avant /releases/). + dups = [ + (s, p) + for s, p in self._qemu_find_files( + seed_dir, "*-server-cloudimg-*.img" + ) + if not os.path.basename(p).startswith("ubuntu-") + ] + self._cleanup_delete_files( + t("Stale codename-named Ubuntu images (duplicates):"), + dups, + t("Delete these duplicate images? (y/N): "), + ) + # 4) Entrées ~/.ssh/config orphelines (erplibre-* sans VM). + self._cleanup_ssh_config(domains) + # 5) Baux DHCP périmés. + self._cleanup_stale_leases() + # 6) Tout le cache d'images de base (option lourde : re-téléchargement). + cached = [ + (s, p) + for s, p in self._qemu_find_files(seed_dir, "*") + if not p.endswith("-seed.iso") and not p.endswith(".part") + ] + self._cleanup_delete_files( + t("All cached base images (reusable):"), + cached, + t("Delete ALL cached base images? (y/N): "), + ) + + def _cleanup_ghost_domains(self): + """VM définies dont plus aucun disque n'existe -> propose undefine.""" + ghosts = [] + for name in self._qemu_list_domains(): + try: + res = subprocess.run( + ["sudo", "virsh", "domblklist", name, "--details"], + capture_output=True, + text=True, + timeout=15, + env=self._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + continue + srcs = [] + for line in res.stdout.splitlines(): + p = line.split() + if len(p) >= 4 and p[1] == "disk" and p[3] not in ("-", ""): + srcs.append(p[3]) + if srcs and all( + subprocess.run( + ["sudo", "test", "-e", s], timeout=10 + ).returncode + != 0 + for s in srcs + ): + ghosts.append(name) + if not ghosts: + return + print( + f"\n{t('Ghost domains (defined but disk missing):')} " + f"{', '.join(ghosts)}" + ) + if not self._is_yes(input(t("Undefine these ghost domains? (y/N): "))): + print(t("Cancelled.")) + return + for name in ghosts: + q = shlex.quote(name) + cmd = ( + f"sudo virsh destroy {q} 2>/dev/null; " + f"sudo virsh undefine {q} --nvram 2>/dev/null " + f"|| sudo virsh undefine {q}" + ) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + print(f"✅ {t('Cleanup done.')}") + + def _cleanup_ssh_config(self, domains): + """Retire les blocs « Host erplibre-* » sans VM correspondante (on ne + touche jamais aux autres hôtes SSH personnels).""" + cfg = os.path.expanduser("~/.ssh/config") + if not os.path.exists(cfg): + return + with open(cfg, encoding="utf-8") as fh: + content = fh.read() + hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content) + orphans = [ + h for h in hosts if h.startswith("erplibre-") and h not in domains + ] + if not orphans: + return + print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}") + if not self._is_yes( + input(t("Remove these ~/.ssh/config entries? (y/N): ")) + ): + print(t("Cancelled.")) + return + for h in orphans: + pat = re.compile( + rf"(?m)^[ \t]*Host[ \t]+{re.escape(h)}[ \t]*\n" + r"(?:[ \t]+[^\n]*\n?)*" + ) + content = pat.sub("", content) + content = content.strip("\n") + content = content + "\n" if content else "" + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(content) + os.chmod(cfg, 0o600) + print(f"✅ {t('Cleanup done.')}") + + def _cleanup_stale_leases(self): + """Baux DHCP libvirt dont la MAC n'appartient à aucune VM (best-effort : + les baux expirent d'eux-mêmes).""" + status = "/var/lib/libvirt/dnsmasq/virbr0.status" + try: + res = subprocess.run( + ["sudo", "cat", status], + capture_output=True, + text=True, + timeout=15, + ) + leases = json.loads(res.stdout or "[]") + except (OSError, subprocess.SubprocessError, ValueError): + return + if not isinstance(leases, list) or not leases: + return + macs = self._qemu_domain_macs() + stale = [ + ln + for ln in leases + if str(ln.get("mac-address", "")).lower() not in macs + ] + if not stale: + return + print(f"\n{t('Stale DHCP leases (no matching VM):')}") + for ln in stale: + print( + f" {ln.get('ip-address', '?'):<16} " + f"{ln.get('mac-address', '?')} {ln.get('hostname', '')}" + ) + if not self._is_yes(input(t("Clear these stale leases? (y/N): "))): + print(t("Cancelled.")) + return + kept = [ln for ln in leases if ln not in stale] + tmp = os.path.join("/tmp", f"virbr0.status.{os.getpid()}.json") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(kept, fh) + cmd = ( + f"sudo cp {shlex.quote(tmp)} {status} && " + "sudo pkill -HUP -F /var/lib/libvirt/dnsmasq/virbr0.pid " + "2>/dev/null || true" + ) + print(f"{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + try: + os.unlink(tmp) + except OSError: + pass + print(f"✅ {t('Cleanup done.')}") + + # ------------------------------------------------------------------ # + # QEMU : déploiement d'un parc « infra ERPLibre » + # ------------------------------------------------------------------ # + ERPLIBRE_GIT_URL = "https://github.com/erplibre/erplibre" + + def _qemu_import_module(self): + """Importe deploy_qemu.py comme module (source de vérité des specs).""" + import importlib.util + + path = self._qemu_script_path() + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + @classmethod + def _qemu_infra_name(cls, distro, version, arch=None): + """Nom de VM stable pour le parc, ex. erplibre-ubuntu-2404. Ajoute un + suffixe d'architecture quand elle diffère de la native de l'hôte (ex. + erplibre-ubuntu-2604-s390x sur un hôte amd64) pour éviter les collisions + de noms entre archis et rendre l'archi visible.""" + base = f"erplibre-{distro}-{version.replace('.', '')}" + if arch and arch != cls._native_arch(): + base += f"-{arch}" + return base + + @staticmethod + def _parse_disk_gb(size): + """« 20G » -> 20 (Go, best effort).""" + m = re.match(r"\s*(\d+)", str(size)) + return int(m.group(1)) if m else 0 + + @staticmethod + def _is_yes(ans): + """Réponse affirmative, FR et EN (o/oui/y/yes).""" + return ans.strip().lower() in ("y", "yes", "o", "oui") + + @staticmethod + def _is_yes_default_yes(ans): + """Comme _is_yes mais le DÉFAUT (réponse vide) est OUI.""" + a = ans.strip().lower() + return a == "" or a in ("y", "yes", "o", "oui") + + @staticmethod + def _is_no(ans): + """Réponse négative explicite, FR et EN (n/no/non). Utile pour les + invites « défaut oui » où tout sauf « non » vaut oui.""" + return ans.strip().lower() in ("n", "no", "non") + + @staticmethod + def _host_free_ram_mb(): + """RAM disponible de l'hôte en Mo (MemAvailable), 0 si inconnu.""" + try: + with open("/proc/meminfo") as fh: + for line in fh: + if line.startswith("MemAvailable:"): + return int(line.split()[1]) // 1024 + except OSError: + pass + return 0 + + @staticmethod + def _parse_index_selection(raw, options): + """« 1 3 » ou « 1,3 » -> sous-liste d'options (indices 1-based).""" + chosen = [] + for tok in re.split(r"[\s,]+", raw.strip()): + if not tok: + continue + try: + idx = int(tok) - 1 + except ValueError: + if tok in options and tok not in chosen: + chosen.append(tok) + continue + if 0 <= idx < len(options) and options[idx] not in chosen: + chosen.append(options[idx]) + return chosen + + def _qemu_domain_exists(self, name): + """Vrai si une VM libvirt de ce nom est déjà définie.""" + try: + res = subprocess.run( + ["sudo", "virsh", "dominfo", name], + capture_output=True, + text=True, + timeout=15, + ) + return res.returncode == 0 + except (OSError, subprocess.SubprocessError): + return False + + @staticmethod + def _qemu_lease_candidates(name): + """Toutes les IPv4 candidates de la VM, agrégées de PLUSIEURS sources : + - lease : base DHCP de dnsmasq (peut manquer sous forte charge, ou + contenir plusieurs baux : bail précoce « ubuntu » périmé + bail + définitif) ; + - agent : qemu-guest-agent DANS la VM (voit l'IP réelle même quand le + bail dnsmasq est absent) ; + - arp : table ARP de l'hôte (VM active sur le réseau). + On combine pour ne jamais rater une IP que le bail seul manquerait + (cas observé : 30 VM émulées, bail dnsmasq vide alors que la VM a une + IP).""" + ips = [] + for source in ("lease", "agent", "arp"): + try: + res = subprocess.run( + ["sudo", "virsh", "domifaddr", name, "--source", source], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + continue + for ip in re.findall(r"(\d+\.\d+\.\d+\.\d+)", res.stdout): + # Ignore la loopback (remontée par --source agent). + if ip != "127.0.0.1" and ip not in ips: + ips.append(ip) + return ips + + @staticmethod + def _qemu_ip_reachable(ip, port=22, timeout=2): + """Vrai si la VM répond sur cette IP (bail ACTIF, pas périmé). On teste + le PING d'abord : il répond dès que le réseau de la VM est up, BIEN + AVANT sshd — sinon on attendait le sshd (lent en émulation) et la + résolution semblait « bloquée » alors que la VM a déjà son IP. Repli + TCP:port si l'ICMP est filtré.""" + try: + res = subprocess.run( + ["ping", "-c", "1", "-W", str(int(timeout)), ip], + capture_output=True, + timeout=timeout + 1, + ) + if res.returncode == 0: + return True + except (OSError, subprocess.SubprocessError): + pass + import socket + + try: + with socket.create_connection((ip, port), timeout=timeout): + return True + except OSError: + return False + + @staticmethod + def _qemu_lease_ip_for_host(name, candidates): + """Parmi `candidates`, l'IP dont le bail dnsmasq porte le hostname de la + VM (le bail DÉFINITIF, pas le bail précoce « ubuntu »). None sinon.""" + try: + res = subprocess.run( + [ + "sudo", + "sh", + "-c", + "cat /var/lib/libvirt/dnsmasq/*.status 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return None + # Plusieurs tableaux JSON concaténés : on parse chaque objet {...}. + for obj in re.findall(r"\{[^{}]*\}", res.stdout or ""): + if re.search(rf'"hostname":\s*"{re.escape(name)}"', obj): + m = re.search(r'"ip-address":\s*"([\d.]+)"', obj) + if m and m.group(1) in candidates: + return m.group(1) + return None + + def _qemu_vm_ip(self, name, timeout=600): + """IPv4 utilisable d'une VM. Gère le cas des baux multiples (hostname + changé au boot) : renvoie en priorité le bail dont le hostname == nom + de la VM, sinon une IP JOIGNABLE (sshd up), pour ne jamais retenir le + bail précoce périmé. Attend jusqu'à `timeout` (boot émulé lent).""" + deadline = time.time() + timeout + cands = [] + while time.time() < deadline: + cands = self._qemu_lease_candidates(name) + if cands: + # 1) bail définitif (hostname == nom de la VM) + host_ip = self._qemu_lease_ip_for_host(name, cands) + if host_ip: + return host_ip + # 2) sinon, une IP déjà joignable (sshd up) + for ip in cands: + if self._qemu_ip_reachable(ip): + return ip + time.sleep(3) + # Meilleur effort : le dernier bail (le plus récent) plutôt que le 1er. + return cands[-1] if cands else None + + @staticmethod + def _fmt_dur(secs): + """Durée lisible : « 45s » ou « 2m05s ».""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + return f"{secs // 60}m{secs % 60:02d}s" + + def _qemu_resolve_ips(self, names, labels=None, timeout=300): + """Résout les IP de plusieurs VM EN PARALLÈLE (le boot émulé est lent), + en affichant la progression au fur et à mesure. Renvoie {nom: ip|None}. + `labels` : {nom: « k/N »} pour préfixer chaque ligne d'un ID de suivi. + `timeout` : délai max PAR VM (borne l'attente d'une VM sans IP). Un + BATTEMENT toutes les 30 s liste les VM encore en attente -> jamais de + silence prolongé qui donne l'impression d'un blocage.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + from concurrent.futures import TimeoutError as _FTimeout + + labels = labels or {} + print( + f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}" + ) + result = {} + t0 = time.time() + starts = {} + workers = min(len(names), (os.cpu_count() or 4)) or 1 + with ThreadPoolExecutor(max_workers=workers) as pool: + futs = {} + for n in names: + starts[n] = time.time() + futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n + pending = set(futs) + done = 0 + while pending: + try: + for fut in as_completed(list(pending), timeout=30): + pending.discard(fut) + n = futs[fut] + try: + ip = fut.result() + except Exception: + ip = None + result[n] = ip + done += 1 + tag = f"[{labels[n]}] " if n in labels else "" + dur = self._fmt_dur(time.time() - starts[n]) + print( + f" [{done}/{len(names)}] {tag}{n}: " + f"{ip or t('no IP')} ({dur})" + ) + except _FTimeout: + # Battement : VM encore en attente (boot/DHCP lent). + waiting = [futs[f] for f in pending] + shown = ", ".join(waiting[:5]) + if len(waiting) > 5: + shown += "…" + print( + f" ⏳ {t('still waiting for')} {len(waiting)} VM " + f"({self._fmt_dur(time.time() - t0)}): {shown}" + ) + got = sum(1 for ip in result.values() if ip) + print( + f" {t('IPs resolved:')} {got}/{len(names)} " + f"({self._fmt_dur(time.time() - t0)})" + ) + return result + + def _qemu_vm_arch(self, name): + """Architecture d'une VM (jeton amd64/arm64/s390x) via virsh dumpxml.""" + try: + res = subprocess.run( + ["sudo", "virsh", "dumpxml", name], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return None + m = re.search(r"]*\barch='([^']+)'", res.stdout) + if not m: + return None + return { + "x86_64": "amd64", + "aarch64": "arm64", + "s390x": "s390x", + }.get(m.group(1), m.group(1)) + + def _qemu_vm_meta(self, name, mod): + """(distro, version, arch) d'une VM déduits de son nom + son arch. Le + nom suit _qemu_infra_name(distro, version, arch) : on retrouve donc + (distro, version) en testant les combinaisons du catalogue.""" + arch = self._qemu_vm_arch(name) or "amd64" + try: + for d, (versions, _default) in mod.DISTROS.items(): + for v in versions: + if self._qemu_infra_name(d, v, arch) == name: + return d, v, arch + except Exception: + pass + return None, None, arch + + def _qemu_branch_list(self): + """Branches distantes d'ERPLibre, triées. Vide si le réseau manque. + + Séparé de l'invite : le formulaire TUI a besoin de la LISTE, et cet + appel réseau (jusqu'à 30 s) doit être fait avant que Textual prenne + le terminal.""" + branches = [] + try: + res = subprocess.run( + ["git", "ls-remote", "--heads", self.ERPLIBRE_GIT_URL], + capture_output=True, + text=True, + timeout=30, + ) + for line in res.stdout.splitlines(): + ref = line.split("\t")[-1] + if ref.startswith("refs/heads/"): + branches.append(ref[len("refs/heads/") :]) + except (OSError, subprocess.SubprocessError): + pass + branches.sort() + return branches + + def _qemu_pick_branch(self): + """Liste les branches distantes d'ERPLibre et en fait choisir une.""" + print(f"\n{t('Fetching ERPLibre branch list...')}") + branches = self._qemu_branch_list() + default = ( + "master" + if "master" in branches + else (branches[0] if branches else "master") + ) + if not branches: + return ( + input(f"{t('Branch (default:')} {default}): ").strip() + or default + ) + print(f"{t('Branches:')}") + for i, b in enumerate(branches, 1): + star = " *" if b == default else "" + print(f" [{i}] {b}{star}") + sel = input(f"{t('Choice (number or name, default:')} {default}): ") + sel = sel.strip() + if not sel: + return default + try: + idx = int(sel) - 1 + if 0 <= idx < len(branches): + return branches[idx] + except ValueError: + if sel in branches: + return sel + return default + + # Cible d'installation Odoo exécutée dans la VM (défaut ERPLibre 1.6.0). + ERPLIBRE_ODOO_TARGET = "install_odoo_18" + # Go ajoutés au disque quand on installe ERPLibre (le minimum d'image ne + # laisse que ~97 Mo libres après l'installation). + ERPLIBRE_EXTRA_DISK_GB = 5 + + @staticmethod + def _qemu_wait_ssh(ip, user="erplibre", timeout=1200): + """Attend que sshd réponde ET que cloud-init soit TERMINÉ, via des + connexions COURTES successives. Au 1er boot, cloud-init régénère les + clés d'hôte et REDÉMARRE sshd : attendre la fin de cloud-init AVANT de + lancer l'install évite qu'une session longue soit tuée (« Connection + closed by remote host », exit 255 — cas Fedora). True si prête.""" + opts = ( + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " + "-o ConnectTimeout=8 -o BatchMode=yes" + ) + # On imprime toujours l'état (|| true) pour matcher sur le TEXTE : + # « status: running » n'a pas de code de sortie fiable selon la version. + probe = ( + "if command -v cloud-init >/dev/null 2>&1; then " + "cloud-init status 2>/dev/null || true; else echo nocloudinit; fi" + ) + ready = ("done", "disabled", "error", "degraded", "nocloudinit") + deadline = time.time() + timeout + ssh_up = False + while time.time() < deadline: + try: + res = subprocess.run( + f"ssh {opts} {user}@{ip} {shlex.quote(probe)}", + shell=True, + capture_output=True, + timeout=20, + text=True, + ) + out = res.stdout or "" + except (OSError, subprocess.SubprocessError): + out = "" + if out.strip(): + ssh_up = True # sshd a répondu + if any(k in out for k in ready): + return True + time.sleep(5) + # cloud-init pas confirmé fini dans le délai : on tente quand même si + # sshd répondait au moins (mieux qu'un abandon silencieux). + return ssh_up + + # Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ». + # Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà + # définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES), + # et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon, + # ajouter l'utilisateur au groupe libvirt et activer le réseau « default ». + # Sans le groupe, virt-install retombe sur qemu:///session où « default » + # n'existe pas : la VM échoue alors que tous les paquets sont installés. + # L'ancien one-liner finissait par « || true » et masquait ses erreurs. + _QEMU_QEMU_PKGS = ( + "./script/qemu/deploy_qemu.py --setup-host --assume-yes" + " --reboot-if-needed" + ) + + def _qemu_ask_prod(self): + """Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est + installé dans /opt/erplibre (au lieu de ~/git/erplibre) et le service + systemd reste CONFINÉ par SELinux (pas d'unconfined).""" + print(f"\n{t('Target environment?')}") + print(f" [1] {t('Development (~/git/erplibre, SELinux relaxed)')} *") + print(f" [2] {t('Production (/opt/erplibre, SELinux enforced)')}") + sel = input(t("Choice (1-2, default 1): ")).strip() + return sel == "2" + + def _qemu_install_profiles(self): + """Profils installables : [(libellé, commande)]. Le premier est le + défaut. Partagé par l'invite en ligne et le formulaire TUI.""" + profiles = [ + ( + f"ERPLibre + Odoo {v}", + f"make install_os && make install_odoo_{v}", + ) + for v in ("18", "17", "16", "15", "14", "13", "12") + ] + profiles += [ + ( + t("ERPLibre + all Odoo versions"), + "make install_os && make install_odoo_all_version", + ), + ( + t("ERPLibre only (no Odoo)"), + "make install_os && ./script/install/install_erplibre.sh", + ), + ( + t("ERPLibre mobile (home)"), + "make install_os && ./mobile/install_and_run.sh", + ), + ( + t("ERPLibre Deployment (+ QEMU + dev)"), + "make install_os && make install_dev && " + + self._QEMU_QEMU_PKGS, + ), + ] + return profiles + + def _qemu_pick_install_profile(self): + """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande + finale exécutée dans ~/git/erplibre).""" + profiles = self._qemu_install_profiles() + print(f"\n{t('What to install on the VM(s)?')}") + for i, (label, _cmd) in enumerate(profiles, 1): + print(f" [{i}] {label}{' *' if i == 1 else ''}") + sel = input(t("Choice (number, blank = Odoo 18): ")).strip() + try: + idx = int(sel) - 1 + if 0 <= idx < len(profiles): + return profiles[idx] + except ValueError: + pass + return profiles[0] # défaut : ERPLibre + Odoo 18 + + @staticmethod + def _qemu_install_dir(prod): + """Répertoire d'installation ERPLibre dans la VM : /opt/erplibre en + PROD (hors /home -> service SELinux confiné possible), sinon + ~/git/erplibre (dev).""" + return "/opt/erplibre" if prod else "$HOME/git/erplibre" + + def _qemu_odoo_service_cmd(self, prod=False): + """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme + service systemd puis l'active. N'est ajouté QUE pour les profils Odoo. + + DEV : ERPLibre sous ~/home. Un service système ne peut PAS exécuter + du user_home_t sous SELinux, et « SELinuxContext=unconfined » ne suffit + pas (transition init_t -> unconfined_t refusée -> toujours 203/EXEC). + Sur une VM de dev jetable, on passe donc SELinux en PERMISSIF (relâché). + PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service + reste CONFINÉ par SELinux ; on restaure les contextes (restorecon).""" + svc_dir = self._qemu_install_dir(prod) + selinux_shell = ( + 'SELINUX_LINE=""; ' # pas de SELinuxContext (inefficace) + ) + if prod: + pre = ( + "command -v restorecon >/dev/null 2>&1 && " + "sudo restorecon -R /opt/erplibre >/dev/null 2>&1 || true; " + ) + else: + # DEV : SELinux permissif (persistant) si actif -> le service peut + # exécuter run.sh/venv sous /home. + pre = ( + "if command -v getenforce >/dev/null 2>&1 && " + '[ "$(getenforce)" = "Enforcing" ]; then ' + "sudo setenforce 0 || true; " + "sudo sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' " + "/etc/selinux/config 2>/dev/null || true; fi; " + ) + return ( + f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; ' + + pre + + selinux_shell + + "sudo tee /etc/systemd/system/erplibre.service >/dev/null <13 et a redémarré le cluster PostgreSQL + # (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa + # connexion et la base intermédiaire est restée à moitié migrée. Effet + # secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou + # apt pendant l'installation. En PROD on ne touche à rien : les + # correctifs de sécurité automatiques doivent rester actifs. + no_auto_upgrade = "" + if not prod: + no_auto_upgrade = ( + "if command -v apt-get >/dev/null 2>&1; then " + "sudo systemctl disable --now unattended-upgrades.service " + "apt-daily.timer apt-daily-upgrade.timer " + ">/dev/null 2>&1 || true; " + 'printf \'APT::Periodic::Update-Package-Lists "0";\\n' + 'APT::Periodic::Unattended-Upgrade "0";\\n\' ' + "| sudo tee /etc/apt/apt.conf.d/99-erplibre-no-auto-upgrade " + ">/dev/null; " + "fi; " + "if command -v dnf >/dev/null 2>&1; then " + "sudo systemctl disable --now dnf-automatic.timer " + "dnf-automatic-install.timer >/dev/null 2>&1 || true; " + "fi; " + ) + return ( + "set -e; " + # Attendre la FIN de cloud-init : pendant sa phase « paquets » il + # tient le verrou apt/dnf/pacman -> sinon « unable to lock + # database » (Arch) / « Could not get lock » (apt). timeout pour ne + # pas bloquer indéfiniment si cloud-init traîne. + "command -v cloud-init >/dev/null 2>&1 && " + "sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 " + "|| true; " + # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre + # le verrou entre l'attente cloud-init et l'installation. + + no_auto_upgrade + + # Outils d'amorçage (absents des images cloud minimales) : curl, + # git, make. Chaque branche RAFRAÎCHIT d'abord les dépôts pour que + # la VM soit la plus rapide possible (miroirs à jour / les plus + # rapides), puis installe. Supporte apt (Debian/Ubuntu), dnf/yum + # (Fedora) et pacman (Arch). + "PKGS='curl git make'; " + "if command -v apt-get >/dev/null 2>&1; then " + # Au 1er boot, cloud-init (install qemu-guest-agent) et/ou + # apt-daily.service tiennent le verrou apt. IMPORTANT : + # « DPkg::Lock::Timeout » NE couvre PAS le verrou + # /var/lib/apt/lists/lock -> « apt-get update » échouait AUSSITÔT + # (« Could not get lock … lists/lock ») -> lists vides -> « Unable + # to locate package git ». On RÉESSAIE donc update jusqu'à ce que + # le verrou se libère (et les lists soient peuplées), borné à ~5 min. + "n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " + "n=$((n+1)); [ $n -ge 30 ] && break; " + 'echo "apt verrouille (tentative $n), attente 10s..."; sleep 10; ' + "done; " + "sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; " + "elif command -v dnf >/dev/null 2>&1; then " + # makecache (dnf5 choisit les miroirs les plus rapides) puis + # install --refresh ; retry avec « clean all » car les images + # cloud fraîches ratent parfois la vérif GPG/checksum d'un miroir. + "sudo dnf -q makecache || true; " + "sudo dnf install -y --refresh $PKGS || " + "{ sudo dnf clean all; sudo dnf install -y --refresh $PKGS; }; " + "elif command -v pacman >/dev/null 2>&1; then " + # Verrou pacman périmé (cloud-init interrompu) : le retirer SEULEMENT + # si aucun pacman ne tourne, sinon on attend qu'il se libère. + "pgrep -x pacman >/dev/null 2>&1 " + "|| sudo rm -f /var/lib/pacman/db.lck; " + # Arch : reflector sélectionne les miroirs HTTPS les plus rapides, + # puis MISE À JOUR COMPLÈTE (-Syu) : Arch (rolling) ne supporte pas + # les màj partielles ; sur une image cloud à la glibc ancienne, un + # « pacman -S » casse avec « GLIBC_x.yy not found ». + "sudo pacman -Sy --needed --noconfirm reflector || true; " + "sudo reflector --latest 20 --protocol https --sort rate " + "--save /etc/pacman.d/mirrorlist || true; " + "sudo pacman -Syu --noconfirm || true; " + "sudo pacman -S --needed --noconfirm $PKGS; " + "elif command -v yum >/dev/null 2>&1; then " + "sudo yum makecache -q || true; sudo yum install -y $PKGS; " + "else echo 'Aucun gestionnaire de paquets " + "(apt/dnf/pacman/yum)'; exit 1; fi; " + # Vérifie explicitement que tout est là : erreur nette plutôt + # qu'un « command not found » cryptique plus loin. + "for t in curl git make; do command -v $t >/dev/null 2>&1 || " + '{ echo "Outil manquant apres installation: $t ' + '(reseau de la VM ?)"; exit 1; }; done; ' + # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur + # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + + ( + ( + "sudo mkdir -p /opt; " + "if [ ! -d /opt/erplibre/.git ]; then " + f"sudo git clone --branch {shlex.quote(branch)} " + f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " + "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " + f"cd /opt/erplibre && {final_cmd}" + ) + if prod + else ( + "mkdir -p ~/git; " + "if [ ! -d ~/git/erplibre/.git ]; then " + f"git clone --branch {shlex.quote(branch)} " + f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " + f"cd ~/git/erplibre && {final_cmd}" + ) + ) + ) + + def _qemu_install_erplibre_monitored( + self, names, branch, ip_map=None, final_cmd=None, prod=False + ): + """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre + le dashboard Textual. Quitter le dashboard n'arrête pas les installs. + `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). + `final_cmd` : commande d'install selon le profil choisi. + `prod` : install /opt/erplibre + service SELinux confiné.""" + from script.todo.qemu_install_monitor import ( + launch_installs, + run_monitor, + ) + + remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod) + try: + mod = self._qemu_import_module() + except Exception: + mod = None + if ip_map is None: + ip_map = self._qemu_resolve_ips(names) + vms = [] + for name in names: + ip = ip_map.get(name) + if ip: + d, v, a = ( + self._qemu_vm_meta(name, mod) + if mod + else (None, None, None) + ) + vms.append( + { + "name": name, + "ip": ip, + "distro": d, + "version": v, + "arch": a, + } + ) + else: + print(f" {name}: {t('no IP, skipped.')}") + if not vms: + print(t("No VM to install.")) + return + manifest = launch_installs(vms, branch, remote) + print(f"\n🖥 {t('Opening the interactive monitor...')}") + # Affiche tous les chemins de log (pour les consulter/partager même si + # on quitte le dashboard avant la fin). + print(f" {t('Log files:')}") + with open(manifest, encoding="utf-8") as _fh: + for entry in json.load(_fh)["vms"]: + print(f" {entry['log']}") + try: + run_monitor(manifest) + except ImportError: + # textual absent : les installs tournent déjà (détachées), on ne + # plante donc pas — on propose de l'installer pour rouvrir. + from script.todo import textual_setup + + if textual_setup.ensure(): + run_monitor(manifest) + print( + f"\n{t('Monitor closed. Installs keep running in the background.')}" + ) + logdir = os.path.dirname(manifest) + print(f" {t('Logs:')} {logdir}") + # Commande prête à copier pour relire/partager tous les logs. + print(f" {t('Read the logs:')} tail -n +1 {logdir}/*.log") + + def _qemu_install_erplibre_vm( + self, name, ssh_key, branch, ip=None, final_cmd=None, prod=False + ): + """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande + d'install du profil choisi (streamé). `ip` : IP déjà résolue ; + `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné.""" + if ip is None: + ip = self._qemu_vm_ip(name) + if not ip: + print( + f" {name}: {t('no IP obtained, ERPLibre install skipped.')}" + ) + return + # Attend que le SSH soit prêt (évite « Connection refused » quand + # l'install démarre avant le sshd de la VM). + print(f" {name} ({ip}): {t('waiting for SSH...')}") + if not self._qemu_wait_ssh(ip): + print( + f" {name} ({ip}): " + f"{t('SSH not reachable, ERPLibre install skipped.')}" + ) + return + remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod) + ssh_opts = ( + "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " + "-o ConnectTimeout=15" + ) + cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}" + print( + f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} " f"({branch})" + ) + print(f" {t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + # Suggestions proposées aux invites de taille. Les lettres démarrent à « a » + # pour ne JAMAIS entrer en conflit avec une valeur tapée directement : toute + # saisie commençant par un chiffre est lue comme la valeur elle-même. + _QEMU_DISK_PRESETS = ( + "20G", + "40G", + "60G", + "80G", + "120G", + "200G", + "400G", + "600G", + "800G", + "1T", + "1.5T", + "2T", + ) + # Jusqu'à 256 Go : les hôtes de virtualisation récents dépassent largement + # 32 Go, et l'invite est en Mo — l'équivalent en Go est donc affiché. + _QEMU_RAM_PRESETS = ( + 1024, + 2048, + 4096, + 8192, + 16384, + 32768, + 65536, + 131072, + 262144, + ) + # KVM autorise plus de vCPU que de cœurs (surengagement) : on prévient + # plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui, + # est un calcul automatique et se borne aux cœurs de l'hôte. + _QEMU_CPU_PRESETS = (1, 2, 4, 6, 8, 16, 24, 32) + + @staticmethod + def _plural(word, count): + """Accord simple : « échec » / « échecs ». Vaut pour fr et en.""" + return word if abs(count) <= 1 else f"{word}s" + + @staticmethod + def _qemu_parse_disk(value): + """Normalise une taille de disque en « G », ou None si invalide. + + Accepte « 60 », « 60G », « 1T », « 1,5T ». Le suffixe T est converti + (1 T = 1024 G) : tout le reste de la chaîne — nom de fichier qcow2, + argument --disk-size — raisonne en gigaoctets. + """ + txt = value.strip().upper().replace(",", ".") + factor = 1 + if txt.endswith("T"): + factor, txt = 1024, txt[:-1] + elif txt.endswith("G"): + txt = txt[:-1] + try: + gigs = int(float(txt) * factor) + except ValueError: + return None + return f"{gigs}G" if gigs > 0 else None + + @staticmethod + def _qemu_ram_label(mb): + """« 65536 (64G) » : l'invite est en Mo, on raisonne en Go.""" + return f"{mb} ({mb // 1024}G)" if mb >= 1024 else str(mb) + + @staticmethod + def _qemu_ask_value(label, current, presets, fmt=str): + """Invite avec raccourcis lettrés. Renvoie '' pour « garder ». + + Une lettre choisit une suggestion, un chiffre reste une valeur + littérale, vide garde la valeur actuelle. Les suggestions sont + réparties sur plusieurs lignes pour rester lisibles. + """ + lst_item = [ + f"[{chr(ord('a') + i)}] {fmt(p)}" for i, p in enumerate(presets) + ] + for start in range(0, len(lst_item), 5): + print(" " + " ".join(lst_item[start : start + 5])) + answer = input(f" {label} ({current}): ").strip() + if not answer: + return "" + if len(answer) == 1 and answer.isalpha(): + index = ord(answer.lower()) - ord("a") + if 0 <= index < len(presets): + return str(presets[index]) + print(f" ⚠ {t('Invalid size.')}") + return "" + return answer + + # Les trois invites ci-dessous sont posées à DEUX endroits — le profil + # global « Personnalisé » et la personnalisation par VM. Elles vivent donc + # ici : une seule définition, mêmes suggestions, mêmes validations. + # Chacune renvoie None pour « garder la valeur actuelle ». + + def _qemu_ask_disk(self, label, current): + raw = self._qemu_ask_value(label, current, self._QEMU_DISK_PRESETS) + if not raw: + return None + parsed = self._qemu_parse_disk(raw) + if not parsed: + print(f" ⚠ {t('Invalid size.')}") + return parsed + + def _qemu_ask_ram(self, label, current): + raw = self._qemu_ask_value( + label, current, self._QEMU_RAM_PRESETS, fmt=self._qemu_ram_label + ) + if not raw: + return None + try: + mb = int(raw) + except ValueError: + mb = 0 + if mb <= 0: + print(f" ⚠ {t('Invalid size.')}") + return None + return mb + + def _qemu_ask_cpu(self, label, current, host_cpu): + raw = self._qemu_ask_value(label, current, self._QEMU_CPU_PRESETS) + if not raw: + return None + try: + n = int(raw) + except ValueError: + n = 0 + if n <= 0: + print(f" ⚠ {t('Invalid vCPU count.')}") + return None + if n > host_cpu: + print(f" ⚠ {t('More vCPU than host cores')} ({host_cpu}).") + return n + + @staticmethod + def _qemu_shared_value(values, fmt=str): + """Valeur commune à toutes les VM, ou « varié » si elles diffèrent. + Sert d'« actuel » aux invites globales, où une seule réponse couvre un + parc qui n'est pas forcément homogène.""" + uniq = set(values) + return fmt(uniq.pop()) if len(uniq) == 1 else t("varies") + + # vCPU de base (x1) par VM. Le multiplicateur monte de là. + _QEMU_BASE_VCPUS = 2 + + def _qemu_prompt_resources(self, selected, host_cpu, free_ram): + """Ressources par VM : multiplicateur x1..x4, ou « Personnalisé ». + + x1..x4 multiplie la RAM (base = minimum de la version) et les vCPU + (base _QEMU_BASE_VCPUS) en bornant ces derniers aux cœurs de l'hôte. + « Personnalisé » pose les mêmes trois questions que la personnalisation + par VM, mais une seule fois pour tout le parc. + + `selected` = liste de (d, v, ram_min, disk, arch). Renvoie + (label, selected) où selected porte désormais les valeurs FINALES et + un vCPU par VM : (d, v, ram, disk, arch, vcpus).""" + base_ram = sum(s[2] for s in selected) # RAM min totale (x1) + base_vcpus = self._QEMU_BASE_VCPUS + print(f"\n{t('Resources per VM (x1 = catalog minimum):')}") + cpu_txt = f"{host_cpu} vCPU" + ram_txt = ( + f"~{free_ram} Mo {t('free')}" + if free_ram + else t("free RAM unknown") + ) + print(f" {t('Host:')} {cpu_txt}, {ram_txt}") + for n in (1, 2, 3, 4): + vcpus = min(base_vcpus * n, host_cpu) + total = base_ram * n + star = " *" if n == 1 else "" + warn = "" + if free_ram and total > free_ram: + warn = f" ⚠ {t('> host free RAM')}" + print( + f" [{n}] x{n}{star} {vcpus} vCPU/VM, " + f"{t('total RAM')} ~{total} Mo{warn}" + ) + print(f" [5] {t('Custom - set vCPU, RAM and disk')}") + sel = input(f"{t('Choice (1-5, default 1):')} ").strip() + try: + mult = int(sel) + except ValueError: + mult = 1 + if not 1 <= mult <= 5: + mult = 1 + + if mult != 5: + vcpus = min(base_vcpus * mult, host_cpu) + return f"x{mult}", [ + (d, v, ram * mult, disk, a, vcpus) + for (d, v, ram, disk, a) in selected + ] + + # Personnalisé : une réponse vide garde la valeur du catalogue, qui + # peut différer d'une VM à l'autre — d'où « varié » comme « actuel ». + cpu = self._qemu_ask_cpu( + t("vCPU per VM, blank = keep"), base_vcpus, host_cpu + ) + ram = self._qemu_ask_ram( + t("New RAM in MB, blank = keep"), + self._qemu_shared_value([s[2] for s in selected]), + ) + disk = self._qemu_ask_disk( + t("New disk size in G, blank = keep"), + self._qemu_shared_value([s[3] for s in selected]), + ) + return t("custom"), [ + ( + d, + v, + ram or vram, + disk or vdisk, + a, + cpu or base_vcpus, + ) + for (d, v, vram, vdisk, a) in selected + ] + + def _qemu_customize_vms(self, selected, host_cpu): + """Personnalise chaque VM avant déploiement : NOM, DISQUE, RAM et vCPU. + `selected` = liste de (d, v, ram, disk, a, vcpus) où les valeurs sont + déjà FINALES (profil de ressources appliqué). + Renvoie (names, selected_maj). Défaut : rien ne change.""" + names = [ + self._qemu_infra_name(d, v, a) for d, v, _r, _dk, a, _c in selected + ] + sel = [list(s) for s in selected] # mutable + + def show(): + print(f"\n{t('VMs (default = no change):')}") + for i, (nm, s) in enumerate(zip(names, sel), 1): + d, v, ram, disk, a, vcpus = s + print( + f" [{i}] {nm} ({d} {v} [{a}]) {vcpus} vCPU " + f"RAM {ram}Mo {t('disk')} {disk}" + ) + + show() + raw = input( + t("Modify which VMs? (numbers, comma-separated; blank = none): ") + ).strip() + for tok in re.split(r"[\s,]+", raw): + if not tok: + continue + try: + i = int(tok) - 1 + except ValueError: + continue + if not (0 <= i < len(sel)): + continue + # Pour la VM i : nom, disque, RAM, vCPU (vide = garder la valeur). + new = input( + f" {names[i]} — {t('new name (blank = keep):')} " + ).strip() + if new: + names[i] = new + dk = self._qemu_ask_disk( + t("New disk size in G, blank = keep"), sel[i][3] + ) + if dk: + sel[i][3] = dk + rm = self._qemu_ask_ram( + t("New RAM in MB, blank = keep"), sel[i][2] + ) + if rm: + sel[i][2] = rm + cpu = self._qemu_ask_cpu( + t("New vCPU count, blank = keep"), sel[i][5], host_cpu + ) + if cpu: + sel[i][5] = cpu + if len(set(names)) != len(names): + print(f" ⚠ {t('Duplicate names detected; keeping as entered.')}") + return names, [tuple(s) for s in sel] + + def _confirm_or_discard(self, question): + """Confirmation à défaut OUI, avec double validation sur le NON. + + Un « non » distrait ferait perdre toutes les réponses déjà saisies. On + ne renonce donc que si l'abandon est confirmé ; sinon on repose la + question.""" + while True: + if self._is_yes_default_yes(input(f"\n{question}")): + return True + if self._is_yes( + input(f" {t('Discard everything and start over? (y/N): ')}") + ): + return False + + @staticmethod + def _qemu_orphan_disks(names): + """qcow2 présents sans VM définie, parmi `names` : [(nom, chemin)]. + + Pur : ni sudo ni virsh — l'appelant fournit déjà les noms qui n'ont + PAS de domaine. C'est ce qui permet au formulaire TUI de recalculer + les collisions à chaque frappe sans déclencher d'invite de mot de + passe.""" + orphans = [] + for name in names: + path = f"/var/lib/libvirt/images/{name}.qcow2" + if os.path.exists(path): + orphans.append((name, path)) + return orphans + + def _qemu_confirm_collisions(self, existing, pending_names): + """Signale les noms qui heurtent l'existant, et demande confirmation. + + Deux cas, de gravité différente : une VM déjà définie est simplement + ignorée — rien n'est écrasé — tandis qu'un qcow2 resté seul (VM + supprimée sans son disque) fait échouer deploy_qemu, qui refuse + d'écraser sans --force. Défaut NON : on ne poursuit que sur un « oui » + explicite.""" + orphans = self._qemu_orphan_disks(pending_names) + if not existing and not orphans: + return True + print(f"\n⚠ {t('Name collisions detected')} :") + skipped = t("VM already defined - SKIPPED, nothing overwritten") + for name in existing: + print(f" {name:<28.28} {skipped}") + for name, path in orphans: + print( + f" {name:<28.28} " + f"{t('disk present without VM - deployment will FAIL')}" + ) + print(f" {'':<28} {path}") + print(f" {'':<28} {t('Remove it by hand, or rename the VM.')}") + return self._is_yes( + input(f"{t('Continue despite these collisions? (y/N): ')}") + ) + + def _qemu_print_recap(self, spec, existing): + """État final soumis à approbation : tout ce qui va changer sur + l'hôte, y compris ce qui ne changera PAS (VM existantes).""" + install = spec.get("install") + branch = install["branch"] if install else None + print(f"\n── {t('Final review before deployment')} ──") + print(f" {t('VMs to create:')} {len(spec['vms'])}") + for vm in spec["vms"]: + # Le disque annoncé est celui qui sera réellement créé : ERPLibre + # ajoute ERPLIBRE_EXTRA_DISK_GB à la demande initiale. + gigs = self._parse_disk_gb(vm["disk"]) + ( + self.ERPLIBRE_EXTRA_DISK_GB if branch else 0 + ) + print( + f" {vm['name']:<30} {vm['distro']} {vm['version']:<7} " + f"[{vm['arch']:<5}] {vm['vcpus']} vCPU RAM {vm['ram']}Mo " + f"{t('disk')} {gigs}G" + ) + if existing: + print(f" {t('Existing, left untouched:')} {', '.join(existing)}") + if install: + env = ( + t("production (/opt, confined)") + if install["prod"] + else t("development (~/git)") + ) + print( + f" {t('ERPLibre install:')} {t('branch')} {branch}, " + f"{t('profile')} {install['label']}, {env}" + ) + else: + print(f" {t('ERPLibre install:')} {t('no')}") + print(f" {t('SSH key:')} {spec.get('ssh_key') or t('none')}") + cfg = ( + t("one entry per VM") if spec["add_ssh_config"] else t("untouched") + ) + print(f" {t('~/.ssh/config:')} {cfg}") + print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}") + + def _qemu_build_deploy_parts( + self, d, v, arch, name, eram, evcpus, disk, ssh_key, branch, dry_run + ): + """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu + dry-run ET le déploiement réel).""" + parts = [] if dry_run else ["sudo"] + parts += [ + self._qemu_script_path(), + "--distro", + d, + "--version", + v, + "--name", + name, + "--memory", + str(eram), + "--vcpus", + str(evcpus), + "--password", + "erplibre", + ] + if not dry_run: + # --no-wait-ip : ne bloque pas 90s/VM, l'IP est collectée après. + parts.append("--no-wait-ip") + if arch and arch != "amd64": + parts += ["--arch", arch] + if ssh_key: + parts += ["--ssh-key", ssh_key] + if branch: + # ERPLibre dépasse le minimum : +5 Go de disque. + bigger = self._parse_disk_gb(disk) + self.ERPLIBRE_EXTRA_DISK_GB + parts += ["--disk-size", f"{bigger}G"] + parts.append("--dry-run" if dry_run else "-y") + return parts + + def _qemu_deploy_parts_for(self, vm, spec, dry_run=False): + """Commande deploy_qemu.py d'une VM de la spec. + + POINT DE PASSAGE UNIQUE des deux interfaces : le formulaire TUI et les + invites en ligne produisent la même spec, donc forcément la même + commande. C'est ce qui rend leur divergence vérifiable par un test.""" + install = spec.get("install") + return self._qemu_build_deploy_parts( + vm["distro"], + vm["version"], + vm["arch"], + vm["name"], + vm["ram"], + vm["vcpus"], + vm["disk"], + spec.get("ssh_key"), + install["branch"] if install else None, + dry_run=dry_run, + ) + + # ---------------------------------------------------------------- # + # Déploiement : catalogue (pur) -> collecte (CLI ou TUI) -> exécution + # ---------------------------------------------------------------- # + + def _qemu_arches_for(self, distro, arch): + """Architectures à déployer pour cette distro selon le choix global. + « all » = uniquement celles que la distro publie réellement.""" + if arch != "all": + return [arch] + out = ["amd64"] + if distro in self._QEMU_ARM64_DISTROS: + out.append("arm64") + if distro in self._QEMU_S390X_DISTROS: + out.append("s390x") + return out + + def _qemu_catalog_entries(self, mod, distros, arch): + """Catalogue APLATI : une entrée par (distro, version, architecture). + + Fonction pure, sans I/O : c'est la source unique de ce qui est + déployable, aussi bien pour la liste granulaire de la CLI que pour la + liste à cocher du formulaire TUI.""" + flat = [] + for d in distros: + versions_map, default_v = mod.DISTROS[d] + for v, (_c, _o, ram, disk) in versions_map.items(): + for a in self._qemu_arches_for(d, arch): + flat.append( + { + "distro": d, + "version": v, + "arch": a, + "ram": ram, + "disk": disk, + "default": v == default_v, + } + ) + return flat + + @staticmethod + def _qemu_make_vm(distro, version, arch, ram, disk, vcpus, name): + """Une VM de la spec. Un seul endroit décrit sa forme.""" + return { + "name": name, + "distro": distro, + "version": version, + "arch": arch, + "ram": ram, + "disk": disk, + "vcpus": vcpus, + } + + def _qemu_split_existing(self, vms, domains): + """Sépare les VM à créer de celles dont le domaine existe déjà. + `domains` est la liste des noms libvirt, obtenue UNE fois (un seul + sudo) et non par VM. Renvoie (à_créer, noms_existants).""" + known = set(domains) + pending = [vm for vm in vms if vm["name"] not in known] + existing = [vm["name"] for vm in vms if vm["name"] in known] + return pending, existing + + def _qemu_ask_ui(self): + """Interface du déploiement : formulaire TUI ou invites en ligne. + La préférence peut trancher d'avance (menu Configuration) ; « ask » + pose la question.""" + pref = todo_prefs.get("qemu_deploy_ui") + if pref in ("tui", "cli"): + return pref + print(f"\n{t('Interface:')}") + print(f" [1] {t('TUI form')} *") + print(f" [2] {t('Classic questions (line by line)')}") + print(f" {t('(change the default in TODO > Configuration)')}") + sel = input(t("Choice (1-2, default 1): ")).strip() + return "cli" if sel == "2" else "tui" + + def _qemu_form_context(self, mod): + """Données préchargées pour le formulaire TUI. + + TOUT ce qui exige sudo (liste des domaines) ou le réseau (branches) + est fait ICI, pendant que le terminal est encore à nous : une invite + de mot de passe pendant que Textual affiche casserait l'écran.""" + native = self._native_arch() + arches = ["amd64", "arm64", "s390x"] + if native not in arches: + arches.insert(0, native) + arches.append("all") + + catalog = {} + for a in arches: + distros = list(mod.DISTROS) + if a != "all": + allowed = self._qemu_arch_distros(a) + if allowed is not None: + distros = [d for d in distros if d in allowed] + entries = self._qemu_catalog_entries(mod, distros, a) + for e in entries: + # Le nom est calculé ici : le formulaire reste pure donnée. + e["name"] = self._qemu_infra_name( + e["distro"], e["version"], e["arch"] + ) + catalog[a] = entries + + print(f"\n{t('Loading (VM list, branches)...')}") + return { + "catalog": catalog, + "arches": arches, + "native": native, + "domains": self._qemu_list_domains(), + "branches": self._qemu_branch_list() or ["master"], + "install_profiles": self._qemu_install_profiles(), + "ssh_key": self._qemu_default_ssh_key(), + "host_cpu": os.cpu_count() or 2, + "free_ram": self._host_free_ram_mb(), + "base_vcpus": self._QEMU_BASE_VCPUS, + "cpu_presets": self._QEMU_CPU_PRESETS, + "ram_presets": self._QEMU_RAM_PRESETS, + "disk_presets": self._QEMU_DISK_PRESETS, + "extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB, + "defaults": { + "install": True, + "add_ssh_config": True, + "monitor": True, + "prod": False, + }, + # L'aperçu passe par le MÊME constructeur que le déploiement. + "build_command": lambda vm, spec, dry: " ".join( + shlex.quote(p) + for p in self._qemu_deploy_parts_for(vm, spec, dry_run=dry) + ), + } + + def _qemu_deploy(self, dry_run=False): + """Déploiement d'un parc de VM, en trois temps : collecte des choix + (formulaire TUI ou invites en ligne), aperçu ou exécution. Les deux + interfaces produisent la MÊME spec.""" + print(f"🚀 {t('Deploy ERPLibre VM(s)!')}") + try: + mod = self._qemu_import_module() + except Exception as exc: + print(f"{t('Cannot load QEMU catalog: ')}{exc}") + return + # Rappel de la dernière installation enregistrée (si historique). + last = self._qemu_last_run_line() + if last: + print(last) + + if self._qemu_ask_ui() == "tui": + spec = self._qemu_deploy_form(mod, dry_run) + if spec is None: + return + if spec: # None = annulé, {} = repli sur la CLI + self._qemu_run_spec(spec) + return + + got = self._qemu_collect_vms_cli(mod) + if not got: + return + res_label, vms = got + + if dry_run: + self._qemu_print_dry_run(vms) + return + + spec = self._qemu_collect_options_cli(vms, res_label) + if not spec: + return + self._qemu_run_spec(spec) + + def _qemu_deploy_form(self, mod, dry_run): + """Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {} + pour retomber sur les invites en ligne (textual absent).""" + from script.todo import textual_setup + + if not textual_setup.ensure(): + return {} + try: + from script.todo.qemu_deploy_form import run_deploy_form + + ctx = self._qemu_form_context(mod) + spec = run_deploy_form(ctx) + except ImportError: + return {} + if not spec: + print(t("Cancelled.")) + return None + if dry_run: + # L'entrée « aperçu » du menu ne crée rien, même depuis la TUI. + self._qemu_print_dry_run(spec["vms"]) + return None + self._qemu_print_recap(spec, spec.get("existing") or []) + if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")): + print(t("Cancelled.")) + return None + return spec + + def _qemu_print_dry_run(self, vms): + """Aperçu : les commandes deploy_qemu, sans rien créer (ni sudo, ni + installation). Passe par le point de passage unique, donc montre + exactement ce qui serait lancé.""" + spec = {"vms": vms, "ssh_key": self._qemu_default_ssh_key()} + print(f"\n{t('Preview (dry-run):')}") + for vm in vms: + parts = self._qemu_deploy_parts_for(vm, spec, dry_run=True) + print(" " + " ".join(shlex.quote(p) for p in parts)) + + def _qemu_collect_vms_cli(self, mod): + """Invites en ligne : architecture, catalogue, ressources, noms. + Renvoie (étiquette_de_profil, vms) ou None si rien à faire.""" + distros = list(mod.DISTROS) + + # 0) Architecture du parc (défaut : native ; [all] = TOUTES les archis + # supportées). Pour une arch précise non-amd64, on restreint le + # catalogue aux distros qui la publient ; pour [all], chaque distro + # reçoit uniquement les archis QU'ELLE publie. + arch = self._qemu_prompt_infra_arch() # amd64/arm64/s390x/all + if arch != "all": + allowed = self._qemu_arch_distros(arch) + if allowed is not None: + keep = [d for d in distros if d in allowed] + dropped = [d for d in distros if d not in keep] + if dropped: + print( + f" ⚠ {t('images for this arch only exist for:')} " + f"{', '.join(allowed)} " + f"({t('ignored:')} {', '.join(dropped)})" + ) + distros = keep + if not distros: + print(t("Nothing selected.")) + return None + + def arches_for(distro): + return self._qemu_arches_for(distro, arch) + + # 1) Distributions : multi-sélection, catalogue complet, principal (la + # version par défaut de chaque distro, marquée d'un *), ou granulaire + # (liste à plat de TOUTES les versions × archis, choix par virgules). + # Avec [all] archis, chaque version se décline en une VM par archi. + print(f"\n{t('Distributions:')}") + for i, d in enumerate(distros, 1): + default_v = mod.DISTROS[d][1] + vers = ", ".join( + (v + " *" if v == default_v else v) for v in mod.DISTROS[d][0] + ) + print(f" [{i}] {d} ({vers}){self._qemu_stat_avg('distro', d)}") + print(f" [all] {t('Whole catalog (every version)')}") + print( + f" [principal] {t('The main version of each distro (marked *)')}" + ) + print( + f" [granulaire] {t('Pick exact versions (comma-separated list)')}" + ) + raw = ( + input( + t( + "Selection (numbers, 'all', 'principal' or 'granulaire'," + " default: all): " + ) + ) + .strip() + .lower() + ) + catalog_all = raw in ("", "all", "*") + principal = raw in ("principal", "each", "p") + granular = raw in ("granulaire", "granular", "g") + + selected = [] # (distro, version, ram_mb, disk_str, arch) + if granular: + # Liste APLATIE distro + version + ARCHITECTURE : on choisit des + # combinaisons précises par numéros séparés de virgules. La liste + # vient du catalogue partagé avec le formulaire TUI. + flat = self._qemu_catalog_entries(mod, distros, arch) + print(f"\n{t('All versions:')}") + for i, e in enumerate(flat, 1): + star = " *" if e["default"] else "" + print( + f" [{i}] {e['distro']} {e['version']}{star} " + f"[{e['arch']}] (RAM≥{e['ram']}Mo, {e['disk']})" + ) + r = ( + input(t("Selection (comma-separated numbers): ")) + .strip() + .lower() + ) + for e in self._parse_index_selection(r, flat): + selected.append( + (e["distro"], e["version"], e["ram"], e["disk"], e["arch"]) + ) + elif principal: + # Une VM par distro (version par défaut) × chaque archi supportée. + for d in distros: + versions_map, default_v = mod.DISTROS[d] + _c, _o, ram, disk = versions_map[default_v] + for a in arches_for(d): + selected.append((d, default_v, ram, disk, a)) + else: + sel_distros = ( + distros + if catalog_all + else self._parse_index_selection(raw, distros) + ) + if not sel_distros: + print(t("Nothing selected.")) + return None + # 2) Versions par distro (multi-sélection) ; « all » si catalogue. + for d in sel_distros: + versions_map = mod.DISTROS[d][0] + vlist = list(versions_map) + if catalog_all: + chosen = vlist + else: + print(f"\n{t('Versions for')} {d.capitalize()} :") + for i, v in enumerate(vlist, 1): + _c, _o, ram, disk = versions_map[v] + stat = self._qemu_stat_avg("version", v, d) + print(f" [{i}] {v} (RAM≥{ram}Mo, {disk}){stat}") + print(f" [all] {t('select all')}") + r = input( + t("Selection (numbers, or 'all', default: all): ") + ).strip() + chosen = ( + vlist + if r.lower() in ("", "all", "*") + else self._parse_index_selection(r.lower(), vlist) + ) + for v in chosen: + _c, _o, ram, disk = versions_map[v] + for a in arches_for(d): + selected.append((d, v, ram, disk, a)) + if not selected: + print(t("Nothing selected.")) + return None + + # 2b) Ressources par VM : multiplicateur x1..x4 ou « Personnalisé ». + # Le profil est CUIT dans `selected`, qui porte dès lors les valeurs + # finales — RAM, disque et vCPU — pour chaque VM. + host_cpu = os.cpu_count() or 2 + free_ram = self._host_free_ram_mb() + res_label, selected = self._qemu_prompt_resources( + selected, host_cpu, free_ram + ) + + # 2c) Personnalisation par VM : nom, disque, RAM, vCPU (à la demande). + names, selected = self._qemu_customize_vms(selected, host_cpu) + + vms = [ + self._qemu_make_vm(d, v, a, ram, disk, vcpus, names[i]) + for i, (d, v, ram, disk, a, vcpus) in enumerate(selected) + ] + self._qemu_print_plan(vms, res_label, host_cpu, free_ram) + return res_label, vms + + def _qemu_print_plan(self, vms, res_label, host_cpu, free_ram): + """Plan + estimation des ressources de l'hôte.""" + total_ram = sum(vm["ram"] for vm in vms) + total_disk = sum(self._parse_disk_gb(vm["disk"]) for vm in vms) + total_cpu = sum(vm["vcpus"] for vm in vms) + print(f"\n{t('Deployment plan')} ({len(vms)} VM, {res_label}) :") + for vm in vms: + print( + f" - {vm['name']:<30} {vm['distro']} {vm['version']:<7} " + f"[{vm['arch']:<5}] {vm['vcpus']} vCPU RAM {vm['ram']}Mo " + f"{t('disk')} {vm['disk']}" + ) + cpu_warn = ( + f" ⚠ {t('> host cores')} ({host_cpu})" + if (total_cpu > host_cpu) + else "" + ) + print(f"\n {t('Total vCPU (all running):')} {total_cpu}{cpu_warn}") + print(f" {t('Total RAM (all running):')} {total_ram} Mo") + print(f" {t('Total virtual disk (thin qcow2):')} ~{total_disk} G") + if free_ram: + print(f" {t('Host RAM available:')} {free_ram} Mo") + if total_ram > free_ram: + warn = t( + "Total RAM exceeds host free RAM: not all VMs will run" + " at once." + ) + print(f" ⚠ {warn}") + + def _qemu_collect_options_cli(self, vms, res_label): + """Invites en ligne : clé SSH, installation ERPLibre, ~/.ssh/config, + parallélisme, puis récapitulatif et confirmation. + Renvoie la spec complète, ou None si l'utilisateur renonce.""" + # Clé SSH (partagée par tout le parc). Sans clé, cloud-init n'en + # injecte aucune : la VM démarre sans accès SSH, donc sans + # installation ni vérification possibles. On propose donc d'en créer + # une plutôt que de laisser passer un déploiement inutilisable. + default_key = self._qemu_default_ssh_key() + if not default_key: + print(f"\n⚠ {t('No SSH public key found in ~/.ssh.')}") + print(f" {t('Without one the VMs start with no SSH access.')}") + if self._is_yes_default_yes(input(t("Generate one now? (Y/n): "))): + default_key = self._ssh_ensure_key() + key_hint = default_key or t("none") + ssh_key = input(f"{t('SSH public key path')} ({key_hint}): ").strip() + if not ssh_key: + ssh_key = default_key + if ssh_key: + ssh_key = os.path.expanduser(ssh_key) + + # 4) Option : installer ERPLibre dans ~/git/erplibre de chaque VM. + install = None + ans = input( + t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ") + ) + if self._is_yes_default_yes(ans): + branch = self._qemu_pick_branch() + # dev (~/git, SELinux relâché) vs prod (/opt, confiné) + prod = self._qemu_ask_prod() + label, cmd = self._qemu_pick_install_profile() + monitor = self._is_yes_default_yes( + input(t("Interactive monitoring dashboard? (y/N): ")) + ) + install = { + "branch": branch, + "prod": prod, + "label": label, + "cmd": cmd, + "monitor": monitor, + } + + add_ssh_config = self._is_yes_default_yes( + input(t("Add each VM to ~/.ssh/config? (Y/n): ")) + ) + + # 5) Sépare les VM à CRÉER des déjà existantes AVANT de proposer le + # parallélisme : on connaît alors le vrai nombre à déployer (affiché + # dans le prompt) et on peut numéroter chaque tâche. + pending, existing = self._qemu_split_existing( + vms, self._qemu_list_domains() + ) + n_jobs = len(pending) + + # Collisions de noms : une VM déjà définie est ignorée (rien n'est + # écrasé), mais un qcow2 orphelin fera ÉCHOUER deploy_qemu, qui refuse + # d'écraser sans --force. On le dit avant, pas après l'attente. + if not self._qemu_confirm_collisions( + existing, [vm["name"] for vm in pending] + ): + print(t("Cancelled.")) + return None + if not pending: + print(t("Nothing to create - every VM already exists.")) + return None + + # Nombre de déploiements en parallèle. Défaut = nombre de CPU de l'hôte + # (borné par le nombre de VM). On affiche aussi le NB DE VM à déployer. + default_par = min(n_jobs, os.cpu_count() or 4) or 1 + raw = input( + f"{t('Parallel deployments (default:')} {default_par}, " + f"{n_jobs} {t('VMs')}): " + ).strip() + try: + parallelism = max(1, int(raw)) if raw else default_par + except ValueError: + parallelism = default_par + + spec = { + "res_label": res_label, + "vms": pending, + "existing": existing, + "ssh_key": ssh_key, + "install": install, + "add_ssh_config": add_ssh_config, + "parallelism": parallelism, + } + + # 6) Récapitulatif final, puis confirmation. Toutes les réponses + # données jusqu'ici sont rassemblées ici : c'est le dernier point où + # une erreur de saisie se rattrape sans avoir rien créé. + self._qemu_print_recap(spec, existing) + if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")): + print(t("Cancelled.")) + return None + return spec + + def _qemu_deploy_jobs_cli(self, jobs, workers): + """Déploiement parallèle, sortie texte. Renvoie + [(nom, rc, sortie, durée)] — même contrat que la vue TUI.""" + from concurrent.futures import ThreadPoolExecutor, as_completed + + def _run(job): + jid, jname, jparts = job + j0 = time.time() + res = subprocess.run(jparts, capture_output=True, text=True) + out = (res.stdout or "") + (res.stderr or "") + return jid, jname, res.returncode, out, time.time() - j0 + + outcome = [] + with ThreadPoolExecutor(max_workers=workers) as pool: + futures = [pool.submit(_run, j) for j in jobs] + # done = ordre de COMPLÉTION (les résultats reviennent dans le + # désordre) ; jid = ordre de préparation (stable). Durée par VM. + for done, fut in enumerate(as_completed(futures), 1): + jid, jname, rc, out, secs = fut.result() + mark = "✅" if rc == 0 else "❌" + print( + f"\n[{done}/{len(jobs)}] {mark} [{jid}] {jname} " + f"(rc={rc}, {self._fmt_dur(secs)})" + ) + for line in [ln for ln in out.strip().splitlines() if ln][-4:]: + print(f" {line}") + outcome.append((jname, rc, out, secs)) + return outcome + + def _qemu_deploy_jobs_tui(self, jobs, workers): + """Même chose, en blocs repliables Textual. Renvoie None si textual + manque, pour que l'appelant retombe sur la sortie texte.""" + from script.todo import textual_setup + + if not textual_setup.ensure(): + return None + try: + from script.todo.qemu_deploy_form import run_deploy_progress + + return run_deploy_progress(jobs, workers) + except ImportError: + return None + + def _qemu_run_spec(self, spec): + """Exécute une spec de déploiement : création des VM en parallèle, + résolution des IP, ~/.ssh/config, installation ERPLibre. + + Ne pose AUCUNE question — tous les choix sont dans la spec, d'où + qu'elle vienne (invites en ligne ou formulaire TUI).""" + pending = spec["vms"] + deployed = list(spec.get("existing") or []) + install = spec.get("install") + install_branch = install["branch"] if install else None + ssh_key = spec.get("ssh_key") + add_ssh_config = spec["add_ssh_config"] + parallelism = spec["parallelism"] + n_jobs = len(pending) + + # Jobs numérotés (k/N) : l'ID suit l'ORDRE de préparation, stable même + # si les résultats reviennent dans le désordre (exécution parallèle). + jobs = [] # (id, name, parts) + for k, vm in enumerate(pending, 1): + parts = self._qemu_deploy_parts_for(vm, spec, dry_run=False) + jobs.append((f"{k}/{n_jobs}", vm["name"], parts)) + + deploy_start = time.time() + n_ok = 0 + if jobs: + workers = min(parallelism, len(jobs)) + print( + f"\n{t('Deploying')} {len(jobs)} VM " + f"({t('parallel jobs:')} {workers})…" + ) + if todo_prefs.get("qemu_deploy_progress") == "tui": + outcome = self._qemu_deploy_jobs_tui(jobs, workers) + else: + outcome = None + if outcome is None: + outcome = self._qemu_deploy_jobs_cli(jobs, workers) + for name, rc, _out, _secs in outcome: + if rc == 0: + deployed.append(name) + n_ok += 1 + print( + f"\n{t('Deploy summary:')} {n_ok} OK, " + f"{len(jobs) - n_ok} {t('failed')}, " + f"{len(jobs)} {t('VMs')}, " + f"{self._fmt_dur(time.time() - deploy_start)}" + ) + + # 6) Résolution des IP EN PARALLÈLE (réutilisée pour ssh_config + + # install) : une boucle EN SÉRIE bloquait plusieurs minutes par VM + # émulée SANS sortie -> le dashboard « n'ouvrait jamais ». + ip_map = {} + if deployed and (add_ssh_config or install_branch): + labels = { + nm: f"{k}/{len(deployed)}" for k, nm in enumerate(deployed, 1) + } + ip_map = self._qemu_resolve_ips(deployed, labels) + + if add_ssh_config: + # La clé injectée par cloud-init est celle de la spec : c'est + # elle que doit présenter ssh, pas la première venue de l'agent. + identity = self._ssh_private_key(ssh_key) + for name in deployed: + ip = ip_map.get(name) + if ip: + self._write_ssh_config_entry( + name, "erplibre", ip, identity_file=identity + ) + + # 7) Installation ERPLibre (clone + make) si demandée. + if install: + if install["monitor"]: + # Installs détachées en parallèle + dashboard Textual. + self._qemu_install_erplibre_monitored( + deployed, + install_branch, + ip_map, + install["cmd"], + install["prod"], + ) + else: + print( + f"\n{t('Installing ERPLibre on each VM')} " + f"({install_branch})…" + ) + for name in deployed: + self._qemu_install_erplibre_vm( + name, + ssh_key, + install_branch, + ip_map.get(name), + install["cmd"], + install["prod"], + ) + + # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install + # synchrone ; l'install monitorée est détachée, non comptée ici). + print(f"\n{'═' * 60}") + print(f" {t('TOTAL summary')}") + print( + f" {t('VMs deployed:')} {n_ok}/{len(jobs) if jobs else 0}" + f" ({t('total incl. existing:')} {len(deployed)})" + ) + print( + f" {t('Total time:')} {self._fmt_dur(time.time() - deploy_start)}" + ) + print(f"{'═' * 60}") + print(f"\n✅ {t('ERPLibre infra deployment done.')}") + print(f" {t('Default login:')} erplibre / erplibre") + print(f" {t('Manage with:')} sudo virsh list --all") + def _deploy_clone_erplibre(self): default_path = os.path.expanduser("~/erplibre") target_path = ( @@ -733,9 +5020,7 @@ class TODO: print( f"\n{t('Deploy a local NTFY push notification server (Ubuntu/Arch)')}" ) - port = ( - input(t("NTFY server port (default: 8080): ")).strip() or "8080" - ) + port = input(t("NTFY server port (default: 8080): ")).strip() or "8080" import socket hostname = socket.gethostname() @@ -746,9 +5031,7 @@ class TODO: default_url = f"http://{local_ip}:{port}" base_url = ( - input( - f"{t('NTFY base URL')} (default: {default_url}): " - ).strip() + input(f"{t('NTFY base URL')} (default: {default_url}): ").strip() or default_url ) @@ -777,6 +5060,173 @@ class TODO: except Exception as e: print(f"{t('Error installing NTFY server: ')}{e}") + @staticmethod + def _ssh_config_hosts(): + """Noms d'hôtes déclarés dans ~/.ssh/config, dans l'ordre du fichier. + + Une ligne « Host » peut porter plusieurs noms : on les rend tous. Les + motifs (`*`, `?`) sont écartés — ce sont des règles, pas des machines + auxquelles se connecter.""" + path = os.path.expanduser("~/.ssh/config") + names = [] + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + if not re.match(r"^[ \t]*Host[ \t]+", line): + continue + for name in line.split()[1:]: + if "*" in name or "?" in name or name in names: + continue + names.append(name) + except OSError: + pass + return names + + @staticmethod + def _ssh_config_user(host): + """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". + + On suit la règle d'OpenSSH : le PREMIER `User` rencontré parmi les + blocs qui correspondent l'emporte, motifs (`Host *`) compris. Sans + déclaration on renvoie "" — il n'y a alors rien à copier, et + l'appelant garde son défaut plutôt que d'inventer le nom de session + locale.""" + import fnmatch + + path = os.path.expanduser("~/.ssh/config") + matching = False + try: + with open(path, encoding="utf-8") as fh: + for line in fh: + stripped = line.strip() + if re.match(r"^Host[ \t]+", stripped): + matching = any( + fnmatch.fnmatch(host, pattern) + for pattern in stripped.split()[1:] + ) + continue + if matching: + found = re.match( + r"^User[ \t]+(\S+)", stripped, re.IGNORECASE + ) + if found: + return found.group(1) + except OSError: + pass + return "" + + @staticmethod + def _port_is_free(port): + """Vrai si rien n'écoute sur ce port en local.""" + import socket + + with socket.socket() as sock: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind(("127.0.0.1", int(port))) + return True + except OSError: + return False + + @staticmethod + def _remote_port_open(host, port): + """Quelqu'un écoute-t-il sur ce port DEPUIS l'hôte distant ? + + True / False / None quand on n'a pas pu conclure (hôte injoignable, + pas de bash). On teste une vraie connexion TCP vers « localhost » et + non la table d'écoute : c'est exactement ce que fera le tunnel, y + compris le choix IPv4/IPv6 de la résolution. + """ + probe = ( + f"exec 3<>/dev/tcp/localhost/{int(port)} && echo OPEN" + " || echo CLOSED" + ) + try: + res = subprocess.run( + [ + "ssh", + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=10", + host, + f"bash -c {shlex.quote(probe)} 2>/dev/null", + ], + capture_output=True, + text=True, + timeout=45, + ) + except (OSError, subprocess.SubprocessError): + return None + out = res.stdout.strip() + if "OPEN" in out: + return True + if "CLOSED" in out: + return False + return None + + def _deploy_port_forward(self): + """Ouvre un tunnel SSH pour joindre un service distant depuis le + navigateur local. + + Le port distant est vu DEPUIS la machine cible : « -L + local:localhost:distant ». Un rebond éventuel n'a pas à être indiqué — + le ProxyJump du bloc ~/.ssh/config s'applique tout seul, ce qui rend + joignable une VM imbriquée sans route directe.""" + print(f"\n🔌 {t('SSH port forwarding')}") + hosts = self._ssh_config_hosts() + if hosts: + for i, name in enumerate(hosts, 1): + print(f" [{i}] {name}") + host = input(f"{t('Host (number or name):')} ").strip() + if not host: + print(t("Cancelled.")) + return + if host.isdigit() and 1 <= int(host) <= len(hosts): + host = hosts[int(host) - 1] + + raw = input(f"{t('Remote port (default:')} 8069): ").strip() + remote = raw if raw.isdigit() else "8069" + raw = input(f"{t('Local port (default:')} {remote}): ").strip() + local = raw if raw.isdigit() else remote + + # Sonde AVANT d'ouvrir : sans elle, un service arrêté à l'autre bout + # ne se manifeste que par un mur de « channel N: open failed » à + # chaque requête du navigateur, qui ne dit pas d'où vient le refus. + print(f" {t('Checking the remote port...')}") + listening = self._remote_port_open(host, remote) + if listening is False: + print( + f" ⚠ {t('Nothing is listening on port')} {remote}" + f" {t('of')} {host}" + ) + print(f" {t('Start the service there, or continue anyway.')}") + if not self._is_yes(input(t("Continue anyway? (y/N): "))): + return + elif listening is None: + print(f" ℹ️ {t('Could not probe the remote port; going on.')}") + + if not self._port_is_free(local): + print(f" ⚠ {t('Local port already in use:')} {local}") + if not self._is_yes(input(t("Try anyway? (y/N): "))): + return + if local != remote: + # Odoo redirige d'après web.base.url : un port local différent + # renvoie le navigateur vers une adresse qui n'existe pas chez lui. + print(f" ⚠ {t('Local port differs from the remote one.')}") + print(f" {t('Odoo redirects using web.base.url; check it')}") + print(f" {t('matches http://localhost:')}{local}") + + cmd = f"ssh -N -L {local}:localhost:{remote} {shlex.quote(host)}" + print(f"\n 🌐 http://localhost:{local}") + print(f" {t('Will execute:')} {cmd}") + print(f" {t('Ctrl+C closes the tunnel.')}\n") + try: + self.execute.exec_command_live(cmd, source_erplibre=False) + except KeyboardInterrupt: + pass + print(f"\n {t('Tunnel closed.')}") + def _configure_sshfs(self): import getpass import re @@ -869,7 +5319,12 @@ class TODO: mount_point = f"/tmp/sshfs_{safe_name}_{timestamp}" os.makedirs(mount_point, exist_ok=True) - cmd = f"sshfs {target} {mount_point}" + # -o follow_symlinks : sshfs résout les liens symboliques CÔTÉ SERVEUR. + # Indispensable pour les dépôts google-repo (ERPLibre) : leur .git est + # une chaîne de symlinks relatifs profonds (.repo/projects -> project- + # objects) que git ne peut pas traverser sur un montage sshfs par + # défaut (« erreur à la lecture de .git » -> git status/commit KO). + cmd = f"sshfs -o follow_symlinks {target} {mount_point}" print(f"{t('Mounting sshfs on: ')}{mount_point}") print(f"{t('Will execute:')} {cmd}") try: @@ -883,7 +5338,9 @@ class TODO: def _get_ssh_params(self): """Prompt for SSH connection parameters. Returns dict or None on cancel.""" - host = click.prompt(t("Remote host (user@hostname or hostname): ")).strip() + host = click.prompt( + t("Remote host (user@hostname or hostname): ") + ).strip() if not host: print(t("SSH host is required!")) return None @@ -892,13 +5349,13 @@ class TODO: or "erplibre" ) port = click.prompt(t("SSH port (default: 22): ")).strip() or "22" - key = ( - click.prompt( - t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ") - ).strip() - ) + key = click.prompt( + t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ") + ).strip() path = ( - click.prompt(t("Remote path (default: ~/erplibre_deploy_2): ")).strip() + click.prompt( + t("Remote path (default: ~/erplibre_deploy_2): ") + ).strip() or "~/erplibre_deploy_2" ) return { @@ -1046,64 +5503,6 @@ class TODO: cmd, source_erplibre=False, single_source_erplibre=True ) - def prompt_execute_code(self): - print(f"🤖 {t('What do you need for development?')}") - # help_info = """Commande : - # [1] Status Git local et distant - # [2] Démarrer le générateur de code - # [3] Format - Formatage automatique selon changement [ou manuelle] - # [4] Qualité - Qualité logiciel, détecter les fichiers qui manquent les licences AGPLv3 - # [0] Retour - # """ - # help_info = """Commande : - # [1] Status Git local et distant - # [0] Retour - # """ - - choices = self.config_file.get_config("code_from_makefile") - - menu_entry = { - "prompt_description": t("Open SHELL"), - } - choices.append(menu_entry) - - menu_entry = { - "prompt_description": t("Upgrade Module"), - } - choices.append(menu_entry) - - choices.append( - { - "prompt_description": t("Debug"), - } - ) - - help_info = self.fill_help_info(choices) - - while True: - status = click.prompt(help_info) - print() - if status == "0": - return False - elif status == str(len(choices)): - self.debug_ide() - elif status == str(len(choices) - 1): - self.upgrade_module() - elif status == str(len(choices) - 2): - self.open_shell_on_database() - else: - cmd_no_found = True - try: - int_cmd = int(status) - if 0 < int_cmd <= len(choices): - cmd_no_found = False - instance = choices[int_cmd - 1] - self.execute_from_configuration(instance) - except ValueError: - pass - if cmd_no_found: - print(t("Command not found !")) - def prompt_execute_git(self): print(f"🤖 {t('Git management tools!')}") choices = [ @@ -1341,46 +5740,6 @@ class TODO: print("-" * 50) print(f"{t('Total:')}" f" {len(files)}") - def _setup_claude_command( - self, command_name, template_filename, personalize=False - ): - dest_dir = os.path.expanduser("~/.claude/commands") - dest_file = os.path.join(dest_dir, f"{command_name}.md") - - if os.path.exists(dest_file): - print(f"{t('File already exists: ')}{dest_file}") - overwrite = input( - t("Do you want to overwrite the file? (y/Y): ") - ).strip() - if overwrite not in ("y", "Y"): - print(t("Nothing to do.")) - return - - template_path = os.path.join( - os.path.dirname(__file__), - "..", - "..", - "conf", - template_filename, - ) - try: - with open(template_path) as f: - content = f.read() - - if personalize: - name = input(t("Enter your full name: ")).strip() - email = input(t("Enter your email: ")).strip() - content = content.replace("your@email.com", email) - content = content.replace("Your Name", name) - - os.makedirs(dest_dir, exist_ok=True) - with open(dest_file, "w") as f: - f.write(content) - - print(f"{t('File created successfully: ')}{dest_file}") - except Exception as e: - print(f"{t('Error creating file: ')}{e}") - def _claude_add_automation(self): description = input(t("Description of the command to add: ")).strip() if not description: @@ -1466,13 +5825,16 @@ class TODO: def prompt_execute_database(self): print(f"🤖 {t('Make changes to databases!')}") choices = [ + {"section": t("Backup")}, + {"prompt_description": t("Create backup (.zip)")}, { "prompt_description": t( "Download database to create backup (.zip)" ) }, + {"section": t("Restore")}, {"prompt_description": t("Restore from backup (.zip)")}, - {"prompt_description": t("Create backup (.zip)")}, + {"section": t("Danger zone")}, {"prompt_description": t("Erase a database")}, ] help_info = self.fill_help_info(choices) @@ -1483,11 +5845,11 @@ class TODO: if status == "0": return False elif status == "1": - self.db_manager.download_database_backup_cli() - elif status == "2": - self.db_manager.restore_from_database() - elif status == "3": self.db_manager.create_backup_from_database() + elif status == "2": + self.db_manager.download_database_backup_cli() + elif status == "3": + self.db_manager.restore_from_database() elif status == "4": self.db_manager.drop_database() else: @@ -1525,12 +5887,15 @@ class TODO: f"🤖 {t('Manage RTK (Rust Token Killer) for token optimization!')}" ) choices = [ + {"section": t("Setup")}, {"prompt_description": t("Install RTK")}, - {"prompt_description": t("Check RTK version")}, - {"prompt_description": t("Show cumulative token savings")}, - {"prompt_description": t("Discover optimization opportunities")}, {"prompt_description": t("Initialize global auto-rewrite hook")}, + {"section": t("Status")}, + {"prompt_description": t("Check RTK version")}, {"prompt_description": t("Check RTK status")}, + {"prompt_description": t("Show cumulative token savings")}, + {"section": t("Optimize")}, + {"prompt_description": t("Discover optimization opportunities")}, ] help_info = self.fill_help_info(choices) @@ -1542,15 +5907,15 @@ class TODO: elif status == "1": self.rtk_install() elif status == "2": - self.rtk_check_version() - elif status == "3": - self.rtk_show_gain() - elif status == "4": - self.rtk_discover() - elif status == "5": self.rtk_init_global() - elif status == "6": + elif status == "3": + self.rtk_check_version() + elif status == "4": self.rtk_check_status() + elif status == "5": + self.rtk_show_gain() + elif status == "6": + self.rtk_discover() else: print(t("Command not found !")) @@ -1639,10 +6004,12 @@ class TODO: def prompt_execute_config(self): print(f"🤖 {t('Manage ERPLibre and Odoo configuration!')}") choices = [ + {"section": t("Generate")}, {"prompt_description": t("Generate all configuration")}, {"prompt_description": t("Generate from pre-configuration")}, {"prompt_description": t("Generate from backup file")}, {"prompt_description": t("Generate from database")}, + {"section": t("Advanced")}, {"prompt_description": t("Setup queue job for parallelism")}, ] help_info = self.fill_help_info(choices) @@ -1757,98 +6124,6 @@ class TODO: else: print(t("Command not found !")) - def execute_test_module(self, coverage=False): - # Module name - module_name = input(t("Module name to test: ")).strip() - if not module_name: - print(t("Module name is required!")) - return - - # Database name - db_name = input( - t("Temporary database name (default: test_todo_tmp): ") - ).strip() - if not db_name: - db_name = "test_todo_tmp" - - # Extra modules - extra_modules = input( - t("Extra modules to install (comma-separated, empty for none): ") - ).strip() - - # Log level - log_level = input(t("Log level (default: test): ")).strip() - if not log_level: - log_level = "test" - - # Build module list - modules_to_install = module_name - if extra_modules: - modules_to_install += f",{extra_modules}" - - # Step 1: Create temp DB - print(f"\n--- {t('Creating temporary database')} '{db_name}' ---") - cmd_restore = f"./script/database/db_restore.py --database {db_name}" - self.execute.exec_command_live( - cmd_restore, - source_erplibre=False, - single_source_erplibre=True, - ) - - # Step 2: Install modules - print(f"\n--- {t('Installing modules')}: {modules_to_install} ---") - cmd_install = ( - f"./script/addons/install_addons.sh" - f" {db_name} {modules_to_install}" - ) - self.execute.exec_command_live( - cmd_install, - source_erplibre=False, - single_source_erplibre=True, - ) - - # Step 3: Run tests - print(f"\n--- {t('Running tests')}: {module_name} ---") - cmd_test = ( - f"ODOO_MODE_TEST=true" - f" ./run.sh" - f" -d {db_name}" - f" -u {module_name}" - f" --log-level={log_level}" - ) - if coverage: - cmd_test = f"ODOO_MODE_COVERAGE=true {cmd_test}" - status_code, output = self.execute.exec_command_live( - cmd_test, - return_status_and_output=True, - source_erplibre=False, - single_source_erplibre=True, - ) - - if status_code == 0: - print(f"\n✅ {t('Tests completed successfully!')}") - else: - print(f"\n❌ {t('Tests failed with return code')} {status_code}") - - # Step 4: Cleanup - lang = get_lang() - keep_input = ( - input(t("Keep the temporary database? (y/N): ")).strip().lower() - ) - keep = keep_input in (("o", "oui") if lang == "fr" else ("y", "yes")) - if keep: - print(f"{t('Database kept')}: {db_name}") - else: - print( - f"\n--- {t('Cleaning up temporary database')} '{db_name}' ---" - ) - cmd_drop = f"./odoo_bin.sh db --drop --database {db_name}" - self.execute.exec_command_live( - cmd_drop, - source_erplibre=False, - single_source_erplibre=True, - ) - def execute_unit_tests(self): print(f"\n--- {t('Running unit tests')} ---") cmd = ( @@ -2150,50 +6425,6 @@ class TODO: upgrade = todo_upgrade.TodoUpgrade(self) upgrade.execute_module_upgrade() - def upgrade_poetry(self): - # Only show the version to the user - status = self.execute.exec_command_live( - f"make version", - source_erplibre=False, - ) - # TODO maybe autodetect to update it - git_repo_update_input = input( - "💬 Would you like to fetch all your git repositories, you need it (y/Y) : " - ) - if git_repo_update_input.strip().lower() == "y": - status = self.execute.exec_command_live( - f"./script/manifest/update_manifest_local_dev.sh", - source_erplibre=False, - ) - - poetry_lock = "./poetry.lock" - try: - os.remove(poetry_lock) - except Exception as e: - pass - odoo_long_version = "" - if os.path.exists("./.erplibre-version"): - with open("./.erplibre-version") as f: - odoo_long_version = f.read() - path_file_odoo_lock = f"./requirement/poetry.{odoo_long_version}.lock" - if odoo_long_version: - try: - os.remove(path_file_odoo_lock) - except Exception as e: - pass - - status = self.execute.exec_command_live( - f"pip install -r requirement/erplibre_require-ments-poetry.txt && " - f"./script/poetry/poetry_update.py -f", - source_erplibre=False, - single_source_erplibre=False, - single_source_odoo=True, - source_odoo=odoo_long_version, - ) - - if os.path.exists(poetry_lock): - shutil.copy2(poetry_lock, path_file_odoo_lock) - def callback_execute_custom_database(self, config): database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) @@ -2241,133 +6472,6 @@ class TODO: self.dir_path = dir_path todo_file_browser.exit_program() - def callback_make_mobile_home(self, config): - # Read file - default_project_name = "ERPLibre" - default_package_name = "ca.erplibre.home" - # Read default information - if os.path.exists(STRINGS_FILE): - tree = ET.parse(STRINGS_FILE) - root = tree.getroot() - for elem in root.findall("string"): - if elem.get("name") == "app_name": - default_project_name = elem.text - if elem.get("name") == "package_name": - default_package_name = elem.text - - default_project_url_name = "https://erplibre.ca" - # Read default information - dotenv_file = dotenv.find_dotenv( - filename=os.path.join(MOBILE_HOME_PATH, "src", ".env.production") - ) - default_project_url_name = dotenv.get_key( - dotenv_file, "VITE_WEBSITE_URL" - ) - default_project_note_subject = dotenv.get_key( - dotenv_file, "VITE_LABEL_NOTE" - ) - - default_debug = False - project_name = default_project_name - project_url_name = default_project_url_name - project_principal_subject = default_project_note_subject - package_name = default_package_name - do_debug = default_debug - do_change_picture_menu = False - - do_personalize = input( - "Do you want to personalize the mobile application (Y) : " - ) - if do_personalize.strip().lower() == "y": - project_name = ( - input( - f'Your project name (Separate by space in title), default "{default_project_name}" : ' - ).strip() - or default_project_name - ) - package_name = ( - input( - f'Your package name (separate by . lower case, 3 works like DOMAIN.NAME.OBJECT), default "{default_package_name}" : ' - ).strip() - or default_package_name - ) - project_url_name = ( - input( - f'Your project url website, default "{default_project_url_name}" : ' - ).strip() - or default_project_url_name - ) - project_principal_subject = ( - input( - f'Your project subject, default "{default_project_note_subject}" : ' - ).strip() - or default_project_note_subject - ) - do_debug = ( - input("Compilation with debug information, default No (Y) : ") - .strip() - .lower() - == "y" - ) - do_change_picture_menu = ( - input( - "Want to change picture from menu, you need android-studio (Y) : " - ) - .strip() - .lower() - == "y" - ) - - # Rename with script bash - cmd_client = f'cd {MOBILE_HOME_PATH} && npx cap init "{project_name}" "{package_name}" && ./rename_android.sh "{project_name}" "{package_name}" && npx cap sync android' - self.execute.exec_command_live(cmd_client, source_erplibre=False) - - # dotenv_mobile = dotenv.dotenv_values(dotenv_file) - # dotenv_mobile["VITE_TITLE"] = project_name - # dotenv_mobile["VITE_WEBSITE_URL"] = project_url_name - dotenv.set_key( - dotenv_file, "VITE_TITLE", project_name, quote_mode="always" - ) - dotenv.set_key( - dotenv_file, - "VITE_WEBSITE_URL", - project_url_name, - quote_mode="always", - ) - dotenv.set_key( - dotenv_file, - "VITE_LABEL_NOTE", - project_principal_subject, - quote_mode="always", - ) - dotenv.set_key( - dotenv_file, - "VITE_DEBUG_DEV", - "true" if do_debug else "false", - quote_mode="never", - ) - - if do_change_picture_menu: - status = self.execute.exec_command_live( - f"cd {MOBILE_HOME_PATH} && npx cap open android;bash", - source_erplibre=False, - new_window=True, - ) - print( - "Guide for Android-Studio, wait loading is finish. Right-click to app/New/Image Asset and load your image." - ) - input( - "Did you finish to update image with Android-Studio ? Press to continue ..." - ) - cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/company_logo.png" - self.execute.exec_command_live(cmd_client, source_erplibre=False) - cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/imgs/logo.png" - self.execute.exec_command_live(cmd_client, source_erplibre=False) - - status = self.execute.exec_command_live( - "./mobile/compile_and_run.sh", source_erplibre=False - ) - if __name__ == "__main__": start_time = time.time() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index fe50a7b..6ddc9f1 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -28,20 +28,20 @@ TRANSLATIONS = { "en": "Command:", }, "Execute": { - "fr": "Exécution", - "en": "Execute", + "fr": "🧰 Exécution", + "en": "🧰 Execute", }, "Install": { - "fr": "Installation", - "en": "Install", + "fr": "📦 Installation", + "en": "📦 Install", }, "Question": { - "fr": "Question", - "en": "Question", + "fr": "❓ Question", + "en": "❓ Question", }, "Fork - Open TODO in a new tab": { - "fr": "Fork - Ouvre TODO dans une nouvelle tabulation", - "en": "Fork - Open TODO in a new tab", + "fr": "🔀 Fork - Ouvre TODO dans une nouvelle tabulation", + "en": "🔀 Fork - Open TODO in a new tab", }, "Quit": { "fr": "Quitter", @@ -51,71 +51,17 @@ TRANSLATIONS = { "fr": "Commande non trouvée !", "en": "Command not found !", }, - "Back": { - "fr": "Retour", - "en": "Back", - }, - # Execute submenu - "Run - Execute and install an instance": { - "fr": "Run - Exécuter et installer une instance", - "en": "Run - Execute and install an instance", - }, - "Automation - Demonstration of developed features": { - "fr": "Automatisation - Demonstration des fonctions développées", - "en": "Automation - Demonstration of developed features", - }, - "Update - Update all developed staging source code": { - "fr": "Mise à jour - Update all developed staging source code", - "en": "Update - Update all developed staging source code", - }, - "Code - Developer tools": { - "fr": "Code - Outil pour développeur", - "en": "Code - Developer tools", - }, - "Doc - Documentation search": { - "fr": "Doc - Recherche de documentation", - "en": "Doc - Documentation search", - }, - "Database - Database tools": { - "fr": "Database - Outils sur les bases de données", - "en": "Database - Database tools", - }, - "Process - Execution tools": { - "fr": "Process - Outils sur les executions", - "en": "Process - Execution tools", - }, - "Config - Configuration file management": { - "fr": "Config - Traitement du fichier de configuration", - "en": "Config - Configuration file management", - }, - "Network - Network tools": { - "fr": "Réseau - Outil réseautique", - "en": "Network - Network tools", - }, - "Security - Dependency security audit": { - "fr": "Sécurité - Audit de sécurité des dépendances", - "en": "Security - Dependency security audit", - }, "RTK - CLI proxy to reduce LLM token consumption": { "fr": "RTK - Proxy CLI pour réduire la consommation de tokens LLM", "en": "RTK - CLI proxy to reduce LLM token consumption", }, - "Language - Change language / Changer la langue": { - "fr": "Langue - Changer la langue / Change language", - "en": "Language - Change language / Changer la langue", - }, - # Deploy section - "Deploy - Deploy ERPLibre locally": { - "fr": "Déploiement - Déployer ERPLibre localement", - "en": "Deploy - Deploy ERPLibre locally", - }, "Deploy ERPLibre to a local directory!": { "fr": "Déployer ERPLibre dans un répertoire local!", "en": "Deploy ERPLibre to a local directory!", }, "Clone ERPLibre locally (git clone)": { - "fr": "Cloner ERPLibre localement (git clone)", - "en": "Clone ERPLibre locally (git clone)", + "fr": "📥 Cloner ERPLibre localement (git clone)", + "en": "📥 Clone ERPLibre locally (git clone)", }, "Target directory path (default: ~/erplibre): ": { "fr": "Chemin du répertoire cible (défaut: ~/erplibre) : ", @@ -138,8 +84,145 @@ TRANSLATIONS = { "en": "Error cloning ERPLibre: ", }, "Configure sshfs": { - "fr": "Configurer sshfs", - "en": "Configure sshfs", + "fr": "📁 Configurer sshfs", + "en": "📁 Configure sshfs", + }, + "Local": { + "fr": "💻 Local", + "en": "💻 Local", + }, + "SSH (remote host)": { + "fr": "SSH (hôte distant)", + "en": "SSH (remote host)", + }, + "Remote & services": { + "fr": "🌐 Distant & services", + "en": "🌐 Remote & services", + }, + "SSH (remote host)...": { + "fr": "🔐 SSH (hôte distant)…", + "en": "🔐 SSH (remote host)...", + }, + "Deploy ERPLibre to a remote host over SSH!": { + "fr": "Déployer ERPLibre sur un hôte distant via SSH !", + "en": "Deploy ERPLibre to a remote host over SSH!", + }, + "Virtualization & notifications": { + "fr": "Virtualisation & notifications", + "en": "Virtualization & notifications", + }, + "Development": { + "fr": "🧰 Développement", + "en": "🧰 Development", + }, + "Data": { + "fr": "📊 Données", + "en": "📊 Data", + }, + "Sources & documentation": { + "fr": "📚 Sources & documentation", + "en": "📚 Sources & documentation", + }, + "AI & automation": { + "fr": "🧠 IA & automatisation", + "en": "🧠 AI & automation", + }, + "Deployment, network & security": { + "fr": "🌐 Déploiement, réseau & sécurité", + "en": "🌐 Deployment, network & security", + }, + "Preferences": { + "fr": "🎨 Préférences", + "en": "🎨 Preferences", + }, + "Deployment": { + "fr": "🚀 Déploiement", + "en": "🚀 Deployment", + }, + "Manage": { + "fr": "🛠 Gérer", + "en": "🛠 Manage", + }, + "Catalog": { + "fr": "📚 Catalogue", + "en": "📚 Catalog", + }, + "Open the console on a VM": { + "fr": "🖥 Ouvrir la console d'une VM", + "en": "🖥 Open the console on a VM", + }, + "Backup": { + "fr": "Sauvegarde", + "en": "Backup", + }, + "Restore": { + "fr": "Restauration", + "en": "Restore", + }, + "Danger zone": { + "fr": "Zone dangereuse", + "en": "Danger zone", + }, + "Setup": { + "fr": "Installation", + "en": "Setup", + }, + "Status": { + "fr": "Statut", + "en": "Status", + }, + "Optimize": { + "fr": "Optimisation", + "en": "Optimize", + }, + "Generate": { + "fr": "Génération", + "en": "Generate", + }, + "Advanced": { + "fr": "Avancé", + "en": "Advanced", + }, + "To leave the console, press Ctrl+] (then Enter).": { + "fr": "Pour quitter la console, appuyez sur Ctrl+] (puis Entrée).", + "en": "To leave the console, press Ctrl+] (then Enter).", + }, + "Default login (if set at deploy): erplibre / erplibre": { + "fr": "Login par défaut (si défini au déploiement) : " + "erplibre / erplibre", + "en": "Default login (if set at deploy): erplibre / erplibre", + }, + "Console password for 'erplibre' (default: erplibre): ": { + "fr": "Mot de passe console pour « erplibre » (défaut : erplibre) : ", + "en": "Console password for 'erplibre' (default: erplibre): ", + }, + "Console/SSH login:": { + "fr": "Connexion console/SSH :", + "en": "Console/SSH login:", + }, + "Default login:": { + "fr": "Login par défaut :", + "en": "Default login:", + }, + "Add this VM to ~/.ssh/config? (y/N): ": { + "fr": "Ajouter cette VM à ~/.ssh/config ? (o/N, défaut : non) : ", + "en": "Add this VM to ~/.ssh/config? (y/N, default: no): ", + }, + "Add each VM to ~/.ssh/config? (Y/n): ": { + "fr": "Ajouter chaque VM à ~/.ssh/config ? (O/n, défaut : oui) : ", + "en": "Add each VM to ~/.ssh/config? (Y/n, default: yes): ", + }, + "Waiting for the VM IP (DHCP lease)...": { + "fr": "Attente de l'IP de la VM (bail DHCP)...", + "en": "Waiting for the VM IP (DHCP lease)...", + }, + "No IP yet; add it later once the VM has booted.": { + "fr": "Pas encore d'IP ; à ajouter plus tard une fois la VM démarrée.", + "en": "No IP yet; add it later once the VM has booted.", + }, + "Added to ~/.ssh/config:": { + "fr": "Ajouté à ~/.ssh/config :", + "en": "Added to ~/.ssh/config:", }, "SSH address input method": { "fr": "Méthode de saisie de l'adresse SSH", @@ -173,10 +256,6 @@ TRANSLATIONS = { "fr": "Hôte SSH (ex: user@192.168.1.100) : ", "en": "SSH host (e.g.: user@192.168.1.100): ", }, - "SSH host is required!": { - "fr": "L'hôte SSH est requis!", - "en": "SSH host is required!", - }, "Mounting sshfs on: ": { "fr": "Montage sshfs sur : ", "en": "Mounting sshfs on: ", @@ -345,6 +424,18 @@ TRANSLATIONS = { "fr": "Aucune base de données sélectionnée.", "en": "No database selected.", }, + "Cannot list the databases (exit code): ": { + "fr": "Impossible de lister les bases (code de retour) : ", + "en": "Cannot list the databases (exit code): ", + }, + "Is PostgreSQL running?": { + "fr": "PostgreSQL est-il démarré ?", + "en": "Is PostgreSQL running?", + }, + "No database on this PostgreSQL server.": { + "fr": "Aucune base sur ce serveur PostgreSQL.", + "en": "No database on this PostgreSQL server.", + }, "Manage execution processes!": { "fr": "Manipuler les processus d'exécution!", "en": "Manage execution processes!", @@ -442,47 +533,11 @@ TRANSLATIONS = { "fr": "Ouvrir ERPLibre avec TODO 🤖", "en": "Open ERPLibre with TODO 🤖", }, - "Update all erplibre_base on database test": { - "fr": "Mise à jour de tous les erplibre_base sur la base de données test", - "en": "Update all erplibre_base on database test", - }, - "Show code status": { - "fr": "Afficher le statut du code", - "en": "Show code status", - }, - "Stash all code": { - "fr": "Remiser tout le code", - "en": "Stash all code", - }, - "Format modified code": { - "fr": "Formater le code modifié", - "en": "Format modified code", - }, # todo.py hardcoded prompt_descriptions "Mobile - Compile and run software": { "fr": "Mobile - Compiler et exécuter le logiciel", "en": "Mobile - Compile and run software", }, - "Upgrade Odoo - Migration Database": { - "fr": "Mise à jour Odoo - Migration de base de données", - "en": "Upgrade Odoo - Migration Database", - }, - "Upgrade Poetry - Dependency of Odoo": { - "fr": "Mise à jour Poetry - Dépendances d'Odoo", - "en": "Upgrade Poetry - Dependency of Odoo", - }, - "Open SHELL": { - "fr": "Ouvrir le SHELL", - "en": "Open SHELL", - }, - "Upgrade Module": { - "fr": "Mise à jour de module", - "en": "Upgrade Module", - }, - "Debug": { - "fr": "Débogage", - "en": "Debug", - }, "Migration module coverage": { "fr": "Couverture de migration des modules", "en": "Migration module coverage", @@ -559,11 +614,6 @@ TRANSLATIONS = { "fr": "Débogage todo.py", "en": "Debug todo.py", }, - # Test section - "Test - Test an Odoo module": { - "fr": "Test - Tester un module Odoo", - "en": "Test - Test an Odoo module", - }, "Test an Odoo module on a temporary database!": { "fr": "Tester un module Odoo sur une base de données temporaire!", "en": "Test an Odoo module on a temporary database!", @@ -624,10 +674,6 @@ TRANSLATIONS = { "fr": "Suppression de la base de données temporaire", "en": "Cleaning up temporary database", }, - "Keep the temporary database? (y/N): ": { - "fr": "Conserver la base de données temporaire? (o/N) : ", - "en": "Keep the temporary database? (y/N): ", - }, "Database kept": { "fr": "Base de données conservée", "en": "Database kept", @@ -644,11 +690,6 @@ TRANSLATIONS = { "fr": "Le nom du module est requis!", "en": "Module name is required!", }, - # Git section - "Git - Git tools": { - "fr": "Git - Outils Git", - "en": "Git - Git tools", - }, "Git management tools!": { "fr": "Outils de gestion Git!", "en": "Git management tools!", @@ -778,48 +819,48 @@ TRANSLATIONS = { "en": "SSH deployment tools!", }, "SSH - Check connection": { - "fr": "SSH - Vérifier la connexion", - "en": "SSH - Check connection", + "fr": "🔌 SSH - Vérifier la connexion", + "en": "🔌 SSH - Check connection", }, "SSH - Sync files (rsync)": { - "fr": "SSH - Synchroniser les fichiers (rsync)", - "en": "SSH - Sync files (rsync)", + "fr": "🔄 SSH - Synchroniser les fichiers (rsync)", + "en": "🔄 SSH - Sync files (rsync)", }, "SSH - Install ERPLibre": { - "fr": "SSH - Installer ERPLibre", - "en": "SSH - Install ERPLibre", + "fr": "📦 SSH - Installer ERPLibre", + "en": "📦 SSH - Install ERPLibre", }, "SSH - Start Odoo": { - "fr": "SSH - Démarrer Odoo", - "en": "SSH - Start Odoo", + "fr": "🟢 SSH - Démarrer Odoo", + "en": "🟢 SSH - Start Odoo", }, "SSH - Stop Odoo": { - "fr": "SSH - Arrêter Odoo", - "en": "SSH - Stop Odoo", + "fr": "🔴 SSH - Arrêter Odoo", + "en": "🔴 SSH - Stop Odoo", }, "SSH - Restart Odoo": { - "fr": "SSH - Redémarrer Odoo", - "en": "SSH - Restart Odoo", + "fr": "🔁 SSH - Redémarrer Odoo", + "en": "🔁 SSH - Restart Odoo", }, "SSH - Service status": { - "fr": "SSH - Statut du service", - "en": "SSH - Service status", + "fr": "📊 SSH - Statut du service", + "en": "📊 SSH - Service status", }, "SSH - View logs": { - "fr": "SSH - Voir les logs", - "en": "SSH - View logs", + "fr": "📜 SSH - Voir les logs", + "en": "📜 SSH - View logs", }, "SSH - Run make target": { - "fr": "SSH - Exécuter une cible make", - "en": "SSH - Run make target", + "fr": "🧰 SSH - Exécuter une cible make", + "en": "🧰 SSH - Run make target", }, "SSH - Install systemd service": { - "fr": "SSH - Installer le service systemd", - "en": "SSH - Install systemd service", + "fr": "🧩 SSH - Installer le service systemd", + "en": "🧩 SSH - Install systemd service", }, "SSH - Configure nginx + SSL": { - "fr": "SSH - Configurer nginx + SSL", - "en": "SSH - Configure nginx + SSL", + "fr": "🔒 SSH - Configurer nginx + SSL", + "en": "🔒 SSH - Configure nginx + SSL", }, "Remote host (user@hostname or hostname): ": { "fr": "Hôte distant (user@hostname ou hostname) : ", @@ -882,11 +923,6 @@ TRANSLATIONS = { "fr": "Interruption clavier", "en": "Keyboard interrupt", }, - # GPT code section - "GPT code - AI assistant tools": { - "fr": "GPT code - Outils d'assistant IA", - "en": "GPT code - AI assistant tools", - }, "AI assistant tools for development!": { "fr": "Outils d'assistant IA pour le développement!", "en": "AI assistant tools for development!", @@ -927,10 +963,6 @@ TRANSLATIONS = { "fr": "Commandes personnalisées Claude Code :", "en": "Claude Code custom commands:", }, - "Total:": { - "fr": "Total :", - "en": "Total:", - }, "File already exists: ": { "fr": "Le fichier existe déjà : ", "en": "File already exists: ", @@ -953,8 +985,8 @@ TRANSLATIONS = { }, # NTFY section "Deploy - Install NTFY notification server": { - "fr": "Déployer - Installer le serveur de notifications NTFY", - "en": "Deploy - Install NTFY notification server", + "fr": "🔔 Déployer - Installer le serveur de notifications NTFY", + "en": "🔔 Deploy - Install NTFY notification server", }, "Deploy a local NTFY push notification server (Ubuntu/Arch)": { "fr": "Déployer un serveur local de notifications NTFY (Ubuntu/Arch)", @@ -984,6 +1016,1804 @@ TRANSLATIONS = { "fr": "Script d'installation NTFY introuvable : ", "en": "NTFY install script not found: ", }, + # QEMU / KVM (libvirt) VM deployment + "QEMU/KVM - Deploy an Ubuntu VM (libvirt)": { + "fr": "💻 QEMU/KVM - Déployer une VM Ubuntu (libvirt)", + "en": "💻 QEMU/KVM - Deploy an Ubuntu VM (libvirt)", + }, + "Deploy a QEMU/KVM virtual machine (libvirt)!": { + "fr": "Déployer une machine virtuelle QEMU/KVM (libvirt) !", + "en": "Deploy a QEMU/KVM virtual machine (libvirt)!", + }, + "QEMU deploy script not found: ": { + "fr": "Script de déploiement QEMU introuvable : ", + "en": "QEMU deploy script not found: ", + }, + "Deploy a new VM": { + "fr": "Déployer une nouvelle VM", + "en": "Deploy a new VM", + }, + "Preview a deployment (dry-run, no sudo)": { + "fr": "🔍 Prévisualiser un déploiement (dry-run, sans sudo)", + "en": "🔍 Preview a deployment (dry-run, no sudo)", + }, + "Download a cloud image only": { + "fr": "⬇ Télécharger seulement une image cloud", + "en": "⬇ Download a cloud image only", + }, + "List VMs (virsh list --all)": { + "fr": "📋 Lister les VM (virsh list --all)", + "en": "📋 List VMs (virsh list --all)", + }, + "Show a VM IP address": { + "fr": "🌐 Afficher l'adresse IP d'une VM", + "en": "🌐 Show a VM IP address", + }, + "List available images and specs": { + "fr": "🗂 Lister les images disponibles et leurs specs", + "en": "🗂 List available images and specs", + }, + "Distribution:": { + "fr": "Distribution :", + "en": "Distribution:", + }, + "Choice (number or name, default: ubuntu): ": { + "fr": "Choix (numéro ou nom, défaut : ubuntu) : ", + "en": "Choice (number or name, default: ubuntu): ", + }, + "Invalid selection, using ubuntu": { + "fr": "Sélection invalide, utilisation d'ubuntu", + "en": "Invalid selection, using ubuntu", + }, + "Version for": { + "fr": "Version des", + "en": "Version for", + }, + "Versions for": { + "fr": "Versions des", + "en": "Versions of", + }, + "Architecture:": { + "fr": "Architecture :", + "en": "Architecture:", + }, + "native": { + "fr": "native", + "en": "native", + }, + "IBM Z — emulated, slow on x86; Ubuntu only": { + "fr": "IBM Z — émulé, lent sur x86 ; Ubuntu uniquement", + "en": "IBM Z — emulated, slow on x86; Ubuntu only", + }, + "IBM Z — emulated, slow; Ubuntu only": { + "fr": "IBM Z — émulé, lent ; Ubuntu uniquement", + "en": "IBM Z — emulated, slow; Ubuntu only", + }, + "ARM 64-bit — emulated, slow": { + "fr": "ARM 64 bits — émulé, lent", + "en": "ARM 64-bit — emulated, slow", + }, + "emulated, slow": { + "fr": "émulé, lent", + "en": "emulated, slow", + }, + "This architecture is emulated (TCG): boot and install are" + " much slower than the native one.": { + "fr": "Cette architecture est émulée (TCG) : le boot et l'installation" + " sont bien plus lents que l'architecture native.", + "en": "This architecture is emulated (TCG): boot and install are" + " much slower than the native one.", + }, + "images for this arch only exist for:": { + "fr": "images pour cette architecture disponibles seulement pour :", + "en": "images for this arch only exist for:", + }, + "All supported architectures": { + "fr": "Toutes les architectures supportées", + "en": "All supported architectures", + }, + "installation": { + "fr": "installation", + "en": "installation", + }, + "Resources per VM (x1 = catalog minimum):": { + "fr": "Ressources par VM (x1 = minimum catalogue) :", + "en": "Resources per VM (x1 = catalog minimum):", + }, + "Host:": { + "fr": "Hôte :", + "en": "Host:", + }, + "free": { + "fr": "libres", + "en": "free", + }, + "free RAM unknown": { + "fr": "RAM libre inconnue", + "en": "free RAM unknown", + }, + "> host free RAM": { + "fr": "> RAM libre de l'hôte", + "en": "> host free RAM", + }, + "total RAM": { + "fr": "RAM totale", + "en": "total RAM", + }, + "Choice (1-4, default 1):": { + "fr": "Choix (1-4, défaut 1) :", + "en": "Choice (1-4, default 1):", + }, + "Choice (1-5, default 1):": { + "fr": "Choix (1-5, défaut 1) :", + "en": "Choice (1-5, default 1):", + }, + "Custom - set vCPU, RAM and disk": { + "fr": "Personnalisé — choisir vCPU, RAM et disque", + "en": "Custom — set vCPU, RAM and disk", + }, + "custom": { + "fr": "personnalisé", + "en": "custom", + }, + "varies": { + "fr": "varié", + "en": "varies", + }, + "vCPU per VM, blank = keep": { + "fr": "vCPU par VM, vide = garder", + "en": "vCPU per VM, blank = keep", + }, + "New vCPU count, blank = keep": { + "fr": "Nouveau nombre de vCPU, vide = garder", + "en": "New vCPU count, blank = keep", + }, + "Invalid vCPU count.": { + "fr": "Nombre de vCPU invalide.", + "en": "Invalid vCPU count.", + }, + "More vCPU than host cores": { + "fr": "Plus de vCPU que de cœurs sur l'hôte", + "en": "More vCPU than host cores", + }, + "> host cores": { + "fr": "> cœurs de l'hôte", + "en": "> host cores", + }, + "Total vCPU (all running):": { + "fr": "vCPU total (toutes démarrées) :", + "en": "Total vCPU (all running):", + }, + "Deploy VM(s) (one or many)": { + "fr": "🚀 Déployer une ou plusieurs VM", + "en": "🚀 Deploy VM(s) (one or many)", + }, + "Deploy ERPLibre VM(s)!": { + "fr": "Déployer une ou plusieurs VM ERPLibre !", + "en": "Deploy ERPLibre VM(s)!", + }, + "VM names (default = auto):": { + "fr": "Noms des VM (défaut = auto) :", + "en": "VM names (default = auto):", + }, + "Rename which VMs? (numbers, comma-separated; blank = none): ": { + "fr": "Renommer quelles VM ? (numéros séparés par des virgules ; " + "vide = aucune) : ", + "en": "Rename which VMs? (numbers, comma-separated; blank = none): ", + }, + "->": { + "fr": "->", + "en": "->", + }, + "VMs (default = no change):": { + "fr": "VM (défaut = aucune modification) :", + "en": "VMs (default = no change):", + }, + "Modify which VMs? (numbers, comma-separated; blank = none): ": { + "fr": "Modifier quelles VM ? (numéros séparés par des virgules ; " + "vide = aucune) : ", + "en": "Modify which VMs? (numbers, comma-separated; blank = none): ", + }, + "new name (blank = keep):": { + "fr": "nouveau nom (vide = garder) :", + "en": "new name (blank = keep):", + }, + "New disk size in G, blank = keep": { + "fr": "Nouvelle taille disque en G, vide = garder", + "en": "New disk size in G, blank = keep", + }, + "New RAM in MB, blank = keep": { + "fr": "Nouvelle RAM en Mo, vide = garder", + "en": "New RAM in MB, blank = keep", + }, + "Duplicate names detected; keeping as entered.": { + "fr": "Noms en double détectés ; conservés tels quels.", + "en": "Duplicate names detected; keeping as entered.", + }, + "Preview (dry-run):": { + "fr": "Aperçu (dry-run) :", + "en": "Preview (dry-run):", + }, + "(includes emulated architectures — some VMs are slow)": { + "fr": "(inclut des architectures émulées — certaines VM sont lentes)", + "en": "(includes emulated architectures — some VMs are slow)", + }, + "install monitoring": { + "fr": "suivi d'installation", + "en": "install monitoring", + }, + "Navigation telemetry (TUI)": { + "fr": "📊 Télémétrie de navigation (TUI)", + "en": "📊 Navigation telemetry (TUI)", + }, + "SSH port forwarding (open Odoo in the browser)": { + "fr": "🔌 Redirection de port SSH (ouvrir Odoo dans le navigateur)", + "en": "🔌 SSH port forwarding (open Odoo in the browser)", + }, + "SSH port forwarding": { + "fr": "Redirection de port SSH", + "en": "SSH port forwarding", + }, + "Host (number or name):": { + "fr": "Hôte (numéro ou nom) :", + "en": "Host (number or name):", + }, + "Remote port (default:": { + "fr": "Port distant (défaut :", + "en": "Remote port (default:", + }, + "Local port (default:": { + "fr": "Port local (défaut :", + "en": "Local port (default:", + }, + "virsh is missing: libvirt is not installed here.": { + "fr": "virsh est absent : libvirt n'est pas installé ici.", + "en": "virsh is missing: libvirt is not installed here.", + }, + "Every VM command will fail until it is.": { + "fr": "Toutes les commandes VM échoueront tant que ce sera le cas.", + "en": "Every VM command will fail until it is.", + }, + "Install the QEMU/libvirt tools now? (Y/n): ": { + "fr": "Installer les outils QEMU/libvirt maintenant ? " + "(O/n, défaut : oui) : ", + "en": "Install the QEMU/libvirt tools now? (Y/n, default: yes): ", + }, + "libvirt is available.": { + "fr": "libvirt est disponible.", + "en": "libvirt is available.", + }, + "virsh still missing; a reboot may be required.": { + "fr": "virsh toujours absent ; un redémarrage est peut-être requis.", + "en": "virsh still missing; a reboot may be required.", + }, + "No SSH public key found in ~/.ssh.": { + "fr": "Aucune clé publique SSH trouvée dans ~/.ssh.", + "en": "No SSH public key found in ~/.ssh.", + }, + "Without one the VMs start with no SSH access.": { + "fr": "Sans elle, les VM démarrent sans accès SSH.", + "en": "Without one the VMs start with no SSH access.", + }, + "Generate one now? (Y/n): ": { + "fr": "En générer une maintenant ? (O/n, défaut : oui) : ", + "en": "Generate one now? (Y/n, default: yes): ", + }, + "Checking the remote port...": { + "fr": "Vérification du port distant...", + "en": "Checking the remote port...", + }, + "Nothing is listening on port": { + "fr": "Rien n'écoute sur le port", + "en": "Nothing is listening on port", + }, + "of": { + "fr": "de", + "en": "of", + }, + "Start the service there, or continue anyway.": { + "fr": "Démarrer le service là-bas, ou continuer quand même.", + "en": "Start the service there, or continue anyway.", + }, + "Continue anyway? (y/N): ": { + "fr": "Continuer quand même ? (o/N, défaut : non) : ", + "en": "Continue anyway? (y/N, default: no): ", + }, + "Could not probe the remote port; going on.": { + "fr": "Impossible de sonder le port distant ; on continue.", + "en": "Could not probe the remote port; going on.", + }, + "Local port already in use:": { + "fr": "Port local déjà occupé :", + "en": "Local port already in use:", + }, + "Try anyway? (y/N): ": { + "fr": "Essayer quand même ? (o/N, défaut : non) : ", + "en": "Try anyway? (y/N, default: no): ", + }, + "Local port differs from the remote one.": { + "fr": "Le port local diffère du port distant.", + "en": "Local port differs from the remote one.", + }, + "Odoo redirects using web.base.url; check it": { + "fr": "Odoo redirige d'après web.base.url ; vérifier qu'il", + "en": "Odoo redirects using web.base.url; check it", + }, + "matches http://localhost:": { + "fr": "vaut bien http://localhost:", + "en": "matches http://localhost:", + }, + "Ctrl+C closes the tunnel.": { + "fr": "Ctrl+C referme le tunnel.", + "en": "Ctrl+C closes the tunnel.", + }, + "Tunnel closed.": { + "fr": "Tunnel refermé.", + "en": "Tunnel closed.", + }, + "Where should the machines come from?": { + "fr": "D'où viennent les machines à configurer ?", + "en": "Where should the machines come from?", + }, + "Local QEMU VMs (virsh)": { + "fr": "VM QEMU locales (virsh)", + "en": "Local QEMU VMs (virsh)", + }, + "Hosts from ~/.ssh/config": { + "fr": "Hôtes de ~/.ssh/config", + "en": "Hosts from ~/.ssh/config", + }, + "Type a host or an IP": { + "fr": "Saisir un hôte ou une IP", + "en": "Type a host or an IP", + }, + "~/.ssh/config holds no host.": { + "fr": "~/.ssh/config ne contient aucun hôte.", + "en": "~/.ssh/config holds no host.", + }, + "Which hosts? (numbers, comma-separated; blank = all): ": { + "fr": "Quels hôtes ? (numéros séparés par des virgules ; " + "vide = tous) : ", + "en": "Which hosts? (numbers, comma-separated; blank = all): ", + }, + "Host or IP:": { + "fr": "Hôte ou IP :", + "en": "Host or IP:", + }, + "User": { + "fr": "Utilisateur", + "en": "User", + }, + "Name for ~/.ssh/config": { + "fr": "Nom pour ~/.ssh/config", + "en": "Name for ~/.ssh/config", + }, + "Depth (1 = these machines only, default:": { + "fr": "Profondeur (1 = ces machines seulement, défaut :", + "en": "Depth (1 = these machines only, default:", + }, + "SSH refused the identity.": { + "fr": "SSH a refusé l'identité.", + "en": "SSH refused the identity.", + }, + "Existing key:": { + "fr": "Clé existante :", + "en": "Existing key:", + }, + "Deploy it on this host (ssh-copy-id)? (Y/n): ": { + "fr": "La déployer sur cet hôte (ssh-copy-id) ? " + "(O/n, défaut : oui) : ", + "en": "Deploy it on this host (ssh-copy-id)? (Y/n, default: yes): ", + }, + "No SSH key in ~/.ssh.": { + "fr": "Aucune clé SSH dans ~/.ssh.", + "en": "No SSH key in ~/.ssh.", + }, + "Create one and deploy it? (Y/n): ": { + "fr": "En créer une et la déployer ? (O/n, défaut : oui) : ", + "en": "Create one and deploy it? (Y/n, default: yes): ", + }, + "access refused": { + "fr": "accès refusé", + "en": "access refused", + }, + "timed out": { + "fr": "délai dépassé", + "en": "timed out", + }, + "unreachable": { + "fr": "injoignable", + "en": "unreachable", + }, + "no QEMU/libvirt here": { + "fr": "pas de QEMU/libvirt ici", + "en": "no QEMU/libvirt here", + }, + "QEMU present, no VM": { + "fr": "QEMU présent, aucune VM", + "en": "QEMU present, no VM", + }, + "Create the SSH key if missing and deploy it? (Y/n): ": { + "fr": "Créer la clé SSH si absente et la déployer ? " + "(O/n, défaut : oui) : ", + "en": "Create the SSH key if missing and deploy it? " + "(Y/n, default: yes): ", + }, + "Generating an ed25519 SSH key": { + "fr": "Génération d'une clé SSH ed25519", + "en": "Generating an ed25519 SSH key", + }, + "Cannot generate the key": { + "fr": "Impossible de générer la clé", + "en": "Cannot generate the key", + }, + "Deploying the key on": { + "fr": "Déploiement de la clé sur", + "en": "Deploying the key on", + }, + "host": { + "fr": "hôte", + "en": "host", + }, + "key already accepted": { + "fr": "clé déjà acceptée", + "en": "key already accepted", + }, + "deployed": { + "fr": "déployée(s)", + "en": "deployed", + }, + "already there": { + "fr": "déjà en place", + "en": "already there", + }, + "virt-manager detected": { + "fr": "virt-manager détecté", + "en": "virt-manager detected", + }, + "virt-manager: every connection already there": { + "fr": "virt-manager : toutes les connexions sont déjà là", + "en": "virt-manager: every connection already there", + }, + "Add the missing connections to virt-manager? (Y/n): ": { + "fr": "Ajouter les connexions manquantes à virt-manager ? " + "(O/n, défaut : oui) : ", + "en": "Add the missing connections to virt-manager? " + "(Y/n, default: yes): ", + }, + "Restart virt-manager to see the new connections": { + "fr": "Redémarrer virt-manager pour voir les nouvelles connexions", + "en": "Restart virt-manager to see the new connections", + }, + "the names apply live": { + "fr": "les noms s'appliquent à chaud", + "en": "the names apply live", + }, + "Interface:": { + "fr": "Interface :", + "en": "Interface:", + }, + "(change the default in TODO > Configuration)": { + "fr": "(changer le défaut dans TODO > Configuration)", + "en": "(change the default in TODO > Configuration)", + }, + "Loading (VM list, branches)...": { + "fr": "Chargement (liste des VM, branches)...", + "en": "Loading (VM list, branches)...", + }, + "Architecture": { + "fr": "Architecture", + "en": "Architecture", + }, + "Resources per VM": { + "fr": "Ressources par VM", + "en": "Resources per VM", + }, + "Install ERPLibre": { + "fr": "Installer ERPLibre", + "en": "Install ERPLibre", + }, + "Production (/opt, confined)": { + "fr": "Production (/opt, confiné)", + "en": "Production (/opt, confined)", + }, + "Monitoring dashboard": { + "fr": "Tableau de bord de suivi", + "en": "Monitoring dashboard", + }, + "Add each VM to ~/.ssh/config": { + "fr": "Ajouter chaque VM à ~/.ssh/config", + "en": "Add each VM to ~/.ssh/config", + }, + "Parallelism": { + "fr": "Parallélisme", + "en": "Parallelism", + }, + "Name": { + "fr": "Nom", + "en": "Name", + }, + "Distro": { + "fr": "Distro", + "en": "Distro", + }, + "Version": { + "fr": "Version", + "en": "Version", + }, + "Arch": { + "fr": "Archi", + "en": "Arch", + }, + "vCPU": { + "fr": "vCPU", + "en": "vCPU", + }, + "RAM (MB)": { + "fr": "RAM (Mo)", + "en": "RAM (MB)", + }, + "all archs": { + "fr": "toutes", + "en": "all", + }, + "Tick what to deploy": { + "fr": "Cocher ce qu'on veut déployer", + "en": "Tick what to deploy", + }, + "F7 main versions · F6 all": { + "fr": "F7 versions principales · F6 tout", + "en": "F7 main versions · F6 all", + }, + "exists - skipped": { + "fr": "existe — ignorée", + "en": "exists - skipped", + }, + "orphan disk - will FAIL": { + "fr": "disque orphelin — ÉCHOUERA", + "en": "orphan disk - will FAIL", + }, + "press F5 again to confirm": { + "fr": "F5 à nouveau pour confirmer", + "en": "press F5 again to confirm", + }, + "Deploy": { + "fr": "Déployer", + "en": "Deploy", + }, + "Edit VM": { + "fr": "Éditer la VM", + "en": "Edit VM", + }, + "Preview": { + "fr": "Aperçu", + "en": "Preview", + }, + "All": { + "fr": "Tout", + "en": "All", + }, + "Main versions": { + "fr": "Versions principales", + "en": "Main versions", + }, + "None": { + "fr": "Rien", + "en": "None", + }, + "Cancel": { + "fr": "Annuler", + "en": "Cancel", + }, + "Apply": { + "fr": "Appliquer", + "en": "Apply", + }, + "Close": { + "fr": "Fermer", + "en": "Close", + }, + "Copy log": { + "fr": "Copier le log", + "en": "Copy log", + }, + "Copy all logs": { + "fr": "Copier tous les logs", + "en": "Copy all logs", + }, + "all logs": { + "fr": "tous les logs", + "en": "all logs", + }, + "c copy log · C copy all · q quit": { + "fr": "c copier le log · C copier tout · q quitter", + "en": "c copy log · C copy all · q quit", + }, + "Clipboard": { + "fr": "Presse-papiers", + "en": "Clipboard", + }, + "chars": { + "fr": "car.", + "en": "chars", + }, + "tail only, log was truncated": { + "fr": "fin seulement, log tronqué", + "en": "tail only, log was truncated", + }, + "Needs an OSC 52 capable terminal.": { + "fr": "Nécessite un terminal compatible OSC 52.", + "en": "Needs an OSC 52 capable terminal.", + }, + "Nothing to copy.": { + "fr": "Rien à copier.", + "en": "Nothing to copy.", + }, + "avg": { + "fr": "moy", + "en": "avg", + }, + "Last install:": { + "fr": "Dernière install :", + "en": "Last install:", + }, + "Resize a VM disk": { + "fr": "📐 Redimensionner le disque d'une VM", + "en": "📐 Resize a VM disk", + }, + "VM name to resize: ": { + "fr": "Nom de la VM à redimensionner : ", + "en": "VM name to resize: ", + }, + "VM not found.": { + "fr": "VM introuvable.", + "en": "VM not found.", + }, + "Main disk not found for this VM.": { + "fr": "Disque principal introuvable pour cette VM.", + "en": "Main disk not found for this VM.", + }, + "Current disk:": { + "fr": "Disque actuel :", + "en": "Current disk:", + }, + "Current virtual size:": { + "fr": "Taille virtuelle actuelle :", + "en": "Current virtual size:", + }, + "VM state:": { + "fr": "État de la VM :", + "en": "VM state:", + }, + "Could not read current disk size; aborting.": { + "fr": "Impossible de lire la taille actuelle du disque ; abandon.", + "en": "Could not read current disk size; aborting.", + }, + "Host free space:": { + "fr": "Espace libre hôte :", + "en": "Host free space:", + }, + "max sustainable total (before host full):": { + "fr": "max soutenable au total (avant hôte plein) :", + "en": "max sustainable total (before host full):", + }, + "Beyond host capacity by ~%.1f G — overcommit.": { + "fr": "Dépasse la capacité de l'hôte de ~%.1f G — surallocation.", + "en": "Beyond host capacity by ~%.1f G — overcommit.", + }, + "The qcow2 is thin: fine until the VM fills it, then the " + "host disk runs out. Max sustainable: ~%.1f G.": { + "fr": "Le qcow2 est creux : OK tant que la VM ne remplit pas, sinon " + "l'hôte tombe à court. Max soutenable : ~%.1f G.", + "en": "The qcow2 is thin: fine until the VM fills it, then the " + "host disk runs out. Max sustainable: ~%.1f G.", + }, + "Safe shrink needs libguestfs (virt-resize).": { + "fr": "La réduction sûre nécessite libguestfs (virt-resize).", + "en": "Safe shrink needs libguestfs (virt-resize).", + }, + "Install libguestfs-tools now? (y/N): ": { + "fr": "Installer libguestfs-tools maintenant ? (o/N) : ", + "en": "Install libguestfs-tools now? (y/N): ", + }, + "Shrink aborted; disk left intact.": { + "fr": "Réduction annulée ; disque laissé intact.", + "en": "Shrink aborted; disk left intact.", + }, + "Could not detect the partition to shrink; aborting.": { + "fr": "Partition à réduire introuvable ; abandon.", + "en": "Could not detect the partition to shrink; aborting.", + }, + "Missing tools for safe shrink:": { + "fr": "Outils manquants pour la réduction sûre :", + "en": "Missing tools for safe shrink:", + }, + "Backing up the disk before shrinking…": { + "fr": "Sauvegarde du disque avant réduction…", + "en": "Backing up the disk before shrinking…", + }, + "Back up the disk before shrinking? (Y/n): ": { + "fr": "Sauvegarder le disque avant réduction ? (O/n, défaut : oui) : ", + "en": "Back up the disk before shrinking? (Y/n, default: yes): ", + }, + "No backup: a failure could leave the disk broken.": { + "fr": "Sans sauvegarde : un échec pourrait rendre le disque inutilisable.", + "en": "No backup: a failure could leave the disk broken.", + }, + "Disk safely shrunk.": { + "fr": "Disque réduit en toute sécurité.", + "en": "Disk safely shrunk.", + }, + "No backup to restore; run fsck on the disk before use.": { + "fr": "Pas de sauvegarde à restaurer ; lancez fsck avant utilisation.", + "en": "No backup to restore; run fsck on the disk before use.", + }, + "A disk backup was kept:": { + "fr": "Une sauvegarde du disque a été conservée :", + "en": "A disk backup was kept:", + }, + "Delete this backup now? (y/N): ": { + "fr": "Effacer cette sauvegarde maintenant ? (o/N) : ", + "en": "Delete this backup now? (y/N): ", + }, + "Backup deleted.": { + "fr": "Sauvegarde effacée.", + "en": "Backup deleted.", + }, + "Backup kept (delete later via Clean up QEMU).": { + "fr": "Sauvegarde conservée (à effacer plus tard via Nettoyer QEMU).", + "en": "Backup kept (delete later via Clean up QEMU).", + }, + "disk backup (resize)": { + "fr": "sauvegarde de disque (redim.)", + "en": "disk backup (resize)", + }, + "Backup failed; aborting.": { + "fr": "Échec de la sauvegarde ; abandon.", + "en": "Backup failed; aborting.", + }, + "Could not attach the disk (nbd); aborting.": { + "fr": "Impossible d'attacher le disque (nbd) ; abandon.", + "en": "Could not attach the disk (nbd); aborting.", + }, + "Only ext2/3/4 can be shrunk safely; aborting.": { + "fr": "Seul ext2/3/4 peut être réduit en sécurité ; abandon.", + "en": "Only ext2/3/4 can be shrunk safely; aborting.", + }, + "Target size too small for this layout; aborting.": { + "fr": "Taille cible trop petite pour cette disposition ; abandon.", + "en": "Target size too small for this layout; aborting.", + }, + "Not enough used-space margin to shrink; aborting.": { + "fr": "Espace utilisé trop proche de la cible ; abandon.", + "en": "Not enough used-space margin to shrink; aborting.", + }, + "Shrinking guest ext filesystem": { + "fr": "Réduction du système de fichiers ext invité", + "en": "Shrinking guest ext filesystem", + }, + "resize2fs failed; reverting.": { + "fr": "resize2fs a échoué ; restauration.", + "en": "resize2fs failed; reverting.", + }, + "Internal size check failed; reverting.": { + "fr": "Contrôle de taille interne échoué ; restauration.", + "en": "Internal size check failed; reverting.", + }, + "Shrinking the partition…": { + "fr": "Réduction de la partition…", + "en": "Shrinking the partition…", + }, + "Partition rewrite failed; reverting.": { + "fr": "Réécriture de la partition échouée ; restauration.", + "en": "Partition rewrite failed; reverting.", + }, + "Shrinking the qcow2 container…": { + "fr": "Réduction du conteneur qcow2…", + "en": "Shrinking the qcow2 container…", + }, + "Container shrink failed; reverting.": { + "fr": "Réduction du conteneur échouée ; restauration.", + "en": "Container shrink failed; reverting.", + }, + "Restoring the original disk from backup…": { + "fr": "Restauration du disque d'origine depuis la sauvegarde…", + "en": "Restoring the original disk from backup…", + }, + "Shrinking guest FS + partition via virt-resize": { + "fr": "Réduction du FS invité + partition via virt-resize", + "en": "Shrinking guest FS + partition via virt-resize", + }, + "virt-resize failed; original disk left intact.": { + "fr": "virt-resize a échoué ; disque d'origine laissé intact.", + "en": "virt-resize failed; original disk left intact.", + }, + "Disk safely shrunk. Backup kept at:": { + "fr": "Disque réduit en toute sécurité. Sauvegarde conservée :", + "en": "Disk safely shrunk. Backup kept at:", + }, + "click to expand": { + "fr": "cliquer pour déplier", + "en": "click to expand", + }, + "No error detected.": { + "fr": "Aucune erreur détectée.", + "en": "No error detected.", + }, + "Test a VM (open Odoo in a CLI browser)": { + "fr": "🧪 Tester une VM (ouvrir Odoo dans un navigateur CLI)", + "en": "🧪 Test a VM (open Odoo in a CLI browser)", + }, + "Reopen install monitoring (last run / history)": { + "fr": "📈 Rouvrir le suivi d'installation (dernier run / historique)", + "en": "📈 Reopen install monitoring (last run / history)", + }, + "No install run found in history.": { + "fr": "Aucun run d'installation dans l'historique.", + "en": "No install run found in history.", + }, + "Install runs (most recent first):": { + "fr": "Runs d'installation (du plus récent) :", + "en": "Install runs (most recent first):", + }, + "Choice (number, blank = last): ": { + "fr": "Choix (numéro, vide = le dernier) : ", + "en": "Choice (number, blank = last): ", + }, + "Invalid selection.": { + "fr": "Sélection invalide.", + "en": "Invalid selection.", + }, + "Resolving VM IP...": { + "fr": "Résolution de l'IP de la VM…", + "en": "Resolving VM IP...", + }, + "No IP found for this VM.": { + "fr": "Aucune IP trouvée pour cette VM.", + "en": "No IP found for this VM.", + }, + "No CLI browser installed. Which to install?": { + "fr": "Aucun navigateur CLI installé. Lequel installer ?", + "en": "No CLI browser installed. Which to install?", + }, + "Which browser to install?": { + "fr": "Quel navigateur installer ?", + "en": "Which browser to install?", + }, + "Install another browser": { + "fr": "Installer un autre navigateur", + "en": "Install another browser", + }, + "Choice (number, blank = w3m): ": { + "fr": "Choix (numéro, vide = w3m) : ", + "en": "Choice (number, blank = w3m): ", + }, + "Unknown package manager; install it manually.": { + "fr": "Gestionnaire de paquets inconnu ; installez-le manuellement.", + "en": "Unknown package manager; install it manually.", + }, + "Install now? (y/N): ": { + "fr": "Installer maintenant ? (o/N) : ", + "en": "Install now? (y/N): ", + }, + "Which browser to view the page?": { + "fr": "Quel navigateur pour voir la page ?", + "en": "Which browser to view the page?", + }, + "Choice (number, blank = first): ": { + "fr": "Choix (numéro, vide = le premier) : ", + "en": "Choice (number, blank = first): ", + }, + "Page may not have loaded: Odoo not started on :8069, " + "or network/firewall.": { + "fr": "La page ne s'est peut-être pas affichée : Odoo n'est pas " + "démarré sur :8069, ou réseau/pare-feu.", + "en": "Page may not have loaded: Odoo not started on :8069, " + "or network/firewall.", + }, + "errors": { + "fr": "erreurs", + "en": "errors", + }, + "warnings": { + "fr": "avertissements", + "en": "warnings", + }, + "Esc to close": { + "fr": "Échap pour fermer", + "en": "Esc to close", + }, + "No running VM to pause.": { + "fr": "Aucune VM en cours à mettre en pause.", + "en": "No running VM to pause.", + }, + "No paused VM to resume.": { + "fr": "Aucune VM en pause à reprendre.", + "en": "No paused VM to resume.", + }, + "resumed": { + "fr": "reprise(s)", + "en": "resumed", + }, + "SSH grow failed; trying the guest agent.": { + "fr": "Échec SSH ; tentative via l'agent invité.", + "en": "SSH grow failed; trying the guest agent.", + }, + "No IP; trying the guest agent (no network).": { + "fr": "Pas d'IP ; tentative via l'agent invité (sans réseau).", + "en": "No IP; trying the guest agent (no network).", + }, + "Guest filesystem grown via guest agent.": { + "fr": "FS invité étendu via l'agent invité.", + "en": "Guest filesystem grown via guest agent.", + }, + "Guest agent grow failed; falling back to console.": { + "fr": "Échec de l'agent invité ; repli sur la console.", + "en": "Guest agent grow failed; falling back to console.", + }, + "Guest agent unavailable; falling back to serial console.": { + "fr": "Agent invité indisponible ; repli sur la console série.", + "en": "Guest agent unavailable; falling back to serial console.", + }, + "Running via guest agent (no network)…": { + "fr": "Exécution via l'agent invité (sans réseau)…", + "en": "Running via guest agent (no network)…", + }, + "SSH grow failed; falling back to serial console.": { + "fr": "Échec de l'extension via SSH ; repli sur la console série.", + "en": "SSH grow failed; falling back to serial console.", + }, + "No IP; falling back to serial console.": { + "fr": "Pas d'IP ; repli sur la console série.", + "en": "No IP; falling back to serial console.", + }, + "Serial console fallback. Log in, then paste:": { + "fr": "Repli console série. Connectez-vous, puis collez :", + "en": "Serial console fallback. Log in, then paste:", + }, + "Open the serial console now? (y/N): ": { + "fr": "Ouvrir la console série maintenant ? (o/N) : ", + "en": "Open the serial console now? (y/N): ", + }, + "The VM must be off. Shut it down and retry? (y/N): ": { + "fr": "La VM doit être éteinte. L'éteindre et réessayer ? (o/N) : ", + "en": "The VM must be off. Shut it down and retry? (y/N): ", + }, + "VM is still not off; aborting.": { + "fr": "La VM n'est toujours pas éteinte ; abandon.", + "en": "VM is still not off; aborting.", + }, + "Waiting for the VM to shut down...": { + "fr": "Attente de l'arrêt de la VM…", + "en": "Waiting for the VM to shut down...", + }, + "VM is off.": { + "fr": "VM éteinte.", + "en": "VM is off.", + }, + "The VM was shut down for the resize.": { + "fr": "La VM a été éteinte pour le redimensionnement.", + "en": "The VM was shut down for the resize.", + }, + "Start the VM now? (y/N): ": { + "fr": "Démarrer la VM maintenant ? (o/N) : ", + "en": "Start the VM now? (y/N): ", + }, + "Graceful shutdown timed out. Force off (destroy)? (y/N): ": { + "fr": "Arrêt gracieux trop long. Forcer l'arrêt (destroy) ? (o/N) : ", + "en": "Graceful shutdown timed out. Force off (destroy)? (y/N): ", + }, + "Show advanced info (vCPU, RAM, disk)? (y/N): ": { + "fr": "Afficher les infos avancées (vCPU, RAM, disque) ? (o/N) : ", + "en": "Show advanced info (vCPU, RAM, disk)? (y/N): ", + }, + "What do you want to do?": { + "fr": "Que voulez-vous faire ?", + "en": "What do you want to do?", + }, + "Advanced info (vCPU, RAM, disk)": { + "fr": "Infos avancées (vCPU, RAM, disque)", + "en": "Advanced info (vCPU, RAM, disk)", + }, + "Change the state of one or more VMs": { + "fr": "Changer l'état d'une ou plusieurs VM", + "en": "Change the state of one or more VMs", + }, + "Enter": { + "fr": "Entrée", + "en": "Enter", + }, + "Nothing": { + "fr": "Rien", + "en": "Nothing", + }, + "Choice: ": { + "fr": "Choix : ", + "en": "Choice: ", + }, + "Available VMs:": { + "fr": "VM disponibles :", + "en": "Available VMs:", + }, + "VMs to change (comma-separated): ": { + "fr": "VM à modifier (séparées par des virgules) : ", + "en": "VMs to change (comma-separated): ", + }, + "Unknown VM(s):": { + "fr": "VM inconnue(s) :", + "en": "Unknown VM(s):", + }, + "Target state:": { + "fr": "État cible :", + "en": "Target state:", + }, + "Target environment?": { + "fr": "Environnement cible ?", + "en": "Target environment?", + }, + "Development (~/git/erplibre, SELinux relaxed)": { + "fr": "Développement (~/git/erplibre, SELinux relâché)", + "en": "Development (~/git/erplibre, SELinux relaxed)", + }, + "Production (/opt/erplibre, SELinux enforced)": { + "fr": "Production (/opt/erplibre, SELinux confiné)", + "en": "Production (/opt/erplibre, SELinux enforced)", + }, + "Choice (1-2, default 1): ": { + "fr": "Choix (1-2, défaut 1) : ", + "en": "Choice (1-2, default 1): ", + }, + "Open (start)": { + "fr": "Ouvrir (démarrer)", + "en": "Open (start)", + }, + "Close (shut down)": { + "fr": "Fermer (éteindre)", + "en": "Close (shut down)", + }, + "start": { + "fr": "démarrer", + "en": "start", + }, + "shut down": { + "fr": "éteindre", + "en": "shut down", + }, + "Apply:": { + "fr": "Appliquer :", + "en": "Apply:", + }, + "Confirm for real? (y/N): ": { + "fr": "Confirmer pour de vrai ? (o/N) : ", + "en": "Confirm for real? (y/N): ", + }, + "Storage": { + "fr": "Stockage", + "en": "Storage", + }, + "total": { + "fr": "au total", + "en": "total", + }, + "used": { + "fr": "utilisés", + "en": "used", + }, + "Enter +NG to grow, -NG to shrink, or NG for a target size " + "(e.g. +20G, -10G, 60G).": { + "fr": "Entrez +NG pour agrandir, -NG pour réduire, ou NG pour une " + "taille cible (ex. +20G, -10G, 60G).", + "en": "Enter +NG to grow, -NG to shrink, or NG for a target size " + "(e.g. +20G, -10G, 60G).", + }, + "Resize: ": { + "fr": "Redimensionner : ", + "en": "Resize: ", + }, + "Invalid size.": { + "fr": "Taille invalide.", + "en": "Invalid size.", + }, + "No change.": { + "fr": "Aucun changement.", + "en": "No change.", + }, + "New virtual size:": { + "fr": "Nouvelle taille virtuelle :", + "en": "New virtual size:", + }, + "SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. " + "Data beyond the new size is LOST. Shrink the guest FS FIRST, " + "and only then shrink here.": { + "fr": "RÉDUIRE est DANGEREUX : le système de fichiers invité n'est " + "PAS réduit. Les données au-delà de la nouvelle taille sont PERDUES. " + "Réduisez d'ABORD le FS invité, puis seulement ici.", + "en": "SHRINKING is DANGEROUS: the guest filesystem is NOT shrunk. " + "Data beyond the new size is LOST. Shrink the guest FS FIRST, " + "and only then shrink here.", + }, + "Shut the VM off before shrinking (virsh shutdown).": { + "fr": "Éteignez la VM avant de réduire (virsh shutdown).", + "en": "Shut the VM off before shrinking (virsh shutdown).", + }, + "Type y to confirm you understand the risk (y/N): ": { + "fr": "Tapez o pour confirmer que vous comprenez le risque " + "(o/N, défaut : non) : ", + "en": "Type y to confirm you understand the risk (y/N): ", + }, + "Resize failed (see error above).": { + "fr": "Échec du redimensionnement (voir l'erreur ci-dessus).", + "en": "Resize failed (see error above).", + }, + "Virtual disk resized.": { + "fr": "Disque virtuel redimensionné.", + "en": "Virtual disk resized.", + }, + "Grow the guest filesystem now (over SSH)? (y/N): ": { + "fr": "Étendre le système de fichiers invité maintenant (via SSH) ? " + "(o/N, défaut : non) : ", + "en": "Grow the guest filesystem now (over SSH)? (y/N): ", + }, + "No IP; grow the guest FS manually once booted.": { + "fr": "Pas d'IP ; étendez le FS invité manuellement après le boot.", + "en": "No IP; grow the guest FS manually once booted.", + }, + "completed": { + "fr": "terminées", + "en": "completed", + }, + "deleted": { + "fr": "effacée", + "en": "deleted", + }, + "paused": { + "fr": "pause", + "en": "paused", + }, + "Waiting for the VM to start (boot + cloud-init)": { + "fr": "En attente du démarrage de la VM (boot + cloud-init)", + "en": "Waiting for the VM to start (boot + cloud-init)", + }, + "(an emulated architecture can be slow; this is normal)": { + "fr": "(une architecture émulée peut être lente ; c'est normal)", + "en": "(an emulated architecture can be slow; this is normal)", + }, + "VM ready - starting the ERPLibre install": { + "fr": "VM prête — installation ERPLibre en cours", + "en": "VM ready - starting the ERPLibre install", + }, + "Choice (number or name, blank = native):": { + "fr": "Choix (numéro ou nom, vide = native) :", + "en": "Choice (number or name, blank = native):", + }, + "Pick exact versions (comma-separated list)": { + "fr": "Choisir des versions précises (liste séparée par des virgules)", + "en": "Pick exact versions (comma-separated list)", + }, + "Selection (numbers, 'all', 'principal' or 'granulaire'," + " default: all): ": { + "fr": "Sélection (numéros, « all », « principal » ou « granulaire »," + " défaut : all) : ", + "en": "Selection (numbers, 'all', 'principal' or 'granulaire'," + " default: all): ", + }, + "All versions:": { + "fr": "Toutes les versions :", + "en": "All versions:", + }, + "Selection (comma-separated numbers): ": { + "fr": "Sélection (numéros séparés par des virgules) : ", + "en": "Selection (comma-separated numbers): ", + }, + "s390x images only exist for:": { + "fr": "images s390x disponibles seulement pour :", + "en": "s390x images only exist for:", + }, + "ignored:": { + "fr": "ignoré :", + "en": "ignored:", + }, + "IBM Z — emulated, slow on x86": { + "fr": "IBM Z — émulé, lent sur x86", + "en": "IBM Z — emulated, slow on x86", + }, + "Choice (number or name, blank = amd64):": { + "fr": "Choix (numéro ou nom, vide = amd64) :", + "en": "Choice (number or name, blank = amd64):", + }, + "s390x is emulated (TCG): boot and install are much " + "slower than x86.": { + "fr": "s390x est émulé (TCG) : le boot et l'installation sont bien " + "plus lents que x86.", + "en": "s390x is emulated (TCG): boot and install are much " + "slower than x86.", + }, + "Choice (number or version, blank = default):": { + "fr": "Choix (numéro ou version, vide = défaut) :", + "en": "Choice (number or version, blank = default):", + }, + "Invalid selection, using": { + "fr": "Sélection invalide, utilisation de", + "en": "Invalid selection, using", + }, + "select all": { + "fr": "tout sélectionner", + "en": "select all", + }, + "Delete VM(s)": { + "fr": "🗑 Effacer une ou plusieurs VM", + "en": "🗑 Delete VM(s)", + }, + "No VM found.": { + "fr": "Aucune VM trouvée.", + "en": "No VM found.", + }, + "Select VMs to delete:": { + "fr": "Sélectionner les VM à effacer :", + "en": "Select VMs to delete:", + }, + "Selection (numbers, or 'all'): ": { + "fr": "Sélection (numéros, ou « all ») : ", + "en": "Selection (numbers, or 'all'): ", + }, + "Also delete disk images (qcow2 + seed ISO)? (y/N): ": { + "fr": "Effacer aussi les disques (qcow2 + seed ISO) ? (o/N, défaut : non) : ", + "en": "Also delete disk images (qcow2 + seed ISO)? (y/N, default: no): ", + }, + "Will delete:": { + "fr": "Sera effacé :", + "en": "Will delete:", + }, + "disk images and seed ISOs": { + "fr": "les disques et les seed ISO", + "en": "disk images and seed ISOs", + }, + "disks kept": { + "fr": "disques conservés", + "en": "disks kept", + }, + "Confirm deletion? (y/N): ": { + "fr": "Confirmer l'effacement ? (o/N, défaut : non) : ", + "en": "Confirm deletion? (y/N, default: no): ", + }, + "Deletion done.": { + "fr": "Effacement terminé.", + "en": "Deletion done.", + }, + "Clean up QEMU (orphan files)": { + "fr": "🧹 Nettoyer QEMU (fichiers orphelins)", + "en": "🧹 Clean up QEMU (orphan files)", + }, + "Scanning for orphan QEMU files...": { + "fr": "Recherche des fichiers QEMU orphelins...", + "en": "Scanning for orphan QEMU files...", + }, + "orphan disk": { + "fr": "disque orphelin", + "en": "orphan disk", + }, + "orphan seed": { + "fr": "seed orphelin", + "en": "orphan seed", + }, + "partial download": { + "fr": "téléchargement interrompu", + "en": "partial download", + }, + "orphan UEFI nvram": { + "fr": "nvram UEFI orpheline", + "en": "orphan UEFI nvram", + }, + "No orphan files found.": { + "fr": "Aucun fichier orphelin trouvé.", + "en": "No orphan files found.", + }, + "Orphan files:": { + "fr": "Fichiers orphelins :", + "en": "Orphan files:", + }, + "Total:": { + "fr": "Total :", + "en": "Total:", + }, + "files": { + "fr": "fichiers", + "en": "files", + }, + "Delete these orphan files? (y/N): ": { + "fr": "Effacer ces fichiers orphelins ? (o/N, défaut : non) : ", + "en": "Delete these orphan files? (y/N, default: no): ", + }, + "Cleanup done.": { + "fr": "Nettoyage terminé.", + "en": "Cleanup done.", + }, + "Cached base images (reusable):": { + "fr": "Images de base en cache (réutilisables) :", + "en": "Cached base images (reusable):", + }, + "Also delete the cached base images? (y/N): ": { + "fr": "Effacer aussi les images de base en cache ? (o/N, défaut : non) : ", + "en": "Also delete the cached base images? (y/N, default: no): ", + }, + "All cached base images (reusable):": { + "fr": "Toutes les images de base en cache (réutilisables) :", + "en": "All cached base images (reusable):", + }, + "Delete ALL cached base images? (y/N): ": { + "fr": "Effacer TOUTES les images de base en cache ? (o/N, défaut : non) : ", + "en": "Delete ALL cached base images? (y/N, default: no): ", + }, + "Ghost domains (defined but disk missing):": { + "fr": "Domaines fantômes (définis, disque manquant) :", + "en": "Ghost domains (defined but disk missing):", + }, + "Undefine these ghost domains? (y/N): ": { + "fr": "Supprimer la définition de ces domaines ? (o/N, défaut : non) : ", + "en": "Undefine these ghost domains? (y/N, default: no): ", + }, + "Stale codename-named Ubuntu images (duplicates):": { + "fr": "Images Ubuntu nommées par codename (doublons) :", + "en": "Stale codename-named Ubuntu images (duplicates):", + }, + "Delete these duplicate images? (y/N): ": { + "fr": "Effacer ces images en double ? (o/N, défaut : non) : ", + "en": "Delete these duplicate images? (y/N, default: no): ", + }, + "Orphan ~/.ssh/config entries:": { + "fr": "Entrées ~/.ssh/config orphelines :", + "en": "Orphan ~/.ssh/config entries:", + }, + "Remove these ~/.ssh/config entries? (y/N): ": { + "fr": "Retirer ces entrées de ~/.ssh/config ? (o/N, défaut : non) : ", + "en": "Remove these ~/.ssh/config entries? (y/N, default: no): ", + }, + "Stale DHCP leases (no matching VM):": { + "fr": "Baux DHCP périmés (aucune VM correspondante) :", + "en": "Stale DHCP leases (no matching VM):", + }, + "Clear these stale leases? (y/N): ": { + "fr": "Effacer ces baux périmés ? (o/N, défaut : non) : ", + "en": "Clear these stale leases? (y/N, default: no): ", + }, + "Deploy ERPLibre infra (one minimal VM per image)": { + "fr": "Déployer l'infra ERPLibre (une VM minimale par image)", + "en": "Deploy ERPLibre infra (one minimal VM per image)", + }, + "Deploy ERPLibre infra: one minimal VM per image": { + "fr": "Déployer l'infra ERPLibre : une VM minimale par image", + "en": "Deploy ERPLibre infra: one minimal VM per image", + }, + "Cannot load QEMU catalog: ": { + "fr": "Impossible de charger le catalogue QEMU : ", + "en": "Cannot load QEMU catalog: ", + }, + "Distributions:": { + "fr": "Distributions :", + "en": "Distributions:", + }, + "Whole catalog (every version)": { + "fr": "Tout le catalogue (chaque version)", + "en": "Whole catalog (every version)", + }, + "The main version of each distro (marked *)": { + "fr": "La version principale de chaque distro (marquée *)", + "en": "The main version of each distro (marked *)", + }, + "Selection (numbers, 'all' or 'principal', default: all): ": { + "fr": "Sélection (numéros, « all » ou « principal », défaut : all) : ", + "en": "Selection (numbers, 'all' or 'principal', default: all): ", + }, + "Selection (numbers, or 'all', default: all): ": { + "fr": "Sélection (numéros, ou « all », défaut : all) : ", + "en": "Selection (numbers, or 'all', default: all): ", + }, + "Nothing selected.": { + "fr": "Rien de sélectionné.", + "en": "Nothing selected.", + }, + "Deployment plan": { + "fr": "Plan de déploiement", + "en": "Deployment plan", + }, + "disk": { + "fr": "disque", + "en": "disk", + }, + "Total RAM (all running):": { + "fr": "RAM totale (toutes actives) :", + "en": "Total RAM (all running):", + }, + "Total virtual disk (thin qcow2):": { + "fr": "Disque virtuel total (qcow2 thin) :", + "en": "Total virtual disk (thin qcow2):", + }, + "Host RAM available:": { + "fr": "RAM disponible de l'hôte :", + "en": "Host RAM available:", + }, + "Total RAM exceeds host free RAM: not all VMs will run at once.": { + "fr": "La RAM totale dépasse la RAM libre de l'hôte : les VM ne " + "tourneront pas toutes en même temps.", + "en": "Total RAM exceeds host free RAM: not all VMs will run at once.", + }, + "Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ": { + "fr": "Installer ERPLibre dans ~/git/erplibre sur chaque VM ? " + "(O/n, défaut : oui) : ", + "en": "Install ERPLibre into ~/git/erplibre on each VM? " + "(Y/n, default: yes): ", + }, + "Deploy these VMs now? (Y/n): ": { + "fr": "Déployer ces VM maintenant ? (O/n, défaut : oui) : ", + "en": "Deploy these VMs now? (Y/n, default: yes): ", + }, + "Discard everything and start over? (y/N): ": { + "fr": "Tout abandonner et recommencer ? (o/N, défaut : non) : ", + "en": "Discard everything and start over? (y/N, default: no): ", + }, + "Final review before deployment": { + "fr": "Récapitulatif final avant déploiement", + "en": "Final review before deployment", + }, + "VMs to create:": { + "fr": "VM à créer :", + "en": "VMs to create:", + }, + "Existing, left untouched:": { + "fr": "Existantes, laissées intactes :", + "en": "Existing, left untouched:", + }, + "ERPLibre install:": { + "fr": "Installation ERPLibre :", + "en": "ERPLibre install:", + }, + "profile": { + "fr": "profil", + "en": "profile", + }, + "branch": { + "fr": "branche", + "en": "branch", + }, + "production (/opt, confined)": { + "fr": "production (/opt, confiné)", + "en": "production (/opt, confined)", + }, + "development (~/git)": { + "fr": "développement (~/git)", + "en": "development (~/git)", + }, + "no": { + "fr": "non", + "en": "no", + }, + "SSH key:": { + "fr": "Clé SSH :", + "en": "SSH key:", + }, + "~/.ssh/config:": { + "fr": "~/.ssh/config :", + "en": "~/.ssh/config:", + }, + "one entry per VM": { + "fr": "une entrée par VM", + "en": "one entry per VM", + }, + "untouched": { + "fr": "inchangé", + "en": "untouched", + }, + "Parallelism:": { + "fr": "Parallélisme :", + "en": "Parallelism:", + }, + "at a time": { + "fr": "à la fois", + "en": "at a time", + }, + "Nothing to create - every VM already exists.": { + "fr": "Rien à créer — toutes les VM existent déjà.", + "en": "Nothing to create — every VM already exists.", + }, + "Name collisions detected": { + "fr": "Collisions de noms détectées", + "en": "Name collisions detected", + }, + "VM already defined - SKIPPED, nothing overwritten": { + "fr": "VM déjà définie — IGNORÉE, rien ne sera écrasé", + "en": "VM already defined — SKIPPED, nothing overwritten", + }, + "disk present without VM - deployment will FAIL": { + "fr": "disque présent sans VM — le déploiement ÉCHOUERA", + "en": "disk present without VM - deployment will FAIL", + }, + "Remove it by hand, or rename the VM.": { + "fr": "L'effacer à la main, ou renommer la VM.", + "en": "Remove it by hand, or rename the VM.", + }, + "Continue despite these collisions? (y/N): ": { + "fr": "Continuer malgré ces collisions ? (o/N, défaut : non) : ", + "en": "Continue despite these collisions? (y/N, default: no): ", + }, + "Resolving VM IPs (parallel, emulated boot is slow)...": { + "fr": "Résolution des IP des VM (en parallèle, le démarrage émulé " + "est lent)...", + "en": "Resolving VM IPs (parallel, emulated boot is slow)...", + }, + "no IP": { + "fr": "pas d'IP", + "en": "no IP", + }, + "Cloning ERPLibre on each VM": { + "fr": "Clonage d'ERPLibre sur chaque VM", + "en": "Cloning ERPLibre on each VM", + }, + "Installing ERPLibre on each VM": { + "fr": "Installation d'ERPLibre sur chaque VM", + "en": "Installing ERPLibre on each VM", + }, + "Install ERPLibre into ~/git/erplibre on this VM? (y/N): ": { + "fr": "Installer ERPLibre dans ~/git/erplibre sur cette VM ? (o/N, défaut : non) : ", + "en": "Install ERPLibre into ~/git/erplibre on this VM? (y/N, default: no): ", + }, + "installing ERPLibre": { + "fr": "installation d'ERPLibre", + "en": "installing ERPLibre", + }, + "no IP obtained, ERPLibre install skipped.": { + "fr": "aucune IP obtenue, installation ERPLibre ignorée.", + "en": "no IP obtained, ERPLibre install skipped.", + }, + "waiting for SSH...": { + "fr": "attente du SSH...", + "en": "waiting for SSH...", + }, + "SSH not reachable, ERPLibre install skipped.": { + "fr": "SSH injoignable, installation ERPLibre ignorée.", + "en": "SSH not reachable, ERPLibre install skipped.", + }, + "What to install on the VM(s)?": { + "fr": "Que veut-on installer sur la/les VM ?", + "en": "What to install on the VM(s)?", + }, + "Choice (number, blank = Odoo 18): ": { + "fr": "Choix (numéro, vide = Odoo 18) : ", + "en": "Choice (number, blank = Odoo 18): ", + }, + "ERPLibre + all Odoo versions": { + "fr": "ERPLibre + toutes les versions Odoo", + "en": "ERPLibre + all Odoo versions", + }, + "ERPLibre only (no Odoo)": { + "fr": "ERPLibre seulement (sans Odoo)", + "en": "ERPLibre only (no Odoo)", + }, + "ERPLibre mobile (home)": { + "fr": "ERPLibre mobile (home)", + "en": "ERPLibre mobile (home)", + }, + "ERPLibre Deployment (+ QEMU + dev)": { + "fr": "ERPLibre Déploiement (+ QEMU + dev)", + "en": "ERPLibre Deployment (+ QEMU + dev)", + }, + "Interactive monitoring dashboard? (y/N): ": { + "fr": "Suivi interactif (dashboard) ? (O/n, défaut : oui) : ", + "en": "Interactive monitoring dashboard? (Y/n, default: yes): ", + }, + "resolving IP...": { + "fr": "résolution de l'IP...", + "en": "resolving IP...", + }, + "no IP, skipped.": { + "fr": "pas d'IP, ignorée.", + "en": "no IP, skipped.", + }, + "No VM to install.": { + "fr": "Aucune VM à installer.", + "en": "No VM to install.", + }, + "Opening the interactive monitor...": { + "fr": "Ouverture du suivi interactif...", + "en": "Opening the interactive monitor...", + }, + "Monitor closed. Installs keep running in the background.": { + "fr": "Suivi fermé. Les installations continuent en arrière-plan.", + "en": "Monitor closed. Installs keep running in the background.", + }, + "Logs:": { + "fr": "Logs :", + "en": "Logs:", + }, + "Read the logs:": { + "fr": "Lire les logs :", + "en": "Read the logs:", + }, + "Log files:": { + "fr": "Fichiers de log :", + "en": "Log files:", + }, + "Parallel deployments (default:": { + "fr": "Déploiements en parallèle (défaut :", + "en": "Parallel deployments (default:", + }, + "VMs": { + "fr": "VM", + "en": "VMs", + }, + "Deploy summary:": { + "fr": "Bilan déploiement :", + "en": "Deploy summary:", + }, + "IPs resolved:": { + "fr": "IP résolues :", + "en": "IPs resolved:", + }, + "still waiting for": { + "fr": "encore en attente de", + "en": "still waiting for", + }, + "TOTAL summary": { + "fr": "Sommaire TOTAL", + "en": "TOTAL summary", + }, + "VMs deployed:": { + "fr": "VM déployées :", + "en": "VMs deployed:", + }, + "total incl. existing:": { + "fr": "total avec existantes :", + "en": "total incl. existing:", + }, + "Total time:": { + "fr": "Temps total :", + "en": "Total time:", + }, + "Deploying": { + "fr": "Déploiement de", + "en": "Deploying", + }, + "parallel jobs:": { + "fr": "tâches parallèles :", + "en": "parallel jobs:", + }, + "ERPLibre infra deployment done.": { + "fr": "Déploiement de l'infra ERPLibre terminé.", + "en": "ERPLibre infra deployment done.", + }, + "Manage with:": { + "fr": "Gérer avec :", + "en": "Manage with:", + }, + "Fetching ERPLibre branch list...": { + "fr": "Récupération de la liste des branches ERPLibre...", + "en": "Fetching ERPLibre branch list...", + }, + "Branch (default:": { + "fr": "Branche (défaut :", + "en": "Branch (default:", + }, + "Branches:": { + "fr": "Branches :", + "en": "Branches:", + }, + "Choice (number or name, default:": { + "fr": "Choix (numéro ou nom, défaut :", + "en": "Choice (number or name, default:", + }, + "no IP obtained, ERPLibre clone skipped.": { + "fr": "aucune IP obtenue, clonage ERPLibre ignoré.", + "en": "no IP obtained, ERPLibre clone skipped.", + }, + "cloning ERPLibre": { + "fr": "clonage d'ERPLibre", + "en": "cloning ERPLibre", + }, + "VM name (required): ": { + "fr": "Nom de la VM (requis) : ", + "en": "VM name (required): ", + }, + "VM name is required!": { + "fr": "Le nom de la VM est requis !", + "en": "VM name is required!", + }, + "VM name: ": { + "fr": "Nom de la VM : ", + "en": "VM name: ", + }, + "VM name or ID: ": { + "fr": "Nom ou ID de la VM : ", + "en": "VM name or ID: ", + }, + "VM name or ID (or 'all'): ": { + "fr": "Nom ou ID de la VM (ou « all ») : ", + "en": "VM name or ID (or 'all'): ", + }, + "RAM in MB (blank = version minimum): ": { + "fr": "RAM en Mo (vide = minimum de la version) : ", + "en": "RAM in MB (blank = version minimum): ", + }, + "vCPUs (default: 2): ": { + "fr": "vCPU (défaut : 2) : ", + "en": "vCPUs (default: 2): ", + }, + "Disk size (blank = version min; e.g. 30G, 1T): ": { + "fr": "Taille du disque (vide = min. version ; ex. 30G, 1T) : ", + "en": "Disk size (blank = version min; e.g. 30G, 1T): ", + }, + "VM name (default: ": { + "fr": "Nom de la VM (défaut : ", + "en": "VM name (default: ", + }, + "Deployment failed (see the error above).": { + "fr": "Échec du déploiement (voir l'erreur ci-dessus).", + "en": "Deployment failed (see the error above).", + }, + "SSH public key path": { + "fr": "Chemin de la clé publique SSH", + "en": "SSH public key path", + }, + "none": { + "fr": "aucune", + "en": "none", + }, + "No SSH key found. Set a password instead? (Y/n): ": { + "fr": "Aucune clé SSH trouvée. Définir un mot de passe ? (O/n) : ", + "en": "No SSH key found. Set a password instead? (Y/n): ", + }, + "Overwrite existing VM disk if present? (y/N): ": { + "fr": "Écraser le disque de la VM s'il existe ? (o/N, défaut : non) : ", + "en": "Overwrite existing VM disk if present? (y/N, default: no): ", + }, + "Verify SHA256 after download? (y/N): ": { + "fr": "Vérifier le SHA256 après téléchargement ? (o/N, défaut : non) : ", + "en": "Verify SHA256 after download? (y/N, default: no): ", + }, + "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)": { + "fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)", + "en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", + }, + # QEMU - statistics screen + "Statistics (installs, durations, VMs)": { + "fr": "📊 Statistiques (installations, durées, VM)", + "en": "📊 Statistics (installs, durations, VMs)", + }, + "QEMU statistics": {"fr": "Statistiques QEMU", "en": "QEMU statistics"}, + "No installation recorded yet.": { + "fr": "Aucune installation enregistrée pour l'instant.", + "en": "No installation recorded yet.", + }, + "Installations": {"fr": "Installations", "en": "Installations"}, + "Total": {"fr": "Total", "en": "Total"}, + "succeeded": {"fr": "réussies", "en": "succeeded"}, + "failed": {"fr": "échouées", "en": "failed"}, + "Period": {"fr": "Période", "en": "Period"}, + "days": {"fr": "jours", "en": "days"}, + "Median duration": {"fr": "Durée médiane", "en": "Median duration"}, + "min": {"fr": "min", "en": "min"}, + "max": {"fr": "max", "en": "max"}, + "Cumulated time": {"fr": "Temps cumulé", "en": "Cumulated time"}, + "By distribution": {"fr": "Par distribution", "en": "By distribution"}, + "By version": {"fr": "Par version", "en": "By version"}, + "By architecture": {"fr": "Par architecture", "en": "By architecture"}, + "Virtual machines": { + "fr": "Machines virtuelles", + "en": "Virtual machines", + }, + "Defined": {"fr": "Définies", "en": "Defined"}, + "running": {"fr": "en cours", "en": "running"}, + "stopped": {"fr": "arrêtées", "en": "stopped"}, + "Disk used": {"fr": "Disque utilisé", "en": "Disk used"}, + "image": {"fr": "image", "en": "image"}, + "failure": {"fr": "échec", "en": "failure"}, + "Reset the statistics": { + "fr": "Réinitialiser les statistiques", + "en": "Reset the statistics", + }, + "Nothing to reset.": {"fr": "Rien à effacer.", "en": "Nothing to reset."}, + "Erase": {"fr": "Effacer", "en": "Erase"}, + "recorded runs": {"fr": "runs enregistrés", "en": "recorded runs"}, + "runs erased": {"fr": "runs effacés", "en": "runs erased"}, + "Cancelled.": {"fr": "Annulé.", "en": "Cancelled."}, } From 4dfe058c705905f0223f52077aba1fc1b825cdbd Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 07/10] [ADD] todo: navigation telemetry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the menus visited and shows them as a tree, a kanban or a list. The tree is derived from the code, so a command never visited appears too, and can be run from there. --- FR --- Enregistre les menus visités et les présente en arbre, en kanban ou en liste. L'arbre est dérivé du code, si bien qu'une commande jamais visitée y figure aussi, et peut être lancée de là. Assisted-by: Claude Opus 4.8 --- script/todo/todo.py | 156 +++++ script/todo/todo_i18n.py | 105 +++ script/todo/todo_telemetry.py | 1245 +++++++++++++++++++++++++++++++++ 3 files changed, 1506 insertions(+) create mode 100644 script/todo/todo_telemetry.py diff --git a/script/todo/todo.py b/script/todo/todo.py index 214118d..6e4275d 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -117,6 +117,61 @@ class TODO: set_lang("en") print(t("Language changed to: English")) + def run(self): + with open(self.config_file.get_logo_ascii_file_path()) as my_file: + print(my_file.read()) + self._ask_language() + print(t("Opening TODO ...")) + print(f"🤖 {t('=> Enter your choice by number and press Enter!')}") + help_info = f"""{self._menu_header()} +[1] {t("Execute")} +[2] {t("Install")} +[3] {t("Question")} +[4] {t("Fork - Open TODO in a new tab")} +[5] {t("Navigation telemetry (TUI)")} +[6] {t("Configuration")} +[0] {t("Quit")} +""" + while True: + try: + status = click.prompt(help_info) + except NameError: + print("Do") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") + sys.exit(1) + except ImportError: + print("Do") + print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") + sys.exit(1) + except click.exceptions.Abort: + sys.exit(0) + print() + if status == "0": + break + elif status == "1": + self.prompt_execute() + elif status == "2": + self.prompt_install() + elif status == "3": + self.execute_prompt_ia() + elif status == "4": + # cmd = ( + # f"gnome-terminal --tab -- bash -c 'source" + # f" ./{VENV_ERPLIBRE}/bin/activate;make todo'" + # ) + cmd = "make todo" + self.execute.exec_command_live(cmd, source_erplibre=True) + elif status == "5": + self._todo_telemetry_tui() + elif status == "6": + self.prompt_configuration() + # elif status == "3" or status == "install": + # print("install") + else: + print(t("Command not found !")) + + print(status) + def prompt_execute(self): help_info = f"""{self._menu_header()} @@ -291,6 +346,107 @@ class TODO: "prompt_configuration": "Configuration", } + def _menu_header(self): + """En-tête de menu : fil d'Ariane (dérivé de la pile d'appels) suivi de + la ligne « Commande : ». Le fil situe le menu courant et se copie pour + décrire sans ambiguïté où l'on se trouve.""" + crumbs = [] + for frame_info in reversed(inspect.stack()): + if frame_info.frame.f_locals.get("self") is not self: + continue + label = self._MENU_LABELS.get(frame_info.function) + if label and (not crumbs or crumbs[-1] != label): + crumbs.append(label) + header = "" + if crumbs: + header = "📍 " + " › ".join(crumbs) + "\n" + # Télémétrie de navigation (best-effort, ne casse jamais le menu). + try: + from script.todo import todo_telemetry + + todo_telemetry.record(" › ".join(crumbs)) + except Exception: + pass + return header + t("Command:") + + def _todo_telemetry_tui(self): + """Ouvre le TUI de télémétrie (arbre/Kanban). Une commande choisie est + exécutée au retour (hors du TUI) ; on propose ensuite de REVENIR (l'état + et la position du curseur sont restaurés) ou de quitter.""" + from script.todo.todo_telemetry import run_tui + + from script.todo import textual_setup + + if not textual_setup.ensure(): + return + state = None + while True: + try: + result = run_tui(state=state) + except ImportError: + return + if not result: + return + action, state = result + if not action: + return # quitté sans choisir de commande + method, kwargs = action + # Fil d'Ariane : la commande étant lancée DEPUIS la télémétrie (et + # non via la navigation), aucun menu n'a affiché le chemin. On le + # montre ici (dernier segment traduit + icône) et on l'enregistre. + path = state.get("path") if isinstance(state, dict) else None + if path: + segs = path.split(" › ") + segs[-1] = t(segs[-1]) + print(f"\n📍 {' › '.join(segs)}") + try: + from script.todo import todo_telemetry + + todo_telemetry.record(path) + except Exception: + pass + fn = getattr(self, method, None) + if not callable(fn): + print(f"{t('Command not found !')} ({method})") + else: + try: + fn(**(kwargs or {})) + except Exception as exc: + print(f"{t('Command failed: ')}{exc}") + # Revenir (curseur restauré) ou quitter ? + ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}") + if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"): + return + + # Préférences éditables depuis le menu Configuration : clé, libellé, et + # valeurs proposées (valeur stockée -> libellé affiché). Une seule table : + # l'écran, la lecture et l'écriture en découlent. + _PREF_CHOICES = { + "qemu_deploy_ui": ( + "QEMU deployment interface", + ( + ("ask", "Ask every time"), + ("tui", "TUI form"), + ("cli", "Classic questions (line by line)"), + ), + ), + "qemu_deploy_progress": ( + "Display while deploying", + ( + ("cli", "CLI output (easy to copy)"), + ("tui", "TUI, collapsible blocks per VM"), + ), + ), + "migration_ui": ( + "Odoo migration interface", + ( + ("ask", "Ask every time"), + ("tui", "TUI form"), + ("cli", "Classic questions (line by line)"), + ), + ), + } + def fill_help_info(self, choices): # Une entrée {"section": "..."} affiche un titre de section SANS # consommer de numéro : la numérotation reste continue sur les vraies diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6ddc9f1..55d558f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1649,6 +1649,42 @@ TRANSLATIONS = { "fr": "Rien à copier.", "en": "Nothing to copy.", }, + "TODO navigation telemetry": { + "fr": "TODO — télémétrie de navigation", + "en": "TODO navigation telemetry", + }, + "navigations": { + "fr": "navigations", + "en": "navigations", + }, + "menus": { + "fr": "menus", + "en": "menus", + }, + "Telemetry reset.": { + "fr": "Télémétrie réinitialisée.", + "en": "Telemetry reset.", + }, + "tree from code": { + "fr": "arbre issu du code", + "en": "tree from code", + }, + "visited paths only": { + "fr": "chemins visités seulement", + "en": "visited paths only", + }, + "Enter = run, F3 = view": { + "fr": "Entrée = exécuter, F3 = vue", + "en": "Enter = run, F3 = view", + }, + "Enter = run, F3/F4 = views": { + "fr": "Entrée = exécuter, F3/F4 = vues", + "en": "Enter = run, F3/F4 = views", + }, + "view": { + "fr": "vue", + "en": "view", + }, "avg": { "fr": "moy", "en": "avg", @@ -1657,6 +1693,63 @@ TRANSLATIONS = { "fr": "Dernière install :", "en": "Last install:", }, + "No command found.": { + "fr": "Aucune commande trouvée.", + "en": "No command found.", + }, + "Back to telemetry (r) or quit (Enter)? ": { + "fr": "Revenir à la télémétrie (r) ou quitter (Entrée) ? ", + "en": "Back to telemetry (r) or quit (Enter)? ", + }, + "F2 system · F3/F4 views · Enter run": { + "fr": "F2 système · F3/F4 vues · Entrée exécute", + "en": "F2 system · F3/F4 views · Enter run", + }, + "State": {"fr": "État", "en": "State"}, + "uptime": {"fr": "actif depuis", "en": "uptime"}, + "load": {"fr": "charge", "en": "load"}, + "cores": {"fr": "cœurs", "en": "cores"}, + "Memory": {"fr": "Mémoire", "en": "Memory"}, + "Disk": {"fr": "Disque", "en": "Disk"}, + "Network": {"fr": "Réseau", "en": "Network"}, + "Battery": {"fr": "Batterie", "en": "Battery"}, + "Temperature": {"fr": "Température", "en": "Temperature"}, + "lm-sensors absent — press i to install": { + "fr": "lm-sensors absent — appuyez sur i pour installer", + "en": "lm-sensors absent — press i to install", + }, + "Sensors already available.": { + "fr": "Capteurs déjà disponibles.", + "en": "Sensors already available.", + }, + "Sensors now available.": { + "fr": "Capteurs désormais disponibles.", + "en": "Sensors now available.", + }, + "Still no temperature (reboot/modprobe may be needed).": { + "fr": "Toujours pas de température (redémarrage/modprobe requis ?).", + "en": "Still no temperature (reboot/modprobe may be needed).", + }, + "Unknown package manager for lm-sensors.": { + "fr": "Gestionnaire de paquets inconnu pour lm-sensors.", + "en": "Unknown package manager for lm-sensors.", + }, + "Proposed install command:": { + "fr": "Commande d'installation proposée :", + "en": "Proposed install command:", + }, + "Install lm-sensors now? (y/N): ": { + "fr": "Installer lm-sensors maintenant ? (o/N) : ", + "en": "Install lm-sensors now? (y/N): ", + }, + "Command failed: ": { + "fr": "Échec de la commande : ", + "en": "Command failed: ", + }, + "Your distribution may package it as python3-textual.": { + "fr": "Votre distribution le fournit peut-être en python3-textual.", + "en": "Your distribution may package it as python3-textual.", + }, "Resize a VM disk": { "fr": "📐 Redimensionner le disque d'une VM", "en": "📐 Resize a VM disk", @@ -1987,6 +2080,18 @@ TRANSLATIONS = { "fr": "Attente de l'arrêt de la VM…", "en": "Waiting for the VM to shut down...", }, + "timeout": { + "fr": "délai max", + "en": "timeout", + }, + "shutting down": { + "fr": "arrêt en cours", + "en": "shutting down", + }, + "remaining": { + "fr": "restantes", + "en": "remaining", + }, "VM is off.": { "fr": "VM éteinte.", "en": "VM is off.", diff --git a/script/todo/todo_telemetry.py b/script/todo/todo_telemetry.py new file mode 100644 index 0000000..1dcb9d8 --- /dev/null +++ b/script/todo/todo_telemetry.py @@ -0,0 +1,1245 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Télémétrie de navigation du CLI TODO. + +Enregistre les fils d'Ariane visités (« A › B › C » -> compteur) et affiche un +DIAGRAMME arborescent des fonctionnalités dans un TUI Textual, trié par usage. + +- record(path) : appelé par Todo._menu_header à chaque affichage de menu ; on + dédupe les ré-affichages consécutifs pour ne compter que les TRANSITIONS. +- run_tui() : ouvre l'arbre de navigation (compteurs par menu). +""" +from __future__ import annotations + +import ast +import asyncio +import glob +import json +import os +import shutil +import subprocess +import time +from pathlib import Path + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# Dernier chemin enregistré : évite de recompter chaque ré-affichage du même +# menu (une commande qui revient au menu ne compte pas comme une navigation). +_LAST = [None] + + +def _path() -> Path: + base = Path(os.path.expanduser("~/.erplibre")) + base.mkdir(parents=True, exist_ok=True) + return base / "todo_telemetry.json" + + +def load() -> dict: + try: + return json.loads(_path().read_text()) + except (OSError, ValueError): + return {"paths": {}} + + +def _save(data: dict) -> None: + try: + _path().write_text(json.dumps(data, ensure_ascii=False, indent=2)) + except OSError: + pass + + +def record(path: str) -> None: + """Incrémente le compteur du menu `path`. Best-effort : ne lève jamais + (la télémétrie ne doit jamais casser la navigation).""" + if not path or path == _LAST[0]: + return + _LAST[0] = path + try: + data = load() + paths = data.setdefault("paths", {}) + paths[path] = paths.get(path, 0) + 1 + data["updated"] = int(time.time()) + _save(data) + except Exception: + pass + + +def reset() -> None: + _save({"paths": {}}) + + +def _nested(paths: dict) -> dict: + """{« A › B › C »: count} -> arbre {nom: {'count', 'children'}}. Chaque + menu enregistre son propre chemin, donc chaque nœud a son compteur.""" + root: dict = {} + for path, count in paths.items(): + cur = root + parts = path.split(" › ") + for i, name in enumerate(parts): + node = cur.setdefault(name, {"count": 0, "children": {}}) + if i == len(parts) - 1: + node["count"] += count + cur = node["children"] + return root + + +# --------------------------------------------------------------------------- # +# Métadonnées de navigation DÉRIVÉES DU CODE (structure réelle des menus) +# --------------------------------------------------------------------------- # +def _str_of(node) -> str | None: + """Chaîne d'un nœud AST : littéral, t("…") ou f-string (parties Constant).""" + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + return node.args[0].value + if isinstance(node, ast.JoinedStr): + return "".join( + p.value + for p in node.values + if isinstance(p, ast.Constant) and isinstance(p.value, str) + ) + return None + + +def _choice_entries(func) -> list: + """Entrées NUMÉROTÉES d'un menu « choices = [...] », dans l'ordre : chaque + commande est {« label », « section »}, la section étant le dernier marqueur + {"section": …} rencontré (fill_help_info ne numérote pas les sections).""" + for node in ast.walk(func): + if ( + isinstance(node, ast.Assign) + and any( + isinstance(tg, ast.Name) and tg.id == "choices" + for tg in node.targets + ) + and isinstance(node.value, ast.List) + ): + entries, section = [], None + for el in node.value.elts: + if not isinstance(el, ast.Dict): + continue + d = {} + for k, v in zip(el.keys, el.values): + if isinstance(k, ast.Constant): + d[k.value] = _str_of(v) + if d.get("section"): + section = d["section"] + continue + lab = d.get("prompt_description") or d.get( + "prompt_description_key" + ) + if lab: + entries.append({"label": lab, "section": section}) + return entries + return [] + + +def _dispatch(func) -> dict: + """{numéro: (nom_de_méthode, kwargs)} depuis « status == "N": self.X(...) ». + Les kwargs LITTÉRAUX (ex. dry_run=True) sont capturés pour pouvoir rejouer + la commande à l'identique depuis la télémétrie.""" + out = {} + for node in ast.walk(func): + if not isinstance(node, ast.If): + continue + test = node.test + if ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "status" + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + and isinstance(test.comparators[0], ast.Constant) + ): + try: + num = int(test.comparators[0].value) + except (TypeError, ValueError): + continue + for stmt in node.body: + found = None + for n in ast.walk(stmt): + if ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "self" + ): + kwargs = { + kw.arg: kw.value.value + for kw in n.keywords + if kw.arg + and isinstance(kw.value, ast.Constant) + } + found = (n.func.attr, kwargs) + break + if found: + out[num] = found + break + return out + + +def _menu_labels(cls) -> dict: + """{méthode: label} depuis l'attribut de classe _MENU_LABELS.""" + for node in ast.walk(cls): + if ( + isinstance(node, ast.Assign) + and any( + isinstance(tg, ast.Name) and tg.id == "_MENU_LABELS" + for tg in node.targets + ) + and isinstance(node.value, ast.Dict) + ): + return { + k.value: v.value + for k, v in zip(node.value.keys, node.value.values) + if isinstance(k, ast.Constant) and isinstance(v, ast.Constant) + } + return {} + + +def _config_list(config_key, todo_dir): + """Entrées d'une liste de config de todo.json (ex. « code_from_makefile »), + cherchée à n'importe quel niveau. [] si absente.""" + try: + data = json.loads( + (Path(todo_dir) / "todo.json").read_text(encoding="utf-8") + ) + except (OSError, ValueError): + return [] + found = [] + + def walk(o): + if isinstance(o, dict): + for k, v in o.items(): + if k == config_key and isinstance(v, list): + found.extend(v) + walk(v) + elif isinstance(o, list): + for x in o: + walk(x) + + walk(data) + return found + + +def _len_choices_offset(comp): + """K pour « str(len(choices)) » -> 0 et « str(len(choices) - N) » -> N ; + None si le nœud n'est pas de cette forme.""" + if not ( + isinstance(comp, ast.Call) + and isinstance(comp.func, ast.Name) + and comp.func.id == "str" + and comp.args + ): + return None + arg = comp.args[0] + + def is_len_choices(x): + return ( + isinstance(x, ast.Call) + and isinstance(x.func, ast.Name) + and x.func.id == "len" + and x.args + and isinstance(x.args[0], ast.Name) + and x.args[0].id == "choices" + ) + + if is_len_choices(arg): + return 0 + if ( + isinstance(arg, ast.BinOp) + and isinstance(arg.op, ast.Sub) + and is_len_choices(arg.left) + and isinstance(arg.right, ast.Constant) + ): + return int(arg.right.value) + return None + + +def _dispatch_len(func) -> dict: + """{K: méthode} pour « status == str(len(choices) - K): self.M() ».""" + out = {} + for node in ast.walk(func): + if not isinstance(node, ast.If): + continue + test = node.test + if not ( + isinstance(test, ast.Compare) + and isinstance(test.left, ast.Name) + and test.left.id == "status" + and len(test.ops) == 1 + and isinstance(test.ops[0], ast.Eq) + ): + continue + k = _len_choices_offset(test.comparators[0]) + if k is None: + continue + method = None + for stmt in node.body: + for n in ast.walk(stmt): + if ( + isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and isinstance(n.func.value, ast.Name) + and n.func.value.id == "self" + ): + method = n.func.attr + break + if method: + break + if method: + out[k] = method + return out + + +def _choices_children(func, todo_dir): + """Commandes d'un menu bâti par « choices » (config_file.get_config + + choices.append/extend) avec dispatch « str(len(choices)-N) ». Renvoie une + liste de (label, méthode, kwargs) dans l'ordre affiché, ou None si le motif + ne s'applique pas. Les entrées de CONFIG rejouent via + execute_from_configuration ; les entrées APPENDÉES via leur méthode.""" + def _dict_label(dnode): + d = {} + for k, v in zip(dnode.keys, dnode.values): + if isinstance(k, ast.Constant): + d[k.value] = _str_of(v) + return d.get("prompt_description") or d.get("prompt_description_key") + + # On collecte affectations et append AVEC leur n° de ligne pour rejouer + # dans l'ORDRE (les entrées « menu_entry = {…}; choices.append(menu_entry) » + # réutilisent la même variable -> il faut suivre la dernière valeur). + config_key = None + events = [] # (lineno, kind, payload) + for node in ast.walk(func): + if ( + isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + ): + var = node.targets[0].id + val = node.value + if isinstance(val, ast.Dict): + events.append((node.lineno, "assign", (var, val))) + elif ( + var == "choices" + and isinstance(val, ast.Call) + and isinstance(val.func, ast.Attribute) + and val.func.attr == "get_config" + and val.args + and isinstance(val.args[0], ast.Constant) + ): + config_key = val.args[0].value + elif ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "append" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "choices" + and node.args + ): + events.append((node.lineno, "append", node.args[0])) + events.sort(key=lambda e: e[0]) + var_dict, appended = {}, [] + for _ln, kind, payload in events: + if kind == "assign": + var_dict[payload[0]] = payload[1] + else: # append : arg = Dict littéral ou Name (variable) + arg = payload + dnode = ( + arg + if isinstance(arg, ast.Dict) + else var_dict.get(arg.id) + if isinstance(arg, ast.Name) + else None + ) + if dnode is not None: + lab = _dict_label(dnode) + if lab: + appended.append(lab) + if config_key is None and not appended: + return None + children = [] + for entry in _config_list(config_key, todo_dir) if config_key else []: + lab = ( + entry.get("prompt_description_key") + or entry.get("prompt_description") + or "?" + ) + children.append((lab, "execute_from_configuration", {"instance": entry})) + len_disp = _dispatch_len(func) + n_config = len(children) # figé : `children` grossit dans la boucle + n_total = n_config + len(appended) + for j, lab in enumerate(appended): + pos = n_config + j # 0-based dans la liste globale (stable) + method = len_disp.get(n_total - 1 - pos) # None si non mappé + children.append((lab, method, {})) + return children + + +def build_code_tree(todo_path=None) -> dict | None: + """Construit l'arbre des menus/commandes EN LISANT le code de todo.py + (AST). Chaque menu (méthode de _MENU_LABELS) devient un nœud ; ses branches + « status == N » deviennent des sous-menus (si la cible est un menu) ou des + commandes (feuilles). None si l'analyse échoue.""" + p = Path(todo_path) if todo_path else (Path(__file__).parent / "todo.py") + todo_dir = p.parent # todo.json est à côté de todo.py + try: + mod = ast.parse(p.read_text(encoding="utf-8")) + except (OSError, SyntaxError): + return None + cls = next( + (n for n in ast.walk(mod) if isinstance(n, ast.ClassDef)), None + ) + if cls is None: + return None + methods = { + n.name: n for n in cls.body if isinstance(n, ast.FunctionDef) + } + labels = _menu_labels(cls) + if "run" not in methods or not labels: + return None + seen = set() + + def build(method): + node = { + "label": labels.get(method, method), + "is_menu": True, + "children": [], + } + if method in seen or method not in methods: + return node + seen.add(method) + func = methods[method] + disp = _dispatch(func) + centries = _choice_entries(func) + for num in sorted(disp): + target, kwargs = disp[num] + if target in labels: # sous-menu + node["children"].append(build(target)) + else: # commande (feuille) exécutable + entry = ( + centries[num - 1] + if 0 <= num - 1 < len(centries) + else None + ) + lab = ( + entry["label"] + if entry + else target.lstrip("_").replace("_", " ") + ) + node["children"].append( + { + "label": lab, + "is_menu": False, + "children": [], + "method": target, + "kwargs": kwargs, + "section": entry["section"] if entry else None, + } + ) + # Menus bâtis par « choices » (get_config + append) sans dispatch + # littéral « status == "N" » : Code, Update, Git, Database… + if not disp: + for lab, tmethod, tkwargs in ( + _choices_children(func, todo_dir) or [] + ): + if tmethod in labels: # une entrée qui ouvre un sous-menu + node["children"].append(build(tmethod)) + else: + node["children"].append( + { + "label": lab, + "is_menu": False, + "children": [], + "method": tmethod, + "kwargs": tkwargs, + "section": None, + } + ) + seen.discard(method) + return node + + return build("run") + + +def _command_columns(tree, paths): + """Colonnes du Kanban : (label, chemin, count, [commandes]) pour CHAQUE + menu qui contient des commandes directes (parcours profondeur d'abord).""" + cols = [] + + def walk(node, path): + cmds = [c for c in node["children"] if not c["is_menu"]] + if cmds: + cols.append((node["label"], path, paths.get(path, 0), cmds)) + for c in node["children"]: + if c["is_menu"]: + walk(c, f"{path} › {c['label']}") + + if tree: + walk(tree, "TODO") + return cols + + +# --------------------------------------------------------------------------- # +# Télémétrie SYSTÈME (vue F2) +# --------------------------------------------------------------------------- # +def _os_id() -> str: + try: + for line in open("/etc/os-release", encoding="utf-8"): + if line.startswith("ID="): + return line.split("=", 1)[1].strip().strip('"').lower() + except OSError: + pass + return "" + + +def sensors_install_command(): + """Commande d'installation de lm-sensors selon l'OS (nos 4 systèmes).""" + apt = ["sudo", "apt-get", "install", "-y", "lm-sensors"] + dnf = ["sudo", "dnf", "install", "-y", "lm_sensors"] + pac = ["sudo", "pacman", "-S", "--needed", "--noconfirm", "lm_sensors"] + cmd = { + "ubuntu": apt, + "debian": apt, + "linuxmint": apt, + "fedora": dnf, + "arch": pac, + }.get(_os_id()) + if cmd: + return cmd + if shutil.which("apt-get"): + return apt + if shutil.which("dnf"): + return dnf + if shutil.which("pacman"): + return pac + return None + + +def _first_int(path): + try: + return int(open(path).read().strip()) + except (OSError, ValueError): + return None + + +def _cpu_sample(): + try: + with open("/proc/stat") as f: + vals = [int(x) for x in f.readline().split()[1:]] + idle = vals[3] + (vals[4] if len(vals) > 4 else 0) + return idle, sum(vals) + except (OSError, ValueError, IndexError): + return None + + +def _net_sample(): + rx = tx = 0 + try: + with open("/proc/net/dev") as f: + for line in f.readlines()[2:]: + iface, _, rest = line.partition(":") + if iface.strip() in ("lo", ""): + continue + cols = rest.split() + if len(cols) >= 9: + rx += int(cols[0]) + tx += int(cols[8]) + except (OSError, ValueError): + return None + return rx, tx + + +def _mem(): + info = {} + try: + for line in open("/proc/meminfo"): + k, _, v = line.partition(":") + info[k] = int(v.split()[0]) * 1024 + except (OSError, ValueError): + return None + total = info.get("MemTotal", 0) + avail = info.get("MemAvailable", info.get("MemFree", 0)) + return total, total - avail + + +def _battery(): + for base in glob.glob("/sys/class/power_supply/BAT*"): + cap = _first_int(os.path.join(base, "capacity")) + try: + status = open(os.path.join(base, "status")).read().strip() + except OSError: + status = "" + if cap is not None: + return cap, status + return None + + +def read_temperature(): + """(source, [°C]) ou None. /sys/class/thermal d'abord (sans dépendance), + puis lm-sensors si présent.""" + temps = [] + for zt in glob.glob("/sys/class/thermal/thermal_zone*/temp"): + v = _first_int(zt) + if v and v > 0: + temps.append(round(v / 1000.0, 1)) + if temps: + return "sysfs", temps + if shutil.which("sensors"): + try: + out = subprocess.run( + ["sensors", "-u"], capture_output=True, text=True, timeout=5 + ).stdout + for line in out.splitlines(): + line = line.strip() + if "_input:" in line: + try: + temps.append(round(float(line.split(":")[1]), 1)) + except (ValueError, IndexError): + pass + temps = [x for x in temps if x > 0] + if temps: + return "sensors", temps + except (OSError, subprocess.SubprocessError): + pass + return None + + +def system_snapshot(prev, full=True): + """Instantané des métriques système. `prev` = (cpu, net, t) pour calculer + les taux CPU/réseau par delta. `full=False` saute la température (évite le + subprocess sensors quand la vue système n'est pas affichée). Renvoie + (métriques, nouveau_prev).""" + now = time.time() + cpu, net = _cpu_sample(), _net_sample() + pcpu, pnet, pt = prev or (None, None, None) + m = {} + if cpu and pcpu and cpu[1] > pcpu[1]: + m["cpu"] = max( + 0, min(100, round(100 * (1 - (cpu[0] - pcpu[0]) / (cpu[1] - pcpu[1])))) + ) + else: + m["cpu"] = None + if net and pnet and pt and now > pt: + dt = now - pt + m["net"] = ((net[0] - pnet[0]) / dt, (net[1] - pnet[1]) / dt) + else: + m["net"] = None + m["mem"] = _mem() + try: + du = shutil.disk_usage("/") + m["disk"] = (du.total, du.used, du.free) + except OSError: + m["disk"] = None + m["battery"] = _battery() + m["temp"] = read_temperature() if full else None + try: + m["uptime"] = float(open("/proc/uptime").read().split()[0]) + except (OSError, ValueError): + m["uptime"] = None + try: + m["load"] = os.getloadavg() + except OSError: + m["load"] = None + m["ncpu"] = os.cpu_count() or 1 + return m, (cpu, net, now) + + +def _fmt_size(nbytes): + if nbytes is None: + return "-" + for unit, div in (("T", 1 << 40), ("G", 1 << 30), ("M", 1 << 20)): + if nbytes >= div: + return f"{nbytes / div:.1f}{unit}" + return f"{nbytes // 1024}K" + + +def _fmt_rate(bps): + if bps is None: + return "?" + for unit, div in (("Go/s", 1 << 30), ("Mo/s", 1 << 20), ("Ko/s", 1 << 10)): + if bps >= div: + return f"{bps / div:.1f} {unit}" + return f"{int(bps)} o/s" + + +def _fmt_uptime(secs): + if not secs: + return "?" + secs = int(secs) + d, r = divmod(secs, 86400) + h, r = divmod(r, 3600) + mnt = r // 60 + if d: + return f"{d}j {h}h" + if h: + return f"{h}h{mnt:02d}" + return f"{mnt}min" + + +# Modes d'affichage du Kanban (F4 les fait défiler). +KANBAN_MODES = ("columns", "swimlanes", "grid") + +# Les vues défilent avec F3 ; chaque vue affiche le NOM de la suivante. +VIEWS = ("tree", "kanban", "list") +VIEW_LABELS = { + "tree": "Arbre", + "kanban": "Kanban", + "list": "Liste", + "system": "Système", +} + + +def _disp(label: str) -> str: + """Libellé d'affichage d'une commande : passe par t() pour récupérer la + traduction ET l'icône (les valeurs i18n portent l'icône). Renvoie le + libellé inchangé s'il n'est pas une clé de traduction.""" + return t(label) if label else label + + +_SENTINEL = object() + + +def _group_by_section(cmds): + """Itère les commandes en groupes (section, [cmds]) dans l'ordre, la + section pouvant être None (aucune).""" + groups, cur, bucket = [], _SENTINEL, [] + for c in cmds: + sec = c.get("section") + if sec != cur: + if bucket: + groups.append((cur, bucket)) + cur, bucket = sec, [] + bucket.append(c) + if bucket: + groups.append((cur, bucket)) + return groups + + +def run_tui(run_app: bool = True, state: dict | None = None): + """TUI de télémétrie : vue Arbre (issue du code) et vue Kanban (F3), la + disposition du Kanban défilant par F4 (colonnes / swimlanes / grille). + Sélectionner une COMMANDE = l'exécuter. `state` restaure la vue + le + curseur au retour. Renvoie (action|None, state) ; run_app=False -> l'app.""" + from textual.app import App, ComposeResult + from textual.containers import ( + Container, + Grid, + HorizontalScroll, + Vertical, + VerticalScroll, + ) + from textual.widgets import ( + Footer, + Header, + Label, + ListItem, + ListView, + Static, + Tree, + ) + + paths = load().get("paths", {}) + code_tree = build_code_tree() + columns = _command_columns(code_tree, paths) # (label, path, cnt, cmds) + state = state or {} + + class CmdItem(ListItem): + """Carte : commande à exécuter + son chemin (pour restaurer le curseur).""" + + def __init__(self, label, method, kwargs, path): + super().__init__(Label(label)) + self.cmd_method = method + self.cmd_kwargs = kwargs or {} + self.cmd_path = path + + class Telemetry(App): + CSS = """ + #summary { height: 1; color: $text-muted; } + Tree { border: solid $accent; } + #kanban { display: none; height: 1fr; } + #list { display: none; height: 1fr; } + #system { display: none; height: 1fr; padding: 1 2; } + .kcol { width: 40; border: solid $accent; margin: 0 1 0 0; } + .ktitle { height: 1; color: $accent; } + .ksec { height: 1; color: $secondary; text-style: italic; } + .listmenu { height: 1; color: $accent; text-style: bold; } + .listsec { height: 1; color: $secondary; text-style: italic; } + .lane { height: auto; } + .lanetitle { height: 1; color: $secondary; } + .kgrid { grid-size: 3; grid-gutter: 1; } + .kbig { row-span: 3; } + """ + BINDINGS = [ + ("q", "quit", "Quitter"), + ("f2", "system", "Système"), + ("f3", "toggle_view", "Vue"), + ("f4", "kanban_layout", "Disposition Kanban"), + ("e", "expand_all", "Tout déplier"), + ("i", "install_sensors", "Installer capteurs"), + ("r", "reset", "Réinitialiser"), + ] + + def __init__(self): + super().__init__() + self._action = None + self._mode = state.get("mode", "tree") + self._kanban_mode = state.get("kanban_mode", "columns") + self._sys_prev = None + + # -- helpers de construction de widgets ------------------------------ # + def _col_widget(self, label, cnt, cmds): + # Regroupe par section pour aider au choix ; icônes via _disp(). + children = [Static(f"{label} ({cnt})", classes="ktitle")] + for section, group in _group_by_section(cmds): + if section: + children.append( + Static(f"── {_disp(section)} ──", classes="ksec") + ) + children.append( + ListView( + *[ + CmdItem( + f"· {_disp(c['label'])}", + c.get("method"), + c.get("kwargs"), + c.get("path"), + ) + for c in group + ] + ) + ) + return VerticalScroll(*children, classes="kcol") + + def _list_view_widget(self): + # Vue Liste : tous les menus empilés verticalement, chaque menu + # avec ses sections et ses commandes (icônes), pour aider le choix. + blocks = [] + for (label, path, cnt, cmds) in columns: + blocks.append( + Static(f"▸ {path} ({cnt})", classes="listmenu") + ) + for section, group in _group_by_section(cmds): + if section: + blocks.append( + Static( + f" ── {_disp(section)} ──", classes="listsec" + ) + ) + blocks.append( + ListView( + *[ + CmdItem( + f" · {_disp(c['label'])}", + c.get("method"), + c.get("kwargs"), + c.get("path"), + ) + for c in group + ] + ) + ) + if not blocks: + return Static(t("No command found.")) + return VerticalScroll(*blocks) + + def _kanban_layout_widget(self): + cols = [ + self._col_widget(label, cnt, cmds) + for (label, _p, cnt, cmds) in columns + ] + if not cols: + return Static(t("No command found.")) + if self._kanban_mode == "grid": + return Grid(*cols, classes="kgrid") + if self._kanban_mode == "swimlanes": + # Une rangée (swimlane) par menu de NIVEAU 1 (Execute, …). + groups, order = {}, [] + for (label, path, cnt, cmds) in columns: + parts = path.split(" › ") + g = parts[1] if len(parts) > 1 else "TODO" + if g not in groups: + groups[g] = [] + order.append(g) + groups[g].append((label, cnt, cmds)) + lanes = [] + for g in order: + lane_cols = [ + self._col_widget(lb, cn, cm) for (lb, cn, cm) in groups[g] + ] + lanes.append( + Vertical( + Static(f"━━ {g} ━━", classes="lanetitle"), + HorizontalScroll(*lane_cols), + classes="lane", + ) + ) + return VerticalScroll(*lanes) + return HorizontalScroll(*cols) # columns + + def compose(self) -> ComposeResult: + yield Header(show_clock=True) + yield Static("", id="summary") + yield Tree("📍 TODO", id="nav") + yield Container(id="kanban") + yield Container(id="list") + yield Static("", id="system") + yield Footer() + + def on_mount(self): + self.title = t("TODO navigation telemetry") + self._populate_tree() + self._update_summary() + # Télémétrie système rafraîchie toutes les 2 s (deltas CPU/réseau). + self.set_interval(2.0, self._tick_system) + # Restaure la vue / la disposition / le curseur au retour. + self.run_worker(self._restore()) + + def _apply_visibility(self): + self.query_one("#nav").display = self._mode == "tree" + self.query_one("#kanban").display = self._mode == "kanban" + self.query_one("#list").display = self._mode == "list" + self.query_one("#system").display = self._mode == "system" + + async def _restore(self): + if self._mode == "kanban": + await self._enter_kanban() + elif self._mode == "list": + await self._enter_list() + elif self._mode == "tree": + self._focus_tree_path(state.get("path")) + self._apply_visibility() + self._update_summary() + + # -- vue Kanban ------------------------------------------------------ # + async def _enter_kanban(self): + box = self.query_one("#kanban", Container) + await box.remove_children() + await box.mount(self._kanban_layout_widget()) + self._apply_visibility() + self._focus_kanban_path(state.get("path")) + + # -- vue Liste ------------------------------------------------------- # + async def _enter_list(self): + box = self.query_one("#list", Container) + await box.remove_children() + await box.mount(self._list_view_widget()) + self._apply_visibility() + self._focus_kanban_path(state.get("path")) + + def _focus_kanban_path(self, path): + if not path: + return + for lv in self.query(ListView): + for i, item in enumerate(lv.children): + if getattr(item, "cmd_path", None) == path: + lv.index = i + lv.focus() + return + + def _focus_tree_path(self, path): + if not path: + return + tree = self.query_one("#nav", Tree) + + def walk(node): + d = getattr(node, "data", None) + if isinstance(d, dict) and d.get("path") == path: + return node + for ch in node.children: + found = walk(ch) + if found: + return found + return None + + node = walk(tree.root) + if node is not None: + tree.move_cursor(node) + tree.scroll_to_node(node) + + def _next_view(self): + cur = self._mode if self._mode in VIEWS else "tree" + return VIEWS[(VIEWS.index(cur) + 1) % len(VIEWS)] + + def _update_f3_hint(self): + # Le footer F3 annonce la PROCHAINE vue (ex. « → Liste »). + nxt = VIEW_LABELS.get(self._next_view(), self._next_view()) + try: + self.bind("f3", "toggle_view", description=f"→ {nxt}") + self.refresh_bindings() + except Exception: + pass + + def _update_summary(self): + total = sum(paths.values()) + src = t("tree from code") if code_tree else t("visited paths only") + extra = ( + f" [{self._kanban_mode}]" if self._mode == "kanban" else "" + ) + cur_lbl = VIEW_LABELS.get(self._mode, self._mode) + nxt_lbl = VIEW_LABELS.get(self._next_view(), self._next_view()) + self._update_f3_hint() + self.query_one("#summary", Static).update( + f" {total} {t('navigations')} · {len(paths)} {t('menus')} · " + f"{src} · {t('view')}: {cur_lbl}{extra} · " + f"F3 → {nxt_lbl} · " + f"{t('F2 system · F3/F4 views · Enter run')}" + ) + + # -- vue Système (F2) ----------------------------------------------- # + async def _tick_system(self): + # I/O système déportées en thread (comme le dashboard) ; on saute + # la température (subprocess sensors) tant que la vue n'est pas + # affichée. + try: + m, self._sys_prev = await asyncio.to_thread( + system_snapshot, self._sys_prev, self._mode == "system" + ) + except Exception: + return + if self._mode == "system": + self.query_one("#system", Static).update( + self._render_system(m) + ) + + def _render_system(self, m): + lines = [] + load = ( + " · " + t("load") + " " + + "/".join(f"{x:.1f}" for x in m["load"]) + if m["load"] + else "" + ) + lines.append( + f" 🖥 {t('State')} : {t('uptime')} " + f"{_fmt_uptime(m['uptime'])}{load}" + ) + cpu = m["cpu"] + lines.append( + f" ⚙ CPU : {cpu if cpu is not None else '?'} %" + f" ({m['ncpu']} {t('cores')})" + ) + if m["mem"]: + tot, used = m["mem"] + pct = int(used / tot * 100) if tot else 0 + lines.append( + f" 🧠 {t('Memory')} : {_fmt_size(used)} / " + f"{_fmt_size(tot)} ({pct} %)" + ) + if m["disk"]: + tot, used, free = m["disk"] + pct = int(used / tot * 100) if tot else 0 + lines.append( + f" 💽 {t('Disk')} / : {_fmt_size(used)} / " + f"{_fmt_size(tot)} ({pct} %) · {t('free')} " + f"{_fmt_size(free)}" + ) + if m["net"]: + rx, tx = m["net"] + lines.append( + f" 🌐 {t('Network')} : ↓ {_fmt_rate(rx)} " + f"↑ {_fmt_rate(tx)}" + ) + if m["battery"]: + cap, status = m["battery"] + lines.append( + f" 🔋 {t('Battery')} : {cap} % ({status})" + ) + temp = m["temp"] + if temp: + lines.append( + f" 🌡 {t('Temperature')} : {max(temp[1]):.0f}°C " + f"({t('max')})" + ) + else: + lines.append( + f" 🌡 {t('Temperature')} : " + f"{t('lm-sensors absent — press i to install')}" + ) + return "\n".join(lines) + + # -- actions --------------------------------------------------------- # + def action_system(self): + self._mode = "system" + self._apply_visibility() + self.run_worker(self._tick_system()) # rafraîchit tout de suite + self._update_summary() + + def action_install_sensors(self): + if self._mode != "system": + return + if read_temperature() is not None: + self.notify(t("Sensors already available.")) + return + cmd = sensors_install_command() + if not cmd: + self.notify( + t("Unknown package manager for lm-sensors."), + severity="warning", + ) + return + printable = " ".join(cmd) + with self.suspend(): + print(f"{t('Proposed install command:')}") + print(f" {printable}") + print(" sudo sensors-detect --auto") + ans = input( + t("Install lm-sensors now? (y/N): ") + ).strip().lower() + if ans in ("o", "oui", "y", "yes"): + os.system(printable + " || true") + os.system("sudo sensors-detect --auto || true") + # Rafraîchit IMMÉDIATEMENT la vue système : on ré-échantillonne + # (température incluse) et on réécrit la case pour que le message + # « installer » disparaisse si les capteurs sont désormais lisibles. + self._sys_prev = None + try: + m, self._sys_prev = system_snapshot(self._sys_prev, full=True) + self.query_one("#system", Static).update( + self._render_system(m) + ) + except Exception: + pass + self.refresh() + if read_temperature() is not None: + self.notify(t("Sensors now available.")) + else: + self.notify( + t("Still no temperature (reboot/modprobe may be needed)."), + severity="warning", + ) + + def on_click(self, event): + # Grille (mosaïque) : clic sur le TITRE d'une case -> l'agrandir + # (row-span sur toute la hauteur) ; re-clic -> taille normale. + if self._mode != "kanban" or self._kanban_mode != "grid": + return + w = getattr(event, "widget", None) + if w is None or "ktitle" not in getattr(w, "classes", ()): + return + card = w + while card is not None and "kcol" not in getattr( + card, "classes", () + ): + card = card.parent + if card is not None: + card.toggle_class("kbig") + + # -- actions --------------------------------------------------------- # + async def action_toggle_view(self): + # Cycle Arbre -> Kanban -> Liste -> Arbre (depuis Système on + # revient dans le cycle sur la vue précédente). + cur = self._mode if self._mode in VIEWS else "tree" + self._mode = VIEWS[(VIEWS.index(cur) + 1) % len(VIEWS)] + if self._mode == "kanban": + await self._enter_kanban() + elif self._mode == "list": + await self._enter_list() + self._apply_visibility() + self._update_summary() + + def _populate_tree(self): + tree = self.query_one("#nav", Tree) + tree.clear() + if code_tree is not None: + tree.root.data = {"path": "TODO"} + tree.root.set_label(f"📍 TODO ({paths.get('TODO', 0)})") + self._add_code(tree.root, code_tree["children"], "TODO") + else: + nested = _nested(paths) + todo = nested.get("TODO", {"count": 0, "children": nested}) + tree.root.set_label(f"📍 TODO ({todo['count']})") + self._add_visited(tree.root, todo["children"]) + tree.root.expand() + + def _add_code(self, tnode, children, parent_path): + for c in children: + path = f"{parent_path} › {c['label']}" + if c["is_menu"]: + child = tnode.add( + f"{c['label']} ({paths.get(path, 0)})", + data={"path": path}, + expand=True, + ) + self._add_code(child, c["children"], path) + else: + # Feuille EXÉCUTABLE : méthode + chemin portés en data. + tnode.add_leaf( + f"· {c['label']}", + data={ + "method": c.get("method"), + "kwargs": c.get("kwargs"), + "path": path, + }, + ) + + def _add_visited(self, tnode, children): + for name, node in sorted( + children.items(), key=lambda kv: -kv[1]["count"] + ): + child = tnode.add(f"{name} ({node['count']})", expand=True) + self._add_visited(child, node["children"]) + + # -- sélection / exécution ------------------------------------------- # + def _run(self, method, kwargs, path): + if not method: + return + self._action = (method, kwargs or {}) + self._exit_state = { + "mode": self._mode, + "kanban_mode": self._kanban_mode, + "path": path, + } + self.exit() + + def on_tree_node_selected(self, event): + d = getattr(event.node, "data", None) + if isinstance(d, dict) and d.get("method"): + self._run(d["method"], d.get("kwargs"), d.get("path")) + + def on_list_view_selected(self, event): + if isinstance(event.item, CmdItem): + self._run( + event.item.cmd_method, + event.item.cmd_kwargs, + event.item.cmd_path, + ) + + async def action_kanban_layout(self): + if self._mode != "kanban": + return + i = KANBAN_MODES.index(self._kanban_mode) + self._kanban_mode = KANBAN_MODES[(i + 1) % len(KANBAN_MODES)] + await self._enter_kanban() + self._update_summary() + + def action_expand_all(self): + if self._mode == "tree": + self.query_one("#nav", Tree).root.expand_all() + + def action_reset(self): + reset() + paths.clear() + self._populate_tree() + self._update_summary() + self.notify(t("Telemetry reset.")) + + app = Telemetry() + app._exit_state = state + if run_app: + app.run() + return app._action, getattr(app, "_exit_state", state) + return app From 1208c919a1e9fccbef539de938f562e3db854322 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 08/10] [ADD] todo: preferences and Configuration menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stores the per-user settings in ~/.erplibre/todo_prefs.json: which deploy interface to use, what to display while deploying, which migration interface. A missing or corrupt file reads as defaults. --- FR --- Range les réglages propres à l'utilisateur dans ~/.erplibre/todo_prefs.json : interface de déploiement, affichage pendant le déploiement, interface de migration. Un fichier absent ou corrompu se lit comme les valeurs par défaut. Assisted-by: Claude Opus 4.8 --- script/todo/todo.py | 91 ++++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 101 ++++++++++++++++++++++++++++++++++++++ script/todo/todo_prefs.py | 74 ++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+) create mode 100644 script/todo/todo_prefs.py diff --git a/script/todo/todo.py b/script/todo/todo.py index 6e4275d..7c9733e 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -27,6 +27,8 @@ from script.config import config_file from script.execute import execute from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager +from script.todo import todo_prefs +from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.version_manager import get_odoo_version ERROR_LOG_PATH = ".erplibre.error.txt" @@ -447,6 +449,79 @@ class TODO: ), } + def _pref_label(self, key): + """Libellé traduit de la valeur courante d'une préférence.""" + value = todo_prefs.get(key) + for stored, label in self._PREF_CHOICES[key][1]: + if stored == value: + return t(label) + return str(value) + + def _pref_edit(self, key): + """Fait choisir une valeur parmi celles proposées pour `key`.""" + title, options = self._PREF_CHOICES[key] + current = todo_prefs.get(key) + print(f"\n{t(title)} :") + for i, (stored, label) in enumerate(options, 1): + star = " *" if stored == current else "" + print(f" [{i}] {t(label)}{star}") + sel = input(f"{t('Choice (number, blank = keep):')} ").strip() + try: + idx = int(sel) - 1 + except ValueError: + return + if 0 <= idx < len(options): + todo_prefs.set(key, options[idx][0]) + print(f" ✅ {t(title)} : {self._pref_label(key)}") + + def prompt_configuration(self): + """Réglages persistants de l'utilisateur (~/.erplibre/todo_prefs.json). + La langue vit à part, dans env_var.sh, et garde son propre mécanisme. + """ + while True: + lang = "français" if get_lang() == "fr" else "English" + choices = [ + {"section": t("Interface")}, + {"prompt_description": f"{t('Language / Langue')} ({lang})"}, + { + "prompt_description": ( + f"{t('QEMU deployment interface')} " + f"({self._pref_label('qemu_deploy_ui')})" + ) + }, + { + "prompt_description": ( + f"{t('Display while deploying')} " + f"({self._pref_label('qemu_deploy_progress')})" + ) + }, + { + "prompt_description": ( + f"{t('Odoo migration interface')} " + f"({self._pref_label('migration_ui')})" + ) + }, + {"section": t("Maintenance")}, + {"prompt_description": t("Reset all preferences")}, + ] + status = click.prompt(self.fill_help_info(choices)) + print() + if status == "0": + return + elif status == "1": + self._change_language() + elif status == "2": + self._pref_edit("qemu_deploy_ui") + elif status == "3": + self._pref_edit("qemu_deploy_progress") + elif status == "4": + self._pref_edit("migration_ui") + elif status == "5": + n = todo_prefs.reset() + print(f"✅ {t('Preferences reset')} ({n})") + else: + print(t("Command not found !")) + def fill_help_info(self, choices): # Une entrée {"section": "..."} affiche un titre de section SANS # consommer de numéro : la numérotation reste continue sur les vraies @@ -1149,6 +1224,22 @@ class TODO: # serait prématurée. Elle est posée à la première identité refusée. self._qemu_ssh_walk(roots, max_depth) + def _qemu_pick_domains(self): + """Fait choisir des VM parmi celles définies. Vide = toutes.""" + names = self._qemu_list_domains() + if not names: + print(t("No VM found.")) + return [] + for i, name in enumerate(names, 1): + print(f" [{i}] {name}") + raw = input( + t("Which VMs? (numbers, comma-separated; blank = all): ") + ).strip() + if not raw: + return names + chosen = self._parse_index_selection(raw, names) + return chosen or names + def _ssh_ensure_key(self): """Chemin de la clé PUBLIQUE, générée si aucune n'existe. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 55d558f..951439b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1246,6 +1246,54 @@ TRANSLATIONS = { "fr": "📊 Télémétrie de navigation (TUI)", "en": "📊 Navigation telemetry (TUI)", }, + "Configuration": { + "fr": "⚙ Configuration", + "en": "⚙ Configuration", + }, + "Interface": { + "fr": "Interface", + "en": "Interface", + }, + "Maintenance": { + "fr": "Maintenance", + "en": "Maintenance", + }, + "Language / Langue": { + "fr": "🌐 Langue / Language", + "en": "🌐 Language / Langue", + }, + "QEMU deployment interface": { + "fr": "🖥 Interface de déploiement QEMU", + "en": "🖥 QEMU deployment interface", + }, + "Display while deploying": { + "fr": "📜 Affichage pendant le déploiement", + "en": "📜 Display while deploying", + }, + "Ask every time": { + "fr": "Demander à chaque fois", + "en": "Ask every time", + }, + "TUI form": { + "fr": "Formulaire TUI", + "en": "TUI form", + }, + "Classic questions (line by line)": { + "fr": "Questions classiques (ligne par ligne)", + "en": "Classic questions (line by line)", + }, + "CLI output (easy to copy)": { + "fr": "Sortie CLI (facile à copier)", + "en": "CLI output (easy to copy)", + }, + "TUI, collapsible blocks per VM": { + "fr": "TUI, blocs repliables par VM", + "en": "TUI, collapsible blocks per VM", + }, + "Choice (number, blank = keep):": { + "fr": "Choix (numéro, vide = garder) :", + "en": "Choice (number, blank = keep):", + }, "SSH port forwarding (open Odoo in the browser)": { "fr": "🔌 Redirection de port SSH (ouvrir Odoo dans le navigateur)", "en": "🔌 SSH port forwarding (open Odoo in the browser)", @@ -1351,6 +1399,14 @@ TRANSLATIONS = { "fr": "Tunnel refermé.", "en": "Tunnel closed.", }, + "SSH configuration (~/.ssh/config, ProxyJump)": { + "fr": "🔑 Configuration SSH (~/.ssh/config, ProxyJump)", + "en": "🔑 SSH configuration (~/.ssh/config, ProxyJump)", + }, + "SSH configuration for QEMU VMs": { + "fr": "Configuration SSH des VM QEMU", + "en": "SSH configuration for QEMU VMs", + }, "Where should the machines come from?": { "fr": "D'où viennent les machines à configurer ?", "en": "Where should the machines come from?", @@ -1392,6 +1448,35 @@ TRANSLATIONS = { "fr": "Profondeur (1 = ces machines seulement, défaut :", "en": "Depth (1 = these machines only, default:", }, + "Which VMs? (numbers, comma-separated; blank = all): ": { + "fr": "Quelles VM ? (numéros séparés par des virgules ; " + "vide = toutes) : ", + "en": "Which VMs? (numbers, comma-separated; blank = all): ", + }, + "Depth (default:": { + "fr": "Profondeur (défaut :", + "en": "Depth (default:", + }, + "entry": { + "fr": "entrée", + "en": "entry", + }, + "written": { + "fr": "écrite(s)", + "en": "written", + }, + "skipped": { + "fr": "ignorée(s)", + "en": "skipped", + }, + "Level": { + "fr": "Niveau", + "en": "Level", + }, + "machines to probe": { + "fr": "machines à sonder", + "en": "machines to probe", + }, "SSH refused the identity.": { "fr": "SSH a refusé l'identité.", "en": "SSH refused the identity.", @@ -1489,6 +1574,22 @@ TRANSLATIONS = { "fr": "les noms s'appliquent à chaud", "en": "the names apply live", }, + "SSH hosts written": { + "fr": "Hôtes SSH écrits", + "en": "SSH hosts written", + }, + "via": { + "fr": "via", + "en": "via", + }, + "Reset all preferences": { + "fr": "🧹 Réinitialiser toutes les préférences", + "en": "🧹 Reset all preferences", + }, + "Preferences reset": { + "fr": "Préférences réinitialisées", + "en": "Preferences reset", + }, "Interface:": { "fr": "Interface :", "en": "Interface:", diff --git a/script/todo/todo_prefs.py b/script/todo/todo_prefs.py new file mode 100644 index 0000000..9273c24 --- /dev/null +++ b/script/todo/todo_prefs.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Préférences persistantes du CLI TODO. + +Réglages qui survivent d'une session à l'autre et qui appartiennent à +l'UTILISATEUR, pas au dépôt : ils vivent donc dans ~/.erplibre (comme la +télémétrie de navigation) et non dans un fichier versionné. + +- get(key, default) / set(key, value) : accès unitaire. +- reset() : efface tout et revient aux défauts. + +Tout est best-effort : une préférence illisible ou un disque plein ne doivent +JAMAIS empêcher le CLI de démarrer. +""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +# Clés connues et leur valeur par défaut. Une clé absente de ce dictionnaire +# reste lisible/écrivable, mais n'apparaît pas dans l'écran de configuration. +DEFAULTS = { + # Interface du déploiement QEMU : "ask" pose la question à chaque fois, + # "tui" ouvre le formulaire directement, "cli" garde les invites en ligne. + "qemu_deploy_ui": "ask", + # Affichage pendant le déploiement : "cli" (sortie texte, facile à copier + # depuis le terminal) ou "tui" (blocs repliables + copie OSC 52). + "qemu_deploy_progress": "cli", + # Interface de la migration Odoo : "ask" / "tui" / "cli". + "migration_ui": "ask", +} + + +def _path() -> Path: + base = Path(os.path.expanduser("~/.erplibre")) + base.mkdir(parents=True, exist_ok=True) + return base / "todo_prefs.json" + + +def load() -> dict: + try: + data = json.loads(_path().read_text()) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _save(data: dict) -> None: + try: + _path().write_text(json.dumps(data, ensure_ascii=False, indent=2)) + except OSError: + pass + + +def get(key: str, default=None): + """Valeur d'une préférence : fichier, puis DEFAULTS, puis `default`.""" + if default is None: + default = DEFAULTS.get(key) + return load().get(key, default) + + +def set(key: str, value) -> None: # noqa: A001 - API voulue : prefs.set(...) + data = load() + data[key] = value + _save(data) + + +def reset() -> int: + """Efface toutes les préférences. Renvoie le nombre de clés effacées.""" + count = len(load()) + _save({}) + return count From 67e902e4167b73b037248b6cd8868926371315e0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH 09/10] [IMP] todo: menu sections, icons and prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups the long menus into sections with icons, accepts o/oui everywhere, shows the default of each yes/no question, and stops offering a traceback line as a database name when PostgreSQL is unreachable. --- FR --- Regroupe les menus longs en sections avec icônes, accepte o/oui partout, affiche la valeur par défaut de chaque question oui/non, et cesse de proposer une ligne de trace d'appel comme nom de base quand PostgreSQL est injoignable. Assisted-by: Claude Opus 4.8 Assisted-by: Claude Opus 5 --- script/todo/database_manager.py | 39 ++- script/todo/todo.py | 570 ++++++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 109 ++++++ 3 files changed, 710 insertions(+), 8 deletions(-) diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index 798a30e..2037e2c 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -31,28 +31,51 @@ class DatabaseManager: self._dir_path = path def select_database(self) -> str | bool: + """Faire choisir une base parmi celles que PostgreSQL expose. + + Le code de retour de « db --list » est vérifié AVANT de construire le + menu. Sans cette vérification, un PostgreSQL injoignable ne se distingue + pas d'une base absente : la sortie et l'erreur sont fusionnées dans le + même flux (`stderr=STDOUT`, execute.py), donc les lignes de la trace + d'appel devenaient les entrées du menu. « Traceback (most recent call + last): » s'affichait comme la base [1], et la choisir renvoyait cette + ligne comme nom de base à l'appelant, qui la passait à sa commande. + """ cmd_server = "./odoo_bin.sh db --list" - status, databases = self._execute.exec_command_live( + status, output = self._execute.exec_command_live( cmd_server, return_status_and_output=True, + quiet=True, source_erplibre=False, single_source_erplibre=True, ) - choices = [{"prompt_description": a.strip()} for a in databases] + if status: + print(f"❌ {t('Cannot list the databases (exit code): ')}{status}") + print(f" {t('Is PostgreSQL running?')}") + for line in output[-5:]: + print(f" {line}") + return False + + databases = [a.strip() for a in output if a.strip()] + if not databases: + print(f"ℹ️ {t('No database on this PostgreSQL server.')}") + return False + + choices = [{"prompt_description": a} for a in databases] help_info = self._fill_help_info(choices) - valid_choices = [str(a) for a in range(len(choices) + 1) if a] + valid_choices = [str(a + 1) for a in range(len(databases))] while True: - status = click.prompt(help_info) + answer = click.prompt(help_info) print() - if status == "0": + if answer == "0": return False - elif status in valid_choices: - database_name = databases[int(status) - 1].strip() + elif answer in valid_choices: + database_name = databases[int(answer) - 1] print(database_name) return database_name else: - print(t("cmd_not_found")) + print(t("Command not found !")) def _confirm_drop(self, message: str) -> bool: """Ask for an explicit 'oui'/'yes' confirmation, default is no.""" diff --git a/script/todo/todo.py b/script/todo/todo.py index 7c9733e..a42cd04 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6,6 +6,7 @@ import ast import configparser import datetime import getpass +import inspect import json import logging import os @@ -173,6 +174,34 @@ class TODO: print(t("Command not found !")) print(status) + # manipuler() + + def execute_prompt_ia(self): + while True: + help_info = f"""{self._menu_header()} +[0] {t("Back")} +{t("Write your question ")}""" + status = click.prompt(help_info) + print() + if status == "0": + return + kp = self.kdbx_manager.get_kdbx() + if not kp: + return + config_name = self.config_file.get_config_value( + ["kdbx_config", "openai", "kdbx_key"] + ) + entry = kp.find_entries_by_title(config_name, first=True) + + client = openai.OpenAI(api_key=entry.password) + prompt_update = status + completion = client.chat.completions.create( + model="gpt-4o", + messages=[{"role": "user", "content": prompt_update}], + ) + + print(completion.choices[0].message.content) + print() def prompt_execute(self): help_info = f"""{self._menu_header()} @@ -268,6 +297,184 @@ class TODO: else: print(t("Command not found !")) + def prompt_install(self): + print("Detect first installation from code source.") + + first_installation_input = ( + input( + "💬 First system installation? This will process system installation" + " before (Y/N): " + ) + .strip() + .lower() + ) + if self._is_yes(first_installation_input): + cmd = "./script/version/update_env_version.py --install" + self.execute.exec_command_live(cmd, source_erplibre=True) + print("Wait after OS installation before continue.") + + # First detect pycharm, need to be open before installation and close to increase speed + has_pycharm = False + has_pycharm_community = False + result = subprocess.run( + ["which", "pycharm"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if result.returncode == 0: + has_pycharm = True + else: + result = subprocess.run( + ["which", "pycharm-community"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + has_pycharm_community = result.returncode == 0 + if (has_pycharm or has_pycharm_community) and not os.path.exists( + ".idea" + ): + pycharm_configuration_input = ( + input("💬 Open Pycharm? (Y/N): ").strip().lower() + ) + if self._is_yes(pycharm_configuration_input): + pycharm_bin = "pycharm" if has_pycharm else "pycharm-community" + + cmd = f"cd {os.getcwd()} && {pycharm_bin} ./" + self.execute.exec_command_live( + cmd, + source_erplibre=False, + single_source_erplibre=False, + new_window=True, + ) + print( + "👹 WAIT and Close Pycharm when processing is done before continue" + " this guide." + ) + # TODO detect last version supported + # cmd_intern = "./script/install/install_erplibre.sh" + # TODO maybe update q to only install erplibre from install_locally + # TODO problem installing with q, the script depend on odoo + key_i = 0 + commands_begin = { + "q": ( + "q", + "q: ERPLibre only with system python without Odoo", + "./script/install/install_erplibre.sh", + ), + "w": ( + "w", + "w: Install all Odoo version with ERPLibre", + "make install_odoo_all_version", + ), + "m": ( + "m", + "m: ERPLibre with mobile home", + "./mobile/install_and_run.sh", + ), + "0": ( + "0", + f"0: {t('Quit')}", + ), + } + commands_end = {} + versions, installed_versions, odoo_installed_version = ( + get_odoo_version() + ) + + for version_info in versions[::-1]: + key_i += 1 + key_s = str(key_i) + label = f"{key_s}: Odoo {version_info.get('odoo_version')}" + + odoo_version = f"odoo{version_info.get('odoo_version')}" + if odoo_version in installed_versions: + label += " - Installed" + if odoo_version == odoo_installed_version: + label += " - Actual" + if version_info.get("Default"): + label += " - Default" + if version_info.get("is_deprecated"): + label += " - Deprecated" + erplibre_version = version_info.get("erplibre_version") + commands_begin[key_s] = ( + key_s, + label, + f"./script/version/update_env_version.py --erplibre_version {erplibre_version} --install_dev", + ) + + # Add final command + install_commands = {**commands_begin, **commands_end} + + # Show command + odoo_version_input = "" + while odoo_version_input not in install_commands: + if odoo_version_input: + print( + f"{t('Error, cannot understand value')} '{odoo_version_input}'" + ) + str_input_dyn_odoo_version = ( + f"💬 {t('Choose a version:')}\n\t" + + "\n\t".join([a[1] for a in install_commands.values()]) + + f"\n{t('Select: ')}" + ) + odoo_version_input = ( + input(str_input_dyn_odoo_version).strip().lower() + ) + + if odoo_version_input == "0": + return + + cmd_intern = install_commands.get(odoo_version_input)[2] + + # For numbered version selections, offer extra modules sub-menu + if odoo_version_input.isdigit(): + extra_choices = { + "1": ( + "1", + f"1: {t('Standard install (without extra modules)')}", + ), + "2": ( + "2", + f"2: {t('Install with extra modules (CybroOdoo - large, slow)')}", + ), + "0": ("0", f"0: {t('Back')}"), + } + extra_input = "" + while extra_input not in extra_choices: + if extra_input: + print( + f"{t('Error, cannot understand value')} '{extra_input}'" + ) + str_extra = ( + f"💬 {t('Install type:')}\n\t" + + "\n\t".join([a[1] for a in extra_choices.values()]) + + f"\n{t('Select: ')}" + ) + extra_input = input(str_extra).strip() + if extra_input == "0": + return + if extra_input == "2": + cmd_intern = cmd_intern + " --with_extra" + + print(f"{t('Will execute:')}\n{cmd_intern}") + + # TODO use external script to detect terminal to use on system + # TODO check script open_terminal_code_generator.sh + # cmd_extern = f"gnome-terminal -- bash -c '{cmd_intern};bash'" + try: + subprocess.run( + cmd_intern, shell=True, executable="/bin/bash", check=True + ) + except subprocess.CalledProcessError as e: + print( + f"{t('The Bash script failed with return code')} {e.returncode}." + ) + print("Wait after installation and open projects by terminal.") + print("make open_terminal") + self.restart_script(str(e)) + def execute_from_configuration( self, instance, exec_run_db=False, ignore_makefile=False ): @@ -5750,6 +5957,76 @@ class TODO: cmd, source_erplibre=False, single_source_erplibre=True ) + def prompt_execute_code(self): + print(f"🤖 {t('What do you need for development?')}") + # help_info = """Commande : + # [1] Status Git local et distant + # [2] Démarrer le générateur de code + # [3] Format - Formatage automatique selon changement [ou manuelle] + # [4] Qualité - Qualité logiciel, détecter les fichiers qui manquent les licences AGPLv3 + # [0] Retour + # """ + # help_info = """Commande : + # [1] Status Git local et distant + # [0] Retour + # """ + + choices = self.config_file.get_config("code_from_makefile") + + menu_entry = { + "prompt_description": t("Open SHELL"), + } + choices.append(menu_entry) + + menu_entry = { + "prompt_description": t("Upgrade Module"), + } + choices.append(menu_entry) + + choices.append( + { + "prompt_description": t("Debug"), + } + ) + + # Déplacé depuis le menu Execute : mise à jour de tout le code source + # de dev en staging (sous-menu de mise à jour). + choices.append( + { + "prompt_description": t( + "Update - Update all developed staging source code" + ), + } + ) + + help_info = self.fill_help_info(choices) + + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == str(len(choices)): + self.prompt_execute_update() + elif status == str(len(choices) - 1): + self.debug_ide() + elif status == str(len(choices) - 2): + self.upgrade_module() + elif status == str(len(choices) - 3): + self.open_shell_on_database() + else: + cmd_no_found = True + try: + int_cmd = int(status) + if 0 < int_cmd <= len(choices): + cmd_no_found = False + instance = choices[int_cmd - 1] + self.execute_from_configuration(instance) + except ValueError: + pass + if cmd_no_found: + print(t("Command not found !")) + def prompt_execute_git(self): print(f"🤖 {t('Git management tools!')}") choices = [ @@ -5987,6 +6264,44 @@ class TODO: print("-" * 50) print(f"{t('Total:')}" f" {len(files)}") + def _setup_claude_command( + self, command_name, template_filename, personalize=False + ): + dest_dir = os.path.expanduser("~/.claude/commands") + dest_file = os.path.join(dest_dir, f"{command_name}.md") + + if os.path.exists(dest_file): + print(f"{t('File already exists: ')}{dest_file}") + overwrite = input(t("Do you want to overwrite the file? (y/Y): ")) + if not self._is_yes(overwrite): + print(t("Nothing to do.")) + return + + template_path = os.path.join( + os.path.dirname(__file__), + "..", + "..", + "conf", + template_filename, + ) + try: + with open(template_path) as f: + content = f.read() + + if personalize: + name = input(t("Enter your full name: ")).strip() + email = input(t("Enter your email: ")).strip() + content = content.replace("your@email.com", email) + content = content.replace("Your Name", name) + + os.makedirs(dest_dir, exist_ok=True) + with open(dest_file, "w") as f: + f.write(content) + + print(f"{t('File created successfully: ')}{dest_file}") + except Exception as e: + print(f"{t('Error creating file: ')}{e}") + def _claude_add_automation(self): description = input(t("Description of the command to add: ")).strip() if not description: @@ -6371,6 +6686,95 @@ class TODO: else: print(t("Command not found !")) + def execute_test_module(self, coverage=False): + # Module name + module_name = input(t("Module name to test: ")).strip() + if not module_name: + print(t("Module name is required!")) + return + + # Database name + db_name = input( + t("Temporary database name (default: test_todo_tmp): ") + ).strip() + if not db_name: + db_name = "test_todo_tmp" + + # Extra modules + extra_modules = input( + t("Extra modules to install (comma-separated, empty for none): ") + ).strip() + + # Log level + log_level = input(t("Log level (default: test): ")).strip() + if not log_level: + log_level = "test" + + # Build module list + modules_to_install = module_name + if extra_modules: + modules_to_install += f",{extra_modules}" + + # Step 1: Create temp DB + print(f"\n--- {t('Creating temporary database')} '{db_name}' ---") + cmd_restore = f"./script/database/db_restore.py --database {db_name}" + self.execute.exec_command_live( + cmd_restore, + source_erplibre=False, + single_source_erplibre=True, + ) + + # Step 2: Install modules + print(f"\n--- {t('Installing modules')}: {modules_to_install} ---") + cmd_install = ( + f"./script/addons/install_addons.sh" + f" {db_name} {modules_to_install}" + ) + self.execute.exec_command_live( + cmd_install, + source_erplibre=False, + single_source_erplibre=True, + ) + + # Step 3: Run tests + print(f"\n--- {t('Running tests')}: {module_name} ---") + cmd_test = ( + f"ODOO_MODE_TEST=true" + f" ./run.sh" + f" -d {db_name}" + f" -u {module_name}" + f" --log-level={log_level}" + ) + if coverage: + cmd_test = f"ODOO_MODE_COVERAGE=true {cmd_test}" + status_code, output = self.execute.exec_command_live( + cmd_test, + return_status_and_output=True, + source_erplibre=False, + single_source_erplibre=True, + ) + + if status_code == 0: + print(f"\n✅ {t('Tests completed successfully!')}") + else: + print(f"\n❌ {t('Tests failed with return code')} {status_code}") + + # Step 4: Cleanup + keep_input = input(t("Keep the temporary database? (y/N): ")) + keep = self._is_yes(keep_input) + if keep: + print(f"{t('Database kept')}: {db_name}") + else: + print( + f"\n--- {t('Cleaning up temporary database')} '{db_name}' ---" + ) + cmd_drop = f"./odoo_bin.sh db --drop --database {db_name}" + self.execute.exec_command_live( + cmd_drop, + source_erplibre=False, + single_source_erplibre=True, + ) + def execute_unit_tests(self): print(f"\n--- {t('Running unit tests')} ---") cmd = ( @@ -6672,6 +7076,50 @@ class TODO: upgrade = todo_upgrade.TodoUpgrade(self) upgrade.execute_module_upgrade() + def upgrade_poetry(self): + # Only show the version to the user + status = self.execute.exec_command_live( + f"make version", + source_erplibre=False, + ) + # TODO maybe autodetect to update it + git_repo_update_input = input( + "💬 Would you like to fetch all your git repositories, you need it (y/Y) : " + ) + if self._is_yes(git_repo_update_input): + status = self.execute.exec_command_live( + f"./script/manifest/update_manifest_local_dev.sh", + source_erplibre=False, + ) + + poetry_lock = "./poetry.lock" + try: + os.remove(poetry_lock) + except Exception as e: + pass + odoo_long_version = "" + if os.path.exists("./.erplibre-version"): + with open("./.erplibre-version") as f: + odoo_long_version = f.read() + path_file_odoo_lock = f"./requirement/poetry.{odoo_long_version}.lock" + if odoo_long_version: + try: + os.remove(path_file_odoo_lock) + except Exception as e: + pass + + status = self.execute.exec_command_live( + f"pip install -r requirement/erplibre_require-ments-poetry.txt && " + f"./script/poetry/poetry_update.py -f", + source_erplibre=False, + single_source_erplibre=False, + single_source_odoo=True, + source_odoo=odoo_long_version, + ) + + if os.path.exists(poetry_lock): + shutil.copy2(poetry_lock, path_file_odoo_lock) + def callback_execute_custom_database(self, config): database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) @@ -6719,6 +7167,128 @@ class TODO: self.dir_path = dir_path todo_file_browser.exit_program() + def callback_make_mobile_home(self, config): + # Read file + default_project_name = "ERPLibre" + default_package_name = "ca.erplibre.home" + # Read default information + if os.path.exists(STRINGS_FILE): + tree = ET.parse(STRINGS_FILE) + root = tree.getroot() + for elem in root.findall("string"): + if elem.get("name") == "app_name": + default_project_name = elem.text + if elem.get("name") == "package_name": + default_package_name = elem.text + + default_project_url_name = "https://erplibre.ca" + # Read default information + dotenv_file = dotenv.find_dotenv( + filename=os.path.join(MOBILE_HOME_PATH, "src", ".env.production") + ) + default_project_url_name = dotenv.get_key( + dotenv_file, "VITE_WEBSITE_URL" + ) + default_project_note_subject = dotenv.get_key( + dotenv_file, "VITE_LABEL_NOTE" + ) + + default_debug = False + project_name = default_project_name + project_url_name = default_project_url_name + project_principal_subject = default_project_note_subject + package_name = default_package_name + do_debug = default_debug + do_change_picture_menu = False + + do_personalize = input( + "Do you want to personalize the mobile application (Y) : " + ) + if self._is_yes(do_personalize): + project_name = ( + input( + f'Your project name (Separate by space in title), default "{default_project_name}" : ' + ).strip() + or default_project_name + ) + package_name = ( + input( + f'Your package name (separate by . lower case, 3 works like DOMAIN.NAME.OBJECT), default "{default_package_name}" : ' + ).strip() + or default_package_name + ) + project_url_name = ( + input( + f'Your project url website, default "{default_project_url_name}" : ' + ).strip() + or default_project_url_name + ) + project_principal_subject = ( + input( + f'Your project subject, default "{default_project_note_subject}" : ' + ).strip() + or default_project_note_subject + ) + do_debug = self._is_yes( + input("Compilation with debug information, default No (Y) : ") + ) + do_change_picture_menu = self._is_yes( + input( + "Want to change picture from menu, you need" + " android-studio (Y) : " + ) + ) + + # Rename with script bash + cmd_client = f'cd {MOBILE_HOME_PATH} && npx cap init "{project_name}" "{package_name}" && ./rename_android.sh "{project_name}" "{package_name}" && npx cap sync android' + self.execute.exec_command_live(cmd_client, source_erplibre=False) + + # dotenv_mobile = dotenv.dotenv_values(dotenv_file) + # dotenv_mobile["VITE_TITLE"] = project_name + # dotenv_mobile["VITE_WEBSITE_URL"] = project_url_name + dotenv.set_key( + dotenv_file, "VITE_TITLE", project_name, quote_mode="always" + ) + dotenv.set_key( + dotenv_file, + "VITE_WEBSITE_URL", + project_url_name, + quote_mode="always", + ) + dotenv.set_key( + dotenv_file, + "VITE_LABEL_NOTE", + project_principal_subject, + quote_mode="always", + ) + dotenv.set_key( + dotenv_file, + "VITE_DEBUG_DEV", + "true" if do_debug else "false", + quote_mode="never", + ) + + if do_change_picture_menu: + status = self.execute.exec_command_live( + f"cd {MOBILE_HOME_PATH} && npx cap open android;bash", + source_erplibre=False, + new_window=True, + ) + print( + "Guide for Android-Studio, wait loading is finish. Right-click to app/New/Image Asset and load your image." + ) + input( + "Did you finish to update image with Android-Studio ? Press to continue ..." + ) + cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/company_logo.png" + self.execute.exec_command_live(cmd_client, source_erplibre=False) + cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/imgs/logo.png" + self.execute.exec_command_live(cmd_client, source_erplibre=False) + + status = self.execute.exec_command_live( + "./mobile/compile_and_run.sh", source_erplibre=False + ) + if __name__ == "__main__": start_time = time.time() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 951439b..addddd8 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -51,10 +51,64 @@ TRANSLATIONS = { "fr": "Commande non trouvée !", "en": "Command not found !", }, + "Back": { + "fr": "🔙 Retour", + "en": "🔙 Back", + }, + # Execute submenu + "Run - Execute and install an instance": { + "fr": "🏃 Run - Exécuter et installer une instance", + "en": "🏃 Run - Execute and install an instance", + }, + "Automation - Demonstration of developed features": { + "fr": "🦾 Automatisation - Demonstration des fonctions développées", + "en": "🦾 Automation - Demonstration of developed features", + }, + "Update - Update all developed staging source code": { + "fr": "🔃 Mise à jour - Update all developed staging source code", + "en": "🔃 Update - Update all developed staging source code", + }, + "Code - Developer tools": { + "fr": "💻 Code - Outil pour développeur", + "en": "💻 Code - Developer tools", + }, + "Doc - Documentation search": { + "fr": "📖 Doc - Recherche de documentation", + "en": "📖 Doc - Documentation search", + }, + "Database - Database tools": { + "fr": "💾 Database - Outils sur les bases de données", + "en": "💾 Database - Database tools", + }, + "Process - Execution tools": { + "fr": "📟 Process - Outils sur les executions", + "en": "📟 Process - Execution tools", + }, + "Config - Configuration file management": { + "fr": "🔧 Config - Traitement du fichier de configuration", + "en": "🔧 Config - Configuration file management", + }, + "Network - Network tools": { + "fr": "📡 Réseau - Outil réseautique", + "en": "📡 Network - Network tools", + }, + "Security - Dependency security audit": { + "fr": "🔒 Sécurité - Audit de sécurité des dépendances", + "en": "🔒 Security - Dependency security audit", + }, "RTK - CLI proxy to reduce LLM token consumption": { "fr": "RTK - Proxy CLI pour réduire la consommation de tokens LLM", "en": "RTK - CLI proxy to reduce LLM token consumption", }, + "Language - Change language / Changer la langue": { + "fr": "🌍 Langue - Changer la langue / Change language", + "en": "🌍 Language - Change language / Changer la langue", + }, + # Deploy section + "Deploy - Deploy ERPLibre locally": { + "fr": "🚀 Déploiement - Déployer ERPLibre localement", + "en": "🚀 Deploy - Deploy ERPLibre locally", + }, "Deploy ERPLibre to a local directory!": { "fr": "Déployer ERPLibre dans un répertoire local!", "en": "Deploy ERPLibre to a local directory!", @@ -533,11 +587,47 @@ TRANSLATIONS = { "fr": "Ouvrir ERPLibre avec TODO 🤖", "en": "Open ERPLibre with TODO 🤖", }, + "Update all erplibre_base on database test": { + "fr": "🔄 Mise à jour de tous les erplibre_base sur la base de données test", + "en": "🔄 Update all erplibre_base on database test", + }, + "Show code status": { + "fr": "🔍 Afficher le statut du code", + "en": "🔍 Show code status", + }, + "Stash all code": { + "fr": "📦 Remiser tout le code", + "en": "📦 Stash all code", + }, + "Format modified code": { + "fr": "🎨 Formater le code modifié", + "en": "🎨 Format modified code", + }, # todo.py hardcoded prompt_descriptions "Mobile - Compile and run software": { "fr": "Mobile - Compiler et exécuter le logiciel", "en": "Mobile - Compile and run software", }, + "Upgrade Odoo - Migration Database": { + "fr": "🚚 Mise à jour Odoo - Migration de base de données", + "en": "🚚 Upgrade Odoo - Migration Database", + }, + "Upgrade Poetry - Dependency of Odoo": { + "fr": "📦 Mise à jour Poetry - Dépendances d'Odoo", + "en": "📦 Upgrade Poetry - Dependency of Odoo", + }, + "Open SHELL": { + "fr": "🐚 Ouvrir le SHELL", + "en": "🐚 Open SHELL", + }, + "Upgrade Module": { + "fr": "🧩 Mise à jour de module", + "en": "🧩 Upgrade Module", + }, + "Debug": { + "fr": "🐛 Débogage", + "en": "🐛 Debug", + }, "Migration module coverage": { "fr": "Couverture de migration des modules", "en": "Migration module coverage", @@ -614,6 +704,11 @@ TRANSLATIONS = { "fr": "Débogage todo.py", "en": "Debug todo.py", }, + # Test section + "Test - Test an Odoo module": { + "fr": "🧪 Test - Tester un module Odoo", + "en": "🧪 Test - Test an Odoo module", + }, "Test an Odoo module on a temporary database!": { "fr": "Tester un module Odoo sur une base de données temporaire!", "en": "Test an Odoo module on a temporary database!", @@ -674,6 +769,10 @@ TRANSLATIONS = { "fr": "Suppression de la base de données temporaire", "en": "Cleaning up temporary database", }, + "Keep the temporary database? (y/N): ": { + "fr": "Conserver la base de données temporaire? (o/N, défaut : non) : ", + "en": "Keep the temporary database? (y/N, default: no): ", + }, "Database kept": { "fr": "Base de données conservée", "en": "Database kept", @@ -690,6 +789,11 @@ TRANSLATIONS = { "fr": "Le nom du module est requis!", "en": "Module name is required!", }, + # Git section + "Git - Git tools": { + "fr": "🌿 Git - Outils Git", + "en": "🌿 Git - Git tools", + }, "Git management tools!": { "fr": "Outils de gestion Git!", "en": "Git management tools!", @@ -923,6 +1027,11 @@ TRANSLATIONS = { "fr": "Interruption clavier", "en": "Keyboard interrupt", }, + # GPT code section + "GPT code - AI assistant tools": { + "fr": "🤖 GPT code - Outils d'assistant IA", + "en": "🤖 GPT code - AI assistant tools", + }, "AI assistant tools for development!": { "fr": "Outils d'assistant IA pour le développement!", "en": "AI assistant tools for development!", From 18a6f064f53880dbe32b82ba5ba580f638df8c80 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:35:20 -0400 Subject: [PATCH 10/10] [FIX] execute: redact the secrets before printing a command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL flagged five clear-text logging alerts here, and they are real: todo.py and kdbx_manager.py build « --default_password_auth '' » and db_restore.py « --master_password=… », while this file printed the command before and after every run and logged it on error. The « if "password" in command » guard covered one branch out of six. Redaction sits at the display point, not at construction: six outputs here against commands built all over the repository. Only the value goes, never the option name, and the RETURNED command stays clear — « [1] redo the command » needs it. Verified that PreferredAuthentications=password survives. --- FR --- CodeQL signalait ici cinq journalisations en clair, et elles sont réelles : todo.py et kdbx_manager.py construisent « --default_password_auth '' » et db_restore.py « --master_password=… », tandis que ce fichier affichait la commande avant et après chaque exécution et la journalisait en erreur. Le garde « if "password" in command » couvrait une branche sur six. Le caviardage est au point d'affichage, non à la construction : six sorties ici, contre des commandes bâties partout dans le dépôt. Seule la valeur part, jamais le nom de l'option, et la commande RENVOYÉE reste en clair — « [1] refaire la commande » en dépend. Vérifié que PreferredAuthentications=password reste intact. Assisted-by: Claude Opus 5 --- script/execute/execute.py | 53 +++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/script/execute/execute.py b/script/execute/execute.py index 62ddcb8..5ef684a 100644 --- a/script/execute/execute.py +++ b/script/execute/execute.py @@ -5,6 +5,7 @@ import datetime import logging import os +import re import shutil import subprocess import sys @@ -17,6 +18,41 @@ except ModuleNotFoundError as e: VENV_ERPLIBRE = ".venv.erplibre" +# Une commande construite ailleurs peut porter un secret en clair : todo.py et +# kdbx_manager.py y mettent « --default_password_auth '' », +# db_restore.py « --master_password=… ». Cette commande est affichée avant et +# après l'exécution, et journalisée en erreur : le secret finissait donc dans le +# terminal, dans les journaux et dans toute sortie CI qui les capture. +# +# On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et +# reproductible, il ne manque que ce qui ne doit pas être lu. +_SECRET_OPTION = re.compile( + r"(?P--?[\w-]*" + r"(?:password|passwd|pwd|secret|token|api[-_]?key)[\w-]*" + r"(?:\s+|=))" + r"(?P'[^']*'|\"[^\"]*\"|\S+)", + re.IGNORECASE, +) +_SECRET_ENV = re.compile( + r"(?P\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)" + r"(?P'[^']*'|\"[^\"]*\"|\S+)" +) + + +def redact_secrets(text): + """Remplace la valeur des options et variables porteuses de secret. + + Appliqué à CHAQUE affichage d'une commande. Filtrer au point d'affichage + plutôt qu'à la construction est ce qui rend la garantie tenable : il n'y a + qu'une poignée de sorties ici, alors que les commandes se construisent + partout dans le dépôt. + """ + if not text: + return text + text = _SECRET_OPTION.sub(lambda m: m.group("opt") + "'***'", text) + return _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text) + + new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") ) @@ -107,7 +143,8 @@ class Execute: source_odoo = f.read() if not source_odoo: _logger.error( - f"You cannot execute Odoo command if no version is installed. Command : {command}" + "You cannot execute Odoo command if no version is" + f" installed. Command : {redact_secrets(command)}" ) return -1 command = f"source ./.venv.{source_odoo}/bin/activate && {command}" @@ -116,7 +153,7 @@ class Execute: if not quiet: print("🏠 ⬇ Execute command :\n") - print(command) + print(redact_secrets(command)) output_lines = [] try: @@ -156,16 +193,10 @@ class Execute: except FileNotFoundError: if not quiet: - if "password" in command: - print( - f"Error: Command '{command.split(' ')[0]}'[...]" - " not found." - ) - else: - print(f"Error: Command '{command}' not found.") + print(f"Error: Command '{redact_secrets(command)}' not found.") except Exception as e: if not quiet: - print(f"An error occurred: {e}") + print(f"An error occurred: {redact_secrets(str(e))}") process_end_time = time.time() duration_sec = process_end_time - process_start_time if humanize: @@ -177,7 +208,7 @@ class Execute: if not quiet: print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n") if not quiet: - print(command) + print(redact_secrets(command)) print() if return_status_and_output_and_command: return exit_code, command, output_lines