From 7d4922bf097cf494ff560821084d49288c8e4bd3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 7 Aug 2026 03:22:26 -0400 Subject: [PATCH] [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."}, }