#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import configparser import datetime import getpass import inspect 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.todo_i18n import 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)")} [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 == "3" or status == "install": # print("install") else: print(t("Command not found !")) print(status) # manipuler() def execute_prompt_ia(self): while True: help_info = f"""{self._menu_header()} [0] {t("Back")} {t("Write your question ")}""" status = click.prompt(help_info) print() if status == "0": return kp = self.kdbx_manager.get_kdbx() if not kp: return config_name = self.config_file.get_config_value( ["kdbx_config", "openai", "kdbx_key"] ) entry = kp.find_entries_by_title(config_name, first=True) client = openai.OpenAI(api_key=entry.password) prompt_update = status completion = client.chat.completions.create( model="gpt-4o", messages=[{"role": "user", "content": prompt_update}], ) print(completion.choices[0].message.content) print() def prompt_execute(self): help_info = f"""{self._menu_header()} ── {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("Update - Update all developed staging source code")} [9] {t("Doc - Documentation search")} ── {t("AI & automation")} ── [10] {t("GPT code - AI assistant tools")} [11] {t("Automation - Demonstration of developed features")} ── {t("Deployment, network & security")} ── [12] {t("Deploy - Deploy ERPLibre locally")} [13] {t("Network - Network tools")} [14] {t("Security - Dependency security audit")} ── {t("Preferences")} ── [15] {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_update() if status is not False: return elif status == "9": status = self.prompt_execute_doc() if status is not False: return elif status == "10": status = self.prompt_execute_gpt_code() if status is not False: return elif status == "11": status = self.prompt_execute_function() if status is not False: return elif status == "12": status = self.prompt_execute_deploy() if status is not False: return elif status == "13": status = self.prompt_execute_network() if status is not False: return elif status == "14": status = self.prompt_execute_security() if status is not False: return elif status == "15": status = self._change_language() if status is not False: return else: print(t("Command not found !")) def prompt_install(self): print("Detect first installation from code source.") first_installation_input = ( input( "💬 First system installation? This will process system installation" " before (Y/N): " ) .strip() .lower() ) if self._is_yes(first_installation_input): cmd = "./script/version/update_env_version.py --install" self.execute.exec_command_live(cmd, source_erplibre=True) print("Wait after OS installation before continue.") # First detect pycharm, need to be open before installation and close to increase speed has_pycharm = False has_pycharm_community = False result = subprocess.run( ["which", "pycharm"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) if result.returncode == 0: has_pycharm = True else: result = subprocess.run( ["which", "pycharm-community"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, ) has_pycharm_community = result.returncode == 0 if (has_pycharm or has_pycharm_community) and not os.path.exists( ".idea" ): pycharm_configuration_input = ( input("💬 Open Pycharm? (Y/N): ").strip().lower() ) if self._is_yes(pycharm_configuration_input): pycharm_bin = "pycharm" if has_pycharm else "pycharm-community" cmd = f"cd {os.getcwd()} && {pycharm_bin} ./" self.execute.exec_command_live( cmd, source_erplibre=False, single_source_erplibre=False, new_window=True, ) print( "đŸ‘č WAIT and Close Pycharm when processing is done before continue" " this guide." ) # TODO detect last version supported # cmd_intern = "./script/install/install_erplibre.sh" # TODO maybe update q to only install erplibre from install_locally # TODO problem installing with q, the script depend on odoo key_i = 0 commands_begin = { "q": ( "q", "q: ERPLibre only with system python without Odoo", "./script/install/install_erplibre.sh", ), "w": ( "w", "w: Install all Odoo version with ERPLibre", "make install_odoo_all_version", ), "m": ( "m", "m: ERPLibre with mobile home", "./mobile/install_and_run.sh", ), "0": ( "0", f"0: {t('Quit')}", ), } commands_end = {} versions, installed_versions, odoo_installed_version = ( get_odoo_version() ) for version_info in versions[::-1]: key_i += 1 key_s = str(key_i) label = f"{key_s}: Odoo {version_info.get('odoo_version')}" odoo_version = f"odoo{version_info.get('odoo_version')}" if odoo_version in installed_versions: label += " - Installed" if odoo_version == odoo_installed_version: label += " - Actual" if version_info.get("Default"): label += " - Default" if version_info.get("is_deprecated"): label += " - Deprecated" erplibre_version = version_info.get("erplibre_version") commands_begin[key_s] = ( key_s, label, f"./script/version/update_env_version.py --erplibre_version {erplibre_version} --install_dev", ) # Add final command install_commands = {**commands_begin, **commands_end} # Show command odoo_version_input = "" while odoo_version_input not in install_commands: if odoo_version_input: print( f"{t('Error, cannot understand value')} '{odoo_version_input}'" ) str_input_dyn_odoo_version = ( f"💬 {t('Choose a version:')}\n\t" + "\n\t".join([a[1] for a in install_commands.values()]) + f"\n{t('Select: ')}" ) odoo_version_input = ( input(str_input_dyn_odoo_version).strip().lower() ) if odoo_version_input == "0": return cmd_intern = install_commands.get(odoo_version_input)[2] # For numbered version selections, offer extra modules sub-menu if odoo_version_input.isdigit(): extra_choices = { "1": ( "1", f"1: {t('Standard install (without extra modules)')}", ), "2": ( "2", f"2: {t('Install with extra modules (CybroOdoo - large, slow)')}", ), "0": ("0", f"0: {t('Back')}"), } extra_input = "" while extra_input not in extra_choices: if extra_input: print( f"{t('Error, cannot understand value')} '{extra_input}'" ) str_extra = ( f"💬 {t('Install type:')}\n\t" + "\n\t".join([a[1] for a in extra_choices.values()]) + f"\n{t('Select: ')}" ) extra_input = input(str_extra).strip() if extra_input == "0": return if extra_input == "2": cmd_intern = cmd_intern + " --with_extra" print(f"{t('Will execute:')}\n{cmd_intern}") # TODO use external script to detect terminal to use on system # TODO check script open_terminal_code_generator.sh # cmd_extern = f"gnome-terminal -- bash -c '{cmd_intern};bash'" try: subprocess.run( cmd_intern, shell=True, executable="/bin/bash", check=True ) except subprocess.CalledProcessError as e: print( f"{t('The Bash script failed with return code')} {e.returncode}." ) print("Wait after installation and open projects by terminal.") print("make open_terminal") self.restart_script(str(e)) def execute_from_configuration( self, instance, exec_run_db=False, ignore_makefile=False ): # 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", } 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 state = None while True: try: result = run_tui(state=state) except ImportError: print(t("Install textual for the telemetry TUI (pip).")) 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 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")}, {"section": t("Remote & services")}, {"prompt_description": t("SSH (remote host)...")}, { "prompt_description": t( "Deploy - Install NTFY notification server" ) }, { "prompt_description": t( "QEMU/KVM - Deploy an Ubuntu VM (libvirt)" ) }, ] 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.prompt_execute_deploy_ssh() elif status == "4": self._deploy_ntfy_server() elif status == "5": self.prompt_execute_qemu() 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 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 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)" ) }, {"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_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 !")) 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, ) 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: print(t("Install textual for the dashboard (pip).")) 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_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, ) 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, ) 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, ) 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 '?'}") # 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" ) # 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 via virt-resize (rĂ©duit FS + partition + GPT). # On opĂšre sur une COPIE : l'original n'est remplacĂ© qu'en cas de # SUCCÈS -> aucune corruption possible. (Le brut « qemu-img resize # --shrink » tronquait le conteneur sans rĂ©duire le FS/partition # -> GPT de secours perdue, racine tronquĂ©e -> dracut-initqueue en # Ă©chec, OS non bootable.) 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. self._qemu_offer_start(name, was_shut_down) 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) def _qemu_safe_shrink(self, name, disk, new_gb): """RĂ©duit le disque SANS casser l'OS : virt-resize (libguestfs) rĂ©duit le systĂšme de fichiers + la partition + la GPT. On Ă©crit dans une NOUVELLE image ; l'originale n'est remplacĂ©e qu'en cas de succĂšs (en cas d'Ă©chec/refus, le disque d'origine est laissĂ© INTACT). Renvoie True si la rĂ©duction a rĂ©ussi.""" if not ( shutil.which("virt-resize") and shutil.which("virt-filesystems") ): print(f"\n{t('Safe shrink needs libguestfs (virt-resize).')}") if not self._is_yes( input(t("Install libguestfs-tools now? (y/N): ")) ) or not self._qemu_install_libguestfs(): print(t("Shrink aborted; disk left intact.")) return False part = self._qemu_largest_partition(disk) if not part: print(t("Could not detect the partition to shrink; aborting.")) return False new_disk = f"{disk}.resized" bak = f"{disk}.bak" subprocess.run(["sudo", "rm", "-f", new_disk], check=False) print( f"\n{t('Shrinking guest FS + partition via virt-resize')} " f"({part} -> {new_gb:g}G)
" ) create = subprocess.run( ["sudo", "qemu-img", "create", "-f", "qcow2", new_disk, f"{new_gb:g}G"] ) if create.returncode != 0: print(f"❌ {t('Resize failed (see error above).')}") subprocess.run(["sudo", "rm", "-f", new_disk], check=False) return False # virt-resize copie old -> new en rĂ©duisant le FS de `part`. rc = subprocess.run( ["sudo", "virt-resize", "--shrink", part, disk, new_disk] ).returncode if rc != 0: print(f"❌ {t('virt-resize failed; original disk left intact.')}") subprocess.run(["sudo", "rm", "-f", new_disk], check=False) return False # SuccĂšs : on sauvegarde l'original et on met la nouvelle image au # MÊME chemin (la dĂ©finition libvirt reste valide). subprocess.run(["sudo", "mv", "-f", disk, bak], check=False) subprocess.run(["sudo", "mv", "-f", new_disk, disk], check=False) print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}") return True @staticmethod def _qemu_largest_partition(disk): """Nom (cĂŽtĂ© invitĂ©, ex. /dev/sda1) de la plus GROSSE partition du disque via virt-filesystems — celle qui porte la racine Ă  rĂ©duire.""" try: res = subprocess.run( ["sudo", "virt-filesystems", "-a", disk, "--partitions", "--long", "-b"], capture_output=True, text=True, timeout=180, ) except (OSError, subprocess.SubprocessError): return None best, best_sz = None, -1 for line in res.stdout.splitlines(): parts = line.split() if not parts or not parts[0].startswith("/dev/"): continue # Colonnes : Name Type MBR Size Parent -> Size = plus grand entier. size = max( (int(p) for p in parts if p.isdigit()), default=-1 ) if size > best_sz: best, best_sz = parts[0], size return best def _qemu_install_libguestfs(self): """Installe libguestfs-tools (apt/dnf/pacman). Renvoie True si virt-resize est ensuite disponible.""" if shutil.which("apt-get"): cmd = "sudo apt-get install -y libguestfs-tools" elif shutil.which("dnf"): cmd = "sudo dnf install -y guestfs-tools libguestfs-tools" elif shutil.which("pacman"): cmd = "sudo pacman -S --needed --noconfirm libguestfs" else: print(t("Unknown package manager; install it manually.")) return False print(f"{t('Will execute:')} {cmd}") os.system(cmd) return shutil.which("virt-resize") is not None # 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) def _write_ssh_config_entry(self, host, user, ip): """Écrit/remplace un bloc « Host » dans ~/.ssh/config.""" 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() # Retire un ancien bloc du mĂȘme Host (ses lignes indentĂ©es). Pas de # drapeau DOTALL : « . » ne doit PAS traverser les sauts de ligne, # sinon .* engloutirait tout jusqu'Ă  la fin du fichier et effacerait # les entrĂ©es suivantes. pattern = re.compile( rf"(?m)^[ \t]*Host[ \t]+{re.escape(host)}[ \t]*\n" r"(?:[ \t]+[^\n]*\n?)*" ) existing = pattern.sub("", existing).rstrip("\n") block = ( f"Host {host}\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" ) 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 {host}") 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"))) 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, ) 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_pick_branch(self): """Liste les branches distantes d'ERPLibre et en fait choisir une.""" print(f"\n{t('Fetching ERPLibre branch list...')}") 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 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 ) branches.sort() 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 # Paquets QEMU/libvirt pour le profil « DĂ©ploiement » (nos 3 gestionnaires). # Paquets SYSTÈME du profil « ERPLibre DĂ©ploiement » : QEMU/libvirt/ # virtinst + libguestfs (virt-resize/virt-filesystems, requis pour la # RÉDUCTION SÛRE de disque). Ce sont des binaires systĂšme (OCaml) : ils ne # peuvent PAS vivre dans .venv.erplibre (venv Python) — d'oĂč leur place ici. _QEMU_QEMU_PKGS = ( "(sudo apt-get install -y qemu-kvm libvirt-daemon-system virtinst " "cloud-image-utils libguestfs-tools 2>/dev/null || sudo dnf install " "-y qemu-kvm libvirt virt-install cloud-utils guestfs-tools " "2>/dev/null || sudo pacman -S --needed --noconfirm qemu-desktop " "libvirt virt-install libguestfs 2>/dev/null || true)" ) 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 = [ ( 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, ), ] 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_odoo_service_cmd(): """Snippet shell (exĂ©cutĂ© dans la VM) qui installe ERPLibre/Odoo comme service systemd (inspirĂ© de script/systemd/install_daemon.sh) puis l'active + dĂ©marre. N'est ajoutĂ© QUE pour les profils avec Odoo.""" return ( 'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); ' 'SVC_DIR="$HOME/git/erplibre"; ' "sudo tee /etc/systemd/system/erplibre.service >/dev/null < 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; " # 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 " # update best-effort (|| true) puis install OBLIGATOIRE : sans le # « || true », un « apt-get update » en Ă©chec (rĂ©seau) sautait # l'install sans erreur (liste &&) -> git/make absents ensuite. "sudo apt-get update -qq || true; " "sudo apt-get 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; ' "mkdir -p ~/git; " "if [ ! -d ~/git/erplibre/.git ]; then " f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " # Installation : commande finale selon le profil choisi. f"cd ~/git/erplibre && {final_cmd}" ) def _qemu_install_erplibre_monitored( self, names, branch, ip_map=None, final_cmd=None ): """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.""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, ) remote = self._qemu_erplibre_remote_cmd(branch, final_cmd) 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 pas — on indique juste oĂč sont les logs. print(f" {t('Install textual for the dashboard (pip).')}") 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 ): """Clone ERPLibre (branche donnĂ©e) dans ~/git/erplibre de la VM puis exĂ©cute la commande d'install du profil choisi (streamĂ©). `ip` : IP dĂ©jĂ  rĂ©solue ; `final_cmd` : commande d'install.""" 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) 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) # vCPU de base (x1) par VM. Le multiplicateur monte de lĂ . _QEMU_BASE_VCPUS = 2 def _qemu_prompt_resource_mult(self, selected, host_cpu, free_ram): """Multiplicateur de ressources par VM : x1 (minimum catalogue) .. x4. Multiplie la RAM (base = minimum de la version) et les vCPU (base _QEMU_BASE_VCPUS), en bornant les vCPU au nombre de cƓurs de l'hĂŽte et en signalant si la RAM totale dĂ©passe la RAM libre. Renvoie (mult, base_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}" ) sel = input(f"{t('Choice (1-4, default 1):')} ").strip() try: n = int(sel) if 1 <= n <= 4: return n, base_vcpus except ValueError: pass return 1, base_vcpus def _qemu_customize_names(self, selected): """Noms des VM (liste parallĂšle Ă  `selected`). DĂ©faut = nom auto (_qemu_infra_name) ; on peut en renommer certains À LA DEMANDE, par leurs numĂ©ros sĂ©parĂ©s de virgules (vide = garder tous les noms auto).""" names = [ self._qemu_infra_name(d, v, a) for d, v, _r, _dk, a in selected ] print(f"\n{t('VM names (default = auto):')}") for i, (nm, s) in enumerate(zip(names, selected), 1): d, v, _r, _dk, a = s print(f" [{i}] {nm} ({d} {v} [{a}])") raw = input( t("Rename 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 0 <= i < len(names): new = input(f" {names[i]} {t('->')} ").strip() if new: names[i] = new if len(set(names)) != len(names): print(f" ⚠ {t('Duplicate names detected; keeping as entered.')}") return names 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(self, dry_run=False): 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 distros = list(mod.DISTROS) # Rappel de la derniĂšre installation enregistrĂ©e (si historique). last = self._qemu_last_run_line() if last: print(last) # 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 def arches_for(distro): """Architectures Ă  dĂ©ployer pour cette distro selon le choix.""" 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 # 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. flat = [] # (distro, version, ram, disk, is_default, arch) for d in distros: versions_map, default_v = mod.DISTROS[d] for v, (_c, _o, ram, disk) in versions_map.items(): for a in arches_for(d): flat.append((d, v, ram, disk, v == default_v, a)) print(f"\n{t('All versions:')}") for i, (d, v, ram, disk, isdef, a) in enumerate(flat, 1): star = " *" if isdef else "" print(f" [{i}] {d} {v}{star} [{a}] (RAM≄{ram}Mo, {disk})") r = ( input(t("Selection (comma-separated numbers): ")) .strip() .lower() ) for d, v, ram, disk, _isdef, a in self._parse_index_selection( r, flat ): selected.append((d, v, ram, disk, a)) 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 # 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 # 2b) Multiplicateur de ressources par VM (x1 = minimum .. x4) : monte # la RAM et les vCPU selon ce que l'hĂŽte peut fournir. host_cpu = os.cpu_count() or 2 free_ram = self._host_free_ram_mb() res_mult, base_vcpus = self._qemu_prompt_resource_mult( selected, host_cpu, free_ram ) def eff_res(ram): """(RAM effective, vCPU) pour une VM selon le multiplicateur.""" return ram * res_mult, min(base_vcpus * res_mult, host_cpu) # 2c) Noms des VM (auto par dĂ©faut, renommage granulaire Ă  la demande). names = self._qemu_customize_names(selected) # 3) Plan + estimation des ressources (avec le multiplicateur appliquĂ©). total_ram = sum(s[2] * res_mult for s in selected) total_disk = sum(self._parse_disk_gb(s[3]) for s in selected) print(f"\n{t('Deployment plan')} ({len(selected)} VM, x{res_mult}) :") for i, (d, v, ram, disk, a) in enumerate(selected): eram, evcpus = eff_res(ram) print( f" - {names[i]:<30} {d} {v:<7} [{a:<5}] " f"{evcpus} vCPU RAM {eram}Mo {t('disk')} {disk}" ) print(f"\n {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}") # Aperçu (dry-run) : montre les commandes deploy_qemu sans rien crĂ©er # (ni sudo, ni installation), puis on s'arrĂȘte. if dry_run: default_key = self._qemu_default_ssh_key() print(f"\n{t('Preview (dry-run):')}") for i, (d, v, ram, disk, a) in enumerate(selected): eram, evcpus = eff_res(ram) parts = self._qemu_build_deploy_parts( d, v, a, names[i], eram, evcpus, disk, default_key, None, dry_run=True, ) print(" " + " ".join(shlex.quote(p) for p in parts)) return # ClĂ© SSH (partagĂ©e par tout le parc). default_key = self._qemu_default_ssh_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_branch = None install_monitor = False install_cmd = None # commande finale selon le profil choisi ans = input( t("Install ERPLibre into ~/git/erplibre on each VM? (y/N): ") ) if self._is_yes(ans): install_branch = self._qemu_pick_branch() _label, install_cmd = self._qemu_pick_install_profile() install_monitor = self._is_yes_default_yes( input(t("Interactive monitoring dashboard? (y/N): ")) ) add_ssh_config = self._is_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 = [] # (name, d, v, ram, disk, a) deployed = [] for i, (d, v, ram, disk, a) in enumerate(selected): name = names[i] if self._qemu_domain_exists(name): print(f"⏭ {name}: {t('already exists, skipped.')}") deployed.append(name) else: pending.append((name, d, v, ram, disk, a)) n_jobs = len(pending) # 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 # Confirmation puis dĂ©ploiement (en parallĂšle). ans = input(f"\n{t('Deploy these VMs now? (y/N): ')}") if not self._is_yes(ans): print(t("Cancelled.")) return # 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, (name, d, v, ram, disk, a) in enumerate(pending, 1): eram, evcpus = eff_res(ram) parts = self._qemu_build_deploy_parts( d, v, a, name, eram, evcpus, disk, ssh_key, install_branch, dry_run=False, ) jobs.append((f"{k}/{n_jobs}", name, parts)) deploy_start = time.time() n_ok = 0 if jobs: from concurrent.futures import ( ThreadPoolExecutor, as_completed, ) workers = min(parallelism, len(jobs)) print( f"\n{t('Deploying')} {len(jobs)} VM " f"({t('parallel jobs:')} {workers})
" ) 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 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)})" ) tail = [ln for ln in out.strip().splitlines() if ln][-4:] for ln in tail: print(f" {ln}") if rc == 0: deployed.append(jname) n_ok += 1 print( f"\n{t('Deploy summary:')} {n_ok} OK, " f"{len(jobs) - n_ok} {t('failed')}, " f"{len(jobs)} {t('VMs')}, {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: for name in deployed: ip = ip_map.get(name) if ip: self._write_ssh_config_entry(name, "erplibre", ip) # 7) Installation ERPLibre (clone + make) si demandĂ©e. if install_branch: if install_monitor: # Installs dĂ©tachĂ©es en parallĂšle + dashboard Textual. self._qemu_install_erplibre_monitored( deployed, install_branch, ip_map, install_cmd ) 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, ) # 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}") 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) cmd = f"sshfs {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_code(self): print(f"đŸ€– {t('What do you need for development?')}") # help_info = """Commande : # [1] Status Git local et distant # [2] DĂ©marrer le gĂ©nĂ©rateur de code # [3] Format - Formatage automatique selon changement [ou manuelle] # [4] QualitĂ© - QualitĂ© logiciel, dĂ©tecter les fichiers qui manquent les licences AGPLv3 # [0] Retour # """ # help_info = """Commande : # [1] Status Git local et distant # [0] Retour # """ choices = self.config_file.get_config("code_from_makefile") menu_entry = { "prompt_description": t("Open SHELL"), } choices.append(menu_entry) menu_entry = { "prompt_description": t("Upgrade Module"), } choices.append(menu_entry) choices.append( { "prompt_description": t("Debug"), } ) help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False elif status == str(len(choices)): self.debug_ide() elif status == str(len(choices) - 1): self.upgrade_module() elif status == str(len(choices) - 2): self.open_shell_on_database() else: cmd_no_found = True try: int_cmd = int(status) if 0 < int_cmd <= len(choices): cmd_no_found = False instance = choices[int_cmd - 1] self.execute_from_configuration(instance) except ValueError: pass if cmd_no_found: print(t("Command not found !")) def prompt_execute_git(self): print(f"đŸ€– {t('Git management tools!')}") choices = [ {"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 _setup_claude_command( self, command_name, template_filename, personalize=False ): dest_dir = os.path.expanduser("~/.claude/commands") dest_file = os.path.join(dest_dir, f"{command_name}.md") if os.path.exists(dest_file): print(f"{t('File already exists: ')}{dest_file}") overwrite = input(t("Do you want to overwrite the file? (y/Y): ")) if not self._is_yes(overwrite): print(t("Nothing to do.")) return template_path = os.path.join( os.path.dirname(__file__), "..", "..", "conf", template_filename, ) try: with open(template_path) as f: content = f.read() if personalize: name = input(t("Enter your full name: ")).strip() email = input(t("Enter your email: ")).strip() content = content.replace( "Your Name ", f"{name} <{email}>", ) content = content.replace( "Your Name ", f"{name} ", ) os.makedirs(dest_dir, exist_ok=True) with open(dest_file, "w") as f: f.write(content) print(f"{t('File created successfully: ')}{dest_file}") except Exception as e: print(f"{t('Error creating file: ')}{e}") def _claude_add_automation(self): description = input(t("Description of the command to add: ")).strip() if not description: 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_test_module(self, coverage=False): # Module name module_name = input(t("Module name to test: ")).strip() if not module_name: print(t("Module name is required!")) return # Database name db_name = input( t("Temporary database name (default: test_todo_tmp): ") ).strip() if not db_name: db_name = "test_todo_tmp" # Extra modules extra_modules = input( t("Extra modules to install (comma-separated, empty for none): ") ).strip() # Log level log_level = input(t("Log level (default: test): ")).strip() if not log_level: log_level = "test" # Build module list modules_to_install = module_name if extra_modules: modules_to_install += f",{extra_modules}" # Step 1: Create temp DB print(f"\n--- {t('Creating temporary database')} '{db_name}' ---") cmd_restore = f"./script/database/db_restore.py --database {db_name}" self.execute.exec_command_live( cmd_restore, source_erplibre=False, single_source_erplibre=True, ) # Step 2: Install modules print(f"\n--- {t('Installing modules')}: {modules_to_install} ---") cmd_install = ( f"./script/addons/install_addons.sh" f" {db_name} {modules_to_install}" ) self.execute.exec_command_live( cmd_install, source_erplibre=False, single_source_erplibre=True, ) # Step 3: Run tests print(f"\n--- {t('Running tests')}: {module_name} ---") cmd_test = ( f"ODOO_MODE_TEST=true" f" ./run.sh" f" -d {db_name}" f" -u {module_name}" f" --log-level={log_level}" ) if coverage: cmd_test = f"ODOO_MODE_COVERAGE=true {cmd_test}" status_code, output = self.execute.exec_command_live( cmd_test, return_status_and_output=True, source_erplibre=False, single_source_erplibre=True, ) if status_code == 0: print(f"\n✅ {t('Tests completed successfully!')}") else: print(f"\n❌ {t('Tests failed with return code')} {status_code}") # Step 4: Cleanup keep_input = input(t("Keep the temporary database? (y/N): ")) keep = self._is_yes(keep_input) if keep: print(f"{t('Database kept')}: {db_name}") else: print( f"\n--- {t('Cleaning up temporary database')} '{db_name}' ---" ) cmd_drop = f"./odoo_bin.sh db --drop --database {db_name}" self.execute.exec_command_live( cmd_drop, source_erplibre=False, single_source_erplibre=True, ) def execute_unit_tests(self): print(f"\n--- {t('Running unit tests')} ---") cmd = ( ".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 upgrade_poetry(self): # Only show the version to the user status = self.execute.exec_command_live( f"make version", source_erplibre=False, ) # TODO maybe autodetect to update it git_repo_update_input = input( "💬 Would you like to fetch all your git repositories, you need it (y/Y) : " ) if self._is_yes(git_repo_update_input): status = self.execute.exec_command_live( f"./script/manifest/update_manifest_local_dev.sh", source_erplibre=False, ) poetry_lock = "./poetry.lock" try: os.remove(poetry_lock) except Exception as e: pass odoo_long_version = "" if os.path.exists("./.erplibre-version"): with open("./.erplibre-version") as f: odoo_long_version = f.read() path_file_odoo_lock = f"./requirement/poetry.{odoo_long_version}.lock" if odoo_long_version: try: os.remove(path_file_odoo_lock) except Exception as e: pass status = self.execute.exec_command_live( f"pip install -r requirement/erplibre_require-ments-poetry.txt && " f"./script/poetry/poetry_update.py -f", source_erplibre=False, single_source_erplibre=False, single_source_odoo=True, source_odoo=odoo_long_version, ) if os.path.exists(poetry_lock): shutil.copy2(poetry_lock, path_file_odoo_lock) def callback_execute_custom_database(self, config): database_name = self.db_manager.select_database() self.prompt_execute_selenium_and_run_db(database_name) 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() def callback_make_mobile_home(self, config): # Read file default_project_name = "ERPLibre" default_package_name = "ca.erplibre.home" # Read default information if os.path.exists(STRINGS_FILE): tree = ET.parse(STRINGS_FILE) root = tree.getroot() for elem in root.findall("string"): if elem.get("name") == "app_name": default_project_name = elem.text if elem.get("name") == "package_name": default_package_name = elem.text default_project_url_name = "https://erplibre.ca" # Read default information dotenv_file = dotenv.find_dotenv( filename=os.path.join(MOBILE_HOME_PATH, "src", ".env.production") ) default_project_url_name = dotenv.get_key( dotenv_file, "VITE_WEBSITE_URL" ) default_project_note_subject = dotenv.get_key( dotenv_file, "VITE_LABEL_NOTE" ) default_debug = False project_name = default_project_name project_url_name = default_project_url_name project_principal_subject = default_project_note_subject package_name = default_package_name do_debug = default_debug do_change_picture_menu = False do_personalize = input( "Do you want to personalize the mobile application (Y) : " ) if self._is_yes(do_personalize): project_name = ( input( f'Your project name (Separate by space in title), default "{default_project_name}" : ' ).strip() or default_project_name ) package_name = ( input( f'Your package name (separate by . lower case, 3 works like DOMAIN.NAME.OBJECT), default "{default_package_name}" : ' ).strip() or default_package_name ) project_url_name = ( input( f'Your project url website, default "{default_project_url_name}" : ' ).strip() or default_project_url_name ) project_principal_subject = ( input( f'Your project subject, default "{default_project_note_subject}" : ' ).strip() or default_project_note_subject ) do_debug = self._is_yes( input("Compilation with debug information, default No (Y) : ") ) do_change_picture_menu = self._is_yes( input( "Want to change picture from menu, you need" " android-studio (Y) : " ) ) # Rename with script bash cmd_client = f'cd {MOBILE_HOME_PATH} && npx cap init "{project_name}" "{package_name}" && ./rename_android.sh "{project_name}" "{package_name}" && npx cap sync android' self.execute.exec_command_live(cmd_client, source_erplibre=False) # dotenv_mobile = dotenv.dotenv_values(dotenv_file) # dotenv_mobile["VITE_TITLE"] = project_name # dotenv_mobile["VITE_WEBSITE_URL"] = project_url_name dotenv.set_key( dotenv_file, "VITE_TITLE", project_name, quote_mode="always" ) dotenv.set_key( dotenv_file, "VITE_WEBSITE_URL", project_url_name, quote_mode="always", ) dotenv.set_key( dotenv_file, "VITE_LABEL_NOTE", project_principal_subject, quote_mode="always", ) dotenv.set_key( dotenv_file, "VITE_DEBUG_DEV", "true" if do_debug else "false", quote_mode="never", ) if do_change_picture_menu: status = self.execute.exec_command_live( f"cd {MOBILE_HOME_PATH} && npx cap open android;bash", source_erplibre=False, new_window=True, ) print( "Guide for Android-Studio, wait loading is finish. Right-click to app/New/Image Asset and load your image." ) input( "Did you finish to update image with Android-Studio ? Press to continue ..." ) cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/company_logo.png" self.execute.exec_command_live(cmd_client, source_erplibre=False) cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/imgs/logo.png" self.execute.exec_command_live(cmd_client, source_erplibre=False) status = self.execute.exec_command_live( "./mobile/compile_and_run.sh", source_erplibre=False ) if __name__ == "__main__": start_time = time.time() 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")