diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index e93815f..116a496 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -377,9 +377,56 @@ sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot ``` +On **s390x and arm64** the parameter lives on the `kvm` module itself, not on +`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even +exist on x86. Reading the wrong file returns a reassuring `0` that commands +nothing: + + +Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non +sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe +même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne +commande rien : + + +```bash +echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot +``` + + +`nested` on a machine means « let MY guests run VMs ». To accelerate a VM +created on host H, the setting belongs to the hypervisor **above** H, not to H +itself. The one command that settles it, run on H: + + +`nested` sur une machine signifie « j'autorise MES invités à faire tourner des +VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à +l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H : + + +```bash +ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé +``` + + +Measured on an s390x host that was itself a KVM guest without nesting: +`/dev/kvm` absent, `virsh dumpxml` showing ``, and a +7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the +VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer +and reports `kvm` even under TCG. Only `` on the host is +conclusive. + If nesting is unavailable, QEMU still runs via software emulation (TCG) — it works but is slow. + +Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm` +absent, `virsh dumpxml` affichant ``, et un démarrage de +7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM +ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et +annonce `kvm` même en TCG. Seul `` sur l'hôte fait foi. + ### Bridge for external access A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN, diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index 22088fd..afae765 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -207,6 +207,37 @@ 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 ``` +Sur **s390x et arm64**, le paramètre vit sur le module `kvm` lui-même, et non +sur `kvm_intel` / `kvm_amd` — et `/sys/module/kvm/parameters/nested` n'existe +même pas sur x86. Lire le mauvais fichier renvoie un `0` rassurant qui ne +commande rien : + +```bash +echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot +``` + +`nested` sur une machine signifie « j'autorise MES invités à faire tourner des +VM ». Pour accélérer une VM créée sur l'hôte H, le réglage appartient à +l'hyperviseur **au-dessus** de H, pas à H. La commande qui tranche, sur H : + +```bash +ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé +``` + +Mesuré sur un hôte s390x lui-même invité KVM sans imbrication : `/dev/kvm` +absent, `virsh dumpxml` affichant ``, et un démarrage de +7 min 30 au lieu de bien moins d'une minute. `systemd-detect-virt` dans la VM +ne prouve **pas** l'accélération — sur s390x, QEMU fabrique la réponse STSI et +annonce `kvm` même en TCG. Seul `` sur l'hôte fait foi. + +### 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. diff --git a/script/qemu/README.md b/script/qemu/README.md index 7675cab..f4d8b02 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -197,16 +197,34 @@ 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 ``` +On **s390x and arm64** the parameter lives on the `kvm` module itself, not on +`kvm_intel` / `kvm_amd` — and `/sys/module/kvm/parameters/nested` does not even +exist on x86. Reading the wrong file returns a reassuring `0` that commands +nothing: + +```bash +echo "options kvm nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf +sudo modprobe -r kvm && sudo modprobe kvm # ou / or reboot +``` + +`nested` on a machine means « let MY guests run VMs ». To accelerate a VM +created on host H, the setting belongs to the hypervisor **above** H, not to H +itself. The one command that settles it, run on H: + +```bash +ls -l /dev/kvm # absent -> pas d'imbrication, tout sera émulé +``` + +Measured on an s390x host that was itself a KVM guest without nesting: +`/dev/kvm` absent, `virsh dumpxml` showing ``, and a +7 min 30 boot instead of well under a minute. `systemd-detect-virt` inside the +VM is **not** proof of acceleration — on s390x, QEMU fabricates the STSI answer +and reports `kvm` even under TCG. Only `` on the host is +conclusive. + 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: diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index cd0f502..f8f570b 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1163,6 +1163,130 @@ def hash_password(plain: str) -> str: return out.stdout.strip() +# Miroirs apt, du plus rapide au dernier recours. cloud-init prend le PREMIER +# joignable de la liste « search » : l'ordre est donc la priorité. +# +# Mesuré depuis Montréal sur l'index main/s390x (1,6 Mo) : +# ports.ubuntu.com 1,61 s 1,0 Mo/s +# mirror.csclub.uwaterloo.ca 0,32 s 5,2 Mo/s +# mirror.us.leaseweb.net 0,88 s 1,9 Mo/s +# +# Le chemin diffère selon l'architecture : les arches « ports » (s390x, arm64, +# ppc64el, riscv64) ne sont PAS sur archive.ubuntu.com, et amd64 n'est pas sur +# ports.ubuntu.com. Une seule liste servirait donc la moitié des cas en 404. +APT_MIRRORS_PORTS = [ + "http://mirror.csclub.uwaterloo.ca/ubuntu-ports", + "http://mirror.us.leaseweb.net/ubuntu-ports", + "http://ports.ubuntu.com/ubuntu-ports", +] +APT_MIRRORS_MAIN = [ + "http://mirror.csclub.uwaterloo.ca/ubuntu", + "http://mirror.us.leaseweb.net/ubuntu", + "http://archive.ubuntu.com/ubuntu", +] +PORTS_ARCHES = ("s390x", "arm64", "aarch64", "ppc64el", "riscv64") + + +def apt_mirror_lines(arch: str, override: str | None = None) -> list[str]: + """Bloc « apt: » du cloud-config, ou [] si rien à écrire. + + Ubuntu seulement : Debian, Fedora et Arch ont leurs propres dépôts, et + « search » y écrirait des URI qui n'existent pas. + """ + mirrors = ( + [override] + if override + else (APT_MIRRORS_PORTS if arch in PORTS_ARCHES else APT_MIRRORS_MAIN) + ) + lines = ["apt:", " primary:", " - arches: [default]", " search:"] + lines += [f" - {m}" for m in mirrors] + # La sécurité suit le même dépôt pour les arches ports ; sur amd64 elle a + # son propre hôte, que les miroirs répliquent sous le même chemin. + lines += [" security:", " - arches: [default]", " search:"] + lines += [f" - {m}" for m in mirrors] + return lines + + +def kvm_available() -> bool: + """L'accélération matérielle est-elle réellement utilisable ici ? + + « Même architecture que l'hôte » ne suffit PAS à conclure à KVM : dans une + VM sans virtualisation imbriquée, libvirt bascule SILENCIEUSEMENT en TCG. + Mesuré sur erplibre01, lui-même invité KVM : une VM s390x sur hôte s390x + est sortie en « », soit de l'émulation intégrale — et + un démarrage de 7 min 30 au lieu de quelques dizaines de secondes, sans + que rien ne le signale. + + /dev/kvm est le test que fait QEMU lui-même. Mais l'ACCÈS n'est concluant + que si on est root : libvirt, lui, tourne en root et se moque de notre + appartenance au groupe kvm. Tester nos propres droits en non-root ferait + crier « pas de KVM » à un utilisateur simplement hors du groupe. + """ + if not os.path.exists("/dev/kvm"): + return False + if os.geteuid() == 0: + return os.access("/dev/kvm", os.R_OK | os.W_OK) + return True + + +def nested_module() -> str: + """Module noyau portant le paramètre « nested » sur CET hôte. + + Le nom change selon l'architecture, et se tromper de module fait lire un + « 0 » rassurant sur un fichier qui ne commande rien : s390x et arm64 + l'exposent sur « kvm », x86 sur « kvm_intel » ou « kvm_amd » selon le + fabricant — et /sys/module/kvm/parameters/nested n'y existe même pas. + """ + arch = host_arch() + if arch != "amd64": + return "kvm" + try: + info = Path("/proc/cpuinfo").read_text(errors="replace") + except OSError: + return "kvm_intel" + return "kvm_amd" if " svm" in info else "kvm_intel" + + +def host_timezone() -> str: + """Fuseau de l'hôte, au format zoneinfo (« America/Montreal »). + + Défaut des VM déployées : une VM qui hérite du fuseau de la machine qui la + crée horodate ses journaux, ses commits et ses bases comme son opérateur. + Sans cela elle démarre en UTC, ce qui ne se voit qu'après coup — des commits + à +0000 alors que tout le reste du dépôt est en -0400. + + Trois sources, de la plus fiable à la plus rustique ; « UTC » en dernier + recours plutôt qu'une exception, car un fuseau indéterminable ne doit pas + empêcher un déploiement. + """ + try: + out = subprocess.run( + ["timedatectl", "show", "-p", "Timezone", "--value"], + capture_output=True, + text=True, + timeout=5, + ).stdout.strip() + if out: + return out + except (OSError, subprocess.SubprocessError): + pass + try: + tz = Path("/etc/timezone").read_text(encoding="utf-8").strip() + if tz: + return tz + except OSError: + pass + try: + # /etc/localtime est un lien vers /usr/share/zoneinfo/ + target = Path("/etc/localtime").resolve() + parts = target.parts + if "zoneinfo" in parts: + return "/".join(parts[parts.index("zoneinfo") + 1 :]) + except OSError: + pass + return "UTC" + + def build_cloud_config( args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str] ) -> str: @@ -1189,6 +1313,11 @@ def build_cloud_config( lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}") lines.append(f"locale: {args.locale}") + lines.append(f"timezone: {args.timezone}") + if getattr(args, "distro", "ubuntu") == "ubuntu": + lines += apt_mirror_lines( + getattr(args, "arch", "amd64"), getattr(args, "apt_mirror", None) + ) lines += [ "keyboard:", f" layout: {args.keyboard_layout}", @@ -1220,15 +1349,41 @@ def build_cloud_config( "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" + # Getty sur la console qui EXISTE VRAIMENT. + # + # Sur s390x, l'image attend /dev/ttysclp0 (généré depuis la ligne de + # commande noyau) alors que le périphérique réellement présent porte un + # autre nom : « Timed out waiting for device dev-ttysclp0.device », puis + # « Dependency failed for serial-getty@ttysclp0 ». La console affiche + # donc tout le démarrage mais n'offre AUCUNE invite de connexion — elle + # est en lecture seule par accident, et c'est justement le seul recours + # quand SSH ne répond pas. On active le getty sur le premier + # périphérique console présent. Ailleurs (x86/arm64) ttyS0 existe et son + # getty tourne déjà : « enable --now » n'y change rien. + " - for d in ttysclp0 sclp_line0 hvc0 ttyS0; do" + " test -c /dev/$d && systemctl enable --now serial-getty@$d.service" + " 2>/dev/null && break; done || true", + # qemu-guest-agent : installé APRÈS sshd, et surtout HORS de cloud-init. + # + # Mesuré sur Ubuntu 26.04 s390x : « apt-get install qemu-guest-agent » + # tire liburing2, ubuntu-helper-virt-hwe et ubuntu-virt depuis + # ports.ubuntu.com, et cloud-final tourne 9 min 47. Or le suivi + # d'installation attend « cloud-init status: done » : dix minutes + # d'attente pour un paquet accessoire, avant même de commencer le + # travail utile. + # + # systemd-run --no-block rend la main tout de suite : cloud-init termine + # en quelques secondes et l'agent apparaît quand il apparaît. On saute + # aussi l'installation quand qemu-ga est déjà là, ce qui est le cas de + # beaucoup d'images. Repli en ligne si systemd-run manque. + " - command -v qemu-ga >/dev/null 2>&1 ||" + " systemd-run --no-block --unit=erplibre-qga --collect" + " /bin/sh -c 'command -v apt-get >/dev/null && { apt-get update -qq" + " || true; apt-get install -y qemu-guest-agent; }" + " || command -v dnf >/dev/null && dnf install -y qemu-guest-agent" + " || command -v pacman >/dev/null && pacman -Sy --noconfirm" + " qemu-guest-agent' 2>/dev/null" + " || (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" @@ -1429,7 +1584,25 @@ def virt_install( runner: Runner, ) -> None: # Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte. + # Deux causes d'émulation, à ne pas confondre : une architecture étrangère + # (voulue, on la choisit), et une absence de KVM (subie, et invisible). La + # seconde ne se déduit PAS de l'architecture — dans une VM sans + # virtualisation imbriquée, libvirt retombe en TCG sans rien dire. emulated = args.arch != host_arch() + if not emulated and not kvm_available(): + emulated = True + print( + "⚠ KVM indisponible (/dev/kvm) : cette VM sera ÉMULÉE, donc TRÈS" + " lente." + ) + print( + " Cause habituelle : l'hôte est lui-même une VM sans" + " virtualisation imbriquée." + ) + print( + " Vérifier : systemd-detect-virt et" + ' virsh capabilities | grep "domain type"' + ) # 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" @@ -1730,6 +1903,24 @@ def build_parser() -> argparse.ArgumentParser: default="fr_CA.UTF-8", help="Locale (défaut : fr_CA.UTF-8).", ) + g_cloud.add_argument( + "--apt-mirror", + metavar="URI", + help=( + "Miroir apt unique (Ubuntu). Par défaut, cloud-init essaie dans" + " l'ordre les miroirs les plus rapides mesurés puis le dépôt" + " officiel." + ), + ) + g_cloud.add_argument( + "--timezone", + default=host_timezone(), + metavar="ZONE", + help=( + "Fuseau horaire de la VM, au format zoneinfo " + f"(défaut : celui de l'hôte, {host_timezone()})." + ), + ) g_cloud.add_argument( "--keyboard-layout", default="ca", diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index e969f20..76df668 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -163,6 +163,7 @@ def build_spec(vms, domains, form): "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"], + "timezone": form.get("timezone", ""), "install": form["install"], "add_ssh_config": form["add_ssh_config"], "parallelism": form["parallelism"], @@ -416,6 +417,12 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("monitor", True), id="f_monitor", ) + yield Static(t("Timezone"), classes="grouptitle") + yield Input( + value=ctx.get("timezone") or "", + placeholder=t("Timezone for the VMs"), + id="f_tz", + ) yield Static("SSH", classes="grouptitle") yield Input( value=ctx.get("ssh_key") or "", @@ -631,6 +638,11 @@ def run_deploy_form(ctx, run_app: bool = True): else f"x{self.profile}" ), "ssh_key": os.path.expanduser(key) if key else "", + # Un champ vidé retombe sur le fuseau de l'hôte plutôt que sur + # rien : sans valeur, la VM démarrerait en UTC. + "timezone": self.query_one("#f_tz", Input).value.strip() + or ctx.get("timezone") + or "", "install": install, "add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value, "parallelism": self.query_one("#f_par", Select).value, diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index f0e16fa..7b68617 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -16,6 +16,7 @@ from __future__ import annotations import asyncio import json import os +import re import shlex import shutil import socket @@ -73,7 +74,9 @@ def list_install_runs() -> list: return runs -def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None: +def _launch_one( + ip: str, remote_cmd: str, log_path: str, name: 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 @@ -95,19 +98,72 @@ def _launch_one(ip: str, remote_cmd: str, log_path: str) -> None: 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") + # L'IP est RÉSOLUE À CHAQUE TOUR, jamais figée. Au 1er boot la VM prend un + # bail sous le nom par défaut de l'image, puis cloud-init pose le vrai nom + # d'hôte et le client DHCP en redemande un AUTRE. L'adresse connue au + # lancement devient donc morte en cours de route, et l'attente échouait + # 20 minutes durant sur une VM parfaitement saine (vécu : bail .247 périmé + # pendant que la VM vivait en .248). + # + # L'agent invité fait foi : il répond depuis l'intérieur, là où le bail + # dnsmasq garde les deux adresses sans dire laquelle est vivante. « sudo -n » + # car ce script tourne DÉTACHÉ : une demande de mot de passe le bloquerait + # sans que personne ne la voie. Sans réponse, on garde l'adresse courante. + # L'agent ne suffit PAS comme source unique : son paquet s'installe hors de + # cloud-init pour ne pas retarder le démarrage, donc il arrive tard — et + # pendant tout ce temps la ré-résolution ne renvoyait rien et gardait + # l'adresse morte. C'est le défaut qui a fait échouer le premier correctif. + # + # Repli sur les baux : dnsmasq les garde tous les deux sans dire lequel est + # vivant, on tranche donc en TESTANT le port 22 — le seul critère qui compte + # ici, puisque c'est par là que l'installation passera. Le bail périmé ne + # répond pas, le bon répond. + # virsh SANS sudo d'abord. Ce script tourne détaché, sans tty : « sudo -n » + # y échoue dès que l'hôte exige une authentification interactive — vécu sur + # erplibre01 (« sudo-rs: interactive authentication is required »), et la + # ré-résolution restait alors muette sans laisser la moindre trace. + # Appartenir au groupe libvirt suffit pour joindre qemu:///system, ce que + # « deploy_qemu.py --setup-host » configure déjà. sudo -n reste en repli + # pour les hôtes où le groupe manque. + name_q = shlex.quote(name) if name else "" + vsh = ( + 'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null ' + '|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; ' + ) + refresh = ( + ( + f"n=$(vsh domifaddr {name_q} --source agent " + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' " + "| grep -v '^127\\.' | head -1); " + 'if [ -z "$n" ]; then ' + f"for c in $(vsh domifaddr {name_q} --source lease " + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' " + "| grep -v '^127\\.'); do " + 'timeout 2 bash -c "echo > /dev/tcp/$c/22" 2>/dev/null ' + '&& n="$c"; done; fi; ' + '[ -n "$n" ] && ip="$n"; ' + ) + if name + else "" + ) wrapper = ( + f"ip={shlex.quote(ip)}; " + f"{vsh if name else ''}" 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"{refresh}" + 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"if [ $((i % 6)) -eq 0 ]; then " + f'echo " ... $((i*5))s ($ip)" >> {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'echo " → $ip" >> {log_q}; ' + f'ssh {SSH_OPTS} "erplibre@$ip" {shlex.quote(remote_cmd)} ' f">> {log_q} 2>&1; " f'echo "{EXIT_MARKER} $?" >> {log_q}' ) @@ -156,7 +212,7 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # 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) + _launch_one(vm["ip"], remote_cmd, log_path, vm["name"]) entries.append( { "name": vm["name"], @@ -594,6 +650,57 @@ def browser_install_command(browser="w3m") -> list | None: return None +def virsh_ip(name: str) -> str: + """Adresse ACTUELLE d'une VM, ou '' si indéterminable. + + Même logique que la sonde du wrapper détaché, et pour la même raison : le + bail que la VM prend au premier démarrage sous le nom par défaut de l'image + est remplacé dès que cloud-init pose le vrai nom d'hôte. L'agent invité fait + foi ; sans lui, on départage les baux en testant le port 22. + + virsh SANS sudo d'abord (groupe libvirt), « sudo -n » en repli : sur un hôte + exigeant une authentification interactive, sudo échoue et ne doit pas + empêcher la lecture. + """ + + def run(source): + for pre in ([], ["sudo", "-n"]): + try: + res = subprocess.run( + pre + + [ + "virsh", + "--connect", + "qemu:///system", + "domifaddr", + name, + "--source", + source, + ], + capture_output=True, + text=True, + timeout=10, + env={**os.environ, "LC_ALL": "C", "LANG": "C"}, + ) + except (OSError, subprocess.SubprocessError): + continue + if res.returncode == 0: + return res.stdout + return "" + + found = re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("agent")) + for ip in found: + if not ip.startswith("127."): + return ip + # Sans agent : plusieurs baux possibles, dont un périmé. Le port 22 tranche. + for ip in re.findall(r"\b(\d{1,3}(?:\.\d{1,3}){3})/", run("lease")): + if ip.startswith("127."): + continue + if _port_open(ip, 22): + return ip + return "" + + 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 @@ -705,6 +812,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): BINDINGS = [ ("q", "quit", "Quitter (détaché)"), ("s", "ssh", "SSH"), + ("v", "console", "Console (virsh)"), ("w", "web", "Web (navigateur CLI)"), ("f", "follow", "Suivre"), ("c", "copy_log", "Copier log"), @@ -829,9 +937,9 @@ def run_monitor(manifest_path: str, run_app: bool = True): 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" {vm['ssh']} (s = SSH · v = console · " + "w = web :8069 · c = copier le log · " + "d = détails erreurs · Maj+glisser = sélectionner)\n" f" Log : {vm['log']}" ) @@ -1100,6 +1208,24 @@ def run_monitor(manifest_path: str, run_app: bool = True): for vm in vms: self._domstate[vm["name"]] = states.get(vm["name"], "gone") + # L'adresse est relue au même rythme. Le processus détaché suivait + # déjà la VM quand son bail changeait, mais les VUES gardaient celle + # du lancement : la barre proposait « ssh erplibre@…222 » alors que + # l'installation parlait à …223, et la touche « s » y menait aussi. + # Rafraîchir ici plutôt que dans un tick à part évite un second + # appel virsh par VM — celui-ci est déjà le relevé lent. + changed = False + for vm in vms: + if self._domstate.get(vm["name"]) == "gone": + continue + ip = await asyncio.to_thread(virsh_ip, vm["name"]) + if ip and ip != vm.get("ip"): + vm["ip"] = ip + vm["ssh"] = f"ssh erplibre@{ip}" + changed = True + if changed: + self._refresh_ssh() + # -- events --------------------------------------------------------- # def on_data_table_row_highlighted(self, event) -> None: # DEBOUNCE : RowHighlighted se déclenche à CHAQUE mouvement du @@ -1133,6 +1259,34 @@ def run_monitor(manifest_path: str, run_app: bool = True): with self.suspend(): os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") + def action_console(self) -> None: + """Console série de la VM, sans quitter le suivi. + + Le seul recours quand SSH ne répond pas : elle ne dépend ni du + réseau de la VM, ni de sshd, ni d'une IP — donc elle montre un + démarrage bloqué, un cloud-init encore en cours ou un réseau sans + bail, que le suivi ne peut que constater de loin. + + « suspend() » rend le terminal avant d'appeler virsh : sudo peut y + demander son mot de passe et la console prendre le clavier, ce qui + casserait l'affichage si Textual le tenait encore. + """ + vm = self._vm_by_name(self._selected) + if not vm: + return + name = shlex.quote(vm["name"]) + with self.suspend(): + # La console n'affiche que ce qui arrive APRÈS l'attachement : + # sur une VM déjà démarrée l'écran reste noir tant qu'on n'a + # rien envoyé. On le dit, plutôt que de laisser croire à un gel. + print(f"\n→ virsh console {vm['name']}") + print( + " Écran vide ? Appuyez sur Entrée : la console ne montre" + " que la sortie qui suit l'attachement." + ) + print(" Ctrl+] puis Entrée pour revenir au suivi.\n") + os.system(f"sudo virsh console {name} || 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 diff --git a/script/todo/todo.py b/script/todo/todo.py index 4ef770b..21e2162 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -6,6 +6,7 @@ import ast import configparser import datetime import getpass +import grp import inspect import json import logging @@ -1118,6 +1119,46 @@ class TODO: # 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 ». + # Sonde exécutée à DISTANCE, dans une session SSH non interactive. + # + # « sudo virsh » y échoue dès que l'hôte demande un mot de passe — vécu sur + # erplibre01 (sudo-rs) — et la sonde répondait alors « pas de QEMU » sur une + # machine qui en fait tourner. On essaie donc virsh SANS sudo d'abord, via + # qemu:///system : appartenir au groupe libvirt suffit, sans tty. + # + # « --connect qemu:///system » est indispensable dans ce cas : sans lui, un + # utilisateur non root tombe sur qemu:///session, qui répond correctement… + # une liste VIDE. On aurait alors « QEMU présent, aucune VM », ce qui est + # pire qu'une erreur puisque c'est plausible. + # + # Trois réponses et non deux : « denied » distingue « virsh est là mais + # inaccessible » de « pas de QEMU ici », deux situations qui appellent des + # gestes opposés. + _QEMU_SSH_PROBE = ( + 'vsh() { virsh --connect qemu:///system "$@" 2>/dev/null ' + '|| sudo -n virsh --connect qemu:///system "$@" 2>/dev/null; }; ' + "if ! command -v virsh >/dev/null 2>&1; then " + "printf 'LIBVIRT\\tno\\n'; exit 0; fi; " + "vms=$(vsh list --all --name) || " + "{ printf 'LIBVIRT\\tdenied\\n'; exit 0; }; " + "printf 'LIBVIRT\\tyes\\n'; " + "for n in $vms; do " + 'ip=$(vsh domifaddr "$n" --source lease ' + "| grep -oE '([0-9]{1,3}\\.){3}[0-9]{1,3}' | head -1); " + 'if [ -z "$ip" ]; then ' + 'ip=$(vsh domifaddr "$n" --source agent ' + "| 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 @@ -1510,6 +1551,206 @@ class TODO: 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 + libvirt = "no" + 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": + libvirt = parts[1].strip() + continue + found.append((parts[0], parts[1].strip())) + if libvirt == "yes": + return "ok", found + # « denied » : virsh est installé mais refuse de répondre — sudo + # interactif, ou utilisateur hors du groupe libvirt. Le confondre avec + # « pas de QEMU » envoyait chercher un problème qui n'existe pas. + return ("denied" if libvirt == "denied" 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 + if status == "denied": + # virsh est là mais ne répond pas : c'est un DROIT qui + # manque, pas un logiciel. Le dire, et donner le geste. + print( + f" 🔒 {parent}: " + f"{t('virsh present but not accessible')}" + ) + print( + f" {t('Add the user to the libvirt group there:')}" + ) + continue + if status != "ok": + 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. @@ -4330,6 +4571,82 @@ class TODO: 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, + timezone=None, + locale=None, + ): + """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 timezone: + # Toujours explicite, jamais implicite : la commande affichée en + # dry-run doit produire la même VM si on la rejoue depuis une autre + # machine, dont le fuseau serait différent. + parts += ["--timezone", timezone] + if locale: + parts += ["--locale", locale] + 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, + timezone=spec.get("timezone"), + locale=spec.get("locale"), + ) + # ---------------------------------------------------------------- # # Déploiement : catalogue (pur) -> collecte (CLI ou TUI) -> exécution # ---------------------------------------------------------------- # @@ -4391,6 +4708,223 @@ class TODO: existing = [vm["name"] for vm in vms if vm["name"] in known] return pending, existing + def _qemu_check_libvirt_group(self): + """Prévient si virsh n'est pas joignable sans sudo, et propose de régler. + + Le suivi d'installation tourne DÉTACHÉ, sans tty : il ne peut pas + répondre à une demande de mot de passe. « sudo -n » y échoue sur tout + hôte exigeant une authentification interactive (vécu sur erplibre01 avec + sudo-rs), et la VM devient alors introuvable dès que son bail DHCP + change. Le groupe libvirt est la seule voie qui n'exige ni root ni tty. + + Vérifié AVANT de créer quoi que ce soit : découvrir le problème après + vingt minutes d'installation coûte bien plus cher qu'une question ici. + """ + probe = subprocess.run( + ["virsh", "--connect", "qemu:///system", "list", "--name"], + capture_output=True, + text=True, + ) + if probe.returncode == 0: + return # joignable sans sudo : rien à signaler + + user = getpass.getuser() + # Être dans /etc/group ne suffit pas : les groupes d'un processus sont + # figés à l'ouverture de session. Distinguer les deux cas évite de + # proposer un usermod déjà fait, et dit la vraie action attendue. + try: + declared = user in grp.getgrnam("libvirt").gr_mem + except KeyError: + declared = False + try: + active = grp.getgrnam("libvirt").gr_gid in os.getgroups() + except KeyError: + active = False + + print(f"\n⚠ {t('virsh cannot reach qemu:///system without sudo.')}") + print(f" {t('The install monitor runs detached and cannot type a')}") + print( + f" {t('password: it would lose the VM when its lease moves.')}" + ) + + if declared and not active: + print( + f"\n {t('You are in the libvirt group, but this session')}" + ) + print(f" {t('predates it. Log out and back in, or run:')}") + print(f" newgrp libvirt") + return + if active: + # Groupe présent mais virsh échoue quand même : libvirtd arrêté, + # socket absente… La cause n'est pas le groupe, ne pas la maquiller. + print(f"\n {t('Group is active, so the cause is elsewhere:')}") + print(f" {(probe.stderr or '').strip()[:200]}") + return + + cmd = f"sudo usermod -aG libvirt {shlex.quote(user)}" + print(f"\n {t('Add your user to the libvirt group?')}") + print(f" {cmd}") + if not self._is_yes_default_yes(input(t("Run it now? (Y/n): "))): + return + if os.system(cmd) != 0: + print(f" ⚠ {t('Command failed.')}") + return + print(f"\n✅ {t('Added. Log out and back in for it to take effect,')}") + print(f" {t('or start a new shell with: newgrp libvirt')}") + + def _qemu_check_kvm(self): + """Prévient quand les VM seront ÉMULÉES faute de KVM. + + « Même architecture que l'hôte » ne veut pas dire accélérée : dans une + VM sans virtualisation imbriquée, libvirt bascule en TCG sans le dire. + Mesuré : une VM s390x sur un hôte s390x lui-même invité KVM est sortie + en « » et a démarré en 7 min 30. Le savoir avant + d'attendre vaut mieux que de chercher la cause après.""" + try: + mod = self._qemu_import_module() + if mod.kvm_available(): + return + except Exception: + return + try: + module = mod.nested_module() + except Exception: + module = "kvm" + print(f"\n⚠ {t('KVM is unavailable: the VMs will be EMULATED.')}") + print(f" {t('A boot then takes 10-15 min, not under a minute.')}") + print( + f" {t('Cause: /dev/kvm is missing. This host is itself a VM')}" + ) + print( + f" {t('whose hypervisor does not expose nested virtualization.')}" + ) + print(f"\n {t('To fix it ON THE PARENT HYPERVISOR, not here:')}") + print( + f' echo "options {module} nested=1"' + f" | sudo tee /etc/modprobe.d/kvm-nested.conf" + ) + print(f" sudo modprobe -r {module} && sudo modprobe {module}") + print( + f" {t('then set this VM to the host-passthrough CPU mode and')}" + ) + print( + f" {t('stop it and start it again - a reboot is not enough.')}" + ) + print( + f"\n {t('Without access to that hypervisor, nothing to do here.')}" + ) + + 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(), + "timezone": self._qemu_host_timezone(), + "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) + + self._qemu_check_libvirt_group() + self._qemu_check_kvm() + + 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).""" @@ -4604,6 +5138,143 @@ class TODO: ) print(f" ⚠ {warn}") + def _qemu_host_timezone(self): + """Fuseau de l'hôte. Défini une seule fois, dans deploy_qemu.py, qui + est aussi ce qui l'écrit dans le cloud-config : l'invite ne peut donc + pas proposer un défaut différent de celui réellement appliqué.""" + try: + mod = self._qemu_import_module() + return mod.host_timezone() + except Exception: + return "UTC" + + def _qemu_ask_timezone(self): + """Fuseau des VM à créer, celui de l'hôte par défaut. + + Une VM qui hérite du fuseau de son opérateur horodate ses journaux et + ses bases comme lui ; en UTC l'écart ne se remarque qu'après coup.""" + default = self._qemu_host_timezone() + answer = input(f"{t('Timezone for the VMs')} ({default}): ").strip() + if not answer: + return default + # Un fuseau inconnu ne casse pas cloud-init : il l'ignore en silence et + # la VM reste en UTC. Mieux vaut le refuser ici que le découvrir plus + # tard sur des horodatages faux. + if not os.path.exists(os.path.join("/usr/share/zoneinfo", answer)): + print(f"⚠ {t('Unknown timezone, keeping')} {default}") + return default + return answer + + def _qemu_ask_locale(self): + """Locale des VM. « C.UTF-8 » par défaut : les autres déclenchent un + locale-gen dans l'invité, mesuré à 36 s sur s390x — payé à chaque + déploiement pour un confort dont une VM jetable n'a pas besoin.""" + default = "C.UTF-8" + answer = input(f"{t('Locale for the VMs')} ({default}): ").strip() + return answer or default + + 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) + + timezone = self._qemu_ask_timezone() + locale = self._qemu_ask_locale() + + # 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, + "timezone": timezone, + "locale": locale, + "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.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5b8eba3..6a248c5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1619,6 +1619,14 @@ TRANSLATIONS = { "fr": "pas de QEMU/libvirt ici", "en": "no QEMU/libvirt here", }, + "virsh present but not accessible": { + "fr": "virsh présent mais inaccessible (droits)", + "en": "virsh present but not accessible (permissions)", + }, + "Add the user to the libvirt group there:": { + "fr": "Ajoutez l'utilisateur au groupe libvirt là-bas :", + "en": "Add the user to the libvirt group there:", + }, "QEMU present, no VM": { "fr": "QEMU présent, aucune VM", "en": "QEMU present, no VM", @@ -3277,6 +3285,106 @@ TRANSLATIONS = { "fr": "Chemin de la clé publique SSH", "en": "SSH public key path", }, + "Timezone": { + "fr": "Fuseau horaire", + "en": "Timezone", + }, + "virsh cannot reach qemu:///system without sudo.": { + "fr": "virsh ne peut pas joindre qemu:///system sans sudo.", + "en": "virsh cannot reach qemu:///system without sudo.", + }, + "The install monitor runs detached and cannot type a": { + "fr": "Le suivi d'installation tourne détaché et ne peut pas saisir", + "en": "The install monitor runs detached and cannot type a", + }, + "password: it would lose the VM when its lease moves.": { + "fr": "de mot de passe : il perdrait la VM au changement de bail.", + "en": "password: it would lose the VM when its lease moves.", + }, + "You are in the libvirt group, but this session": { + "fr": "Vous êtes dans le groupe libvirt, mais cette session est", + "en": "You are in the libvirt group, but this session", + }, + "predates it. Log out and back in, or run:": { + "fr": "antérieure. Reconnectez-vous, ou lancez :", + "en": "predates it. Log out and back in, or run:", + }, + "Group is active, so the cause is elsewhere:": { + "fr": "Le groupe est actif : la cause est donc ailleurs.", + "en": "Group is active, so the cause is elsewhere:", + }, + "Add your user to the libvirt group?": { + "fr": "Ajouter votre utilisateur au groupe libvirt ?", + "en": "Add your user to the libvirt group?", + }, + "Run it now? (Y/n): ": { + "fr": "L'exécuter maintenant ? (O/n) : ", + "en": "Run it now? (Y/n): ", + }, + "Command failed.": { + "fr": "La commande a échoué.", + "en": "Command failed.", + }, + "Added. Log out and back in for it to take effect,": { + "fr": "Ajouté. Reconnectez-vous pour que ça prenne effet,", + "en": "Added. Log out and back in for it to take effect,", + }, + "or start a new shell with: newgrp libvirt": { + "fr": "ou ouvrez un shell neuf avec : newgrp libvirt", + "en": "or start a new shell with: newgrp libvirt", + }, + "Timezone for the VMs": { + "fr": "Fuseau horaire des VM", + "en": "Timezone for the VMs", + }, + "Locale for the VMs": { + "fr": "Locale des VM", + "en": "Locale for the VMs", + }, + "KVM is unavailable: the VMs will be EMULATED.": { + "fr": "KVM indisponible : les VM seront ÉMULÉES.", + "en": "KVM is unavailable: the VMs will be EMULATED.", + }, + "A boot then takes 10-15 min, not under a minute.": { + "fr": "Un démarrage prend alors 10 à 15 min, pas moins d'une minute.", + "en": "A boot then takes 10-15 min, not under a minute.", + }, + "Cause: /dev/kvm is missing. This host is itself a VM": { + "fr": "Cause : /dev/kvm est absent. Cet hôte est lui-même une VM", + "en": "Cause: /dev/kvm is missing. This host is itself a VM", + }, + "whose hypervisor does not expose nested virtualization.": { + "fr": "dont l'hyperviseur n'expose pas la virtualisation imbriquée.", + "en": "whose hypervisor does not expose nested virtualization.", + }, + "To fix it ON THE PARENT HYPERVISOR, not here:": { + "fr": "Pour corriger, SUR L'HYPERVISEUR PARENT, pas ici :", + "en": "To fix it ON THE PARENT HYPERVISOR, not here:", + }, + "then set this VM to the host-passthrough CPU mode and": { + "fr": "puis donner à cette VM le mode CPU « host-passthrough », et", + "en": "then set this VM to the host-passthrough CPU mode and", + }, + "stop it and start it again - a reboot is not enough.": { + "fr": "l'arrêter puis la redémarrer — un reboot ne suffit pas.", + "en": "stop it and start it again - a reboot is not enough.", + }, + "Without access to that hypervisor, nothing to do here.": { + "fr": "Sans accès à cet hyperviseur, il n'y a rien à régler ici.", + "en": "Without access to that hypervisor, nothing to do here.", + }, + "Usual cause: this host is itself a VM without nested": { + "fr": "Cause habituelle : cet hôte est lui-même une VM sans", + "en": "Usual cause: this host is itself a VM without nested", + }, + "virtualization. Check: systemd-detect-virt": { + "fr": "virtualisation imbriquée. Vérifier : systemd-detect-virt", + "en": "virtualization. Check: systemd-detect-virt", + }, + "Unknown timezone, keeping": { + "fr": "Fuseau inconnu, on garde", + "en": "Unknown timezone, keeping", + }, "none": { "fr": "aucune", "en": "none",