#!/usr/bin/env python3 # © 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 import time import xml.etree.ElementTree as ET import zipfile new_path = os.path.normpath( os.path.join(os.path.dirname(__file__), "..", "..") ) sys.path.append(new_path) from script.config import config_file from script.execute import execute from script.todo.database_manager import DatabaseManager from script.todo.kdbx_manager import KdbxManager from script.todo import todo_prefs from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t from script.todo.version_manager import get_odoo_version ERROR_LOG_PATH = ".erplibre.error.txt" VENV_ERPLIBRE = ".venv.erplibre" ENABLE_CRASH = False CRASH_E = None # Support mobile ERPLibre ANDROID_DIR = "android" MOBILE_HOME_PATH = "./mobile/erplibre_home_mobile" STRINGS_FILE = os.path.join( MOBILE_HOME_PATH, ANDROID_DIR, "app/src/main/res/values/strings.xml" ) GRADLE_FILE = os.path.join(MOBILE_HOME_PATH, ANDROID_DIR, "app/build.gradle") try: import click import dotenv import humanize import openai import todo_file_browser # import urwid # TODO implement rich for beautiful print and table # import rich import todo_upgrade from pykeepass import PyKeePass except ModuleNotFoundError as e: humanize = None ENABLE_CRASH = True CRASH_E = e if not ENABLE_CRASH: print(t("Importation success!")) logging.basicConfig( format=( "%(asctime)s,%(msecs)d %(levelname)-8s [%(filename)s:%(lineno)d]" " %(message)s" ), datefmt="%Y-%m-%d:%H:%M:%S", level=logging.INFO, ) _logger = logging.getLogger(__name__) CONFIG_FILE = "./script/todo/todo.json" CONFIG_OVERRIDE_FILE = "./private/todo/todo.json" LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt" class TODO: def __init__(self): self.dir_path = None self.selected_file_path = None self.config_file = config_file.ConfigFile() self.execute = execute.Execute() self.kdbx_manager = KdbxManager(self.config_file) self.db_manager = DatabaseManager(self.execute, self.fill_help_info) def _ask_language(self): if not lang_is_configured(): print() print("Choisir la langue / Choose language:") print("[1] Francais") print("[2] English") choice = "" while choice not in ("1", "2"): choice = input("Select / Choisir : ").strip() if choice == "1": set_lang("fr") else: set_lang("en") def _change_language(self): print() print(t("Choose language / Choisir la langue") + ":") print(f"[1] {t('French')}") print(f"[2] {t('English')}") print(f"[0] {t('Back')}") choice = "" while choice not in ("0", "1", "2"): choice = input(t("Select: ")).strip() if choice == "0": return False elif choice == "1": set_lang("fr") else: set_lang("en") print(t("Language changed to: English")) def run(self): with open(self.config_file.get_logo_ascii_file_path()) as my_file: print(my_file.read()) self._ask_language() print(t("Opening TODO ...")) print(f"đŸ€– {t('=> Enter your choice by number and press Enter!')}") help_info = f"""{self._menu_header()} [1] {t("Execute")} [2] {t("Install")} [3] {t("Question")} [4] {t("Fork - Open TODO in a new tab")} [5] {t("Navigation telemetry (TUI)")} [6] {t("Configuration")} [0] {t("Quit")} """ while True: try: status = click.prompt(help_info) except NameError: print("Do") print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") sys.exit(1) except ImportError: print("Do") print(f"source ./{VENV_ERPLIBRE}/bin/activate && make") sys.exit(1) except click.exceptions.Abort: sys.exit(0) print() if status == "0": break elif status == "1": self.prompt_execute() elif status == "2": self.prompt_install() elif status == "3": self.execute_prompt_ia() elif status == "4": # cmd = ( # f"gnome-terminal --tab -- bash -c 'source" # f" ./{VENV_ERPLIBRE}/bin/activate;make todo'" # ) cmd = "make todo" self.execute.exec_command_live(cmd, source_erplibre=True) elif status == "5": self._todo_telemetry_tui() elif status == "6": self.prompt_configuration() # elif status == "3" or status == "install": # print("install") else: print(t("Command not found !")) print(status) def prompt_execute(self): help_info = f"""{self._menu_header()} ── {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: status = click.prompt(help_info) print() if status == "0": return elif status == "1": status = self.prompt_execute_code() if status is not False: return elif status == "2": status = self.prompt_execute_config() if status is not False: return elif status == "3": status = self.prompt_execute_instance() if status is not False: return elif status == "4": status = self.prompt_execute_test() if status is not False: return elif status == "5": status = self.prompt_execute_process() if status is not False: return 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 execute_from_configuration( self, instance, exec_run_db=False, ignore_makefile=False ): # exec_run_db need argument database kdbx_key = instance.get("kdbx_key") odoo_user = instance.get("user") odoo_password = instance.get("password") if kdbx_key: extra_cmd_web_login = self.kdbx_manager.get_extra_command_user( kdbx_key ) elif odoo_user and odoo_password: extra_cmd_web_login = ( f" --default_email_auth {odoo_user} --default_password_auth" f" '{odoo_password}'" ) else: extra_cmd_web_login = "" makefile_cmd = instance.get("makefile_cmd") if makefile_cmd and not ignore_makefile: status = self.execute.exec_command_live( f"make {makefile_cmd}", source_erplibre=False, single_source_erplibre=True, ) if status: _logger.error( f"Status {status} - exit execute_from_configuration" ) return if exec_run_db: db_name = instance.get("database") self.prompt_execute_selenium_and_run_db( db_name, extra_cmd_web_login=extra_cmd_web_login ) bash_command = instance.get("bash_command") if bash_command: print(f"{t('Will execute:')} {bash_command}") self.execute.exec_command_live(bash_command, source_erplibre=False) command = instance.get("Command:") if command: self.prompt_execute_selenium( command=command, extra_cmd_web_login=extra_cmd_web_login ) callback = instance.get("callback") 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 _menu_header(self): """En-tĂȘte de menu : fil d'Ariane (dĂ©rivĂ© de la pile d'appels) suivi de la ligne « Commande : ». Le fil situe le menu courant et se copie pour dĂ©crire sans ambiguĂŻtĂ© oĂč l'on se trouve.""" crumbs = [] for frame_info in reversed(inspect.stack()): if frame_info.frame.f_locals.get("self") is not self: continue label = self._MENU_LABELS.get(frame_info.function) if label and (not crumbs or crumbs[-1] != label): crumbs.append(label) header = "" if crumbs: header = "📍 " + " â€ș ".join(crumbs) + "\n" # TĂ©lĂ©mĂ©trie de navigation (best-effort, ne casse jamais le menu). try: from script.todo import todo_telemetry todo_telemetry.record(" â€ș ".join(crumbs)) except Exception: pass return header + t("Command:") def _todo_telemetry_tui(self): """Ouvre le TUI de tĂ©lĂ©mĂ©trie (arbre/Kanban). Une commande choisie est exĂ©cutĂ©e au retour (hors du TUI) ; on propose ensuite de REVENIR (l'Ă©tat et la position du curseur sont restaurĂ©s) ou de quitter.""" from script.todo.todo_telemetry import run_tui from script.todo import textual_setup if not textual_setup.ensure(): return state = None while True: try: result = run_tui(state=state) except ImportError: return if not result: return action, state = result if not action: return # quittĂ© sans choisir de commande method, kwargs = action # Fil d'Ariane : la commande Ă©tant lancĂ©e DEPUIS la tĂ©lĂ©mĂ©trie (et # non via la navigation), aucun menu n'a affichĂ© le chemin. On le # montre ici (dernier segment traduit + icĂŽne) et on l'enregistre. path = state.get("path") if isinstance(state, dict) else None if path: segs = path.split(" â€ș ") segs[-1] = t(segs[-1]) print(f"\n📍 {' â€ș '.join(segs)}") try: from script.todo import todo_telemetry todo_telemetry.record(path) except Exception: pass fn = getattr(self, method, None) if not callable(fn): print(f"{t('Command not found !')} ({method})") else: try: fn(**(kwargs or {})) except Exception as exc: print(f"{t('Command failed: ')}{exc}") # Revenir (curseur restaurĂ©) ou quitter ? ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}") if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"): return # PrĂ©fĂ©rences Ă©ditables depuis le menu Configuration : clĂ©, libellĂ©, et # valeurs proposĂ©es (valeur stockĂ©e -> libellĂ© affichĂ©). Une seule table : # l'Ă©cran, la lecture et l'Ă©criture en dĂ©coulent. _PREF_CHOICES = { "qemu_deploy_ui": ( "QEMU deployment interface", ( ("ask", "Ask every time"), ("tui", "TUI form"), ("cli", "Classic questions (line by line)"), ), ), "qemu_deploy_progress": ( "Display while deploying", ( ("cli", "CLI output (easy to copy)"), ("tui", "TUI, collapsible blocks per VM"), ), ), "migration_ui": ( "Odoo migration interface", ( ("ask", "Ask every time"), ("tui", "TUI form"), ("cli", "Classic questions (line by line)"), ), ), } def _pref_label(self, key): """LibellĂ© traduit de la valeur courante d'une prĂ©fĂ©rence.""" value = todo_prefs.get(key) for stored, label in self._PREF_CHOICES[key][1]: if stored == value: return t(label) return str(value) def _pref_edit(self, key): """Fait choisir une valeur parmi celles proposĂ©es pour `key`.""" title, options = self._PREF_CHOICES[key] current = todo_prefs.get(key) print(f"\n{t(title)} :") for i, (stored, label) in enumerate(options, 1): star = " *" if stored == current else "" print(f" [{i}] {t(label)}{star}") sel = input(f"{t('Choice (number, blank = keep):')} ").strip() try: idx = int(sel) - 1 except ValueError: return if 0 <= idx < len(options): todo_prefs.set(key, options[idx][0]) print(f" ✅ {t(title)} : {self._pref_label(key)}") def prompt_configuration(self): """RĂ©glages persistants de l'utilisateur (~/.erplibre/todo_prefs.json). La langue vit Ă  part, dans env_var.sh, et garde son propre mĂ©canisme. """ while True: lang = "français" if get_lang() == "fr" else "English" choices = [ {"section": t("Interface")}, {"prompt_description": f"{t('Language / Langue')} ({lang})"}, { "prompt_description": ( f"{t('QEMU deployment interface')} " f"({self._pref_label('qemu_deploy_ui')})" ) }, { "prompt_description": ( f"{t('Display while deploying')} " f"({self._pref_label('qemu_deploy_progress')})" ) }, { "prompt_description": ( f"{t('Odoo migration interface')} " f"({self._pref_label('migration_ui')})" ) }, {"section": t("Maintenance")}, {"prompt_description": t("Reset all preferences")}, ] status = click.prompt(self.fill_help_info(choices)) print() if status == "0": return elif status == "1": self._change_language() elif status == "2": self._pref_edit("qemu_deploy_ui") elif status == "3": self._pref_edit("qemu_deploy_progress") elif status == "4": self._pref_edit("migration_ui") elif status == "5": n = todo_prefs.reset() print(f"✅ {t('Preferences reset')} ({n})") else: print(t("Command not found !")) def fill_help_info(self, choices): # Une entrĂ©e {"section": "..."} affiche un titre de section SANS # consommer de numĂ©ro : la numĂ©rotation reste continue sur les vraies # commandes (compatible avec les elif codĂ©s en dur des menus). help_info = self._menu_header() + "\n" help_end = f"[0] {t('Back')}\n" 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"[{n}] " + desc + "\n" help_info += help_end return help_info def prompt_execute_instance(self): # TODO proposer le dĂ©ploiement Ă  distance # TODO proposer l'exĂ©cution de docker # TODO proposer la crĂ©ation de docker choices = self.config_file.get_config("instance") init_len = len(choices) # Support mobile ERPLibre if os.path.exists(MOBILE_HOME_PATH): menu_entry = { "prompt_description": t("Mobile - Compile and run software"), "callback": self.callback_make_mobile_home, } choices.append(menu_entry) # Support custom database to execute menu_entry = { "prompt_description": t("Choose your database"), "callback": self.callback_execute_custom_database, } choices.insert(0, menu_entry) help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False else: cmd_no_found = True try: int_cmd = int(status) if 1 < int_cmd <= init_len: cmd_no_found = False status = click.confirm( t("Do you want a new instance?") ) instance = choices[int_cmd - 1] self.execute_from_configuration( instance, exec_run_db=True, ignore_makefile=not bool(status), ) elif int_cmd <= len(choices) or 1 == int_cmd: cmd_no_found = False # Execute dynamic instance 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_function(self): choices = self.config_file.get_config("function") help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False 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_update(self): # self.execute.exec_command_live(f"make {makefile_cmd}") print(f"đŸ€– {t('Development update')}") # TODO dĂ©tecter les modules en modification pour faire la mise Ă  jour en cours # TODO demander sur quel BD faire la mise Ă  jour # TODO proposer les modules manuelles selon la configuration Ă  mettre Ă  jour # TODO proposer la mise Ă  jour de l'IDE # TODO proposer la mise Ă  jour des git-repo # TODO faire la mise Ă  jour de ERPLibre # TODO faire l'upgrade d'un odoo vers un autre choices = self.config_file.get_config("update_from_makefile") menu_entry = { "prompt_description": t("Upgrade Odoo - Migration Database"), } choices.append(menu_entry) poetry_entry = { "prompt_description": t("Upgrade Poetry - Dependency of Odoo"), } choices.append(poetry_entry) 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) - 1): upgrade = todo_upgrade.TodoUpgrade(self) upgrade.execute_odoo_upgrade() elif status == str(len(choices)): self.upgrade_poetry() else: cmd_no_found = True try: int_cmd = int(status) - 1 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_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 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" ) }, ] 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_clone_erplibre() elif status == "2": self._configure_sshfs() elif status == "3": self._deploy_port_forward() elif status == "4": self.prompt_execute_deploy_ssh() elif status == "5": self.prompt_execute_qemu() elif status == "6": 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 _qemu_pick_domains(self): """Fait choisir des VM parmi celles dĂ©finies. Vide = toutes.""" names = self._qemu_list_domains() if not names: print(t("No VM found.")) return [] for i, name in enumerate(names, 1): print(f" [{i}] {name}") raw = input( t("Which VMs? (numbers, comma-separated; blank = all): ") ).strip() if not raw: return names chosen = self._parse_index_selection(raw, names) return chosen or names def _ssh_ensure_key(self): """Chemin de la clĂ© PUBLIQUE, gĂ©nĂ©rĂ©e si aucune n'existe. 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 = ( input(t("Target directory path (default: ~/erplibre): ")).strip() or default_path ) target_path = os.path.expanduser(target_path) if os.path.exists(target_path): print(f"{t('Directory already exists: ')}{target_path}") return print(t("Cloning ERPLibre...")) cmd = ( "git clone" " https://github.com/erplibre/erplibre" f" {target_path}" ) print(f"{t('Will execute:')} {cmd}") try: self.execute.exec_command_live(cmd, source_erplibre=False) print(f"{t('ERPLibre cloned successfully to: ')}" f"{target_path}") except Exception as e: print(f"{t('Error cloning ERPLibre: ')}{e}") def _deploy_ntfy_server(self): print( f"\n{t('Deploy a local NTFY push notification server (Ubuntu/Arch)')}" ) port = input(t("NTFY server port (default: 8080): ")).strip() or "8080" import socket hostname = socket.gethostname() try: local_ip = socket.gethostbyname(hostname) except Exception: local_ip = "127.0.0.1" default_url = f"http://{local_ip}:{port}" base_url = ( input(f"{t('NTFY base URL')} (default: {default_url}): ").strip() or default_url ) script_path = os.path.join( os.path.dirname(os.path.abspath(__file__)), "..", "install", "install_ntfy.sh", ) script_path = os.path.realpath(script_path) if not os.path.isfile(script_path): print(f"{t('NTFY install script not found: ')}{script_path}") return print(f"\n{t('Installing NTFY server (requires sudo)...')}") cmd = ( f"sudo NTFY_PORT={port}" f" NTFY_BASE_URL={base_url}" f" bash {script_path}" ) print(f"{t('Will execute:')} {cmd}\n") try: self.execute.exec_command_live(cmd, source_erplibre=False) print(f"\n{t('NTFY server installed and started successfully!')}") 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 from datetime import datetime print(f"\n{t('SSH address input method')}") print(f"[1] {t('Manual entry')}") print(f"[2] {t('From ~/.ssh/config')}") choice = input(t("Your choice (1/2): ")).strip() user = None hostname = None ssh_name = None if choice == "2": ssh_config_path = os.path.expanduser("~/.ssh/config") hosts = [] if os.path.exists(ssh_config_path): current_host = None current_info = {} with open(ssh_config_path) as f: for line in f: line = line.strip() if line.lower().startswith("host "): host_val = line.split(None, 1)[1].strip() if host_val != "*": if current_host: hosts.append((current_host, current_info)) current_host = host_val current_info = {} elif current_host: key = line.split(None, 1) if len(key) == 2: k = key[0].lower() v = key[1].strip() if k == "hostname": current_info["hostname"] = v elif k == "user": current_info["user"] = v if current_host: hosts.append((current_host, current_info)) if not hosts: print(t("No SSH hosts found in ~/.ssh/config")) return print() for i, (host, info) in enumerate(hosts, 1): hn = info.get("hostname", host) u = info.get("user", "") desc = host if hn != host: desc += f" ({hn})" if u: desc += f" [{u}]" print(f"[{i}] {desc}") sel = input(t("Select SSH host number: ")).strip() try: idx = int(sel) - 1 if idx < 0 or idx >= len(hosts): print(t("Invalid selection!")) return except ValueError: print(t("Invalid selection!")) return host_name, host_info = hosts[idx] hostname = host_info.get("hostname", host_name) user = host_info.get("user", getpass.getuser()) ssh_name = host_name target = f"{host_name}:/" else: ssh_host = input( t("SSH host (e.g.: user@192.168.1.100): ") ).strip() if not ssh_host: print(t("SSH host is required!")) return if "@" in ssh_host: user, hostname = ssh_host.split("@", 1) else: hostname = ssh_host user = getpass.getuser() ssh_name = hostname target = f"{user}@{hostname}:/" safe_name = re.sub(r"[^a-zA-Z0-9_-]", "_", ssh_name) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") mount_point = f"/tmp/sshfs_{safe_name}_{timestamp}" os.makedirs(mount_point, exist_ok=True) # -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: self.execute.exec_command_live(cmd, source_erplibre=False) print(f"{t('Mounted on: ')}{mount_point}") print(f"mount | grep sshfs") print(f"{t('To unmount: ')}" f"fusermount -u {mount_point}") print(f"nautilus {mount_point}/home/{user}") except Exception as e: print(f"{t('Error mounting sshfs: ')}{e}") 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() if not host: print(t("SSH host is required!")) return None user = ( click.prompt(t("SSH user (default: erplibre): ")).strip() 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() path = ( click.prompt( t("Remote path (default: ~/erplibre_deploy_2): ") ).strip() or "~/erplibre_deploy_2" ) return { "SSH_HOST": host, "SSH_USER": user, "SSH_PORT": port, "SSH_KEY": key, "SSH_PATH": path, } def _build_ssh_make_cmd(self, target, params, extra=None): """Build a make SSH command string from params dict.""" parts = [f"make {target}"] for k, v in params.items(): if v: parts.append(f'{k}="{v}"') if extra: for k, v in extra.items(): if v: parts.append(f'{k}="{v}"') return " ".join(parts) def _deploy_ssh_check(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_check", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_push(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_push", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_install(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_install", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_run(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_run", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_stop(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_stop", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_restart(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_restart", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_status(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_status", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_logs(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_logs", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_make(self): params = self._get_ssh_params() if not params: return target = click.prompt(t("Make target to run remotely: ")).strip() if not target: print(t("SSH host is required!")) return cmd = self._build_ssh_make_cmd( "ssh_make", params, extra={"SSH_TARGET": target} ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_install_systemd(self): params = self._get_ssh_params() if not params: return cmd = self._build_ssh_make_cmd("ssh_install_systemd", params) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def _deploy_ssh_install_nginx(self): params = self._get_ssh_params() if not params: return domain = click.prompt(t("Domain name (e.g.: example.com): ")).strip() if not domain: print(t("SSH host is required!")) return email = click.prompt(t("Admin email for SSL certificate: ")).strip() cmd = self._build_ssh_make_cmd( "ssh_install_nginx", params, extra={"SSH_DOMAIN": domain, "SSH_ADMIN_EMAIL": email}, ) print(f"{t('Will execute:')} {cmd}") self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True ) def prompt_execute_git(self): print(f"đŸ€– {t('Git management tools!')}") choices = [ {"prompt_description": t("Local git server")}, {"prompt_description": t("Add a remote to a local repository")}, ] # Append config-driven entries config_entries = self.config_file.get_config("git_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.prompt_execute_git_local_server() elif status == "2": self._git_add_remote() 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 _git_add_remote(self): remote_name = ( input(t("Remote name (default: localhost): ")).strip() or "localhost" ) remote_url = input( t("Repository address (e.g.: git://192.168.1.100/my-repo.git): ") ).strip() if not remote_url: print(t("Repository address is required!")) return cmd = f"git remote add {remote_name} {remote_url}" print(f"{t('Will execute:')} {cmd}") try: self.execute.exec_command_live(cmd, source_erplibre=False) print(t("Remote added successfully!")) except Exception as e: print(f"{t('Error adding remote: ')}{e}") def prompt_execute_git_local_server(self): print(f"đŸ€– {t('Manage local git repository server!')}") choices = [ { "prompt_description": t( "Deploy a local git server (~/.git-server)" ) }, { "prompt_description": t( "Deploy a production git server (/srv/git, root required)" ) }, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self._prompt_git_server_actions(production_ready=False) elif status == "2": self._prompt_git_server_actions(production_ready=True) else: print(t("Command not found !")) def _prompt_git_server_actions(self, production_ready=False): mode = ( t("Production mode (/srv/git, root required)") if production_ready else t("Local mode (~/.git-server)") ) print(f"đŸ€– {mode}") choices = [ { "prompt_description": t( "Run all (init + remote + push + serve)" ) }, {"prompt_description": t("Init - Create bare repos")}, {"prompt_description": t("Remote - Add local remotes")}, {"prompt_description": t("Push - Push to local server")}, {"prompt_description": t("Serve - Start git daemon")}, ] 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_git_server( production_ready=production_ready, action="all", ) elif status == "2": self._deploy_git_server( production_ready=production_ready, action="init", ) elif status == "3": self._deploy_git_server( production_ready=production_ready, action="remote", ) elif status == "4": self._deploy_git_server( production_ready=production_ready, action="push", ) elif status == "5": self._deploy_git_server( production_ready=production_ready, action="serve", ) else: print(t("Command not found !")) def _deploy_git_server(self, production_ready=False, action="all"): print(t("Starting git server deployment...")) cmd = ( "python3 ./script/git/git_local_server.py -v" f" --action {action}" ) if production_ready: cmd += " --production-ready" self.execute.exec_command_live( cmd, source_erplibre=False, ) def prompt_execute_gpt_code(self): print(f"đŸ€– {t('AI assistant tools for development!')}") choices = [ {"prompt_description": t("Configure Claude Code configurations")}, { "prompt_description": t( "Add an automation with Claude in todo.py" ) }, { "prompt_description": t( "RTK - CLI proxy to reduce LLM token consumption" ) }, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self._prompt_claude_configs() elif status == "2": self._claude_add_automation() elif status == "3": self.prompt_execute_rtk() else: print(t("Command not found !")) def _prompt_claude_configs(self): print(f"đŸ€– {t('Deploy Claude Code commands!')}") choices = [ {"prompt_description": t("Commit - OCA/Odoo commit command")}, { "prompt_description": t( "Todo Add Command - Add a command to todo.py menu" ) }, {"prompt_description": t("Show installed custom commands")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self._setup_claude_command( "commit", "template_claude_commands_commit.md", personalize=True, ) elif status == "2": self._setup_claude_command( "todo_add_command", "template_claude_commands_todo_add_command.md", ) elif status == "3": self._list_claude_commands() else: print(t("Command not found !")) def _list_claude_commands(self): commands_dir = os.path.expanduser("~/.claude/commands") if not os.path.isdir(commands_dir): print(t("No custom commands found in ~/.claude/commands/")) return files = sorted( f for f in os.listdir(commands_dir) if f.endswith(".md") ) if not files: print(t("No custom commands found in ~/.claude/commands/")) return print(t("Claude Code custom commands:")) print("-" * 50) for f in files: filepath = os.path.join(commands_dir, f) mtime = os.path.getmtime(filepath) date_str = datetime.datetime.fromtimestamp(mtime).strftime( "%Y-%m-%d %H:%M" ) name = f[:-3] # remove .md print(f" /{name:<30} {date_str}") print("-" * 50) print(f"{t('Total:')}" f" {len(files)}") def _claude_add_automation(self): description = input(t("Description of the command to add: ")).strip() if not description: return command = input(t("Bash command to execute: ")).strip() if not command: return section = ( input( t("Menu section (git/code/config/network/process): ") ).strip() or "git" ) section_key = f"{section}_from_makefile" config_path = os.path.join(os.path.dirname(__file__), "todo.json") try: with open(config_path) as f: config = json.load(f) if section_key not in config: config[section_key] = [] config[section_key].append( { "prompt_description": description, "bash_command": command, } ) with open(config_path, "w") as f: json.dump(config, f, indent=4, ensure_ascii=False) f.write("\n") print(t("Automation added successfully in todo.json!")) except Exception as e: print(f"{t('Error adding automation: ')}{e}") def prompt_execute_doc(self): print(f"đŸ€– {t('Looking for documentation?')}") choices = [ {"prompt_description": t("Migration module coverage")}, {"prompt_description": t("What change between version")}, {"prompt_description": t("OCA guidelines")}, {"prompt_description": t("OCA migration Odoo 19 milestone")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": str_version = input( "Select version to upgrade Odoo CE (5-17) : " ) try: int_version = int(str_version) print( "https://oca.github.io/OpenUpgrade/coverage_analysis/modules" f"{int_version * 10}-{(int_version + 1) * 10}.html" ) except ValueError: print( "https://oca.github.io/OpenUpgrade/030_coverage_analysis.html" ) elif status == "2": str_version = input( "Select version to show what change for Odoo CE version 8-18) : " ) try: int_version = int(str_version) print( f"https://github.com/OCA/maintainer-tools/wiki/Migration-to-version-{int_version}.0" ) except ValueError: print("https://github.com/OCA/maintainer-tools/wiki") elif status == "3": print( "https://github.com/OCA/odoo-community.org/blob/master/website/Contribution/CONTRIBUTING.rst" ) elif status == "4": print("https://github.com/OCA/maintainer-tools/issues/658") else: print(t("Command not found !")) 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)")}, {"section": t("Danger zone")}, {"prompt_description": t("Erase a database")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": 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: print(t("Command not found !")) def prompt_execute_process(self): print(f"đŸ€– {t('Manage execution processes!')}") choices = [ {"prompt_description": t("Kill Odoo process from actual port")}, {"prompt_description": t("Kill git daemon server process")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.process_kill_from_port() elif status == "2": self.process_kill_git_daemon() else: print(t("Command not found !")) def process_kill_git_daemon(self): self.execute.exec_command_live( "pkill -f 'git daemon'", source_erplibre=False, ) print(t("Git daemon process killed.")) def prompt_execute_rtk(self): print( f"đŸ€– {t('Manage RTK (Rust Token Killer) for token optimization!')}" ) choices = [ {"section": t("Setup")}, {"prompt_description": t("Install RTK")}, {"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) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.rtk_install() elif status == "2": self.rtk_init_global() 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 !")) def rtk_install(self): print(f"đŸ€– {t('Installation method:')}") choices = [ {"prompt_description": t("curl - Automatic install script")}, {"prompt_description": t("brew - Homebrew (macOS/Linux)")}, { "prompt_description": t( "cargo - Build from source (Rust required)" ) }, ] help_info = self.fill_help_info(choices) status = click.prompt(help_info) print() if status == "0": return elif status == "1": self.execute.exec_command_live( "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/refs/heads/master/install.sh | sh", source_erplibre=False, ) elif status == "2": self.execute.exec_command_live( "brew install rtk", source_erplibre=False, ) elif status == "3": self.execute.exec_command_live( "cargo install --git https://github.com/rtk-ai/rtk", source_erplibre=False, ) else: print(t("Command not found !")) def rtk_check_version(self): self.execute.exec_command_live( "rtk --version", source_erplibre=False, ) def rtk_show_gain(self): self.execute.exec_command_live( "rtk gain", source_erplibre=False, ) def rtk_discover(self): self.execute.exec_command_live( "rtk discover", source_erplibre=False, ) def rtk_init_global(self): self.execute.exec_command_live( "rtk init --global", source_erplibre=False, ) def rtk_check_status(self): rtk_path = shutil.which("rtk") if rtk_path is None: print(t("RTK is not installed. Use option 1 to install it.")) return result = self.execute.exec_command_live( "rtk --version", source_erplibre=False, quiet=True, return_status_and_output=True, ) if isinstance(result, tuple) and result[0] == 0: version_output = " ".join(result[1]).strip() print(f"{t('RTK is installed, version: ')}{version_output}") else: print(f"{t('RTK is installed, version: ')}?") config_path = os.path.expanduser("~/.config/rtk/config.toml") if os.path.exists(config_path): print(t("Global auto-rewrite hook: active")) else: print(t("Global auto-rewrite hook: inactive")) 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) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.generate_config() elif status == "2": self.generate_config_from_preconfiguration() elif status == "3": self.generate_config_from_backup() elif status == "4": self.generate_config_from_database() elif status == "5": self.generate_config_queue_job() else: print(t("Command not found !")) def prompt_execute_network(self): print(f"đŸ€– {t('Network tools!')}") choices = [ {"prompt_description": t("SSH port-forwarding")}, { "prompt_description": t( "Network performance request per second" ) }, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.generate_network_port_forwarding() elif status == "2": self.generate_network_performance_test() else: print(t("Command not found !")) def generate_network_port_forwarding(self, add_arg=None): # ssh -L local_port:localhost:remote_port SSH_connection ssh_connection = click.prompt( "SSH connection, check ~/.ssh/config or user@address" ) local_port = click.prompt("local port (8069)") remote_port = click.prompt("remote port (8069)") cmd = f"ssh -L {local_port}:localhost:{remote_port} {ssh_connection}" self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=False, ) def generate_network_performance_test(self, add_arg=None): # ./script/performance/test_performance.sh address = click.prompt("https address, like https://erplibre.com") cmd = f"./script/performance/test_performance.sh {address}" self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True, ) def prompt_execute_security(self): print(f"đŸ€– {t('Dependency security audit!')}") choices = [ { "prompt_description": t( "pip-audit - Check vulnerabilities on Python environments" ) }, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.execute_pip_audit() else: print(t("Command not found !")) def prompt_execute_test(self): print(f"đŸ€– {t('Test an Odoo module on a temporary database!')}") choices = [ {"prompt_description": t("Test a module")}, {"prompt_description": t("Test a module with code coverage")}, {"prompt_description": t("ERPLibre unit tests")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.execute_test_module(coverage=False) elif status == "2": self.execute_test_module(coverage=True) elif status == "3": self.execute_unit_tests() else: print(t("Command not found !")) def execute_unit_tests(self): print(f"\n--- {t('Running unit tests')} ---") cmd = ( ".venv.erplibre/bin/python -m unittest discover" " -s test -p 'test_*.py' -v" ) status_code, output = self.execute.exec_command_live( cmd, source_erplibre=False, return_status_and_output=True, ) if status_code == 0: print(f"\n✅ {t('All unit tests passed')}") else: print( f"\n❌ {t('Some unit tests failed, exit code')}: {status_code}" ) def execute_pip_audit(self): versions, installed_versions, odoo_installed_version = ( get_odoo_version() ) # Build list of installed environments environments = {} key_i = 0 for version_info in versions[::-1]: erplibre_version = version_info.get("erplibre_version") venv_path = f".venv.{erplibre_version}" req_path = f"requirement/requirements.{erplibre_version}.txt" odoo_version = f"odoo{version_info.get('odoo_version')}" if not os.path.isdir(venv_path): continue key_i += 1 key_s = str(key_i) label = f"{key_s}: {erplibre_version}" if odoo_version == odoo_installed_version: label += f" - {t('Current')}" if version_info.get("Default"): label += f" - {t('Default')}" environments[key_s] = { "label": label, "venv_path": venv_path, "req_path": req_path, "erplibre_version": erplibre_version, } if not environments: print( t( "No installed environment found. Install an Odoo version first." ) ) return # Show selection menu str_input = ( f"💬 {t('Choose an environment for the audit:')}\n\t" + "\n\t".join([v["label"] for v in environments.values()]) + f"\n\t0: {t('Back')}" + f"\n{t('Select: ')}" ) env_input = "" while env_input not in environments and env_input != "0": if env_input: print( f"{t('Error, cannot understand value')}" f" '{env_input}'" ) env_input = input(str_input).strip() if env_input == "0": return selected = environments[env_input] venv_path = selected["venv_path"] req_path = selected["req_path"] if not os.path.isfile(req_path): print(f"{t('Dependencies file not found: ')}{req_path}") return # TODO support bash from parameter if open gnome-terminal cmd = f"pip-audit -r {req_path} -l;bash" print(f"{t('Execution: ')}{cmd}") self.execute.exec_command_live( cmd, source_erplibre=True, single_source_erplibre=False, ) def generate_config(self, add_arg=None): # Repeating to get all item before get group cmd = ( f"./script/git/git_merge_repo_manifest.py --output .repo/local_manifests/erplibre_manifest.xml --with_OCA;" f"./script/git/git_repo_update_group.py;" f"./script/generate_config.sh" ) if add_arg: cmd += ( f";./script/git/git_repo_update_group.py {add_arg};" f"./script/generate_config.sh" ) self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True, ) def generate_config_from_preconfiguration(self): choices = [ {"prompt_description": t("base")}, {"prompt_description": t("base + code_generator")}, {"prompt_description": t("base + image_db")}, {"prompt_description": t("all")}, # {"prompt_description": "base + migration"}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": group = "base" str_group = f"--group {group}" self.generate_config(add_arg=str_group) elif status == "2": group = "base,code_generator" str_group = f"--group {group}" self.generate_config(add_arg=str_group) elif status == "3": group = "base,image_db" str_group = f"--group {group}" self.generate_config(add_arg=str_group) elif status == "4": self.generate_config() # elif status == "5": # group = "base,migration" # str_group = f"--group {group}" # self.generate_config(add_arg=str_group) else: print(t("Command not found !")) def debug_ide(self): choices = [ {"prompt_description": t("Debug todo.py")}, ] help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == "1": self.open_pycharm_file( os.getcwd(), os.path.join(os.getcwd(), "script/todo/todo.py"), ) else: print(t("Command not found !")) def generate_config_from_backup(self): file_name = self.db_manager.open_file_image_db() add_arg = f"--from_backup_name {file_name} --add_repo odoo18.0/addons/MathBenTech_development" self.generate_config(add_arg=add_arg) def generate_config_from_database(self): database_name = self.db_manager.select_database() str_arg = f"--database {database_name}" self.generate_config(add_arg=str_arg) return False def generate_config_queue_job(self): cmd = "./script/config/setup_odoo_config_conf_devops.py" self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=True, ) def prompt_execute_selenium_and_run_db( self, db_name, extra_cmd_web_login="" ): cmd_server = f"./run.sh -d {db_name};bash" self.execute.exec_command_live(cmd_server) cmd_client = ( f"sleep 3;./script/selenium/web_login.py{extra_cmd_web_login};bash" ) self.execute.exec_command_live(cmd_client) def prompt_execute_selenium(self, command=None, extra_cmd_web_login=""): commands = [] if not command: cmd = "./script/selenium/web_login.py" else: cmd = command if type(extra_cmd_web_login) is list: for item in extra_cmd_web_login: commands.append(cmd + item) else: commands.append(cmd + extra_cmd_web_login) if len(commands) == 1: self.execute.exec_command_live(commands[0]) elif len(commands) > 1: new_cmd = "parallel ::: " for i, cmd in enumerate(commands): new_cmd += f' "sleep {1 * i};{cmd}"' self.execute.exec_command_live(new_cmd) def crash_diagnostic(self, e): # TODO show message at start if os.path.exists(ERROR_LOG_PATH) if os.path.exists(ERROR_LOG_PATH) and not os.path.exists( VENV_ERPLIBRE ): print("Got error : ") print(e) print("Got error at first execution.", ERROR_LOG_PATH) try: file = open(ERROR_LOG_PATH, "r") content = file.read() # TODO si vide, ajouter notre erreur print(content) except FileNotFoundError: print("Error: File not found.") finally: if "file" in locals() and file: file.close() # Force auto installation print("Auto installation") time.sleep(0.5) cmd = "./script/todo/source_todo.sh" # self.restart_script(e) self.execute.exec_command_live(cmd, source_erplibre=True) sys.exit(1) if os.path.exists(VENV_ERPLIBRE): print("Import error : ") print(e) # TODO auto-detect gnome-terminal, or choose another. Is it done already? self.restart_script(e) # self.prompt_install() # print( # f"You forgot to activate source \nsource ./{VENV_ERPLIBRE}/bin/activate" # ) # time.sleep(0.5) # cmd = "./script/todo/source_todo.sh" print("Re-execute TODO đŸ€– or execute :") print() print(f"source {VENV_ERPLIBRE}/bin/activate;make") print() cmd = "./script/todo/todo.py" # # self.restart_script(e) try: # TODO duplicate import click import humanize import openai import urwid from pykeepass import PyKeePass except ImportError: print("Rerun and exit") self.execute.exec_command_live(cmd, source_erplibre=True) sys.exit(1) print("No error") else: self.prompt_install() def open_shell_on_database(self): database = self.db_manager.select_database() if database: cmd_server = f"./odoo_bin.sh shell -d {database}" status, databases = self.execute.exec_command_live( cmd_server, return_status_and_output=True, source_erplibre=False, single_source_erplibre=True, new_window=True, ) def open_pycharm_file(self, folder, filename): cmd = "~/.local/share/JetBrains/Toolbox/scripts/pycharm" # cmd = "/snap/bin/pycharm-community" # if pycharm_arg: # cmd += f" {pycharm_arg}" if folder: cmd += f" {folder}" if filename: cmd += f" --line 1 {filename}" self.execute.exec_command_live(cmd, source_erplibre=False) def upgrade_module(self): upgrade = todo_upgrade.TodoUpgrade(self) upgrade.execute_module_upgrade() def callback_execute_custom_database(self, config): database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) def process_kill_from_port(self): cfg = configparser.ConfigParser() cfg.read("./config.conf") http_port = cfg.getint("options", "http_port") status = self.execute.exec_command_live( f"./script/process/kill_process_by_port.py {http_port} --kill-tree --nb_parent 2", source_erplibre=False, ) def restart_script(self, last_error): print(f"đŸ€– {t('Reboot TODO ...')}") # os.execv(sys.executable, ['python'] + sys.argv) # TODO mettre check que le rĂ©pertoire est créé, s'il existe, auto-loop Ă  corriger if os.path.exists(VENV_ERPLIBRE) and not os.path.exists( ERROR_LOG_PATH ): # TODO mettre check import suivant ne vont pas planter try: with open(ERROR_LOG_PATH, "w") as f_file: f_file.write(str(last_error)) pass # The file is created and closed here, no content is written print( f"Try to reopen process with before :\nsource ./{VENV_ERPLIBRE}/bin/activate && exec python " + " ".join(sys.argv) ) os.execv( "/bin/bash", [ "/bin/bash", "-c", f"source ./{VENV_ERPLIBRE}/bin/activate && exec python " + " ".join(sys.argv), ], ) except Exception as e: print("Error detect at first execution.") print(e) def on_dir_selected(self, dir_path): self.dir_path = dir_path todo_file_browser.exit_program() if __name__ == "__main__": start_time = time.time() try: todo = TODO() if ENABLE_CRASH: todo.crash_diagnostic(CRASH_E) todo.run() except KeyboardInterrupt: print(t("Keyboard interrupt")) finally: end_time = time.time() duration_sec = end_time - start_time if humanize: duration_delta = datetime.timedelta(seconds=duration_sec) humain_time = humanize.precisedelta(duration_delta) print(f"\n{t('TODO execution time')} {humain_time}\n") else: print(f"\n{t('TODO execution time')} {duration_sec:.2f} sec.\n")