2025-08-07 06:09:37 -04:00
|
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-12-22 04:02:44 -05:00
|
|
|
|
import configparser
|
2025-08-07 06:09:37 -04:00
|
|
|
|
import datetime
|
2025-04-26 17:12:38 -04:00
|
|
|
|
import getpass
|
2026-07-19 02:41:39 -04:00
|
|
|
|
import inspect
|
2025-04-26 17:12:38 -04:00
|
|
|
|
import json
|
|
|
|
|
|
import logging
|
|
|
|
|
|
import os
|
2026-07-19 03:07:20 -04:00
|
|
|
|
import re
|
2026-07-15 07:48:12 -04:00
|
|
|
|
import shlex
|
2025-08-07 06:09:37 -04:00
|
|
|
|
import shutil
|
|
|
|
|
|
import subprocess
|
2025-05-04 00:40:05 -04:00
|
|
|
|
import sys
|
|
|
|
|
|
import time
|
2025-12-27 05:23:48 -05:00
|
|
|
|
import xml.etree.ElementTree as ET
|
2025-10-31 01:10:54 -04:00
|
|
|
|
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
|
2026-02-19 12:27:35 -05:00
|
|
|
|
from script.execute import execute
|
2026-03-10 03:59:35 -04:00
|
|
|
|
from script.todo.database_manager import DatabaseManager
|
|
|
|
|
|
from script.todo.kdbx_manager import KdbxManager
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
from script.todo import todo_prefs
|
|
|
|
|
|
from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
|
2026-03-10 03:59:35 -04:00
|
|
|
|
from script.todo.version_manager import get_odoo_version
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
2026-03-10 03:06:07 -04:00
|
|
|
|
ERROR_LOG_PATH = ".erplibre.error.txt"
|
|
|
|
|
|
VENV_ERPLIBRE = ".venv.erplibre"
|
2025-08-07 06:09:37 -04:00
|
|
|
|
ENABLE_CRASH = False
|
|
|
|
|
|
CRASH_E = None
|
2025-12-27 05:23:48 -05:00
|
|
|
|
# Support mobile ERPLibre
|
|
|
|
|
|
ANDROID_DIR = "android"
|
2025-12-28 00:37:45 -05:00
|
|
|
|
MOBILE_HOME_PATH = "./mobile/erplibre_home_mobile"
|
2025-12-27 05:23:48 -05:00
|
|
|
|
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")
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2026-02-14 05:53:31 -05:00
|
|
|
|
|
2025-05-04 00:40:05 -04:00
|
|
|
|
try:
|
|
|
|
|
|
import click
|
2025-10-01 04:53:08 -04:00
|
|
|
|
import dotenv
|
2025-08-07 06:09:37 -04:00
|
|
|
|
import humanize
|
2025-05-04 00:40:05 -04:00
|
|
|
|
import openai
|
2025-10-31 01:10:54 -04:00
|
|
|
|
import todo_file_browser
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
|
|
|
|
|
# import urwid
|
|
|
|
|
|
# TODO implement rich for beautiful print and table
|
|
|
|
|
|
# import rich
|
2025-10-31 01:10:54 -04:00
|
|
|
|
import todo_upgrade
|
|
|
|
|
|
from pykeepass import PyKeePass
|
2025-05-04 00:40:05 -04:00
|
|
|
|
except ModuleNotFoundError as e:
|
2025-08-07 06:09:37 -04:00
|
|
|
|
humanize = None
|
|
|
|
|
|
ENABLE_CRASH = True
|
|
|
|
|
|
CRASH_E = e
|
2025-05-04 01:02:12 -04:00
|
|
|
|
|
2025-08-07 06:09:37 -04:00
|
|
|
|
if not ENABLE_CRASH:
|
2026-03-07 02:59:07 -05:00
|
|
|
|
print(t("Importation success!"))
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
|
|
CONFIG_FILE = "./script/todo/todo.json"
|
2025-10-31 01:10:54 -04:00
|
|
|
|
CONFIG_OVERRIDE_FILE = "./private/todo/todo.json"
|
2025-04-26 17:12:38 -04:00
|
|
|
|
LOGO_ASCII_FILE = "./script/todo/logo_ascii.txt"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TODO:
|
|
|
|
|
|
def __init__(self):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
self.dir_path = None
|
2026-03-10 03:45:11 -04:00
|
|
|
|
self.selected_file_path = None
|
2025-10-31 01:10:54 -04:00
|
|
|
|
self.config_file = config_file.ConfigFile()
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute = execute.Execute()
|
2026-03-10 03:59:35 -04:00
|
|
|
|
self.kdbx_manager = KdbxManager(self.config_file)
|
2026-03-10 04:05:04 -04:00
|
|
|
|
self.db_manager = DatabaseManager(self.execute, self.fill_help_info)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2026-03-07 02:59:07 -05:00
|
|
|
|
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()
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Choose language / Choisir la langue") + ":")
|
|
|
|
|
|
print(f"[1] {t('French')}")
|
|
|
|
|
|
print(f"[2] {t('English')}")
|
|
|
|
|
|
print(f"[0] {t('Back')}")
|
2026-03-07 02:59:07 -05:00
|
|
|
|
choice = ""
|
|
|
|
|
|
while choice not in ("0", "1", "2"):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
choice = input(t("Select: ")).strip()
|
2026-03-07 02:59:07 -05:00
|
|
|
|
if choice == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif choice == "1":
|
|
|
|
|
|
set_lang("fr")
|
|
|
|
|
|
else:
|
|
|
|
|
|
set_lang("en")
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Language changed to: English"))
|
2026-03-07 02:59:07 -05:00
|
|
|
|
|
2025-04-26 17:12:38 -04:00
|
|
|
|
def run(self):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
with open(self.config_file.get_logo_ascii_file_path()) as my_file:
|
2025-04-26 17:12:38 -04:00
|
|
|
|
print(my_file.read())
|
2026-03-07 02:59:07 -05:00
|
|
|
|
self._ask_language()
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Opening TODO ..."))
|
|
|
|
|
|
print(f"🤖 {t('=> Enter your choice by number and press Enter!')}")
|
2026-07-19 02:41:39 -04:00
|
|
|
|
help_info = f"""{self._menu_header()}
|
2026-03-12 05:31:45 -04:00
|
|
|
|
[1] {t("Execute")}
|
|
|
|
|
|
[2] {t("Install")}
|
|
|
|
|
|
[3] {t("Question")}
|
|
|
|
|
|
[4] {t("Fork - Open TODO in a new tab")}
|
2026-07-29 05:35:31 -04:00
|
|
|
|
[5] {t("Navigation telemetry (TUI)")}
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
[6] {t("Configuration")}
|
2026-03-12 05:31:45 -04:00
|
|
|
|
[0] {t("Quit")}
|
2025-04-26 17:12:38 -04:00
|
|
|
|
"""
|
|
|
|
|
|
while True:
|
2025-08-07 06:09:37 -04:00
|
|
|
|
try:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
except NameError:
|
|
|
|
|
|
print("Do")
|
2026-03-10 03:06:07 -04:00
|
|
|
|
print(f"source ./{VENV_ERPLIBRE}/bin/activate && make")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print("Do")
|
2026-03-10 03:06:07 -04:00
|
|
|
|
print(f"source ./{VENV_ERPLIBRE}/bin/activate && make")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
sys.exit(1)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
except click.exceptions.Abort:
|
|
|
|
|
|
sys.exit(0)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
break
|
|
|
|
|
|
elif status == "1":
|
|
|
|
|
|
self.prompt_execute()
|
2025-05-04 01:02:12 -04:00
|
|
|
|
elif status == "2":
|
2025-08-07 06:09:37 -04:00
|
|
|
|
self.prompt_install()
|
2025-04-26 17:12:38 -04:00
|
|
|
|
elif status == "3":
|
2025-08-07 06:09:37 -04:00
|
|
|
|
self.execute_prompt_ia()
|
|
|
|
|
|
elif status == "4":
|
|
|
|
|
|
# cmd = (
|
|
|
|
|
|
# f"gnome-terminal --tab -- bash -c 'source"
|
2026-03-10 03:06:07 -04:00
|
|
|
|
# f" ./{VENV_ERPLIBRE}/bin/activate;make todo'"
|
2025-08-07 06:09:37 -04:00
|
|
|
|
# )
|
|
|
|
|
|
cmd = "make todo"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=True)
|
2026-07-29 05:35:31 -04:00
|
|
|
|
elif status == "5":
|
|
|
|
|
|
self._todo_telemetry_tui()
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
elif status == "6":
|
|
|
|
|
|
self.prompt_configuration()
|
2025-04-26 17:12:38 -04:00
|
|
|
|
# elif status == "3" or status == "install":
|
|
|
|
|
|
# print("install")
|
|
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
|
|
|
|
|
print(status)
|
|
|
|
|
|
# manipuler()
|
|
|
|
|
|
|
|
|
|
|
|
def execute_prompt_ia(self):
|
|
|
|
|
|
while True:
|
2026-07-19 02:41:39 -04:00
|
|
|
|
help_info = f"""{self._menu_header()}
|
2026-03-12 05:31:45 -04:00
|
|
|
|
[0] {t("Back")}
|
|
|
|
|
|
{t("Write your question ")}"""
|
2025-04-26 17:12:38 -04:00
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return
|
2026-03-10 03:59:35 -04:00
|
|
|
|
kp = self.kdbx_manager.get_kdbx()
|
2025-04-26 17:12:38 -04:00
|
|
|
|
if not kp:
|
|
|
|
|
|
return
|
2026-03-10 03:45:11 -04:00
|
|
|
|
config_name = self.config_file.get_config_value(
|
2025-04-26 17:12:38 -04:00
|
|
|
|
["kdbx_config", "openai", "kdbx_key"]
|
|
|
|
|
|
)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
entry = kp.find_entries_by_title(config_name, first=True)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
|
|
|
|
|
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):
|
2026-07-19 02:41:39 -04:00
|
|
|
|
help_info = f"""{self._menu_header()}
|
2026-07-19 03:41:00 -04:00
|
|
|
|
|
|
|
|
|
|
── {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")}
|
2026-07-30 23:03:48 -04:00
|
|
|
|
[8] {t("Doc - Documentation search")}
|
2026-07-19 03:41:00 -04:00
|
|
|
|
|
|
|
|
|
|
── {t("AI & automation")} ──
|
2026-07-30 23:03:48 -04:00
|
|
|
|
[9] {t("GPT code - AI assistant tools")}
|
|
|
|
|
|
[10] {t("Automation - Demonstration of developed features")}
|
2026-07-19 03:41:00 -04:00
|
|
|
|
|
|
|
|
|
|
── {t("Deployment, network & security")} ──
|
2026-07-30 23:03:48 -04:00
|
|
|
|
[11] {t("Deploy - Deploy ERPLibre locally")}
|
|
|
|
|
|
[12] {t("Network - Network tools")}
|
|
|
|
|
|
[13] {t("Security - Dependency security audit")}
|
2026-07-19 03:41:00 -04:00
|
|
|
|
|
|
|
|
|
|
── {t("Preferences")} ──
|
2026-07-30 23:03:48 -04:00
|
|
|
|
[14] {t("Language - Change language / Changer la langue")}
|
2026-03-12 05:31:45 -04:00
|
|
|
|
[0] {t("Back")}
|
2025-04-26 17:12:38 -04:00
|
|
|
|
"""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return
|
|
|
|
|
|
elif status == "1":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_code()
|
2025-04-26 17:12:38 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
|
|
|
|
|
elif status == "2":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_config()
|
2025-04-26 17:12:38 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2025-05-04 01:02:12 -04:00
|
|
|
|
elif status == "3":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_instance()
|
2025-05-04 01:02:12 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
|
|
|
|
|
elif status == "4":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_test()
|
2025-05-04 01:02:12 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2025-10-31 01:10:54 -04:00
|
|
|
|
elif status == "5":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_process()
|
2025-10-31 01:10:54 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
|
|
|
|
|
elif status == "6":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_database()
|
2025-10-31 01:10:54 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2025-12-22 04:02:44 -05:00
|
|
|
|
elif status == "7":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_git()
|
2025-12-22 04:02:44 -05:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-02-13 03:46:27 -05:00
|
|
|
|
elif status == "8":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_doc()
|
2026-02-28 03:24:22 -05:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "9":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_gpt_code()
|
2026-03-07 01:38:55 -05:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "10":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_function()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "11":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_deploy()
|
2026-03-09 16:34:43 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "12":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_network()
|
2026-03-07 02:59:07 -05:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "13":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self.prompt_execute_security()
|
2026-03-10 02:05:53 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2026-07-30 23:03:48 -04:00
|
|
|
|
elif status == "14":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
status = self._change_language()
|
2026-03-12 05:40:22 -04:00
|
|
|
|
if status is not False:
|
|
|
|
|
|
return
|
2025-04-26 17:12:38 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-08-07 06:09:37 -04:00
|
|
|
|
def prompt_install(self):
|
|
|
|
|
|
print("Detect first installation from code source.")
|
|
|
|
|
|
|
|
|
|
|
|
first_installation_input = (
|
|
|
|
|
|
input(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
"💬 First system installation? This will process system installation"
|
2025-08-07 06:09:37 -04:00
|
|
|
|
" before (Y/N): "
|
|
|
|
|
|
)
|
|
|
|
|
|
.strip()
|
2025-10-31 01:10:54 -04:00
|
|
|
|
.lower()
|
2025-08-07 06:09:37 -04:00
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
if self._is_yes(first_installation_input):
|
2025-08-07 06:09:37 -04:00
|
|
|
|
cmd = "./script/version/update_env_version.py --install"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=True)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
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 = (
|
2025-10-31 01:10:54 -04:00
|
|
|
|
input("💬 Open Pycharm? (Y/N): ").strip().lower()
|
2025-08-07 06:09:37 -04:00
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
if self._is_yes(pycharm_configuration_input):
|
2025-08-07 06:09:37 -04:00
|
|
|
|
pycharm_bin = "pycharm" if has_pycharm else "pycharm-community"
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
|
|
|
|
|
cmd = f"cd {os.getcwd()} && {pycharm_bin} ./"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
cmd,
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
single_source_erplibre=False,
|
|
|
|
|
|
new_window=True,
|
|
|
|
|
|
)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
print(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
"👹 WAIT and Close Pycharm when processing is done before continue"
|
2025-08-07 06:09:37 -04:00
|
|
|
|
" this guide."
|
|
|
|
|
|
)
|
|
|
|
|
|
# TODO detect last version supported
|
2025-10-31 01:10:54 -04:00
|
|
|
|
# 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
|
2026-03-10 03:45:11 -04:00
|
|
|
|
commands_begin = {
|
2025-10-31 01:10:54 -04:00
|
|
|
|
"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",
|
|
|
|
|
|
),
|
2025-10-01 03:01:00 -04:00
|
|
|
|
"m": (
|
|
|
|
|
|
"m",
|
|
|
|
|
|
"m: ERPLibre with mobile home",
|
|
|
|
|
|
"./mobile/install_and_run.sh",
|
|
|
|
|
|
),
|
2025-10-31 01:10:54 -04:00
|
|
|
|
"0": (
|
|
|
|
|
|
"0",
|
2026-03-12 05:31:45 -04:00
|
|
|
|
f"0: {t('Quit')}",
|
2025-10-31 01:10:54 -04:00
|
|
|
|
),
|
|
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
commands_end = {}
|
|
|
|
|
|
versions, installed_versions, odoo_installed_version = (
|
2026-03-10 03:59:35 -04:00
|
|
|
|
get_odoo_version()
|
2025-08-07 06:09:37 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
for version_info in versions[::-1]:
|
2025-10-31 01:10:54 -04:00
|
|
|
|
key_i += 1
|
|
|
|
|
|
key_s = str(key_i)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
label = f"{key_s}: Odoo {version_info.get('odoo_version')}"
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
odoo_version = f"odoo{version_info.get('odoo_version')}"
|
|
|
|
|
|
if odoo_version in installed_versions:
|
2025-10-31 01:10:54 -04:00
|
|
|
|
label += " - Installed"
|
|
|
|
|
|
if odoo_version == odoo_installed_version:
|
|
|
|
|
|
label += " - Actual"
|
2026-03-12 05:31:45 -04:00
|
|
|
|
if version_info.get("Default"):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
label += " - Default"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if version_info.get("is_deprecated"):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
label += " - Deprecated"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
erplibre_version = version_info.get("erplibre_version")
|
|
|
|
|
|
commands_begin[key_s] = (
|
2025-10-31 01:10:54 -04:00
|
|
|
|
key_s,
|
|
|
|
|
|
label,
|
|
|
|
|
|
f"./script/version/update_env_version.py --erplibre_version {erplibre_version} --install_dev",
|
2025-08-07 06:09:37 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
# Add final command
|
2026-03-10 03:45:11 -04:00
|
|
|
|
install_commands = {**commands_begin, **commands_end}
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
|
|
|
|
|
# Show command
|
|
|
|
|
|
odoo_version_input = ""
|
2026-03-10 03:45:11 -04:00
|
|
|
|
while odoo_version_input not in install_commands:
|
2025-10-31 01:10:54 -04:00
|
|
|
|
if odoo_version_input:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Error, cannot understand value')} '{odoo_version_input}'"
|
|
|
|
|
|
)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
str_input_dyn_odoo_version = (
|
2026-03-12 05:31:45 -04:00
|
|
|
|
f"💬 {t('Choose a version:')}\n\t"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
+ "\n\t".join([a[1] for a in install_commands.values()])
|
2026-03-12 05:31:45 -04:00
|
|
|
|
+ f"\n{t('Select: ')}"
|
2025-10-31 01:10:54 -04:00
|
|
|
|
)
|
|
|
|
|
|
odoo_version_input = (
|
|
|
|
|
|
input(str_input_dyn_odoo_version).strip().lower()
|
|
|
|
|
|
)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
if odoo_version_input == "0":
|
|
|
|
|
|
return
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
cmd_intern = install_commands.get(odoo_version_input)[2]
|
2026-03-30 14:46:56 -04:00
|
|
|
|
|
|
|
|
|
|
# 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"
|
|
|
|
|
|
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Will execute:')}\n{cmd_intern}")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
# 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:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"{t('The Bash script failed with return code')} {e.returncode}."
|
|
|
|
|
|
)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
print("Wait after installation and open projects by terminal.")
|
|
|
|
|
|
print("make open_terminal")
|
|
|
|
|
|
self.restart_script(str(e))
|
2025-08-07 06:09:37 -04:00
|
|
|
|
|
2025-04-28 00:32:51 -04:00
|
|
|
|
def execute_from_configuration(
|
2026-03-10 03:45:11 -04:00
|
|
|
|
self, instance, exec_run_db=False, ignore_makefile=False
|
2025-04-28 00:32:51 -04:00
|
|
|
|
):
|
|
|
|
|
|
# exec_run_db need argument database
|
2026-03-10 03:45:11 -04:00
|
|
|
|
kdbx_key = instance.get("kdbx_key")
|
|
|
|
|
|
odoo_user = instance.get("user")
|
|
|
|
|
|
odoo_password = instance.get("password")
|
2025-04-28 00:32:51 -04:00
|
|
|
|
|
|
|
|
|
|
if kdbx_key:
|
2026-03-10 04:05:04 -04:00
|
|
|
|
extra_cmd_web_login = self.kdbx_manager.get_extra_command_user(
|
|
|
|
|
|
kdbx_key
|
|
|
|
|
|
)
|
2025-04-28 00:32:51 -04:00
|
|
|
|
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 = ""
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
makefile_cmd = instance.get("makefile_cmd")
|
2025-04-28 00:32:51 -04:00
|
|
|
|
if makefile_cmd and not ignore_makefile:
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
f"make {makefile_cmd}",
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
single_source_erplibre=True,
|
|
|
|
|
|
)
|
2025-11-10 03:23:57 -05:00
|
|
|
|
if status:
|
|
|
|
|
|
_logger.error(
|
|
|
|
|
|
f"Status {status} - exit execute_from_configuration"
|
|
|
|
|
|
)
|
|
|
|
|
|
return
|
2025-04-28 00:32:51 -04:00
|
|
|
|
|
|
|
|
|
|
if exec_run_db:
|
2026-03-10 03:45:11 -04:00
|
|
|
|
db_name = instance.get("database")
|
2025-04-28 00:32:51 -04:00
|
|
|
|
self.prompt_execute_selenium_and_run_db(
|
|
|
|
|
|
db_name, extra_cmd_web_login=extra_cmd_web_login
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-12 04:04:52 -04:00
|
|
|
|
bash_command = instance.get("bash_command")
|
|
|
|
|
|
if bash_command:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Will execute:')} {bash_command}")
|
2026-03-13 15:04:14 -04:00
|
|
|
|
self.execute.exec_command_live(bash_command, source_erplibre=False)
|
2026-03-12 04:04:52 -04:00
|
|
|
|
|
2026-03-12 05:31:45 -04:00
|
|
|
|
command = instance.get("Command:")
|
2025-04-28 00:32:51 -04:00
|
|
|
|
if command:
|
|
|
|
|
|
self.prompt_execute_selenium(
|
|
|
|
|
|
command=command, extra_cmd_web_login=extra_cmd_web_login
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
callback = instance.get("callback")
|
2025-10-01 04:53:08 -04:00
|
|
|
|
if callback:
|
2026-03-10 03:45:11 -04:00
|
|
|
|
callback(instance)
|
2025-10-01 04:53:08 -04:00
|
|
|
|
|
2026-07-19 02:41:39 -04:00
|
|
|
|
# É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",
|
2026-07-29 04:14:50 -04:00
|
|
|
|
"prompt_execute_deploy_ssh": "SSH",
|
2026-07-19 02:41:39 -04:00
|
|
|
|
"prompt_execute_qemu": "QEMU/KVM",
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
"prompt_configuration": "Configuration",
|
2026-07-19 02:41:39 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-07-29 05:35:31 -04:00
|
|
|
|
# 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
|
2026-07-19 02:41:39 -04:00
|
|
|
|
return header + t("Command:")
|
|
|
|
|
|
|
2026-07-29 05:35:31 -04:00
|
|
|
|
def _todo_telemetry_tui(self):
|
2026-07-29 06:09:06 -04:00
|
|
|
|
"""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."""
|
[IMP] script todo: télémétrie — exécuter une commande + vue Kanban (F3)
Le TUI de télémétrie devient aussi un LANCEUR, avec deux vues :
- Sélectionner une COMMANDE (feuille de l'arbre, ou carte du Kanban) +
Entrée -> le TUI se ferme et la commande est EXÉCUTÉE (getattr(self, méthode)
(**kwargs)). Les kwargs littéraux sont extraits du code (ex. Aperçu ->
_qemu_deploy(dry_run=True)), donc la commande est rejouée à l'identique.
- Vue KANBAN : une colonne par menu contenant des commandes (issu du code),
cartes = commandes exécutables + compteur de visites du menu.
- F3 bascule entre vue Arbre et vue Kanban. Résumé mis à jour (Entrée =
exécuter, F3 = vue).
_dispatch capture désormais (méthode, kwargs) ; les feuilles de l'arbre
portent la méthode en data ; run_tui renvoie (méthode, kwargs) à exécuter.
Validé headless : 11 colonnes Kanban, kwargs (dry_run/production_ready)
capturés, bascule F3, capture de l'action à exécuter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 05:56:50 -04:00
|
|
|
|
from script.todo.todo_telemetry import run_tui
|
2026-07-29 05:35:31 -04:00
|
|
|
|
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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
|
2026-07-29 07:23:54 -04:00
|
|
|
|
# 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
|
2026-07-29 06:09:06 -04:00
|
|
|
|
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 ?
|
2026-08-01 02:42:45 -04:00
|
|
|
|
ans = input(f"\n{t('Back to telemetry (r) or quit (Enter)? ')}")
|
2026-07-29 06:09:06 -04:00
|
|
|
|
if ans.strip().lower() not in ("r", "revenir", "o", "oui", "y"):
|
|
|
|
|
|
return
|
2026-07-29 05:35:31 -04:00
|
|
|
|
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
# Préférences éditables depuis le menu Configuration : clé, libellé, et
|
|
|
|
|
|
# valeurs proposées (valeur stockée -> libellé affiché). Une seule table :
|
|
|
|
|
|
# l'écran, la lecture et l'écriture en découlent.
|
|
|
|
|
|
_PREF_CHOICES = {
|
|
|
|
|
|
"qemu_deploy_ui": (
|
|
|
|
|
|
"QEMU deployment interface",
|
|
|
|
|
|
(
|
|
|
|
|
|
("ask", "Ask every time"),
|
|
|
|
|
|
("tui", "TUI form"),
|
|
|
|
|
|
("cli", "Classic questions (line by line)"),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
"qemu_deploy_progress": (
|
|
|
|
|
|
"Display while deploying",
|
|
|
|
|
|
(
|
|
|
|
|
|
("cli", "CLI output (easy to copy)"),
|
|
|
|
|
|
("tui", "TUI, collapsible blocks per VM"),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
[ADD] migration: a TUI resume screen, same first question as the prompt
The migration now opens with the same choice the QEMU deployment does — TUI
or line-by-line prompts — settled in advance by TODO > Configuration if you
want it to be. Choosing the TUI loads the saved progression and asks the very
same first question: where does this migration stand, and where do we resume?
prompt_resume was one function that rendered, read and decided at once, so a
second view would have meant a second copy of the decision. It is now three:
resume_context() the progression -> plain data (file, database, target,
steps with their icon and detail, version bumps)
print_resume() renders it on the terminal
apply_resume_answer() answer -> (progression, changed)
The TUI returns the SAME answer strings as the prompt — c, n, r, q, 0..4,
4.<version> — so apply_resume_answer stays the only place that decides what a
choice means. Neither view can drift into describing the migration
differently, because both render the same context.
In the TUI the steps are a table and the version bumps a list: Enter on either
replays from there, which is what « [0-4] » and « [4.N] » meant in text. The
cursor opens on the first unfinished step and on the first unmigrated version
— where it stopped is where one usually wants to act.
Both views gain « q », quit without doing anything. A TUI needs Escape to do
something sane, and an escape hatch present in only one of the two views is
exactly the kind of divergence this split exists to prevent. execute_odoo_
upgrade returns immediately on it, writing nothing.
Verified on a 12->18 progression with steps 0-3 done and 2 of 6 bumps
migrated: identical context feeding both views, Enter on step 2 giving « 2 »,
Enter on the 15 bump giving « 4.15 », the c/n/r/q/Escape shortcuts, and every
answer producing the same progression through both paths — « 2 » leaving only
the state of steps 0 and 1, « 4.15 » resetting the clone list from the third
bump on so the half-migrated intermediate database gets rebuilt. « q » checked
to leave the progression file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:38:31 -04:00
|
|
|
|
"migration_ui": (
|
|
|
|
|
|
"Odoo migration interface",
|
|
|
|
|
|
(
|
|
|
|
|
|
("ask", "Ask every time"),
|
|
|
|
|
|
("tui", "TUI form"),
|
|
|
|
|
|
("cli", "Classic questions (line by line)"),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _pref_label(self, key):
|
|
|
|
|
|
"""Libellé traduit de la valeur courante d'une préférence."""
|
|
|
|
|
|
value = todo_prefs.get(key)
|
|
|
|
|
|
for stored, label in self._PREF_CHOICES[key][1]:
|
|
|
|
|
|
if stored == value:
|
|
|
|
|
|
return t(label)
|
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
|
|
def _pref_edit(self, key):
|
|
|
|
|
|
"""Fait choisir une valeur parmi celles proposées pour `key`."""
|
|
|
|
|
|
title, options = self._PREF_CHOICES[key]
|
|
|
|
|
|
current = todo_prefs.get(key)
|
|
|
|
|
|
print(f"\n{t(title)} :")
|
|
|
|
|
|
for i, (stored, label) in enumerate(options, 1):
|
|
|
|
|
|
star = " *" if stored == current else ""
|
|
|
|
|
|
print(f" [{i}] {t(label)}{star}")
|
|
|
|
|
|
sel = input(f"{t('Choice (number, blank = keep):')} ").strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(sel) - 1
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return
|
|
|
|
|
|
if 0 <= idx < len(options):
|
|
|
|
|
|
todo_prefs.set(key, options[idx][0])
|
|
|
|
|
|
print(f" ✅ {t(title)} : {self._pref_label(key)}")
|
|
|
|
|
|
|
|
|
|
|
|
def prompt_configuration(self):
|
|
|
|
|
|
"""Réglages persistants de l'utilisateur (~/.erplibre/todo_prefs.json).
|
|
|
|
|
|
La langue vit à part, dans env_var.sh, et garde son propre mécanisme.
|
|
|
|
|
|
"""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
lang = "français" if get_lang() == "fr" else "English"
|
|
|
|
|
|
choices = [
|
|
|
|
|
|
{"section": t("Interface")},
|
|
|
|
|
|
{"prompt_description": f"{t('Language / Langue')} ({lang})"},
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": (
|
|
|
|
|
|
f"{t('QEMU deployment interface')} "
|
|
|
|
|
|
f"({self._pref_label('qemu_deploy_ui')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": (
|
|
|
|
|
|
f"{t('Display while deploying')} "
|
|
|
|
|
|
f"({self._pref_label('qemu_deploy_progress')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
[ADD] migration: a TUI resume screen, same first question as the prompt
The migration now opens with the same choice the QEMU deployment does — TUI
or line-by-line prompts — settled in advance by TODO > Configuration if you
want it to be. Choosing the TUI loads the saved progression and asks the very
same first question: where does this migration stand, and where do we resume?
prompt_resume was one function that rendered, read and decided at once, so a
second view would have meant a second copy of the decision. It is now three:
resume_context() the progression -> plain data (file, database, target,
steps with their icon and detail, version bumps)
print_resume() renders it on the terminal
apply_resume_answer() answer -> (progression, changed)
The TUI returns the SAME answer strings as the prompt — c, n, r, q, 0..4,
4.<version> — so apply_resume_answer stays the only place that decides what a
choice means. Neither view can drift into describing the migration
differently, because both render the same context.
In the TUI the steps are a table and the version bumps a list: Enter on either
replays from there, which is what « [0-4] » and « [4.N] » meant in text. The
cursor opens on the first unfinished step and on the first unmigrated version
— where it stopped is where one usually wants to act.
Both views gain « q », quit without doing anything. A TUI needs Escape to do
something sane, and an escape hatch present in only one of the two views is
exactly the kind of divergence this split exists to prevent. execute_odoo_
upgrade returns immediately on it, writing nothing.
Verified on a 12->18 progression with steps 0-3 done and 2 of 6 bumps
migrated: identical context feeding both views, Enter on step 2 giving « 2 »,
Enter on the 15 bump giving « 4.15 », the c/n/r/q/Escape shortcuts, and every
answer producing the same progression through both paths — « 2 » leaving only
the state of steps 0 and 1, « 4.15 » resetting the clone list from the third
bump on so the half-migrated intermediate database gets rebuilt. « q » checked
to leave the progression file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:38:31 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": (
|
|
|
|
|
|
f"{t('Odoo migration interface')} "
|
|
|
|
|
|
f"({self._pref_label('migration_ui')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
{"section": t("Maintenance")},
|
|
|
|
|
|
{"prompt_description": t("Reset all preferences")},
|
|
|
|
|
|
]
|
|
|
|
|
|
status = click.prompt(self.fill_help_info(choices))
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return
|
|
|
|
|
|
elif status == "1":
|
|
|
|
|
|
self._change_language()
|
|
|
|
|
|
elif status == "2":
|
|
|
|
|
|
self._pref_edit("qemu_deploy_ui")
|
|
|
|
|
|
elif status == "3":
|
|
|
|
|
|
self._pref_edit("qemu_deploy_progress")
|
|
|
|
|
|
elif status == "4":
|
[ADD] migration: a TUI resume screen, same first question as the prompt
The migration now opens with the same choice the QEMU deployment does — TUI
or line-by-line prompts — settled in advance by TODO > Configuration if you
want it to be. Choosing the TUI loads the saved progression and asks the very
same first question: where does this migration stand, and where do we resume?
prompt_resume was one function that rendered, read and decided at once, so a
second view would have meant a second copy of the decision. It is now three:
resume_context() the progression -> plain data (file, database, target,
steps with their icon and detail, version bumps)
print_resume() renders it on the terminal
apply_resume_answer() answer -> (progression, changed)
The TUI returns the SAME answer strings as the prompt — c, n, r, q, 0..4,
4.<version> — so apply_resume_answer stays the only place that decides what a
choice means. Neither view can drift into describing the migration
differently, because both render the same context.
In the TUI the steps are a table and the version bumps a list: Enter on either
replays from there, which is what « [0-4] » and « [4.N] » meant in text. The
cursor opens on the first unfinished step and on the first unmigrated version
— where it stopped is where one usually wants to act.
Both views gain « q », quit without doing anything. A TUI needs Escape to do
something sane, and an escape hatch present in only one of the two views is
exactly the kind of divergence this split exists to prevent. execute_odoo_
upgrade returns immediately on it, writing nothing.
Verified on a 12->18 progression with steps 0-3 done and 2 of 6 bumps
migrated: identical context feeding both views, Enter on step 2 giving « 2 »,
Enter on the 15 bump giving « 4.15 », the c/n/r/q/Escape shortcuts, and every
answer producing the same progression through both paths — « 2 » leaving only
the state of steps 0 and 1, « 4.15 » resetting the clone list from the third
bump on so the half-migrated intermediate database gets rebuilt. « q » checked
to leave the progression file byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:38:31 -04:00
|
|
|
|
self._pref_edit("migration_ui")
|
|
|
|
|
|
elif status == "5":
|
[ADD] todo: persistent preferences and a Configuration menu
Nothing in the CLI could be remembered from one session to the next except the
language, which lives in env_var.sh with its own ad-hoc parser. Entry [6]
Configuration, below Telemetry, now groups the settings that belong to the
USER rather than to the repository.
todo_prefs.py stores them in ~/.erplibre/todo_prefs.json — the same place and
the same best-effort shape as todo_telemetry.py, since both are per-user and
per-machine, and neither must ever prevent the CLI from starting. A DEFAULTS
table gives every known key its fallback, so a missing or corrupt file simply
reads as defaults.
The two keys added here prepare the QEMU deploy form: which interface to use
(ask / TUI / classic) and what to display while deploying (CLI output or TUI).
They are declared once in _PREF_CHOICES — the screen, the current-value label
and the editor all derive from that single table, so adding a preference is
one entry, not three edits.
Language keeps its own mechanism: it is read before the preferences file
exists and is consumed by shell scripts too.
Verified against a temporary HOME: defaults returned with no file on disk, a
change persisted and reflected in the menu, and reset falling back to
defaults.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:59:12 -04:00
|
|
|
|
n = todo_prefs.reset()
|
|
|
|
|
|
print(f"✅ {t('Preferences reset')} ({n})")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(t("Command not found !"))
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
def fill_help_info(self, choices):
|
2026-07-19 03:30:06 -04:00
|
|
|
|
# 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).
|
2026-07-19 02:41:39 -04:00
|
|
|
|
help_info = self._menu_header() + "\n"
|
2026-03-12 05:31:45 -04:00
|
|
|
|
help_end = f"[0] {t('Back')}\n"
|
2026-07-19 03:30:06 -04:00
|
|
|
|
n = 0
|
|
|
|
|
|
for instance in choices:
|
|
|
|
|
|
section = instance.get("section")
|
|
|
|
|
|
if section:
|
|
|
|
|
|
help_info += f"\n── {section} ──\n"
|
|
|
|
|
|
continue
|
|
|
|
|
|
n += 1
|
2026-03-10 03:45:11 -04:00
|
|
|
|
desc_key = instance.get("prompt_description_key")
|
2026-03-07 02:59:07 -05:00
|
|
|
|
if desc_key:
|
|
|
|
|
|
desc = t(desc_key)
|
|
|
|
|
|
else:
|
2026-03-10 03:45:11 -04:00
|
|
|
|
desc = instance["prompt_description"]
|
2026-07-19 03:30:06 -04:00
|
|
|
|
help_info += f"[{n}] " + desc + "\n"
|
2025-04-26 17:12:38 -04:00
|
|
|
|
help_info += help_end
|
2025-04-28 00:32:51 -04:00
|
|
|
|
return help_info
|
|
|
|
|
|
|
|
|
|
|
|
def prompt_execute_instance(self):
|
2025-05-04 01:02:12 -04:00
|
|
|
|
# TODO proposer le déploiement à distance
|
|
|
|
|
|
# TODO proposer l'exécution de docker
|
|
|
|
|
|
# TODO proposer la création de docker
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = self.config_file.get_config("instance")
|
|
|
|
|
|
init_len = len(choices)
|
2025-10-01 04:53:08 -04:00
|
|
|
|
|
2026-02-28 01:35:32 -05:00
|
|
|
|
# Support mobile ERPLibre
|
2025-12-27 05:23:48 -05:00
|
|
|
|
if os.path.exists(MOBILE_HOME_PATH):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
menu_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Mobile - Compile and run software"),
|
2025-10-01 04:53:08 -04:00
|
|
|
|
"callback": self.callback_make_mobile_home,
|
|
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(menu_entry)
|
2026-02-28 01:35:32 -05:00
|
|
|
|
|
|
|
|
|
|
# Support custom database to execute
|
2026-03-10 03:45:11 -04:00
|
|
|
|
menu_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Choose your database"),
|
2026-02-28 01:35:32 -05:00
|
|
|
|
"callback": self.callback_execute_custom_database,
|
|
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.insert(0, menu_entry)
|
|
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
int_cmd = int(status)
|
2026-02-28 01:35:32 -05:00
|
|
|
|
if 1 < int_cmd <= init_len:
|
2025-04-26 17:12:38 -04:00
|
|
|
|
cmd_no_found = False
|
2026-03-13 15:04:14 -04:00
|
|
|
|
status = click.confirm(
|
|
|
|
|
|
t("Do you want a new instance?")
|
|
|
|
|
|
)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
2025-04-28 00:32:51 -04:00
|
|
|
|
self.execute_from_configuration(
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance,
|
2025-04-28 00:32:51 -04:00
|
|
|
|
exec_run_db=True,
|
|
|
|
|
|
ignore_makefile=not bool(status),
|
|
|
|
|
|
)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif int_cmd <= len(choices) or 1 == int_cmd:
|
2026-02-28 01:35:32 -05:00
|
|
|
|
cmd_no_found = False
|
2025-10-01 04:53:08 -04:00
|
|
|
|
# Execute dynamic instance
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
2025-10-01 04:53:08 -04:00
|
|
|
|
self.execute_from_configuration(
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance,
|
2025-10-01 04:53:08 -04:00
|
|
|
|
)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
def prompt_execute_function(self):
|
|
|
|
|
|
choices = self.config_file.get_config("function")
|
|
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
int_cmd = int(status)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if 0 < int_cmd <= len(choices):
|
2025-04-26 17:12:38 -04:00
|
|
|
|
cmd_no_found = False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
|
|
|
|
|
self.execute_from_configuration(instance)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-05-04 01:02:12 -04:00
|
|
|
|
def prompt_execute_update(self):
|
2026-02-13 01:27:38 -05:00
|
|
|
|
# self.execute.exec_command_live(f"make {makefile_cmd}")
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Development update')}")
|
2025-05-04 01:02:12 -04:00
|
|
|
|
# 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
|
2025-10-31 01:10:54 -04:00
|
|
|
|
# TODO faire la mise à jour de ERPLibre
|
|
|
|
|
|
# TODO faire l'upgrade d'un odoo vers un autre
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = self.config_file.get_config("update_from_makefile")
|
|
|
|
|
|
menu_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Upgrade Odoo - Migration Database"),
|
2025-10-31 01:10:54 -04:00
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(menu_entry)
|
|
|
|
|
|
poetry_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Upgrade Poetry - Dependency of Odoo"),
|
2025-10-31 01:10:54 -04:00
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(poetry_entry)
|
|
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-05-04 01:02:12 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif status == str(len(choices) - 1):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
upgrade = todo_upgrade.TodoUpgrade(self)
|
|
|
|
|
|
upgrade.execute_odoo_upgrade()
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif status == str(len(choices)):
|
2025-10-31 01:10:54 -04:00
|
|
|
|
self.upgrade_poetry()
|
2025-05-04 01:02:12 -04:00
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
2025-10-31 01:10:54 -04:00
|
|
|
|
int_cmd = int(status) - 1
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if 0 < int_cmd <= len(choices):
|
2025-05-04 01:02:12 -04:00
|
|
|
|
cmd_no_found = False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
|
|
|
|
|
self.execute_from_configuration(instance)
|
2025-05-04 01:02:12 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-05-04 01:02:12 -04:00
|
|
|
|
|
2026-03-12 05:40:22 -04:00
|
|
|
|
def prompt_execute_deploy(self):
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(f"🤖 {t('Deploy ERPLibre to a local directory!')}")
|
2026-03-12 05:40:22 -04:00
|
|
|
|
choices = [
|
2026-07-19 03:30:06 -04:00
|
|
|
|
{"section": t("Local")},
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{"prompt_description": t("Clone ERPLibre locally (git clone)")},
|
2026-03-22 22:28:24 -04:00
|
|
|
|
{"prompt_description": t("Configure sshfs")},
|
2026-07-29 04:14:50 -04:00
|
|
|
|
{"section": t("Remote & services")},
|
|
|
|
|
|
{"prompt_description": t("SSH (remote host)...")},
|
2026-04-11 00:13:56 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Deploy - Install NTFY notification server"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-07-15 07:48:12 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"QEMU/KVM - Deploy an Ubuntu VM (libvirt)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-12 05:40:22 -04:00
|
|
|
|
]
|
|
|
|
|
|
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()
|
2026-03-22 22:28:24 -04:00
|
|
|
|
elif status == "2":
|
|
|
|
|
|
self._configure_sshfs()
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
elif status == "3":
|
2026-07-29 04:14:50 -04:00
|
|
|
|
self.prompt_execute_deploy_ssh()
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
elif status == "4":
|
2026-07-29 04:14:50 -04:00
|
|
|
|
self._deploy_ntfy_server()
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
elif status == "5":
|
2026-07-29 04:14:50 -04:00
|
|
|
|
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":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_install()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "4":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_run()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "5":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_stop()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "6":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_restart()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "7":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_status()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "8":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_logs()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "9":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_make()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "10":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_install_systemd()
|
2026-07-29 04:14:50 -04:00
|
|
|
|
elif status == "11":
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
self._deploy_ssh_install_nginx()
|
2026-03-12 05:40:22 -04:00
|
|
|
|
else:
|
|
|
|
|
|
print(t("Command not found !"))
|
|
|
|
|
|
|
2026-07-15 07:48:12 -04:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# 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 ""
|
|
|
|
|
|
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
# 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": (
|
2026-07-19 04:41:24 -04:00
|
|
|
|
["20.04", "22.04", "24.04", "25.10", "26.04"],
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
"24.04",
|
|
|
|
|
|
),
|
|
|
|
|
|
"debian": (["11", "12", "13"], "12"),
|
|
|
|
|
|
"fedora": (["41", "42", "43", "44"], "42"),
|
2026-07-28 01:28:41 -04:00
|
|
|
|
"arch": (["latest"], "latest"),
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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, ([], ""))
|
2026-07-29 07:29:53 -04:00
|
|
|
|
print(f"\n{t('Version for')} {distro.capitalize()} :")
|
2026-07-15 07:48:12 -04:00
|
|
|
|
for i, v in enumerate(versions, 1):
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
suffix = " *" if v == default else ""
|
2026-07-29 07:29:53 -04:00
|
|
|
|
stat = self._qemu_stat_avg("version", v, distro)
|
|
|
|
|
|
print(f" [{i}] {v}{suffix}{stat}")
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
sel = input(
|
|
|
|
|
|
f"{t('Choice (number or version, blank = default):')} "
|
|
|
|
|
|
).strip()
|
2026-07-15 07:48:12 -04:00
|
|
|
|
if not sel:
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
return default
|
2026-07-15 07:48:12 -04:00
|
|
|
|
try:
|
|
|
|
|
|
idx = int(sel) - 1
|
|
|
|
|
|
if 0 <= idx < len(versions):
|
|
|
|
|
|
return versions[idx]
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
if sel in versions:
|
|
|
|
|
|
return sel
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
print(f"{t('Invalid selection, using')} {default}")
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
# Distros publiant des images cloud par architecture (cohérent avec
|
|
|
|
|
|
# S390X_DISTROS / ARM64_DISTROS de deploy_qemu.py). amd64 : toutes.
|
[ADD] script qemu: support architecture s390x (IBM Z, Ubuntu)
Ajoute --arch s390x au déploiement QEMU. Réalité constatée (juillet 2026) :
seul Ubuntu publie des images cloud s390x (cloud-images.ubuntu.com : OK) ;
Debian et Fedora n'en publient pas (404), Arch ne cible que x86_64/aarch64.
s390x est donc scopé à Ubuntu et rejeté proprement pour les autres distros.
deploy_qemu.py :
- FOREIGN_ARCHES / S390X_DISTROS : s390x n'est valide que pour Ubuntu.
- virt_install : sur s390x -> --arch s390x, --machine s390-ccw-virtio,
--virt-type qemu (émulation TCG, pas de KVM sur hôte x86), console SCLP
(pas de série ISA), amorçage IPL/zipl (aucun --boot UEFI/BIOS). Les
disques/réseau bus=virtio sont mappés en virtio-ccw par libvirt.
- ensure_s390x_emulator : installe qemu-system-s390x au besoin
(apt: qemu-system-misc, dnf: qemu-system-s390x, pacman:
qemu-emulators-full, zypper: qemu-s390).
- Avertit que s390x est ÉMULÉ (lent) sur un hôte x86.
todo.py : le menu « Déployer une VM » demande l'architecture (s390x proposé
uniquement pour Ubuntu, avec avertissement lenteur) et passe --arch.
install_debian_dependency.sh : wkhtmltopdf n'a pas de build s390x -> skip
best-effort au lieu d'avorter l'install (la branche s390x rust-all existait
déjà pour compiler les wheels).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:03:19 -04:00
|
|
|
|
_QEMU_S390X_DISTROS = ("ubuntu",)
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
_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"}
|
[ADD] script qemu: support architecture s390x (IBM Z, Ubuntu)
Ajoute --arch s390x au déploiement QEMU. Réalité constatée (juillet 2026) :
seul Ubuntu publie des images cloud s390x (cloud-images.ubuntu.com : OK) ;
Debian et Fedora n'en publient pas (404), Arch ne cible que x86_64/aarch64.
s390x est donc scopé à Ubuntu et rejeté proprement pour les autres distros.
deploy_qemu.py :
- FOREIGN_ARCHES / S390X_DISTROS : s390x n'est valide que pour Ubuntu.
- virt_install : sur s390x -> --arch s390x, --machine s390-ccw-virtio,
--virt-type qemu (émulation TCG, pas de KVM sur hôte x86), console SCLP
(pas de série ISA), amorçage IPL/zipl (aucun --boot UEFI/BIOS). Les
disques/réseau bus=virtio sont mappés en virtio-ccw par libvirt.
- ensure_s390x_emulator : installe qemu-system-s390x au besoin
(apt: qemu-system-misc, dnf: qemu-system-s390x, pacman:
qemu-emulators-full, zypper: qemu-s390).
- Avertit que s390x est ÉMULÉ (lent) sur un hôte x86.
todo.py : le menu « Déployer une VM » demande l'architecture (s390x proposé
uniquement pour Ubuntu, avec avertissement lenteur) et passe --arch.
install_debian_dependency.sh : wkhtmltopdf n'a pas de build s390x -> skip
best-effort au lieu d'avorter l'install (la branche s390x rust-all existait
déjà pour compiler les wheels).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:03:19 -04:00
|
|
|
|
|
2026-07-28 03:35:25 -04:00
|
|
|
|
@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")
|
|
|
|
|
|
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-29 06:46:21 -04:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
2026-07-29 07:29:53 -04:00
|
|
|
|
def _qemu_stat_avg(self, field, value, distro=None):
|
2026-07-29 06:46:21 -04:00
|
|
|
|
"""Suffixe « · ~5m moy (3) » : durée d'install MOYENNE historique pour
|
2026-07-29 07:29:53 -04:00
|
|
|
|
cette archi/distro/version (fichier .venv.erplibre), ou '' si aucune
|
|
|
|
|
|
donnée. Pour field='version', `distro` est requis."""
|
2026-07-29 06:46:21 -04:00
|
|
|
|
try:
|
|
|
|
|
|
from script.todo import qemu_install_monitor as mon
|
|
|
|
|
|
|
2026-07-29 07:29:53 -04:00
|
|
|
|
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)
|
2026-07-29 06:46:21 -04:00
|
|
|
|
if secs:
|
|
|
|
|
|
return f" · ~{mon._fmt_secs(secs)} {t('avg')} ({n})"
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
2026-07-28 04:51:45 -04:00
|
|
|
|
def _qemu_ask_arch(self, opts, native, allow_all=False):
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
"""Affiche les architectures `opts` (natif marqué d'un *) et renvoie le
|
2026-07-28 04:51:45 -04:00
|
|
|
|
choix. Si `allow_all`, propose aussi [all] = toutes les archis (renvoie
|
|
|
|
|
|
« all »). Toute arch non native est ÉMULÉE (TCG, lente)."""
|
2026-07-28 03:35:25 -04:00
|
|
|
|
print(f"\n{t('Architecture:')}")
|
|
|
|
|
|
for i, a in enumerate(opts, 1):
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
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')})"
|
2026-07-29 06:46:21 -04:00
|
|
|
|
print(f" [{i}] {label}{self._qemu_stat_avg('arch', a)}")
|
2026-07-28 04:51:45 -04:00
|
|
|
|
if allow_all:
|
|
|
|
|
|
print(f" [all] {t('All supported architectures')}")
|
2026-07-28 03:35:25 -04:00
|
|
|
|
sel = (
|
|
|
|
|
|
input(f"{t('Choice (number or name, blank = native):')} ")
|
|
|
|
|
|
.strip()
|
|
|
|
|
|
.lower()
|
|
|
|
|
|
)
|
2026-07-28 04:51:45 -04:00
|
|
|
|
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
|
[IMP] script qemu: support arm64/aarch64 (+ archi native marquée d'un *)
Ajoute l'architecture arm64 (aarch64) au déploiement, en plus d'amd64
(x86_64) et s390x. Disponibilité réelle vérifiée (juillet 2026) :
- arm64 : Ubuntu, Debian, Fedora (Arch : pas d'image cloud aarch64 -> rejet).
- s390x : Ubuntu seulement (inchangé).
deploy_qemu.py :
- host_arch() : arch native de l'hôte ; toute arch différente est ÉMULÉE
(TCG, --virt-type qemu) — plus de liste FOREIGN_ARCHES figée.
- virt_install : arm64 -> --arch aarch64 --machine virt --boot uefi (firmware
AAVMF résolu par libvirt) ; s390x inchangé ; x86 UEFI/OVMF inchangé.
- ensure_emulator(arch) généralisé : installe qemu-system-aarch64 + firmware
UEFI AAVMF (arm64) ou qemu-system-s390x (s390x), selon le gestionnaire de
paquets, avec vérif de présence (binaire + firmware pour arm64).
- Validation --arch tôt via ARCH_DISTRO_SUPPORT (message clair si la distro
ne publie pas l'arch). Les URLs d'images arm64 marchent déjà via les alias
(fedora/arch aarch64 ; ubuntu/debian arm64).
todo.py : menus d'architecture (VM unique + infra) proposent amd64/arm64/
s390x selon la distro ; l'architecture NATIVE de l'hôte est marquée d'un *
(défaut) ; les autres sont signalées « émulé, lent ». Le catalogue infra est
restreint aux distros publiant l'arch choisie (arm64 -> ubuntu/debian/fedora,
s390x -> ubuntu).
install_debian_dependency.sh : wkhtmltopdf (deb amd64 en dur) ignoré
best-effort sur toute arch non-x86_64 (au lieu d'avorter l'install).
Validé : dry-runs Ubuntu/Debian/Fedora arm64 (bonnes URLs + virt-install
aarch64/virt/uefi/qemu), rejet Arch arm64, non-régression s390x, menus et
filtrage simulés (natif *, arm64 par distro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:56:20 -04:00
|
|
|
|
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)
|
2026-07-28 04:51:45 -04:00
|
|
|
|
return self._qemu_ask_arch(opts, native, allow_all=True)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
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)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
|
|
|
|
|
|
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 = [
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Deployment")},
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
{"prompt_description": t("Deploy VM(s) (one or many)")},
|
2026-07-15 07:48:12 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Preview a deployment (dry-run, no sudo)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
{"prompt_description": t("Download a cloud image only")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"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")},
|
2026-07-29 05:27:20 -04:00
|
|
|
|
{"prompt_description": t("Resize a VM disk")},
|
2026-07-19 03:26:28 -04:00
|
|
|
|
{"prompt_description": t("Delete VM(s)")},
|
2026-07-27 22:31:56 -04:00
|
|
|
|
{"prompt_description": t("Clean up QEMU (orphan files)")},
|
2026-07-29 23:17:58 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Test a VM (open Odoo in a CLI browser)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-07-30 00:22:37 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Reopen install monitoring (last run / history)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
[ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.
record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.
Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.
Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.
Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
|
|
|
|
{"prompt_description": t("Statistics (installs, durations, VMs)")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Catalog")},
|
|
|
|
|
|
{"prompt_description": t("List available images and specs")},
|
2026-07-15 07:48:12 -04:00
|
|
|
|
]
|
|
|
|
|
|
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":
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
self._qemu_deploy(dry_run=False)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
elif status == "2":
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
self._qemu_deploy(dry_run=True)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
elif status == "3":
|
|
|
|
|
|
self._qemu_download_image()
|
|
|
|
|
|
elif status == "4":
|
[IMP] script todo: QEMU « Lister les VM » — option infos avancées
Après « sudo virsh list --all », le menu propose désormais (o/N) un
tableau détaillé par VM : état, vCPU, RAM allouée (Max memory), taille
disque virtuelle et réelle (qemu-img info -U, lit même VM allumée), plus
l'espace total/libre/utilisé du stockage des images (shutil.disk_usage).
- _qemu_list_vms(ask_advanced=False) : seul le menu [4] prompte ;
les appels internes (IP, console, resize, delete) restent inchangés.
- Helpers _qemu_dominfo (vcpu + Max memory) et _qemu_disk_sizes
(virtuel + réel, -U). Validé en réel : 8 vCPU / 8.0G / 30.0G / 2.5G.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 07:01:00 -04:00
|
|
|
|
self._qemu_list_vms(ask_advanced=True)
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
elif status == "5":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self._qemu_show_ip()
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
elif status == "6":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self._qemu_console()
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
elif status == "7":
|
2026-07-29 05:27:20 -04:00
|
|
|
|
self._qemu_resize_disk()
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
elif status == "8":
|
2026-07-29 05:27:20 -04:00
|
|
|
|
self._qemu_delete_vm()
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
elif status == "9":
|
2026-07-29 05:27:20 -04:00
|
|
|
|
self._qemu_cleanup()
|
|
|
|
|
|
elif status == "10":
|
2026-07-29 23:17:58 -04:00
|
|
|
|
self._qemu_test_vm()
|
|
|
|
|
|
elif status == "11":
|
2026-07-30 00:22:37 -04:00
|
|
|
|
self._qemu_reopen_monitor()
|
|
|
|
|
|
elif status == "12":
|
[ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.
record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.
Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.
Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.
Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
|
|
|
|
self._qemu_stats()
|
|
|
|
|
|
elif status == "13":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self._qemu_list_images()
|
2026-07-15 07:48:12 -04:00
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
int_cmd = int(status)
|
2026-07-19 03:41:00 -04:00
|
|
|
|
# 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):
|
2026-07-15 07:48:12 -04:00
|
|
|
|
cmd_no_found = False
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.execute_from_configuration(real[int_cmd - 1])
|
2026-07-15 07:48:12 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
|
|
|
|
|
print(t("Command not found !"))
|
|
|
|
|
|
|
[ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.
record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.
Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.
Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.
Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
|
|
|
|
def _qemu_stats(self):
|
|
|
|
|
|
"""Statistiques d'utilisation de QEMU, et remise à zéro.
|
|
|
|
|
|
|
|
|
|
|
|
Tout vient de l'historique tenu par le moniteur d'installation
|
|
|
|
|
|
(.venv.erplibre/qemu_install_stats.json) et de l'état libvirt courant.
|
|
|
|
|
|
"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from script.todo import qemu_install_monitor as mon
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print(t("Install textual for the dashboard (pip)."))
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
summary = mon.stats_summary()
|
|
|
|
|
|
print(f"\n📊 {t('QEMU statistics')}")
|
|
|
|
|
|
if not summary:
|
|
|
|
|
|
print(f" {t('No installation recorded yet.')}")
|
|
|
|
|
|
else:
|
|
|
|
|
|
rate = 100 * summary["ok"] // max(summary["total"], 1)
|
|
|
|
|
|
print(f"\n── {t('Installations')} ──")
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Total'):<18}: {summary['total']}"
|
|
|
|
|
|
f" ({summary['ok']} {t('succeeded')},"
|
|
|
|
|
|
f" {summary['failed']} {t('failed')} — {rate} %)"
|
|
|
|
|
|
)
|
|
|
|
|
|
if summary["first_ts"]:
|
|
|
|
|
|
days = max(
|
|
|
|
|
|
1,
|
|
|
|
|
|
(summary["last_ts"] - summary["first_ts"]) // 86400,
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Period'):<18}:"
|
|
|
|
|
|
f" {self._qemu_stamp(summary['first_ts'])}"
|
|
|
|
|
|
f" → {self._qemu_stamp(summary['last_ts'])}"
|
|
|
|
|
|
f" ({days} {t('days')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Median duration'):<18}:"
|
|
|
|
|
|
f" {mon._fmt_secs(summary['median'])}"
|
|
|
|
|
|
f" ({t('min')} {mon._fmt_secs(summary['min'])} ·"
|
|
|
|
|
|
f" {t('max')} {mon._fmt_secs(summary['max'])})"
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Cumulated time'):<18}:"
|
|
|
|
|
|
f" {mon._fmt_secs(summary['total_secs'])}"
|
|
|
|
|
|
)
|
|
|
|
|
|
for field, title in (
|
|
|
|
|
|
("distro", t("By distribution")),
|
|
|
|
|
|
("version", t("By version")),
|
|
|
|
|
|
("arch", t("By architecture")),
|
|
|
|
|
|
):
|
|
|
|
|
|
rows = mon.stats_by(field)
|
|
|
|
|
|
if not rows:
|
|
|
|
|
|
continue
|
|
|
|
|
|
print(f"\n── {title} ──")
|
|
|
|
|
|
for key, count, avg, failed in rows[:8]:
|
|
|
|
|
|
# Un groupe sans aucun succès n'a pas de moyenne : « — »
|
|
|
|
|
|
# plutôt qu'un « ~0s » trompeur.
|
|
|
|
|
|
moy = f"~{mon._fmt_secs(avg)}" if count else "—"
|
|
|
|
|
|
fail = (
|
|
|
|
|
|
f" ⚠ {failed} {self._plural(t('failure'), failed)}"
|
|
|
|
|
|
if failed
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f" {key:<22} {count:>3} × {moy:<8}{fail}")
|
|
|
|
|
|
|
|
|
|
|
|
self._qemu_stats_vms(mon)
|
|
|
|
|
|
print(f"\n [r] {t('Reset the statistics')}")
|
|
|
|
|
|
print(f" [0] {t('Back')}")
|
|
|
|
|
|
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
|
|
|
|
|
|
if answer in ("", "0"):
|
|
|
|
|
|
return
|
|
|
|
|
|
if answer == "r":
|
|
|
|
|
|
if not summary:
|
|
|
|
|
|
print(f" {t('Nothing to reset.')}")
|
|
|
|
|
|
continue
|
|
|
|
|
|
confirm = input(
|
|
|
|
|
|
f" {t('Erase')} {summary['total']}"
|
|
|
|
|
|
f" {t('recorded runs')}? (y/N): "
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
if self._is_yes(confirm):
|
|
|
|
|
|
count = mon.reset_stats()
|
|
|
|
|
|
print(f" ✅ {count} {t('runs erased')}.")
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" {t('Cancelled.')}")
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_stamp(ts):
|
|
|
|
|
|
"""Horodatage court « 2026-08-01 »."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
|
|
|
|
|
|
except (OSError, OverflowError, ValueError):
|
|
|
|
|
|
return "?"
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_stats_vms(self, mon):
|
|
|
|
|
|
"""Machines virtuelles actuelles : nombre, états, place disque."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
states = mon.virsh_domstates()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return
|
|
|
|
|
|
if not states:
|
|
|
|
|
|
return
|
|
|
|
|
|
running = sum(1 for s in states.values() if s == "running")
|
|
|
|
|
|
total_bytes = 0
|
|
|
|
|
|
counted = 0
|
|
|
|
|
|
for name in states:
|
|
|
|
|
|
try:
|
|
|
|
|
|
# vm_disk_path attend un dict ; le chemin par défaut de libvirt
|
|
|
|
|
|
# se déduit du seul nom.
|
|
|
|
|
|
size = mon.disk_actual_size(mon.vm_disk_path({"name": name}))
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
size = None
|
|
|
|
|
|
if size:
|
|
|
|
|
|
total_bytes += size
|
|
|
|
|
|
counted += 1
|
|
|
|
|
|
print(f"\n── {t('Virtual machines')} ──")
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Defined'):<18}: {len(states)}"
|
|
|
|
|
|
f" ({running} {t('running')},"
|
|
|
|
|
|
f" {len(states) - running} {t('stopped')})"
|
|
|
|
|
|
)
|
|
|
|
|
|
if counted:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('Disk used'):<18}:"
|
|
|
|
|
|
f" {mon._fmt_size(total_bytes)}"
|
|
|
|
|
|
f" ({counted} {self._plural(t('image'), counted)})"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-15 07:48:12 -04:00
|
|
|
|
def _qemu_download_image(self):
|
|
|
|
|
|
script_path = self._qemu_script_path()
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
distro = self._qemu_prompt_distro()
|
|
|
|
|
|
version = self._qemu_prompt_version(distro)
|
2026-07-19 03:07:20 -04:00
|
|
|
|
ans = input(t("Verify SHA256 after download? (y/N): "))
|
2026-07-15 07:48:12 -04:00
|
|
|
|
parts = [
|
|
|
|
|
|
"sudo",
|
|
|
|
|
|
script_path,
|
|
|
|
|
|
"--download-only",
|
[IMP] script qemu: multi-distro (Debian/Fedora), resilient osinfo, 26.04
Generalise the deployer beyond Ubuntu with a distro registry and a new
--distro flag (ubuntu default, debian, fedora). Each distro keeps its
own codenames, osinfo ids and per-version minimum RAM/disk. Image URLs
are built per distro (Ubuntu current/, Debian latest/, Fedora resolved
from the release index since it has no "latest" link).
Add --list-images to print the whole catalogue with specs, exposed in
the todo menu ("List available images and specs"); the deploy/download
flows now prompt for the distro first.
Resilient osinfo: when the local osinfo-db does not know an id (e.g.
ubuntu26.04, fedora43+), fall back to virt-install detect=on,require=off
instead of failing. Ubuntu 26.04 (resolute) is now enabled.
Also: throttle the download progress to integer-percent steps (single
updating line on a TTY, at most 101 lines when captured by the menu),
and skip the spurious "network is already active" error by checking the
libvirt network state before net-start.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 02:38:04 -04:00
|
|
|
|
"--distro",
|
|
|
|
|
|
distro,
|
2026-07-15 07:48:12 -04:00
|
|
|
|
"--version",
|
|
|
|
|
|
version,
|
|
|
|
|
|
]
|
2026-07-19 03:07:20 -04:00
|
|
|
|
if self._is_yes(ans):
|
2026-07-15 07:48:12 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
[IMP] script todo: QEMU « Lister les VM » — option infos avancées
Après « sudo virsh list --all », le menu propose désormais (o/N) un
tableau détaillé par VM : état, vCPU, RAM allouée (Max memory), taille
disque virtuelle et réelle (qemu-img info -U, lit même VM allumée), plus
l'espace total/libre/utilisé du stockage des images (shutil.disk_usage).
- _qemu_list_vms(ask_advanced=False) : seul le menu [4] prompte ;
les appels internes (IP, console, resize, delete) restent inchangés.
- Helpers _qemu_dominfo (vcpu + Max memory) et _qemu_disk_sizes
(virtuel + réel, -U). Validé en réel : 8 vCPU / 8.0G / 30.0G / 2.5G.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 07:01:00 -04:00
|
|
|
|
def _qemu_list_vms(self, ask_advanced=False):
|
2026-07-15 07:48:12 -04:00
|
|
|
|
cmd = "sudo virsh list --all"
|
|
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
|
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
2026-07-29 07:09:40 -04:00
|
|
|
|
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":
|
[IMP] script todo: QEMU « Lister les VM » — option infos avancées
Après « sudo virsh list --all », le menu propose désormais (o/N) un
tableau détaillé par VM : état, vCPU, RAM allouée (Max memory), taille
disque virtuelle et réelle (qemu-img info -U, lit même VM allumée), plus
l'espace total/libre/utilisé du stockage des images (shutil.disk_usage).
- _qemu_list_vms(ask_advanced=False) : seul le menu [4] prompte ;
les appels internes (IP, console, resize, delete) restent inchangés.
- Helpers _qemu_dominfo (vcpu + Max memory) et _qemu_disk_sizes
(virtuel + réel, -U). Validé en réel : 8 vCPU / 8.0G / 30.0G / 2.5G.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 07:01:00 -04:00
|
|
|
|
self._qemu_list_vms_advanced()
|
2026-07-29 07:09:40 -04:00
|
|
|
|
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)}"
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")):
|
2026-07-29 07:09:40 -04:00
|
|
|
|
print(t("Cancelled."))
|
|
|
|
|
|
return
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if not self._is_yes(input(t("Confirm for real? (y/N): "))):
|
2026-07-29 07:09:40 -04:00
|
|
|
|
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)
|
[IMP] script todo: QEMU « Lister les VM » — option infos avancées
Après « sudo virsh list --all », le menu propose désormais (o/N) un
tableau détaillé par VM : état, vCPU, RAM allouée (Max memory), taille
disque virtuelle et réelle (qemu-img info -U, lit même VM allumée), plus
l'espace total/libre/utilisé du stockage des images (shutil.disk_usage).
- _qemu_list_vms(ask_advanced=False) : seul le menu [4] prompte ;
les appels internes (IP, console, resize, delete) restent inchangés.
- Helpers _qemu_dominfo (vcpu + Max memory) et _qemu_disk_sizes
(virtuel + réel, -U). Validé en réel : 8 vCPU / 8.0G / 30.0G / 2.5G.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 07:01:00 -04:00
|
|
|
|
|
|
|
|
|
|
@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,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
env=TODO._qemu_c_env(),
|
[IMP] script todo: QEMU « Lister les VM » — option infos avancées
Après « sudo virsh list --all », le menu propose désormais (o/N) un
tableau détaillé par VM : état, vCPU, RAM allouée (Max memory), taille
disque virtuelle et réelle (qemu-img info -U, lit même VM allumée), plus
l'espace total/libre/utilisé du stockage des images (shutil.disk_usage).
- _qemu_list_vms(ask_advanced=False) : seul le menu [4] prompte ;
les appels internes (IP, console, resize, delete) restent inchangés.
- Helpers _qemu_dominfo (vcpu + Max memory) et _qemu_disk_sizes
(virtuel + réel, -U). Validé en réel : 8 vCPU / 8.0G / 30.0G / 2.5G.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 07:01:00 -04:00
|
|
|
|
)
|
|
|
|
|
|
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')})"
|
|
|
|
|
|
)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
|
|
|
|
|
|
def _qemu_show_ip(self):
|
2026-07-19 02:27:00 -04:00
|
|
|
|
# Affiche d'abord les VM (avec leur ID) pour que l'utilisateur sache
|
2026-07-19 03:41:00 -04:00
|
|
|
|
# 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.
|
2026-07-19 02:27:00 -04:00
|
|
|
|
self._qemu_list_vms()
|
|
|
|
|
|
print()
|
|
|
|
|
|
name = input(t("VM name or ID: ")).strip()
|
2026-07-15 07:48:12 -04:00
|
|
|
|
if not name:
|
|
|
|
|
|
print(t("VM name is required!"))
|
|
|
|
|
|
return
|
2026-07-19 03:41:00 -04:00
|
|
|
|
print(f"\n💡 {t('To leave the console, press Ctrl+] (then Enter).')}")
|
2026-07-19 03:47:33 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}"
|
|
|
|
|
|
)
|
2026-07-19 03:41:00 -04:00
|
|
|
|
cmd = f"sudo virsh console {shlex.quote(name)}"
|
2026-07-15 07:48:12 -04:00
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
|
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
2026-07-29 05:27:20 -04:00
|
|
|
|
|
2026-07-29 23:17:58 -04:00
|
|
|
|
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}")
|
2026-07-29 23:21:10 -04:00
|
|
|
|
# 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)}")
|
2026-07-29 23:17:58 -04:00
|
|
|
|
if rc != 0:
|
|
|
|
|
|
msg = t(
|
|
|
|
|
|
"Page may not have loaded: Odoo not started on :8069, "
|
|
|
|
|
|
"or network/firewall."
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"⚠ {msg}")
|
|
|
|
|
|
|
2026-07-30 00:22:37 -04:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-08-01 02:42:45 -04:00
|
|
|
|
sel = input(t("Choice (number, blank = last): ")).strip()
|
2026-07-30 00:22:37 -04:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-07-29 23:17:58 -04:00
|
|
|
|
def _qemu_choose_cli_browser(self):
|
2026-07-29 23:23:28 -04:00
|
|
|
|
"""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
|
2026-07-29 23:17:58 -04:00
|
|
|
|
|
|
|
|
|
|
available = [b for b in CLI_BROWSERS if shutil.which(b)]
|
|
|
|
|
|
if not available:
|
2026-07-29 23:23:28 -04:00
|
|
|
|
return self._qemu_install_cli_browser()
|
2026-07-29 23:17:58 -04:00
|
|
|
|
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 ''}")
|
2026-07-29 23:23:28 -04:00
|
|
|
|
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()
|
2026-07-29 23:17:58 -04:00
|
|
|
|
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]
|
|
|
|
|
|
|
2026-07-29 23:23:28 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-29 05:27:20 -04:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# Redimensionnement du disque d'une VM
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
2026-07-30 01:04:07 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_c_env():
|
|
|
|
|
|
"""Environnement forçant LC_ALL=C : la sortie des outils (virsh,
|
|
|
|
|
|
sgdisk, resize2fs, dumpe2fs…) reste en ANGLAIS quelle que soit la
|
|
|
|
|
|
locale de l'hôte. Sinon « running » devient « en cours d'exécution »
|
|
|
|
|
|
(fr) et les comparaisons/parsing d'état cassent."""
|
|
|
|
|
|
env = dict(os.environ)
|
|
|
|
|
|
env["LC_ALL"] = "C"
|
|
|
|
|
|
env["LANG"] = "C"
|
|
|
|
|
|
return env
|
|
|
|
|
|
|
2026-07-29 05:27:20 -04:00
|
|
|
|
@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,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
env=TODO._qemu_c_env(),
|
2026-07-29 05:27:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return res.stdout.strip() if res.returncode == 0 else ""
|
|
|
|
|
|
|
2026-07-29 07:05:24 -04:00
|
|
|
|
@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,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
env=TODO._qemu_c_env(),
|
2026-07-29 07:05:24 -04:00
|
|
|
|
)
|
|
|
|
|
|
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):
|
2026-07-29 07:23:54 -04:00
|
|
|
|
"""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).
|
2026-07-29 07:05:24 -04:00
|
|
|
|
Renvoie True si la VM est bien éteinte."""
|
|
|
|
|
|
name = self._qemu_domname(name)
|
|
|
|
|
|
if self._qemu_domstate(name) == "shut off":
|
|
|
|
|
|
return True
|
2026-07-29 07:23:54 -04:00
|
|
|
|
# --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"
|
2026-07-29 07:05:24 -04:00
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
|
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Waiting for the VM to shut down...')} "
|
|
|
|
|
|
f"({t('timeout')}: {timeout} s)"
|
|
|
|
|
|
)
|
2026-07-29 07:05:24 -04:00
|
|
|
|
deadline = time.time() + timeout
|
|
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
|
if self._qemu_domstate(name) == "shut off":
|
2026-07-29 07:23:54 -04:00
|
|
|
|
# Efface la ligne de compte à rebours puis confirme.
|
|
|
|
|
|
print(f"\r{' ' * 40}\r✅ {name}: {t('VM is off.')}")
|
2026-07-29 07:05:24 -04:00
|
|
|
|
return True
|
2026-07-29 07:23:54 -04:00
|
|
|
|
remaining = int(deadline - time.time())
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"\r ⏳ {t('shutting down')}… "
|
|
|
|
|
|
f"{remaining:>3d} s {t('remaining')}",
|
|
|
|
|
|
end="",
|
|
|
|
|
|
flush=True,
|
|
|
|
|
|
)
|
2026-07-29 07:23:54 -04:00
|
|
|
|
time.sleep(2)
|
|
|
|
|
|
print() # newline après le compte à rebours
|
2026-07-29 07:05:24 -04:00
|
|
|
|
# Arrêt gracieux trop long : proposer un arrêt forcé.
|
|
|
|
|
|
if self._is_yes(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
input(
|
|
|
|
|
|
t(
|
|
|
|
|
|
"Graceful shutdown timed out. Force off (destroy)? "
|
|
|
|
|
|
"(y/N): "
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-29 07:05:24 -04:00
|
|
|
|
):
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-29 05:27:20 -04:00
|
|
|
|
@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,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
env=TODO._qemu_c_env(),
|
2026-07-29 05:27:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
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:
|
2026-07-29 06:57:22 -04:00
|
|
|
|
# -U (--force-share) : lit même si la VM tourne (libvirt tient le
|
|
|
|
|
|
# lock d'écriture). Sans ça : « Failed to get shared write lock ».
|
2026-07-29 05:27:20 -04:00
|
|
|
|
res = subprocess.run(
|
2026-07-29 06:57:22 -04:00
|
|
|
|
["sudo", "qemu-img", "info", "-U", "--output=json", disk],
|
2026-07-29 05:27:20 -04:00
|
|
|
|
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
|
2026-07-29 23:25:54 -04:00
|
|
|
|
# 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)
|
2026-07-29 05:27:20 -04:00
|
|
|
|
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}")
|
2026-07-29 06:57:22 -04:00
|
|
|
|
# -U : lecture sûre même VM allumée (sinon « shared write lock »).
|
2026-07-29 05:27:20 -04:00
|
|
|
|
self.execute.exec_command_live(
|
2026-07-29 06:57:22 -04:00
|
|
|
|
f"sudo qemu-img info -U {shlex.quote(disk)}", source_erplibre=False
|
2026-07-29 05:27:20 -04:00
|
|
|
|
)
|
|
|
|
|
|
cur_bytes = self._qemu_disk_virtual_bytes(disk)
|
|
|
|
|
|
cur_gb = cur_bytes / (1 << 30)
|
|
|
|
|
|
state = self._qemu_domstate(name)
|
2026-07-29 06:57:22 -04:00
|
|
|
|
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")
|
2026-07-29 05:27:20 -04:00
|
|
|
|
print(f"{t('VM state:')} {state or '?'}")
|
|
|
|
|
|
|
2026-07-30 01:08:13 -04:00
|
|
|
|
# Espace HÔTE : le qcow2 est creux (sparse), donc on PEUT fixer une
|
|
|
|
|
|
# taille virtuelle plus grande que l'espace réel — mais si la VM la
|
|
|
|
|
|
# remplit, l'hôte tombe à court. Max « soutenable » ≈ taille réelle
|
|
|
|
|
|
# actuelle + espace libre de l'hôte. On l'AFFICHE (avertissement, pas
|
|
|
|
|
|
# de blocage) pour guider le choix.
|
|
|
|
|
|
g = 1 << 30
|
|
|
|
|
|
_virt, actual = self._qemu_disk_sizes(disk)
|
|
|
|
|
|
try:
|
|
|
|
|
|
free = shutil.disk_usage(os.path.dirname(disk)).free
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
free = 0
|
|
|
|
|
|
max_safe_gb = (actual + free) / g if free else 0
|
|
|
|
|
|
if max_safe_gb:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Host free space:')} {free / g:.1f} G · "
|
|
|
|
|
|
f"{t('max sustainable total (before host full):')} "
|
|
|
|
|
|
f"~{max_safe_gb:.1f} G"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-29 05:27:20 -04:00
|
|
|
|
# 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
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(f"\n{t('New virtual size:')} {cur_gb:.1f} G -> {new_gb:.1f} G")
|
2026-07-30 01:08:13 -04:00
|
|
|
|
# Avertissement (NON bloquant) : agrandir au-delà de ce que l'hôte
|
|
|
|
|
|
# peut soutenir -> surallocation, l'hôte se remplira si la VM utilise
|
|
|
|
|
|
# tout l'espace.
|
|
|
|
|
|
if not shrink and max_safe_gb and new_gb > max_safe_gb:
|
|
|
|
|
|
over = new_gb - max_safe_gb
|
|
|
|
|
|
msg1 = t("Beyond host capacity by ~%.1f G — overcommit.") % over
|
2026-08-01 02:42:45 -04:00
|
|
|
|
msg2 = (
|
|
|
|
|
|
t(
|
|
|
|
|
|
"The qcow2 is thin: fine until the VM fills it, then the "
|
|
|
|
|
|
"host disk runs out. Max sustainable: ~%.1f G."
|
|
|
|
|
|
)
|
|
|
|
|
|
% max_safe_gb
|
|
|
|
|
|
)
|
2026-07-30 01:08:13 -04:00
|
|
|
|
print(f"⚠ {msg1}")
|
|
|
|
|
|
print(f" {msg2}")
|
2026-07-29 05:27:20 -04:00
|
|
|
|
|
|
|
|
|
|
# 3) Application selon agrandir/réduire et l'état de la VM.
|
2026-07-29 07:07:46 -04:00
|
|
|
|
was_shut_down = False # la VM a-t-elle été éteinte pour l'occasion ?
|
2026-08-01 02:42:45 -04:00
|
|
|
|
cmd = (
|
|
|
|
|
|
None # commande d'AGRANDISSEMENT (la réduction a son propre flux)
|
|
|
|
|
|
)
|
2026-07-29 05:27:20 -04:00
|
|
|
|
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":
|
2026-07-29 07:05:24 -04:00
|
|
|
|
if not self._is_yes(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
input(
|
|
|
|
|
|
t(
|
|
|
|
|
|
"The VM must be off. Shut it down and retry? "
|
|
|
|
|
|
"(y/N): "
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-29 07:05:24 -04:00
|
|
|
|
):
|
|
|
|
|
|
print(t("Cancelled."))
|
|
|
|
|
|
return
|
|
|
|
|
|
if not self._qemu_shutdown_wait(name):
|
|
|
|
|
|
print(t("VM is still not off; aborting."))
|
|
|
|
|
|
return
|
|
|
|
|
|
state = "shut off"
|
2026-07-29 07:07:46 -04:00
|
|
|
|
was_shut_down = True
|
2026-07-29 05:27:20 -04:00
|
|
|
|
if not self._is_yes(
|
|
|
|
|
|
input(t("Type y to confirm you understand the risk (y/N): "))
|
|
|
|
|
|
):
|
|
|
|
|
|
print(t("Cancelled."))
|
|
|
|
|
|
return
|
2026-07-30 00:48:42 -04:00
|
|
|
|
# Réduction SÛRE (qemu-nbd : réduit FS + partition + GPT, avec
|
|
|
|
|
|
# sauvegarde optionnelle restaurée en cas d'échec).
|
2026-07-30 00:01:30 -04:00
|
|
|
|
if not self._qemu_safe_shrink(name, disk, new_gb):
|
|
|
|
|
|
self._qemu_offer_start(name, was_shut_down)
|
|
|
|
|
|
return
|
2026-07-29 05:27:20 -04:00
|
|
|
|
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"
|
|
|
|
|
|
|
2026-07-30 00:01:30 -04:00
|
|
|
|
# 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)
|
|
|
|
|
|
|
2026-07-30 00:48:42 -04:00
|
|
|
|
# 5) La VM a été éteinte pour l'opération : proposer de la redémarrer
|
|
|
|
|
|
# (l'utilisateur peut ainsi TESTER avant de décider du backup).
|
2026-07-30 00:01:30 -04:00
|
|
|
|
self._qemu_offer_start(name, was_shut_down)
|
|
|
|
|
|
|
2026-07-30 00:48:42 -04:00
|
|
|
|
# 6) Réduction réussie AVEC sauvegarde : proposer de l'effacer une fois
|
|
|
|
|
|
# la VM testée (défaut : NON -> on garde le backup par prudence).
|
|
|
|
|
|
bak = getattr(self, "_shrink_backup", None)
|
|
|
|
|
|
if shrink and bak and os.path.exists(bak):
|
|
|
|
|
|
print(f"\n{t('A disk backup was kept:')} {bak}")
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if self._is_yes(input(t("Delete this backup now? (y/N): "))):
|
2026-07-30 00:48:42 -04:00
|
|
|
|
subprocess.run(["sudo", "rm", "-f", bak], check=False)
|
|
|
|
|
|
print(t("Backup deleted."))
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(t("Backup kept (delete later via Clean up QEMU)."))
|
|
|
|
|
|
self._shrink_backup = None
|
|
|
|
|
|
|
2026-07-30 00:01:30 -04:00
|
|
|
|
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:
|
2026-07-29 05:27:20 -04:00
|
|
|
|
return
|
2026-07-30 00:01:30 -04:00
|
|
|
|
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 <id> »
|
|
|
|
|
|
# é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)
|
2026-07-29 05:27:20 -04:00
|
|
|
|
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
# Outils requis pour la réduction sûre (tous « base » : e2fsprogs, gdisk,
|
|
|
|
|
|
# util-linux, qemu-utils) — PAS libguestfs (souvent cassé : appliance
|
|
|
|
|
|
# supermin sans noyau dans /boot).
|
|
|
|
|
|
_SHRINK_TOOLS = (
|
2026-08-01 02:42:45 -04:00
|
|
|
|
"qemu-nbd",
|
|
|
|
|
|
"e2fsck",
|
|
|
|
|
|
"resize2fs",
|
|
|
|
|
|
"sgdisk",
|
|
|
|
|
|
"partprobe",
|
|
|
|
|
|
"lsblk",
|
|
|
|
|
|
"dumpe2fs",
|
|
|
|
|
|
"blockdev",
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
)
|
|
|
|
|
|
_SECT = 512
|
|
|
|
|
|
_MiB = 1024 * 1024
|
|
|
|
|
|
|
2026-07-30 00:01:30 -04:00
|
|
|
|
def _qemu_safe_shrink(self, name, disk, new_gb):
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
"""Réduit le disque SANS casser l'OS, via qemu-nbd + resize2fs +
|
|
|
|
|
|
sgdisk (sans libguestfs) : on réduit le FS (ext), puis la partition,
|
|
|
|
|
|
puis le conteneur qcow2, puis on répare la GPT de secours. Une COPIE
|
|
|
|
|
|
.bak est faite AVANT ; en cas d'échec on RESTAURE -> jamais de
|
|
|
|
|
|
corruption. ext2/3/4 uniquement. Renvoie True si réduit."""
|
|
|
|
|
|
import math
|
|
|
|
|
|
|
|
|
|
|
|
missing = [b for b in self._SHRINK_TOOLS if not shutil.which(b)]
|
|
|
|
|
|
if missing:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Missing tools for safe shrink:')} {', '.join(missing)}"
|
|
|
|
|
|
)
|
2026-07-30 00:01:30 -04:00
|
|
|
|
return False
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
target = int(round(new_gb * (1 << 30)))
|
2026-07-30 00:48:42 -04:00
|
|
|
|
# Sauvegarde OPTIONNELLE (défaut OUI) : permet de restaurer en cas
|
|
|
|
|
|
# d'échec, et de tester la VM avant de la supprimer (proposé à la fin).
|
|
|
|
|
|
self._shrink_backup = None
|
|
|
|
|
|
bak = None
|
|
|
|
|
|
if self._is_yes_default_yes(
|
|
|
|
|
|
input(t("Back up the disk before shrinking? (Y/n): "))
|
|
|
|
|
|
):
|
|
|
|
|
|
bak = f"{disk}.bak"
|
|
|
|
|
|
print(f"\n{t('Backing up the disk before shrinking…')}")
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if (
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"sudo",
|
|
|
|
|
|
"cp",
|
|
|
|
|
|
"--reflink=auto",
|
|
|
|
|
|
"--sparse=always",
|
|
|
|
|
|
disk,
|
|
|
|
|
|
bak,
|
|
|
|
|
|
]
|
|
|
|
|
|
).returncode
|
|
|
|
|
|
!= 0
|
|
|
|
|
|
):
|
2026-07-30 00:48:42 -04:00
|
|
|
|
print(t("Backup failed; aborting."))
|
|
|
|
|
|
return False
|
|
|
|
|
|
else:
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"⚠ {t('No backup: a failure could leave the disk broken.')}"
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
subprocess.run(["sudo", "modprobe", "nbd", "max_part=16"], check=False)
|
|
|
|
|
|
dev = None
|
|
|
|
|
|
try:
|
|
|
|
|
|
dev = self._qemu_nbd_connect(disk)
|
|
|
|
|
|
if not dev:
|
|
|
|
|
|
print(t("Could not attach the disk (nbd); aborting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=False)
|
|
|
|
|
|
part, start, fstype = self._qemu_root_part(dev)
|
|
|
|
|
|
if not part:
|
|
|
|
|
|
print(t("Could not detect the partition to shrink; aborting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=False)
|
|
|
|
|
|
if not fstype.startswith("ext"):
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Only ext2/3/4 can be shrunk safely; aborting.')}"
|
|
|
|
|
|
f" ({fstype})"
|
|
|
|
|
|
)
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=False)
|
|
|
|
|
|
n = self._qemu_part_number(dev, part)
|
|
|
|
|
|
info = self._qemu_part_info(dev, n)
|
|
|
|
|
|
# fsck AVANT toute opération.
|
|
|
|
|
|
subprocess.run(["sudo", "e2fsck", "-f", "-y", part], check=False)
|
|
|
|
|
|
bs = self._qemu_fs_blocksize(part)
|
|
|
|
|
|
# Cibles (octets), en gardant 2 Mio pour la GPT de secours + marge.
|
|
|
|
|
|
part_start_b = start * self._SECT
|
|
|
|
|
|
max_fs_b = target - part_start_b - 4 * self._MiB
|
|
|
|
|
|
if max_fs_b <= 0:
|
|
|
|
|
|
print(t("Target size too small for this layout; aborting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=False)
|
|
|
|
|
|
min_blocks = self._qemu_fs_min_blocks(part)
|
|
|
|
|
|
if min_blocks and min_blocks * bs > max_fs_b:
|
|
|
|
|
|
print(t("Not enough used-space margin to shrink; aborting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=False)
|
|
|
|
|
|
fs_target_mib = max_fs_b // self._MiB
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Shrinking guest ext filesystem')} {part} "
|
|
|
|
|
|
f"-> {fs_target_mib} MiB…"
|
|
|
|
|
|
)
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if (
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["sudo", "resize2fs", part, f"{fs_target_mib}M"]
|
|
|
|
|
|
).returncode
|
|
|
|
|
|
!= 0
|
|
|
|
|
|
):
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
print(t("resize2fs failed; reverting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
|
|
|
|
|
# Fin de partition = début + taille RÉELLE du FS + 1 Mio, alignée.
|
|
|
|
|
|
fs_bytes = self._qemu_fs_blocks(part) * bs
|
2026-08-01 02:42:45 -04:00
|
|
|
|
new_end = start + int(
|
|
|
|
|
|
math.ceil((fs_bytes + self._MiB) / self._SECT)
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
new_end = ((new_end + 2047) // 2048) * 2048 - 1 # align 2048
|
|
|
|
|
|
if (new_end + 34) * self._SECT > target:
|
|
|
|
|
|
print(t("Internal size check failed; reverting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
|
|
|
|
|
# Réécrit la partition (mêmes type/UUID/nom -> PARTUUID préservé).
|
|
|
|
|
|
print(f"{t('Shrinking the partition…')} ({part})")
|
|
|
|
|
|
subprocess.run(["sudo", "sgdisk", "-d", n, dev], check=False)
|
|
|
|
|
|
rc = subprocess.run(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
[
|
|
|
|
|
|
"sudo",
|
|
|
|
|
|
"sgdisk",
|
|
|
|
|
|
"-n",
|
|
|
|
|
|
f"{n}:{start}:{new_end}",
|
|
|
|
|
|
"-t",
|
|
|
|
|
|
f"{n}:{info['type']}",
|
|
|
|
|
|
"-u",
|
|
|
|
|
|
f"{n}:{info['uuid']}",
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
f"{n}:{info['name']}",
|
|
|
|
|
|
dev,
|
|
|
|
|
|
]
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
).returncode
|
|
|
|
|
|
if rc != 0:
|
|
|
|
|
|
print(t("Partition rewrite failed; reverting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
2026-07-30 00:41:54 -04:00
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["sudo", "partprobe", dev], check=False, capture_output=True
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
# Détache puis tronque le conteneur qcow2.
|
|
|
|
|
|
self._qemu_nbd_disconnect(dev)
|
|
|
|
|
|
dev = None
|
|
|
|
|
|
print(f"{t('Shrinking the qcow2 container…')} {new_gb:g}G")
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if (
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"sudo",
|
|
|
|
|
|
"qemu-img",
|
|
|
|
|
|
"resize",
|
|
|
|
|
|
"--shrink",
|
|
|
|
|
|
disk,
|
|
|
|
|
|
f"{new_gb:g}G",
|
|
|
|
|
|
]
|
|
|
|
|
|
).returncode
|
|
|
|
|
|
!= 0
|
|
|
|
|
|
):
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
print(t("Container shrink failed; reverting."))
|
|
|
|
|
|
return self._qemu_shrink_revert(bak, disk, changed=True)
|
|
|
|
|
|
# Répare la GPT de secours (fin du disque) + fsck final.
|
|
|
|
|
|
dev = self._qemu_nbd_connect(disk)
|
|
|
|
|
|
if dev:
|
|
|
|
|
|
subprocess.run(["sudo", "sgdisk", "-e", dev], check=False)
|
2026-07-30 00:41:54 -04:00
|
|
|
|
subprocess.run(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
["sudo", "partprobe", dev],
|
|
|
|
|
|
check=False,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
p2 = self._qemu_root_part(dev)[0]
|
|
|
|
|
|
if p2:
|
|
|
|
|
|
subprocess.run(
|
|
|
|
|
|
["sudo", "e2fsck", "-f", "-y", p2], check=False
|
|
|
|
|
|
)
|
|
|
|
|
|
self._qemu_nbd_disconnect(dev)
|
|
|
|
|
|
dev = None
|
2026-07-30 00:48:42 -04:00
|
|
|
|
self._shrink_backup = bak # proposé à la suppression après le boot
|
|
|
|
|
|
if bak:
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(f"✅ {t('Disk safely shrunk. Backup kept at:')} {bak}")
|
2026-07-30 00:48:42 -04:00
|
|
|
|
else:
|
|
|
|
|
|
print(f"✅ {t('Disk safely shrunk.')}")
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
return True
|
|
|
|
|
|
finally:
|
|
|
|
|
|
if dev:
|
|
|
|
|
|
self._qemu_nbd_disconnect(dev)
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_shrink_revert(self, bak, disk, changed):
|
2026-07-30 00:48:42 -04:00
|
|
|
|
"""Restaure le disque depuis la sauvegarde si on l'a modifié (changed)
|
|
|
|
|
|
et qu'une sauvegarde existe ; sinon retire la sauvegarde inutile.
|
|
|
|
|
|
Renvoie False (la réduction a échoué)."""
|
|
|
|
|
|
if changed and bak:
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
print(t("Restoring the original disk from backup…"))
|
|
|
|
|
|
subprocess.run(["sudo", "mv", "-f", bak, disk], check=False)
|
2026-07-30 00:48:42 -04:00
|
|
|
|
elif changed and not bak:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"⚠ {t('No backup to restore; run fsck on the disk before use.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
elif bak:
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
subprocess.run(["sudo", "rm", "-f", bak], check=False)
|
|
|
|
|
|
return False
|
2026-07-29 05:27:20 -04:00
|
|
|
|
|
2026-07-30 00:01:30 -04:00
|
|
|
|
@staticmethod
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
def _qemu_nbd_connect(disk):
|
2026-07-30 00:41:54 -04:00
|
|
|
|
"""Attache `disk` à un /dev/nbdN libre et renvoie le chemin, ou None.
|
|
|
|
|
|
Attend que les sous-périphériques de partition (nbdNpM) APPARAISSENT
|
|
|
|
|
|
(sinon lsblk/resize2fs ne voient rien juste après le connect)."""
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
for i in range(16):
|
|
|
|
|
|
dev = f"/dev/nbd{i}"
|
|
|
|
|
|
# /sys/block/nbdN/pid absent => device libre.
|
|
|
|
|
|
if os.path.exists(f"/sys/block/nbd{i}/pid"):
|
|
|
|
|
|
continue
|
|
|
|
|
|
rc = subprocess.run(
|
|
|
|
|
|
["sudo", "qemu-nbd", "-c", dev, disk],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
).returncode
|
2026-07-30 00:41:54 -04:00
|
|
|
|
if rc != 0:
|
|
|
|
|
|
continue
|
|
|
|
|
|
base = f"nbd{i}"
|
|
|
|
|
|
for _ in range(15):
|
|
|
|
|
|
subprocess.run(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
["sudo", "partprobe", dev],
|
|
|
|
|
|
check=False,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
time.sleep(1)
|
2026-07-30 00:41:54 -04:00
|
|
|
|
if any(
|
|
|
|
|
|
os.path.exists(f"/sys/class/block/{base}p{n}")
|
|
|
|
|
|
for n in range(1, 32)
|
|
|
|
|
|
):
|
|
|
|
|
|
break
|
|
|
|
|
|
return dev
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_nbd_disconnect(dev):
|
|
|
|
|
|
subprocess.run(["sudo", "qemu-nbd", "-d", dev], check=False)
|
|
|
|
|
|
time.sleep(1)
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_root_part(dev):
|
|
|
|
|
|
"""(partition la plus grosse, secteur de début, type FS) du disque nbd.
|
2026-07-30 00:41:54 -04:00
|
|
|
|
(None, 0, '') si introuvable. Format lsblk -P (paires) : robuste aux
|
|
|
|
|
|
colonnes VIDES — juste après le connect, FSTYPE peut être vide, et un
|
|
|
|
|
|
parsing positionnel décalait/ignorait alors toutes les partitions."""
|
|
|
|
|
|
import re
|
|
|
|
|
|
|
2026-07-30 00:01:30 -04:00
|
|
|
|
try:
|
|
|
|
|
|
res = subprocess.run(
|
2026-07-30 00:41:54 -04:00
|
|
|
|
["lsblk", "-Pbno", "NAME,SIZE,TYPE,FSTYPE", dev],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=30,
|
2026-07-30 00:01:30 -04:00
|
|
|
|
)
|
|
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
return None, 0, ""
|
|
|
|
|
|
best, best_sz, best_fs = None, -1, ""
|
2026-07-30 00:01:30 -04:00
|
|
|
|
for line in res.stdout.splitlines():
|
2026-07-30 00:41:54 -04:00
|
|
|
|
d = dict(re.findall(r'(\w+)="([^"]*)"', line))
|
|
|
|
|
|
if d.get("TYPE") != "part":
|
2026-07-30 00:01:30 -04:00
|
|
|
|
continue
|
2026-07-30 00:41:54 -04:00
|
|
|
|
try:
|
|
|
|
|
|
size = int(d.get("SIZE") or 0)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
size = 0
|
2026-07-30 00:01:30 -04:00
|
|
|
|
if size > best_sz:
|
2026-08-01 02:42:45 -04:00
|
|
|
|
best, best_sz, best_fs = (
|
|
|
|
|
|
d.get("NAME"),
|
|
|
|
|
|
size,
|
|
|
|
|
|
d.get("FSTYPE", ""),
|
|
|
|
|
|
)
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
if not best:
|
|
|
|
|
|
return None, 0, ""
|
|
|
|
|
|
part = f"/dev/{best}"
|
|
|
|
|
|
try:
|
2026-08-01 02:42:45 -04:00
|
|
|
|
start = int(open(f"/sys/class/block/{best}/start").read().strip())
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
except OSError:
|
|
|
|
|
|
start = 0
|
|
|
|
|
|
if not best_fs:
|
2026-07-30 00:41:54 -04:00
|
|
|
|
# FSTYPE pas encore en cache : sonder directement avec blkid.
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
best_fs = subprocess.run(
|
|
|
|
|
|
["sudo", "blkid", "-o", "value", "-s", "TYPE", part],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
).stdout.strip()
|
|
|
|
|
|
return part, start, best_fs
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_part_number(dev, part):
|
|
|
|
|
|
"""Numéro de partition (ex. « 1 ») depuis /dev/nbd0p1."""
|
2026-08-01 02:42:45 -04:00
|
|
|
|
return part[len(dev) :].lstrip("p")
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_part_info(dev, n):
|
|
|
|
|
|
"""{type, uuid, name} d'une partition via « sgdisk -i »."""
|
|
|
|
|
|
info = {"type": "", "uuid": "", "name": ""}
|
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
|
["sudo", "sgdisk", "-i", n, dev],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=TODO._qemu_c_env(),
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
)
|
|
|
|
|
|
for line in res.stdout.splitlines():
|
|
|
|
|
|
low = line.lower()
|
|
|
|
|
|
if low.startswith("partition guid code"):
|
|
|
|
|
|
info["type"] = line.split(":", 1)[1].split()[0]
|
|
|
|
|
|
elif low.startswith("partition unique guid"):
|
|
|
|
|
|
info["uuid"] = line.split(":", 1)[1].strip()
|
|
|
|
|
|
elif low.startswith("partition name"):
|
|
|
|
|
|
info["name"] = line.split(":", 1)[1].strip().strip("'")
|
|
|
|
|
|
return info
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_fs_blocksize(part):
|
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
|
["sudo", "dumpe2fs", "-h", part],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=TODO._qemu_c_env(),
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
)
|
|
|
|
|
|
for line in res.stdout.splitlines():
|
|
|
|
|
|
if line.startswith("Block size:"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(line.split(":", 1)[1].strip())
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return 4096
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_fs_blocks(part):
|
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
|
["sudo", "dumpe2fs", "-h", part],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=TODO._qemu_c_env(),
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
)
|
|
|
|
|
|
for line in res.stdout.splitlines():
|
|
|
|
|
|
if line.startswith("Block count:"):
|
|
|
|
|
|
try:
|
|
|
|
|
|
return int(line.split(":", 1)[1].strip())
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_fs_min_blocks(part):
|
|
|
|
|
|
"""Taille minimale (blocs) du FS via « resize2fs -P »."""
|
|
|
|
|
|
res = subprocess.run(
|
|
|
|
|
|
["sudo", "resize2fs", "-P", part],
|
2026-08-01 02:42:45 -04:00
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=TODO._qemu_c_env(),
|
[FIX] script todo: réduction disque via qemu-nbd (libguestfs cassé sur l'hôte)
La réduction « sûre » via virt-resize ne marchait pas : (1) j'utilisais
« virt-filesystems -b » (option INEXISTANTE -> aucune partition détectée ->
« abandon »), et (2) libguestfs est inutilisable ici (aucun vmlinuz dans
/boot -> l'appliance supermin échoue). La VM restait donc à 25G.
Réécriture SANS libguestfs, avec des outils de base présents et éprouvés
(qemu-nbd, e2fsck, resize2fs, sgdisk, parted/partprobe) :
1. copie .bak AVANT toute modification ;
2. nbd + détection de la racine (plus grosse partition, ext seulement) ;
3. e2fsck -> resize2fs (FS) -> sgdisk réécrit la partition en PRÉSERVANT
type/UUID/nom (PARTUUID intact) -> qemu-img --shrink (conteneur) ->
sgdisk -e (GPT de secours) -> fsck final ;
4. en cas d'échec à N'IMPORTE quelle étape : restauration depuis .bak
-> corruption impossible.
Validé de bout en bout sur une image jetable (GPT + ext4, racine à offset
élevé, 800M de données) : 25G -> 15G, GPT « No problems found », fsck
propre, UUID préservé, données intactes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-30 00:34:45 -04:00
|
|
|
|
)
|
|
|
|
|
|
for tok in res.stdout.replace(":", " ").split():
|
|
|
|
|
|
if tok.isdigit():
|
|
|
|
|
|
return int(tok)
|
|
|
|
|
|
return 0
|
2026-07-29 07:07:46 -04:00
|
|
|
|
|
2026-07-29 07:29:53 -04:00
|
|
|
|
# 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 /); "
|
2026-08-01 02:42:45 -04:00
|
|
|
|
'dev=$(lsblk -no PKNAME "$root" | head -1); '
|
2026-07-29 07:29:53 -04:00
|
|
|
|
"part=$(echo \"$root\" | grep -oE '[0-9]+$'); "
|
|
|
|
|
|
"sudo growpart /dev/$dev $part || true; "
|
|
|
|
|
|
"fstype=$(findmnt -no FSTYPE /); "
|
|
|
|
|
|
'case "$fstype" in '
|
2026-08-01 02:42:45 -04:00
|
|
|
|
'ext*) sudo resize2fs "$root";; '
|
2026-07-29 07:29:53 -04:00
|
|
|
|
"xfs) sudo xfs_growfs /;; "
|
|
|
|
|
|
"btrfs) sudo btrfs filesystem resize max /;; "
|
2026-08-01 02:42:45 -04:00
|
|
|
|
"esac; "
|
2026-07-29 07:29:53 -04:00
|
|
|
|
"df -h /"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-29 05:27:20 -04:00
|
|
|
|
def _qemu_grow_guest_fs(self, name):
|
2026-07-29 07:29:53 -04:00
|
|
|
|
"""É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)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# 1) SSH : IP résolue avec BATTEMENT (parallèle, boot émulé lent)
|
2026-07-29 07:29:53 -04:00
|
|
|
|
# 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
|
2026-07-29 07:40:43 -04:00
|
|
|
|
print(f"⚠ {t('SSH grow failed; trying the guest agent.')}")
|
2026-07-29 07:29:53 -04:00
|
|
|
|
else:
|
2026-07-29 07:40:43 -04:00
|
|
|
|
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
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"⚠ {t('Guest agent grow failed; falling back to console.')}"
|
|
|
|
|
|
)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
else:
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
t("Guest agent unavailable; falling back to serial console.")
|
|
|
|
|
|
)
|
2026-07-29 07:40:43 -04:00
|
|
|
|
# 3) Console série (commande prête à coller, login interactif).
|
2026-07-29 07:29:53 -04:00
|
|
|
|
self._qemu_grow_via_console(real, remote)
|
|
|
|
|
|
|
2026-07-29 07:40:43 -04:00
|
|
|
|
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(
|
|
|
|
|
|
[
|
2026-08-01 02:42:45 -04:00
|
|
|
|
"sudo",
|
|
|
|
|
|
"virsh",
|
|
|
|
|
|
"qemu-agent-command",
|
|
|
|
|
|
name,
|
|
|
|
|
|
json.dumps(payload),
|
2026-07-29 07:40:43 -04:00
|
|
|
|
],
|
|
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-29 07:29:53 -04:00
|
|
|
|
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')}"
|
2026-07-29 05:27:20 -04:00
|
|
|
|
)
|
2026-08-01 02:42:45 -04:00
|
|
|
|
if not self._is_yes(input(t("Open the serial console now? (y/N): "))):
|
2026-07-29 07:29:53 -04:00
|
|
|
|
return
|
|
|
|
|
|
cmd = f"sudo virsh console {shlex.quote(name)}"
|
2026-07-29 05:27:20 -04:00
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
|
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
|
2026-07-19 03:47:33 -04:00
|
|
|
|
def _write_ssh_config_entry(self, host, user, ip):
|
|
|
|
|
|
"""Écrit/remplace un bloc « Host <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()
|
2026-07-19 04:55:50 -04:00
|
|
|
|
# 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.
|
2026-07-19 03:47:33 -04:00
|
|
|
|
pattern = re.compile(
|
2026-07-19 04:55:50 -04:00
|
|
|
|
rf"(?m)^[ \t]*Host[ \t]+{re.escape(host)}[ \t]*\n"
|
|
|
|
|
|
r"(?:[ \t]+[^\n]*\n?)*"
|
2026-07-19 03:47:33 -04:00
|
|
|
|
)
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-07-19 03:26:28 -04:00
|
|
|
|
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.')}")
|
|
|
|
|
|
|
2026-07-27 22:31:56 -04:00
|
|
|
|
@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
|
|
|
|
|
|
|
2026-07-27 23:12:43 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-27 22:31:56 -04:00
|
|
|
|
def _qemu_cleanup(self):
|
2026-07-27 23:12:43 -04:00
|
|
|
|
"""Repère les restes QEMU orphelins et propose de les effacer."""
|
2026-07-27 22:31:56 -04:00
|
|
|
|
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())
|
|
|
|
|
|
|
2026-07-27 23:12:43 -04:00
|
|
|
|
# 1) Fichiers orphelins : disques / seeds / .part / nvram.
|
2026-07-27 22:31:56 -04:00
|
|
|
|
orphans = [] # (taille, chemin, motif)
|
|
|
|
|
|
for size, path in self._qemu_find_files(disk_dir, "*.qcow2"):
|
2026-07-27 23:12:43 -04:00
|
|
|
|
if os.path.basename(path)[: -len(".qcow2")] not in domains:
|
2026-07-27 22:31:56 -04:00
|
|
|
|
orphans.append((size, path, t("orphan disk")))
|
|
|
|
|
|
for size, path in self._qemu_find_files(seed_dir, "*-seed.iso"):
|
2026-07-27 23:12:43 -04:00
|
|
|
|
if os.path.basename(path)[: -len("-seed.iso")] not in domains:
|
2026-07-27 22:31:56 -04:00
|
|
|
|
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")))
|
2026-07-30 00:48:42 -04:00
|
|
|
|
# Sauvegardes de disque laissées par un redimensionnement (.qcow2.bak).
|
|
|
|
|
|
for size, path in self._qemu_find_files(disk_dir, "*.qcow2.bak"):
|
|
|
|
|
|
orphans.append((size, path, t("disk backup (resize)")))
|
2026-07-27 23:12:43 -04:00
|
|
|
|
if orphans:
|
2026-07-27 22:31:56 -04:00
|
|
|
|
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."))
|
2026-07-27 23:12:43 -04:00
|
|
|
|
else:
|
|
|
|
|
|
print(f"✅ {t('No orphan files found.')}")
|
2026-07-27 22:31:56 -04:00
|
|
|
|
|
2026-07-27 23:12:43 -04:00
|
|
|
|
# 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).
|
2026-07-27 22:31:56 -04:00
|
|
|
|
cached = [
|
|
|
|
|
|
(s, p)
|
|
|
|
|
|
for s, p in self._qemu_find_files(seed_dir, "*")
|
|
|
|
|
|
if not p.endswith("-seed.iso") and not p.endswith(".part")
|
|
|
|
|
|
]
|
2026-07-27 23:12:43 -04:00
|
|
|
|
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,
|
2026-07-30 01:04:07 -04:00
|
|
|
|
env=self._qemu_c_env(),
|
2026-07-27 23:12:43 -04:00
|
|
|
|
)
|
|
|
|
|
|
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:
|
2026-07-27 22:31:56 -04:00
|
|
|
|
print(
|
2026-07-27 23:12:43 -04:00
|
|
|
|
f" {ln.get('ip-address', '?'):<16} "
|
|
|
|
|
|
f"{ln.get('mac-address', '?')} {ln.get('hostname', '')}"
|
2026-07-27 22:31:56 -04:00
|
|
|
|
)
|
2026-07-27 23:12:43 -04:00
|
|
|
|
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.')}")
|
2026-07-27 22:31:56 -04:00
|
|
|
|
|
2026-07-19 02:52:39 -04:00
|
|
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
# 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
|
|
|
|
|
|
|
2026-07-28 04:15:46 -04:00
|
|
|
|
@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
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
|
|
|
|
|
@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
|
|
|
|
|
|
|
2026-07-19 03:07:20 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _is_yes(ans):
|
|
|
|
|
|
"""Réponse affirmative, FR et EN (o/oui/y/yes)."""
|
|
|
|
|
|
return ans.strip().lower() in ("y", "yes", "o", "oui")
|
|
|
|
|
|
|
2026-07-30 00:09:37 -04:00
|
|
|
|
@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")
|
|
|
|
|
|
|
2026-07-19 03:10:55 -04:00
|
|
|
|
@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")
|
|
|
|
|
|
|
2026-07-19 02:52:39 -04:00
|
|
|
|
@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
|
|
|
|
|
|
|
2026-07-28 04:33:50 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_lease_candidates(name):
|
2026-07-29 04:54:36 -04:00
|
|
|
|
"""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é."""
|
2026-07-28 04:33:50 -04:00
|
|
|
|
try:
|
|
|
|
|
|
res = subprocess.run(
|
2026-07-29 04:54:36 -04:00
|
|
|
|
["ping", "-c", "1", "-W", str(int(timeout)), ip],
|
2026-07-28 04:33:50 -04:00
|
|
|
|
capture_output=True,
|
2026-07-29 04:54:36 -04:00
|
|
|
|
timeout=timeout + 1,
|
2026-07-28 04:33:50 -04:00
|
|
|
|
)
|
2026-07-29 04:54:36 -04:00
|
|
|
|
if res.returncode == 0:
|
|
|
|
|
|
return True
|
2026-07-28 04:33:50 -04:00
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
2026-07-29 04:54:36 -04:00
|
|
|
|
pass
|
2026-07-28 04:33:50 -04:00
|
|
|
|
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)."""
|
2026-07-19 02:52:39 -04:00
|
|
|
|
deadline = time.time() + timeout
|
2026-07-28 04:33:50 -04:00
|
|
|
|
cands = []
|
2026-07-19 02:52:39 -04:00
|
|
|
|
while time.time() < deadline:
|
2026-07-28 04:33:50 -04:00
|
|
|
|
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
|
2026-07-19 02:52:39 -04:00
|
|
|
|
time.sleep(3)
|
2026-07-28 04:33:50 -04:00
|
|
|
|
# Meilleur effort : le dernier bail (le plus récent) plutôt que le 1er.
|
|
|
|
|
|
return cands[-1] if cands else None
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
2026-07-29 04:46:48 -04:00
|
|
|
|
@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"
|
|
|
|
|
|
|
2026-07-29 04:54:36 -04:00
|
|
|
|
def _qemu_resolve_ips(self, names, labels=None, timeout=300):
|
2026-07-28 05:22:46 -04:00
|
|
|
|
"""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}.
|
2026-07-29 04:33:50 -04:00
|
|
|
|
`labels` : {nom: « k/N »} pour préfixer chaque ligne d'un ID de suivi.
|
2026-07-29 04:54:36 -04:00
|
|
|
|
`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."""
|
2026-07-28 05:22:46 -04:00
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
2026-07-29 04:54:36 -04:00
|
|
|
|
from concurrent.futures import TimeoutError as _FTimeout
|
2026-07-28 05:22:46 -04:00
|
|
|
|
|
2026-07-29 04:33:50 -04:00
|
|
|
|
labels = labels or {}
|
2026-08-01 02:42:45 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Resolving VM IPs (parallel, emulated boot is slow)...')}"
|
|
|
|
|
|
)
|
2026-07-28 05:22:46 -04:00
|
|
|
|
result = {}
|
2026-07-29 04:46:48 -04:00
|
|
|
|
t0 = time.time()
|
2026-07-29 04:54:36 -04:00
|
|
|
|
starts = {}
|
2026-07-28 05:22:46 -04:00
|
|
|
|
workers = min(len(names), (os.cpu_count() or 4)) or 1
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
2026-07-29 04:46:48 -04:00
|
|
|
|
futs = {}
|
|
|
|
|
|
for n in names:
|
|
|
|
|
|
starts[n] = time.time()
|
2026-07-29 04:54:36 -04:00
|
|
|
|
futs[pool.submit(self._qemu_vm_ip, n, timeout)] = n
|
|
|
|
|
|
pending = set(futs)
|
|
|
|
|
|
done = 0
|
|
|
|
|
|
while pending:
|
2026-07-28 05:22:46 -04:00
|
|
|
|
try:
|
2026-07-29 04:54:36 -04:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
2026-07-29 04:46:48 -04:00
|
|
|
|
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)})"
|
|
|
|
|
|
)
|
2026-07-28 05:22:46 -04:00
|
|
|
|
return result
|
|
|
|
|
|
|
2026-07-28 04:57:36 -04:00
|
|
|
|
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"<type[^>]*\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
|
|
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
def _qemu_branch_list(self):
|
|
|
|
|
|
"""Branches distantes d'ERPLibre, triées. Vide si le réseau manque.
|
|
|
|
|
|
|
|
|
|
|
|
Séparé de l'invite : le formulaire TUI a besoin de la LISTE, et cet
|
|
|
|
|
|
appel réseau (jusqu'à 30 s) doit être fait avant que Textual prenne
|
|
|
|
|
|
le terminal."""
|
2026-07-19 02:52:39 -04:00
|
|
|
|
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
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
branches.sort()
|
|
|
|
|
|
return branches
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_pick_branch(self):
|
|
|
|
|
|
"""Liste les branches distantes d'ERPLibre et en fait choisir une."""
|
|
|
|
|
|
print(f"\n{t('Fetching ERPLibre branch list...')}")
|
|
|
|
|
|
branches = self._qemu_branch_list()
|
2026-07-19 02:52:39 -04:00
|
|
|
|
default = (
|
|
|
|
|
|
"master"
|
|
|
|
|
|
if "master" in branches
|
|
|
|
|
|
else (branches[0] if branches else "master")
|
|
|
|
|
|
)
|
|
|
|
|
|
if not branches:
|
|
|
|
|
|
return (
|
|
|
|
|
|
input(f"{t('Branch (default:')} {default}): ").strip()
|
|
|
|
|
|
or default
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"{t('Branches:')}")
|
|
|
|
|
|
for i, b in enumerate(branches, 1):
|
|
|
|
|
|
star = " *" if b == default else ""
|
|
|
|
|
|
print(f" [{i}] {b}{star}")
|
|
|
|
|
|
sel = input(f"{t('Choice (number or name, default:')} {default}): ")
|
|
|
|
|
|
sel = sel.strip()
|
|
|
|
|
|
if not sel:
|
|
|
|
|
|
return default
|
|
|
|
|
|
try:
|
|
|
|
|
|
idx = int(sel) - 1
|
|
|
|
|
|
if 0 <= idx < len(branches):
|
|
|
|
|
|
return branches[idx]
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
if sel in branches:
|
|
|
|
|
|
return sel
|
|
|
|
|
|
return default
|
|
|
|
|
|
|
2026-07-27 23:48:56 -04:00
|
|
|
|
# Cible d'installation Odoo exécutée dans la VM (défaut ERPLibre 1.6.0).
|
|
|
|
|
|
ERPLIBRE_ODOO_TARGET = "install_odoo_18"
|
2026-07-28 02:48:48 -04:00
|
|
|
|
# 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
|
2026-07-27 23:48:56 -04:00
|
|
|
|
|
2026-07-28 00:10:33 -04:00
|
|
|
|
@staticmethod
|
2026-07-28 04:15:46 -04:00
|
|
|
|
def _qemu_wait_ssh(ip, user="erplibre", timeout=1200):
|
2026-07-28 03:19:17 -04:00
|
|
|
|
"""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."""
|
2026-07-28 00:10:33 -04:00
|
|
|
|
opts = (
|
|
|
|
|
|
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
|
|
|
|
|
"-o ConnectTimeout=8 -o BatchMode=yes"
|
|
|
|
|
|
)
|
2026-07-28 03:19:17 -04:00
|
|
|
|
# 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")
|
2026-07-28 00:10:33 -04:00
|
|
|
|
deadline = time.time() + timeout
|
2026-07-28 03:19:17 -04:00
|
|
|
|
ssh_up = False
|
2026-07-28 00:10:33 -04:00
|
|
|
|
while time.time() < deadline:
|
|
|
|
|
|
try:
|
|
|
|
|
|
res = subprocess.run(
|
2026-07-28 03:19:17 -04:00
|
|
|
|
f"ssh {opts} {user}@{ip} {shlex.quote(probe)}",
|
2026-07-28 00:10:33 -04:00
|
|
|
|
shell=True,
|
|
|
|
|
|
capture_output=True,
|
2026-07-28 03:19:17 -04:00
|
|
|
|
timeout=20,
|
|
|
|
|
|
text=True,
|
2026-07-28 00:10:33 -04:00
|
|
|
|
)
|
2026-07-28 03:19:17 -04:00
|
|
|
|
out = res.stdout or ""
|
2026-07-28 00:10:33 -04:00
|
|
|
|
except (OSError, subprocess.SubprocessError):
|
2026-07-28 03:19:17 -04:00
|
|
|
|
out = ""
|
|
|
|
|
|
if out.strip():
|
|
|
|
|
|
ssh_up = True # sshd a répondu
|
|
|
|
|
|
if any(k in out for k in ready):
|
2026-07-28 00:10:33 -04:00
|
|
|
|
return True
|
|
|
|
|
|
time.sleep(5)
|
2026-07-28 03:19:17 -04:00
|
|
|
|
# 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
|
2026-07-28 00:10:33 -04:00
|
|
|
|
|
[FIX] qemu: really prepare the host in the Deployment profile
The « ERPLibre Deployment (+ QEMU + dev) » profile installed packages with a
one-liner ending in « || true » and hiding stderr, so a broken host looked
installed. Reproduced on erplibre-arch-latest: every binary was present, yet
virt-install failed and « virsh list --all » could not reach any hypervisor.
Three distinct causes, all unhandled:
- The user was never added to the libvirt group. Without it a non-root libvirt
client falls back to qemu:///session, where the « default » network does not
exist, so « --network network=default » fails while everything looks
installed. Being in the group grants the RIGHT to reach qemu:///system but
does NOT change the default URI, so virt-install and virsh now pass
« --connect qemu:///system » explicitly (new LIBVIRT_URI constant).
- dnsmasq was missing: ensure_tools only installed DAEMON_PACKAGES when the
daemon was absent, and libvirt was already there. setup_host now forces them.
- On a rolling distro, « make install_os » upgrades the kernel and the package
manager removes /lib/modules/<running kernel>. modprobe bridge then fails and
libvirt cannot create virbr0 (« Unable to create bridge virbr0: Package not
installed »). No package fixes that, only a reboot: it is now diagnosed and
reported instead of surfacing as an unreadable virsh error.
The profile now calls « deploy_qemu.py --setup-host », which reuses the
existing ensure_* chain, so package names stay defined in one place
(TOOL_PACKAGES / DAEMON_PACKAGES) and keep working for apt, dnf, pacman,
zypper and brew. It fails loudly instead of « || true ».
Verified on the Arch VM that failed: setup-host reports the stale kernel and
exits 1; after a reboot it installs dnsmasq, joins libvirt/kvm, starts the
default network, and reports « Hôte prêt » (exit 0, idempotent on rerun).
The generated command now reads « virt-install --connect qemu:///system ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 03:15:46 -04:00
|
|
|
|
# Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ».
|
|
|
|
|
|
# Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà
|
|
|
|
|
|
# définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES),
|
|
|
|
|
|
# et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon,
|
|
|
|
|
|
# ajouter l'utilisateur au groupe libvirt et activer le réseau « default ».
|
|
|
|
|
|
# Sans le groupe, virt-install retombe sur qemu:///session où « default »
|
|
|
|
|
|
# n'existe pas : la VM échoue alors que tous les paquets sont installés.
|
|
|
|
|
|
# L'ancien one-liner finissait par « || true » et masquait ses erreurs.
|
[FIX] qemu: reboot after the install upgrades the kernel, and fix virtinst cache
On a rolling distro every fresh development VM was born unable to create VMs.
« make install_os » runs a full system upgrade, the kernel package is replaced
and /lib/modules/<running kernel> disappears. modprobe bridge then fails and
libvirt cannot create virbr0, so the « default » network stays inactive and
virt-install dies on « network 'default' is not active » -- with every package
correctly installed. Measured twice on a freshly created Arch VM: booted on
7.1.3-arch1-3 at 05:44, upgraded to 7.1.5.arch1-2 at 05:46.
Only a reboot fixes it, and one is enough: the default network is already
flagged autostart, so libvirt brings it up by itself once the modules match.
--setup-host therefore gains --reboot-if-needed, used by the deployment
profile. The reboot is scheduled through systemd-run --on-active=5 rather than
issued immediately, otherwise it would kill the installer's SSH session and the
orchestrator would report a failure for a VM that actually succeeded. Without
the flag the behaviour is unchanged: explain and exit 1, which is what a
workstation wants.
Also fix « Error setting up logfile: No write access to
/var/tmp/erplibre-virtinst/virt-manager »: that path was shared, so a first run
under sudo created it as root and later non-root runs could not write. It is
now per-UID.
Verified on the Arch VM that failed: without the flag it exits 1 with the
diagnosis; with it, the reboot is scheduled and the command still returns 0;
after the reboot the kernel and modules match, « default » is active on its own
and virbr0 exists. The cache directory is created as erplibre:erplibre 0700.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 04:00:21 -04:00
|
|
|
|
_QEMU_QEMU_PKGS = (
|
|
|
|
|
|
"./script/qemu/deploy_qemu.py --setup-host --assume-yes"
|
|
|
|
|
|
" --reboot-if-needed"
|
|
|
|
|
|
)
|
2026-07-29 06:42:02 -04:00
|
|
|
|
|
2026-07-30 02:09:29 -04:00
|
|
|
|
def _qemu_ask_prod(self):
|
|
|
|
|
|
"""Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est
|
|
|
|
|
|
installé dans /opt/erplibre (au lieu de ~/git/erplibre) et le service
|
|
|
|
|
|
systemd reste CONFINÉ par SELinux (pas d'unconfined)."""
|
|
|
|
|
|
print(f"\n{t('Target environment?')}")
|
|
|
|
|
|
print(f" [1] {t('Development (~/git/erplibre, SELinux relaxed)')} *")
|
|
|
|
|
|
print(f" [2] {t('Production (/opt/erplibre, SELinux enforced)')}")
|
|
|
|
|
|
sel = input(t("Choice (1-2, default 1): ")).strip()
|
|
|
|
|
|
return sel == "2"
|
|
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
def _qemu_install_profiles(self):
|
|
|
|
|
|
"""Profils installables : [(libellé, commande)]. Le premier est le
|
|
|
|
|
|
défaut. Partagé par l'invite en ligne et le formulaire TUI."""
|
2026-07-29 06:42:02 -04:00
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
]
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
return profiles
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_pick_install_profile(self):
|
|
|
|
|
|
"""Choix de CE QU'ON installe sur la VM. Renvoie (label, commande
|
|
|
|
|
|
finale exécutée dans ~/git/erplibre)."""
|
|
|
|
|
|
profiles = self._qemu_install_profiles()
|
2026-07-29 06:42:02 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-07-30 02:09:29 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_install_dir(prod):
|
|
|
|
|
|
"""Répertoire d'installation ERPLibre dans la VM : /opt/erplibre en
|
|
|
|
|
|
PROD (hors /home -> service SELinux confiné possible), sinon
|
|
|
|
|
|
~/git/erplibre (dev)."""
|
|
|
|
|
|
return "/opt/erplibre" if prod else "$HOME/git/erplibre"
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_odoo_service_cmd(self, prod=False):
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
"""Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme
|
2026-07-30 02:09:29 -04:00
|
|
|
|
service systemd puis l'active. N'est ajouté QUE pour les profils Odoo.
|
|
|
|
|
|
|
2026-07-30 03:21:58 -04:00
|
|
|
|
DEV : ERPLibre sous ~/home. Un service système ne peut PAS exécuter
|
|
|
|
|
|
du user_home_t sous SELinux, et « SELinuxContext=unconfined » ne suffit
|
|
|
|
|
|
pas (transition init_t -> unconfined_t refusée -> toujours 203/EXEC).
|
|
|
|
|
|
Sur une VM de dev jetable, on passe donc SELinux en PERMISSIF (relâché).
|
2026-07-30 02:09:29 -04:00
|
|
|
|
PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service
|
|
|
|
|
|
reste CONFINÉ par SELinux ; on restaure les contextes (restorecon)."""
|
|
|
|
|
|
svc_dir = self._qemu_install_dir(prod)
|
2026-08-01 02:42:45 -04:00
|
|
|
|
selinux_shell = (
|
|
|
|
|
|
'SELINUX_LINE=""; ' # pas de SELinuxContext (inefficace)
|
|
|
|
|
|
)
|
2026-07-30 02:09:29 -04:00
|
|
|
|
if prod:
|
|
|
|
|
|
pre = (
|
|
|
|
|
|
"command -v restorecon >/dev/null 2>&1 && "
|
|
|
|
|
|
"sudo restorecon -R /opt/erplibre >/dev/null 2>&1 || true; "
|
|
|
|
|
|
)
|
|
|
|
|
|
else:
|
2026-07-30 03:21:58 -04:00
|
|
|
|
# DEV : SELinux permissif (persistant) si actif -> le service peut
|
|
|
|
|
|
# exécuter run.sh/venv sous /home.
|
|
|
|
|
|
pre = (
|
2026-07-30 02:09:29 -04:00
|
|
|
|
"if command -v getenforce >/dev/null 2>&1 && "
|
2026-07-30 03:21:58 -04:00
|
|
|
|
'[ "$(getenforce)" = "Enforcing" ]; then '
|
|
|
|
|
|
"sudo setenforce 0 || true; "
|
|
|
|
|
|
"sudo sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' "
|
|
|
|
|
|
"/etc/selinux/config 2>/dev/null || true; fi; "
|
2026-07-30 02:09:29 -04:00
|
|
|
|
)
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
return (
|
2026-07-30 02:09:29 -04:00
|
|
|
|
f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
|
|
|
|
|
|
+ pre
|
|
|
|
|
|
+ selinux_shell
|
|
|
|
|
|
+ "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
"[Unit]\n"
|
|
|
|
|
|
"Description=ERPLibre\n"
|
|
|
|
|
|
"Requires=postgresql.service\n"
|
|
|
|
|
|
"After=network.target network-online.target postgresql.service\n"
|
|
|
|
|
|
"\n"
|
|
|
|
|
|
"[Service]\n"
|
|
|
|
|
|
"Type=simple\n"
|
|
|
|
|
|
"User=$SVC_USER\n"
|
|
|
|
|
|
"Group=$SVC_GROUP\n"
|
|
|
|
|
|
"Restart=always\n"
|
|
|
|
|
|
"RestartSec=5\n"
|
|
|
|
|
|
"ExecStart=$SVC_DIR/run.sh\n"
|
|
|
|
|
|
"WorkingDirectory=$SVC_DIR\n"
|
|
|
|
|
|
"StandardOutput=journal+console\n"
|
2026-07-30 01:52:55 -04:00
|
|
|
|
"$SELINUX_LINE\n"
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
"\n"
|
|
|
|
|
|
"[Install]\n"
|
|
|
|
|
|
"WantedBy=multi-user.target\n"
|
|
|
|
|
|
"UNIT\n"
|
|
|
|
|
|
"sudo systemctl daemon-reload; "
|
|
|
|
|
|
"sudo systemctl enable --now erplibre.service"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-30 02:09:29 -04:00
|
|
|
|
def _qemu_erplibre_remote_cmd(self, branch, final_cmd=None, prod=False):
|
2026-07-28 00:27:25 -04:00
|
|
|
|
"""Script d'installation ERPLibre exécuté DANS la VM (curl/git/make
|
2026-07-30 02:09:29 -04:00
|
|
|
|
puis clone + `final_cmd`). `final_cmd` par défaut : install_os +
|
|
|
|
|
|
install_odoo_18. `prod` : installe dans /opt/erplibre (au lieu de
|
|
|
|
|
|
~/git/erplibre) + service SELinux confiné."""
|
2026-07-29 06:42:02 -04:00
|
|
|
|
if not final_cmd:
|
|
|
|
|
|
final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}"
|
[IMP] qemu: table lisible/scrollable, guest-exec autorisé, service Odoo, choix navigateur
Suivi d'installation (qemu_install_monitor.py) :
- Table VM/État LISIBLE : largeurs de colonnes fixes (VM 26, ⚠ 4, État 12,
Durée 7, Disque 8) -> l'État n'est plus tronqué à 3-4 caractères
(« ❌ effacée », « ⏸ en pause » lisibles) ; table défilable (overflow-x,
height 1fr) pour les longs noms et les gros parcs.
- « w » web : offre désormais la LISTE des navigateurs CLI installés
(_choose_browser) pour choisir lequel utiliser, + option [i] installer.
Déploiement (deploy_qemu.py) :
- guest-exec AUTORISÉ : on vide la liste de blocage de qemu-ga
(block-rpcs/blacklist vides — pas allow-rpcs qui est une liste BLANCHE et
casserait les autres RPC), + neutralise /etc/sysconfig/qemu-ga (Fedora).
Installation (todo.py) :
- Profils AVEC Odoo (install_odoo*) UNIQUEMENT : Odoo est enregistré comme
service systemd (erplibre.service, inspiré de script/systemd/
install_daemon.sh) puis enable --now. Pas pour ERPLibre seul / mobile /
Déploiement.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 08:06:29 -04:00
|
|
|
|
# Profils AVEC Odoo (install_odoo*) uniquement : après l'install, on
|
|
|
|
|
|
# enregistre Odoo comme service systemd (enable + start). Pas pour
|
|
|
|
|
|
# « ERPLibre seul », « mobile » ni « Déploiement ».
|
|
|
|
|
|
if "install_odoo" in final_cmd:
|
2026-07-30 02:09:29 -04:00
|
|
|
|
final_cmd = f"{final_cmd} && {self._qemu_odoo_service_cmd(prod)}"
|
2026-08-01 02:42:45 -04:00
|
|
|
|
# VM de DÉVELOPPEMENT uniquement : couper les mises à jour automatiques.
|
|
|
|
|
|
# Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en
|
|
|
|
|
|
# pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL
|
|
|
|
|
|
# (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa
|
|
|
|
|
|
# connexion et la base intermédiaire est restée à moitié migrée. Effet
|
|
|
|
|
|
# secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou
|
|
|
|
|
|
# apt pendant l'installation. En PROD on ne touche à rien : les
|
|
|
|
|
|
# correctifs de sécurité automatiques doivent rester actifs.
|
|
|
|
|
|
no_auto_upgrade = ""
|
|
|
|
|
|
if not prod:
|
|
|
|
|
|
no_auto_upgrade = (
|
|
|
|
|
|
"if command -v apt-get >/dev/null 2>&1; then "
|
|
|
|
|
|
"sudo systemctl disable --now unattended-upgrades.service "
|
|
|
|
|
|
"apt-daily.timer apt-daily-upgrade.timer "
|
|
|
|
|
|
">/dev/null 2>&1 || true; "
|
|
|
|
|
|
'printf \'APT::Periodic::Update-Package-Lists "0";\\n'
|
|
|
|
|
|
'APT::Periodic::Unattended-Upgrade "0";\\n\' '
|
|
|
|
|
|
"| sudo tee /etc/apt/apt.conf.d/99-erplibre-no-auto-upgrade "
|
|
|
|
|
|
">/dev/null; "
|
|
|
|
|
|
"fi; "
|
|
|
|
|
|
"if command -v dnf >/dev/null 2>&1; then "
|
|
|
|
|
|
"sudo systemctl disable --now dnf-automatic.timer "
|
|
|
|
|
|
"dnf-automatic-install.timer >/dev/null 2>&1 || true; "
|
|
|
|
|
|
"fi; "
|
|
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
return (
|
2026-07-28 00:03:03 -04:00
|
|
|
|
"set -e; "
|
2026-07-28 02:27:15 -04:00
|
|
|
|
# Attendre la FIN de cloud-init : pendant sa phase « paquets » il
|
|
|
|
|
|
# tient le verrou apt/dnf/pacman -> sinon « unable to lock
|
|
|
|
|
|
# database » (Arch) / « Could not get lock » (apt). timeout pour ne
|
|
|
|
|
|
# pas bloquer indéfiniment si cloud-init traîne.
|
|
|
|
|
|
"command -v cloud-init >/dev/null 2>&1 && "
|
|
|
|
|
|
"sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 "
|
|
|
|
|
|
"|| true; "
|
2026-08-01 02:42:45 -04:00
|
|
|
|
# Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre
|
|
|
|
|
|
# le verrou entre l'attente cloud-init et l'installation.
|
|
|
|
|
|
+ no_auto_upgrade +
|
2026-07-28 02:10:58 -04:00
|
|
|
|
# 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).
|
2026-07-28 00:03:03 -04:00
|
|
|
|
"PKGS='curl git make'; "
|
|
|
|
|
|
"if command -v apt-get >/dev/null 2>&1; then "
|
2026-07-30 02:36:31 -04:00
|
|
|
|
# Au 1er boot, cloud-init (install qemu-guest-agent) et/ou
|
|
|
|
|
|
# apt-daily.service tiennent le verrou apt. IMPORTANT :
|
|
|
|
|
|
# « DPkg::Lock::Timeout » NE couvre PAS le verrou
|
|
|
|
|
|
# /var/lib/apt/lists/lock -> « apt-get update » échouait AUSSITÔT
|
|
|
|
|
|
# (« Could not get lock … lists/lock ») -> lists vides -> « Unable
|
|
|
|
|
|
# to locate package git ». On RÉESSAIE donc update jusqu'à ce que
|
|
|
|
|
|
# le verrou se libère (et les lists soient peuplées), borné à ~5 min.
|
|
|
|
|
|
"n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do "
|
|
|
|
|
|
"n=$((n+1)); [ $n -ge 30 ] && break; "
|
|
|
|
|
|
'echo "apt verrouille (tentative $n), attente 10s..."; sleep 10; '
|
|
|
|
|
|
"done; "
|
2026-07-30 01:52:55 -04:00
|
|
|
|
"sudo apt-get -o DPkg::Lock::Timeout=600 install -y $PKGS; "
|
2026-07-28 00:03:03 -04:00
|
|
|
|
"elif command -v dnf >/dev/null 2>&1; then "
|
2026-07-28 02:10:58 -04:00
|
|
|
|
# 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; "
|
2026-07-28 01:07:10 -04:00
|
|
|
|
"sudo dnf install -y --refresh $PKGS || "
|
|
|
|
|
|
"{ sudo dnf clean all; sudo dnf install -y --refresh $PKGS; }; "
|
2026-07-28 02:10:58 -04:00
|
|
|
|
"elif command -v pacman >/dev/null 2>&1; then "
|
2026-07-28 02:27:15 -04:00
|
|
|
|
# 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; "
|
2026-07-28 02:10:58 -04:00
|
|
|
|
# Arch : reflector sélectionne les miroirs HTTPS les plus rapides,
|
[FIX] install Arch: mise à jour complète (pacman -Syu) contre glibc mismatch
Après le passage à pacman, l'init PostgreSQL échouait :
« /usr/bin/postgres: /usr/lib/libm.so.6: version GLIBC_2.44 not found ».
Cause : Arch est en rolling release et NE SUPPORTE PAS les mises à jour
partielles. Sur une image cloud dont la glibc date du build de l'image, un
« pacman -S <paquet récent> » (postgresql 18.4) installe un binaire lié à
une glibc plus récente que celle du système -> symbole introuvable.
Fix : mise à jour COMPLÈTE (pacman -Syu) avant d'installer, dans les deux
endroits : install_arch_linux.sh (deps OS) et le bootstrap Arch de
_qemu_erplibre_remote_cmd (todo.py, remplace -Syy par -Syu).
Validé sur VM Arch réelle : après -Syu, « postgres (PostgreSQL) 18.4 » OK,
initdb OK, service postgresql actif, superuser erplibre créé, psql connecte.
Chaîne complète confirmée : gcc 16.1.1, make, node, psql tous présents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:27:44 -04:00
|
|
|
|
# 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 <paquet récent> » casse avec « GLIBC_x.yy not found ».
|
2026-07-28 02:10:58 -04:00
|
|
|
|
"sudo pacman -Sy --needed --noconfirm reflector || true; "
|
|
|
|
|
|
"sudo reflector --latest 20 --protocol https --sort rate "
|
|
|
|
|
|
"--save /etc/pacman.d/mirrorlist || true; "
|
[FIX] install Arch: mise à jour complète (pacman -Syu) contre glibc mismatch
Après le passage à pacman, l'init PostgreSQL échouait :
« /usr/bin/postgres: /usr/lib/libm.so.6: version GLIBC_2.44 not found ».
Cause : Arch est en rolling release et NE SUPPORTE PAS les mises à jour
partielles. Sur une image cloud dont la glibc date du build de l'image, un
« pacman -S <paquet récent> » (postgresql 18.4) installe un binaire lié à
une glibc plus récente que celle du système -> symbole introuvable.
Fix : mise à jour COMPLÈTE (pacman -Syu) avant d'installer, dans les deux
endroits : install_arch_linux.sh (deps OS) et le bootstrap Arch de
_qemu_erplibre_remote_cmd (todo.py, remplace -Syy par -Syu).
Validé sur VM Arch réelle : après -Syu, « postgres (PostgreSQL) 18.4 » OK,
initdb OK, service postgresql actif, superuser erplibre créé, psql connecte.
Chaîne complète confirmée : gcc 16.1.1, make, node, psql tous présents.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-28 03:27:44 -04:00
|
|
|
|
"sudo pacman -Syu --noconfirm || true; "
|
2026-07-28 02:10:58 -04:00
|
|
|
|
"sudo pacman -S --needed --noconfirm $PKGS; "
|
2026-07-28 00:03:03 -04:00
|
|
|
|
"elif command -v yum >/dev/null 2>&1; then "
|
2026-07-28 02:10:58 -04:00
|
|
|
|
"sudo yum makecache -q || true; sudo yum install -y $PKGS; "
|
|
|
|
|
|
"else echo 'Aucun gestionnaire de paquets "
|
|
|
|
|
|
"(apt/dnf/pacman/yum)'; exit 1; fi; "
|
2026-07-28 00:40:18 -04:00
|
|
|
|
# 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; '
|
2026-07-30 02:09:29 -04:00
|
|
|
|
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
|
|
|
|
|
|
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
|
|
|
|
|
|
+ (
|
|
|
|
|
|
(
|
|
|
|
|
|
"sudo mkdir -p /opt; "
|
|
|
|
|
|
"if [ ! -d /opt/erplibre/.git ]; then "
|
|
|
|
|
|
f"sudo git clone --branch {shlex.quote(branch)} "
|
|
|
|
|
|
f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; "
|
|
|
|
|
|
"sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; "
|
|
|
|
|
|
f"cd /opt/erplibre && {final_cmd}"
|
|
|
|
|
|
)
|
|
|
|
|
|
if prod
|
|
|
|
|
|
else (
|
|
|
|
|
|
"mkdir -p ~/git; "
|
|
|
|
|
|
"if [ ! -d ~/git/erplibre/.git ]; then "
|
|
|
|
|
|
f"git clone --branch {shlex.quote(branch)} "
|
|
|
|
|
|
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
|
|
|
|
|
|
f"cd ~/git/erplibre && {final_cmd}"
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
2026-07-29 06:42:02 -04:00
|
|
|
|
def _qemu_install_erplibre_monitored(
|
2026-07-30 02:09:29 -04:00
|
|
|
|
self, names, branch, ip_map=None, final_cmd=None, prod=False
|
2026-07-29 06:42:02 -04:00
|
|
|
|
):
|
2026-07-28 00:27:25 -04:00
|
|
|
|
"""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.
|
2026-07-29 06:42:02 -04:00
|
|
|
|
`ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE).
|
2026-07-30 02:09:29 -04:00
|
|
|
|
`final_cmd` : commande d'install selon le profil choisi.
|
|
|
|
|
|
`prod` : install /opt/erplibre + service SELinux confiné."""
|
2026-07-28 00:27:25 -04:00
|
|
|
|
from script.todo.qemu_install_monitor import (
|
|
|
|
|
|
launch_installs,
|
|
|
|
|
|
run_monitor,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-30 02:09:29 -04:00
|
|
|
|
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
|
2026-07-28 04:57:36 -04:00
|
|
|
|
try:
|
|
|
|
|
|
mod = self._qemu_import_module()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
mod = None
|
2026-07-28 05:22:46 -04:00
|
|
|
|
if ip_map is None:
|
|
|
|
|
|
ip_map = self._qemu_resolve_ips(names)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
vms = []
|
|
|
|
|
|
for name in names:
|
2026-07-28 05:22:46 -04:00
|
|
|
|
ip = ip_map.get(name)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
if ip:
|
2026-07-28 04:57:36 -04:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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...')}")
|
2026-07-28 00:41:41 -04:00
|
|
|
|
# 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']}")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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.')}"
|
|
|
|
|
|
)
|
2026-07-28 00:40:18 -04:00
|
|
|
|
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")
|
2026-07-28 00:27:25 -04:00
|
|
|
|
|
2026-07-29 06:42:02 -04:00
|
|
|
|
def _qemu_install_erplibre_vm(
|
2026-07-30 02:09:29 -04:00
|
|
|
|
self, name, ssh_key, branch, ip=None, final_cmd=None, prod=False
|
2026-07-29 06:42:02 -04:00
|
|
|
|
):
|
2026-07-30 02:09:29 -04:00
|
|
|
|
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
|
|
|
|
|
|
d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
|
|
|
|
|
|
`final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné."""
|
2026-07-28 05:22:46 -04:00
|
|
|
|
if ip is None:
|
|
|
|
|
|
ip = self._qemu_vm_ip(name)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
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
|
2026-07-30 02:09:29 -04:00
|
|
|
|
remote = self._qemu_erplibre_remote_cmd(branch, final_cmd, prod)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
ssh_opts = (
|
|
|
|
|
|
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
|
|
|
|
|
"-o ConnectTimeout=15"
|
|
|
|
|
|
)
|
|
|
|
|
|
cmd = f"ssh {ssh_opts} erplibre@{ip} {shlex.quote(remote)}"
|
2026-07-27 23:48:56 -04:00
|
|
|
|
print(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
f"\n 📦 {name} ({ip}): {t('installing ERPLibre')} " f"({branch})"
|
2026-07-27 23:48:56 -04:00
|
|
|
|
)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(f" {t('Will execute:')} {cmd}")
|
|
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
|
|
|
|
|
|
2026-08-01 04:59:02 -04:00
|
|
|
|
# Suggestions proposées aux invites de taille. Les lettres démarrent à « a »
|
|
|
|
|
|
# pour ne JAMAIS entrer en conflit avec une valeur tapée directement : toute
|
|
|
|
|
|
# saisie commençant par un chiffre est lue comme la valeur elle-même.
|
[ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.
record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.
Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.
Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.
Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
|
|
|
|
_QEMU_DISK_PRESETS = (
|
|
|
|
|
|
"20G",
|
|
|
|
|
|
"40G",
|
|
|
|
|
|
"60G",
|
|
|
|
|
|
"80G",
|
|
|
|
|
|
"120G",
|
|
|
|
|
|
"200G",
|
|
|
|
|
|
"400G",
|
|
|
|
|
|
"600G",
|
|
|
|
|
|
"800G",
|
|
|
|
|
|
"1T",
|
|
|
|
|
|
"1.5T",
|
|
|
|
|
|
"2T",
|
|
|
|
|
|
)
|
2026-08-01 05:08:01 -04:00
|
|
|
|
# Jusqu'à 256 Go : les hôtes de virtualisation récents dépassent largement
|
|
|
|
|
|
# 32 Go, et l'invite est en Mo — l'équivalent en Go est donc affiché.
|
|
|
|
|
|
_QEMU_RAM_PRESETS = (
|
|
|
|
|
|
1024,
|
|
|
|
|
|
2048,
|
|
|
|
|
|
4096,
|
|
|
|
|
|
8192,
|
|
|
|
|
|
16384,
|
|
|
|
|
|
32768,
|
|
|
|
|
|
65536,
|
|
|
|
|
|
131072,
|
|
|
|
|
|
262144,
|
|
|
|
|
|
)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# KVM autorise plus de vCPU que de cœurs (surengagement) : on prévient
|
|
|
|
|
|
# plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui,
|
|
|
|
|
|
# est un calcul automatique et se borne aux cœurs de l'hôte.
|
|
|
|
|
|
_QEMU_CPU_PRESETS = (1, 2, 4, 6, 8, 16, 24, 32)
|
2026-08-01 05:08:01 -04:00
|
|
|
|
|
[ADD] qemu: statistics screen, with reset — and record failures too
The install history was only surfaced as a « ~5m avg (3) » suffix next to a
distro. Entry [12] now shows what that history actually contains: totals and
success rate, period covered, median/min/max and cumulated duration, then a
breakdown by distribution, version and architecture, plus the current VMs and
the disk they occupy. « [r] » erases the history after confirmation.
record_duration() was only called on success, so no success rate could ever be
computed. Failures are now recorded with ok=False. They are counted separately
and EXCLUDED from the averages and the ETA: how long a failed install ran says
nothing about how long a successful one takes. Entries written before the flag
existed have no « ok » key and are read as successes, which they were.
Aggregation lives in qemu_install_monitor.py as pure functions (stats_summary,
stats_by, all_runs, reset_stats), display in todo.py — the split the file
already follows.
Disk presets extended to 400G, 600G, 800G, 1T, 1.5T and 2T. The parser only
understood G, so « 1T » would have been rejected: sizes are now normalised
through _qemu_parse_disk (1 T = 1024 G, decimal comma accepted), since the rest
of the chain reasons in gigabytes.
Verified with a synthetic history of 9 runs including 2 failures: the rate,
the per-group failure counts and the reset all behave; a group with no success
shows « — » rather than a misleading « ~0s ».
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:16:27 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _plural(word, count):
|
|
|
|
|
|
"""Accord simple : « échec » / « échecs ». Vaut pour fr et en."""
|
|
|
|
|
|
return word if abs(count) <= 1 else f"{word}s"
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_parse_disk(value):
|
|
|
|
|
|
"""Normalise une taille de disque en « <n>G », ou None si invalide.
|
|
|
|
|
|
|
|
|
|
|
|
Accepte « 60 », « 60G », « 1T », « 1,5T ». Le suffixe T est converti
|
|
|
|
|
|
(1 T = 1024 G) : tout le reste de la chaîne — nom de fichier qcow2,
|
|
|
|
|
|
argument --disk-size — raisonne en gigaoctets.
|
|
|
|
|
|
"""
|
|
|
|
|
|
txt = value.strip().upper().replace(",", ".")
|
|
|
|
|
|
factor = 1
|
|
|
|
|
|
if txt.endswith("T"):
|
|
|
|
|
|
factor, txt = 1024, txt[:-1]
|
|
|
|
|
|
elif txt.endswith("G"):
|
|
|
|
|
|
txt = txt[:-1]
|
|
|
|
|
|
try:
|
|
|
|
|
|
gigs = int(float(txt) * factor)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return f"{gigs}G" if gigs > 0 else None
|
|
|
|
|
|
|
2026-08-01 05:08:01 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_ram_label(mb):
|
|
|
|
|
|
"""« 65536 (64G) » : l'invite est en Mo, on raisonne en Go."""
|
|
|
|
|
|
return f"{mb} ({mb // 1024}G)" if mb >= 1024 else str(mb)
|
2026-08-01 04:59:02 -04:00
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
2026-08-01 05:08:01 -04:00
|
|
|
|
def _qemu_ask_value(label, current, presets, fmt=str):
|
2026-08-01 04:59:02 -04:00
|
|
|
|
"""Invite avec raccourcis lettrés. Renvoie '' pour « garder ».
|
|
|
|
|
|
|
2026-08-01 05:08:01 -04:00
|
|
|
|
Une lettre choisit une suggestion, un chiffre reste une valeur
|
|
|
|
|
|
littérale, vide garde la valeur actuelle. Les suggestions sont
|
|
|
|
|
|
réparties sur plusieurs lignes pour rester lisibles.
|
2026-08-01 04:59:02 -04:00
|
|
|
|
"""
|
2026-08-01 05:08:01 -04:00
|
|
|
|
lst_item = [
|
|
|
|
|
|
f"[{chr(ord('a') + i)}] {fmt(p)}" for i, p in enumerate(presets)
|
|
|
|
|
|
]
|
|
|
|
|
|
for start in range(0, len(lst_item), 5):
|
|
|
|
|
|
print(" " + " ".join(lst_item[start : start + 5]))
|
2026-08-01 04:59:02 -04:00
|
|
|
|
answer = input(f" {label} ({current}): ").strip()
|
|
|
|
|
|
if not answer:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
if len(answer) == 1 and answer.isalpha():
|
|
|
|
|
|
index = ord(answer.lower()) - ord("a")
|
|
|
|
|
|
if 0 <= index < len(presets):
|
|
|
|
|
|
return str(presets[index])
|
|
|
|
|
|
print(f" ⚠ {t('Invalid size.')}")
|
|
|
|
|
|
return ""
|
|
|
|
|
|
return answer
|
|
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# Les trois invites ci-dessous sont posées à DEUX endroits — le profil
|
|
|
|
|
|
# global « Personnalisé » et la personnalisation par VM. Elles vivent donc
|
|
|
|
|
|
# ici : une seule définition, mêmes suggestions, mêmes validations.
|
|
|
|
|
|
# Chacune renvoie None pour « garder la valeur actuelle ».
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_ask_disk(self, label, current):
|
|
|
|
|
|
raw = self._qemu_ask_value(label, current, self._QEMU_DISK_PRESETS)
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return None
|
|
|
|
|
|
parsed = self._qemu_parse_disk(raw)
|
|
|
|
|
|
if not parsed:
|
|
|
|
|
|
print(f" ⚠ {t('Invalid size.')}")
|
|
|
|
|
|
return parsed
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_ask_ram(self, label, current):
|
|
|
|
|
|
raw = self._qemu_ask_value(
|
|
|
|
|
|
label, current, self._QEMU_RAM_PRESETS, fmt=self._qemu_ram_label
|
|
|
|
|
|
)
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
mb = int(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
mb = 0
|
|
|
|
|
|
if mb <= 0:
|
|
|
|
|
|
print(f" ⚠ {t('Invalid size.')}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
return mb
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_ask_cpu(self, label, current, host_cpu):
|
|
|
|
|
|
raw = self._qemu_ask_value(label, current, self._QEMU_CPU_PRESETS)
|
|
|
|
|
|
if not raw:
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
n = int(raw)
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
n = 0
|
|
|
|
|
|
if n <= 0:
|
|
|
|
|
|
print(f" ⚠ {t('Invalid vCPU count.')}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
if n > host_cpu:
|
|
|
|
|
|
print(f" ⚠ {t('More vCPU than host cores')} ({host_cpu}).")
|
|
|
|
|
|
return n
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_shared_value(values, fmt=str):
|
|
|
|
|
|
"""Valeur commune à toutes les VM, ou « varié » si elles diffèrent.
|
|
|
|
|
|
Sert d'« actuel » aux invites globales, où une seule réponse couvre un
|
|
|
|
|
|
parc qui n'est pas forcément homogène."""
|
|
|
|
|
|
uniq = set(values)
|
|
|
|
|
|
return fmt(uniq.pop()) if len(uniq) == 1 else t("varies")
|
|
|
|
|
|
|
2026-07-28 05:39:59 -04:00
|
|
|
|
# vCPU de base (x1) par VM. Le multiplicateur monte de là.
|
|
|
|
|
|
_QEMU_BASE_VCPUS = 2
|
|
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
def _qemu_prompt_resources(self, selected, host_cpu, free_ram):
|
|
|
|
|
|
"""Ressources par VM : multiplicateur x1..x4, ou « Personnalisé ».
|
|
|
|
|
|
|
|
|
|
|
|
x1..x4 multiplie la RAM (base = minimum de la version) et les vCPU
|
|
|
|
|
|
(base _QEMU_BASE_VCPUS) en bornant ces derniers aux cœurs de l'hôte.
|
|
|
|
|
|
« Personnalisé » pose les mêmes trois questions que la personnalisation
|
|
|
|
|
|
par VM, mais une seule fois pour tout le parc.
|
|
|
|
|
|
|
|
|
|
|
|
`selected` = liste de (d, v, ram_min, disk, arch). Renvoie
|
|
|
|
|
|
(label, selected) où selected porte désormais les valeurs FINALES et
|
|
|
|
|
|
un vCPU par VM : (d, v, ram, disk, arch, vcpus)."""
|
2026-07-28 05:39:59 -04:00
|
|
|
|
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}"
|
|
|
|
|
|
)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
print(f" [5] {t('Custom - set vCPU, RAM and disk')}")
|
|
|
|
|
|
sel = input(f"{t('Choice (1-5, default 1):')} ").strip()
|
2026-07-28 05:39:59 -04:00
|
|
|
|
try:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
mult = int(sel)
|
2026-07-28 05:39:59 -04:00
|
|
|
|
except ValueError:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
mult = 1
|
|
|
|
|
|
if not 1 <= mult <= 5:
|
|
|
|
|
|
mult = 1
|
|
|
|
|
|
|
|
|
|
|
|
if mult != 5:
|
|
|
|
|
|
vcpus = min(base_vcpus * mult, host_cpu)
|
|
|
|
|
|
return f"x{mult}", [
|
|
|
|
|
|
(d, v, ram * mult, disk, a, vcpus)
|
|
|
|
|
|
for (d, v, ram, disk, a) in selected
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Personnalisé : une réponse vide garde la valeur du catalogue, qui
|
|
|
|
|
|
# peut différer d'une VM à l'autre — d'où « varié » comme « actuel ».
|
|
|
|
|
|
cpu = self._qemu_ask_cpu(
|
|
|
|
|
|
t("vCPU per VM, blank = keep"), base_vcpus, host_cpu
|
|
|
|
|
|
)
|
|
|
|
|
|
ram = self._qemu_ask_ram(
|
|
|
|
|
|
t("New RAM in MB, blank = keep"),
|
|
|
|
|
|
self._qemu_shared_value([s[2] for s in selected]),
|
|
|
|
|
|
)
|
|
|
|
|
|
disk = self._qemu_ask_disk(
|
|
|
|
|
|
t("New disk size in G, blank = keep"),
|
|
|
|
|
|
self._qemu_shared_value([s[3] for s in selected]),
|
|
|
|
|
|
)
|
|
|
|
|
|
return t("custom"), [
|
|
|
|
|
|
(
|
|
|
|
|
|
d,
|
|
|
|
|
|
v,
|
|
|
|
|
|
ram or vram,
|
|
|
|
|
|
disk or vdisk,
|
|
|
|
|
|
a,
|
|
|
|
|
|
cpu or base_vcpus,
|
|
|
|
|
|
)
|
|
|
|
|
|
for (d, v, vram, vdisk, a) in selected
|
|
|
|
|
|
]
|
2026-07-28 05:39:59 -04:00
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
def _qemu_customize_vms(self, selected, host_cpu):
|
|
|
|
|
|
"""Personnalise chaque VM avant déploiement : NOM, DISQUE, RAM et vCPU.
|
|
|
|
|
|
`selected` = liste de (d, v, ram, disk, a, vcpus) où les valeurs sont
|
|
|
|
|
|
déjà FINALES (profil de ressources appliqué).
|
2026-07-30 14:58:50 -04:00
|
|
|
|
Renvoie (names, selected_maj). Défaut : rien ne change."""
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
names = [
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
self._qemu_infra_name(d, v, a) for d, v, _r, _dk, a, _c in selected
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
]
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
sel = [list(s) for s in selected] # mutable
|
2026-07-30 14:58:50 -04:00
|
|
|
|
|
|
|
|
|
|
def show():
|
|
|
|
|
|
print(f"\n{t('VMs (default = no change):')}")
|
|
|
|
|
|
for i, (nm, s) in enumerate(zip(names, sel), 1):
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
d, v, ram, disk, a, vcpus = s
|
2026-07-30 14:58:50 -04:00
|
|
|
|
print(
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
f" [{i}] {nm} ({d} {v} [{a}]) {vcpus} vCPU "
|
2026-07-30 14:58:50 -04:00
|
|
|
|
f"RAM {ram}Mo {t('disk')} {disk}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
show()
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
raw = input(
|
2026-07-30 14:58:50 -04:00
|
|
|
|
t("Modify which VMs? (numbers, comma-separated; blank = none): ")
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
).strip()
|
|
|
|
|
|
for tok in re.split(r"[\s,]+", raw):
|
|
|
|
|
|
if not tok:
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
|
|
|
|
|
i = int(tok) - 1
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
continue
|
2026-07-30 14:58:50 -04:00
|
|
|
|
if not (0 <= i < len(sel)):
|
|
|
|
|
|
continue
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# Pour la VM i : nom, disque, RAM, vCPU (vide = garder la valeur).
|
2026-07-30 14:58:50 -04:00
|
|
|
|
new = input(
|
|
|
|
|
|
f" {names[i]} — {t('new name (blank = keep):')} "
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
if new:
|
|
|
|
|
|
names[i] = new
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
dk = self._qemu_ask_disk(
|
|
|
|
|
|
t("New disk size in G, blank = keep"), sel[i][3]
|
2026-08-01 02:42:45 -04:00
|
|
|
|
)
|
2026-07-30 14:58:50 -04:00
|
|
|
|
if dk:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
sel[i][3] = dk
|
|
|
|
|
|
rm = self._qemu_ask_ram(
|
|
|
|
|
|
t("New RAM in MB, blank = keep"), sel[i][2]
|
2026-08-01 04:59:02 -04:00
|
|
|
|
)
|
2026-07-30 14:58:50 -04:00
|
|
|
|
if rm:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
sel[i][2] = rm
|
|
|
|
|
|
cpu = self._qemu_ask_cpu(
|
|
|
|
|
|
t("New vCPU count, blank = keep"), sel[i][5], host_cpu
|
|
|
|
|
|
)
|
|
|
|
|
|
if cpu:
|
|
|
|
|
|
sel[i][5] = cpu
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
if len(set(names)) != len(names):
|
|
|
|
|
|
print(f" ⚠ {t('Duplicate names detected; keeping as entered.')}")
|
2026-07-30 14:58:50 -04:00
|
|
|
|
return names, [tuple(s) for s in sel]
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
def _confirm_or_discard(self, question):
|
|
|
|
|
|
"""Confirmation à défaut OUI, avec double validation sur le NON.
|
|
|
|
|
|
|
|
|
|
|
|
Un « non » distrait ferait perdre toutes les réponses déjà saisies. On
|
|
|
|
|
|
ne renonce donc que si l'abandon est confirmé ; sinon on repose la
|
|
|
|
|
|
question."""
|
|
|
|
|
|
while True:
|
|
|
|
|
|
if self._is_yes_default_yes(input(f"\n{question}")):
|
|
|
|
|
|
return True
|
|
|
|
|
|
if self._is_yes(
|
|
|
|
|
|
input(f" {t('Discard everything and start over? (y/N): ')}")
|
|
|
|
|
|
):
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_orphan_disks(names):
|
|
|
|
|
|
"""qcow2 présents sans VM définie, parmi `names` : [(nom, chemin)].
|
|
|
|
|
|
|
|
|
|
|
|
Pur : ni sudo ni virsh — l'appelant fournit déjà les noms qui n'ont
|
|
|
|
|
|
PAS de domaine. C'est ce qui permet au formulaire TUI de recalculer
|
|
|
|
|
|
les collisions à chaque frappe sans déclencher d'invite de mot de
|
|
|
|
|
|
passe."""
|
|
|
|
|
|
orphans = []
|
|
|
|
|
|
for name in names:
|
|
|
|
|
|
path = f"/var/lib/libvirt/images/{name}.qcow2"
|
|
|
|
|
|
if os.path.exists(path):
|
|
|
|
|
|
orphans.append((name, path))
|
|
|
|
|
|
return orphans
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_confirm_collisions(self, existing, pending_names):
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
"""Signale les noms qui heurtent l'existant, et demande confirmation.
|
|
|
|
|
|
|
|
|
|
|
|
Deux cas, de gravité différente : une VM déjà définie est simplement
|
|
|
|
|
|
ignorée — rien n'est écrasé — tandis qu'un qcow2 resté seul (VM
|
|
|
|
|
|
supprimée sans son disque) fait échouer deploy_qemu, qui refuse
|
|
|
|
|
|
d'écraser sans --force. Défaut NON : on ne poursuit que sur un « oui »
|
|
|
|
|
|
explicite."""
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
orphans = self._qemu_orphan_disks(pending_names)
|
|
|
|
|
|
if not existing and not orphans:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
return True
|
|
|
|
|
|
print(f"\n⚠ {t('Name collisions detected')} :")
|
|
|
|
|
|
skipped = t("VM already defined - SKIPPED, nothing overwritten")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
for name in existing:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
print(f" {name:<28.28} {skipped}")
|
|
|
|
|
|
for name, path in orphans:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {name:<28.28} "
|
|
|
|
|
|
f"{t('disk present without VM - deployment will FAIL')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f" {'':<28} {path}")
|
|
|
|
|
|
print(f" {'':<28} {t('Remove it by hand, or rename the VM.')}")
|
|
|
|
|
|
return self._is_yes(
|
|
|
|
|
|
input(f"{t('Continue despite these collisions? (y/N): ')}")
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
def _qemu_print_recap(self, spec, existing):
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
"""État final soumis à approbation : tout ce qui va changer sur
|
|
|
|
|
|
l'hôte, y compris ce qui ne changera PAS (VM existantes)."""
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
install = spec.get("install")
|
|
|
|
|
|
branch = install["branch"] if install else None
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
print(f"\n── {t('Final review before deployment')} ──")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
print(f" {t('VMs to create:')} {len(spec['vms'])}")
|
|
|
|
|
|
for vm in spec["vms"]:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# Le disque annoncé est celui qui sera réellement créé : ERPLibre
|
|
|
|
|
|
# ajoute ERPLIBRE_EXTRA_DISK_GB à la demande initiale.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
gigs = self._parse_disk_gb(vm["disk"]) + (
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
self.ERPLIBRE_EXTRA_DISK_GB if branch else 0
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
f" {vm['name']:<30} {vm['distro']} {vm['version']:<7} "
|
|
|
|
|
|
f"[{vm['arch']:<5}] {vm['vcpus']} vCPU RAM {vm['ram']}Mo "
|
|
|
|
|
|
f"{t('disk')} {gigs}G"
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
)
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
if existing:
|
|
|
|
|
|
print(f" {t('Existing, left untouched:')} {', '.join(existing)}")
|
|
|
|
|
|
if install:
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
env = (
|
|
|
|
|
|
t("production (/opt, confined)")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
if install["prod"]
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
else t("development (~/git)")
|
|
|
|
|
|
)
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" {t('ERPLibre install:')} {t('branch')} {branch}, "
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
f"{t('profile')} {install['label']}, {env}"
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
)
|
|
|
|
|
|
else:
|
|
|
|
|
|
print(f" {t('ERPLibre install:')} {t('no')}")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
print(f" {t('SSH key:')} {spec.get('ssh_key') or t('none')}")
|
|
|
|
|
|
cfg = (
|
|
|
|
|
|
t("one entry per VM") if spec["add_ssh_config"] else t("untouched")
|
|
|
|
|
|
)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
print(f" {t('~/.ssh/config:')} {cfg}")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
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(),
|
2026-08-01 02:42:45 -04:00
|
|
|
|
"--distro",
|
|
|
|
|
|
d,
|
|
|
|
|
|
"--version",
|
|
|
|
|
|
v,
|
|
|
|
|
|
"--name",
|
|
|
|
|
|
name,
|
|
|
|
|
|
"--memory",
|
|
|
|
|
|
str(eram),
|
|
|
|
|
|
"--vcpus",
|
|
|
|
|
|
str(evcpus),
|
|
|
|
|
|
"--password",
|
|
|
|
|
|
"erplibre",
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
]
|
|
|
|
|
|
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
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
def _qemu_deploy_parts_for(self, vm, spec, dry_run=False):
|
|
|
|
|
|
"""Commande deploy_qemu.py d'une VM de la spec.
|
|
|
|
|
|
|
|
|
|
|
|
POINT DE PASSAGE UNIQUE des deux interfaces : le formulaire TUI et les
|
|
|
|
|
|
invites en ligne produisent la même spec, donc forcément la même
|
|
|
|
|
|
commande. C'est ce qui rend leur divergence vérifiable par un test."""
|
|
|
|
|
|
install = spec.get("install")
|
|
|
|
|
|
return self._qemu_build_deploy_parts(
|
|
|
|
|
|
vm["distro"],
|
|
|
|
|
|
vm["version"],
|
|
|
|
|
|
vm["arch"],
|
|
|
|
|
|
vm["name"],
|
|
|
|
|
|
vm["ram"],
|
|
|
|
|
|
vm["vcpus"],
|
|
|
|
|
|
vm["disk"],
|
|
|
|
|
|
spec.get("ssh_key"),
|
|
|
|
|
|
install["branch"] if install else None,
|
|
|
|
|
|
dry_run=dry_run,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
|
# Déploiement : catalogue (pur) -> collecte (CLI ou TUI) -> exécution
|
|
|
|
|
|
# ---------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_arches_for(self, distro, arch):
|
|
|
|
|
|
"""Architectures à déployer pour cette distro selon le choix global.
|
|
|
|
|
|
« all » = uniquement celles que la distro publie réellement."""
|
|
|
|
|
|
if arch != "all":
|
|
|
|
|
|
return [arch]
|
|
|
|
|
|
out = ["amd64"]
|
|
|
|
|
|
if distro in self._QEMU_ARM64_DISTROS:
|
|
|
|
|
|
out.append("arm64")
|
|
|
|
|
|
if distro in self._QEMU_S390X_DISTROS:
|
|
|
|
|
|
out.append("s390x")
|
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_catalog_entries(self, mod, distros, arch):
|
|
|
|
|
|
"""Catalogue APLATI : une entrée par (distro, version, architecture).
|
|
|
|
|
|
|
|
|
|
|
|
Fonction pure, sans I/O : c'est la source unique de ce qui est
|
|
|
|
|
|
déployable, aussi bien pour la liste granulaire de la CLI que pour la
|
|
|
|
|
|
liste à cocher du formulaire TUI."""
|
|
|
|
|
|
flat = []
|
|
|
|
|
|
for d in distros:
|
|
|
|
|
|
versions_map, default_v = mod.DISTROS[d]
|
|
|
|
|
|
for v, (_c, _o, ram, disk) in versions_map.items():
|
|
|
|
|
|
for a in self._qemu_arches_for(d, arch):
|
|
|
|
|
|
flat.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"distro": d,
|
|
|
|
|
|
"version": v,
|
|
|
|
|
|
"arch": a,
|
|
|
|
|
|
"ram": ram,
|
|
|
|
|
|
"disk": disk,
|
|
|
|
|
|
"default": v == default_v,
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
return flat
|
|
|
|
|
|
|
|
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _qemu_make_vm(distro, version, arch, ram, disk, vcpus, name):
|
|
|
|
|
|
"""Une VM de la spec. Un seul endroit décrit sa forme."""
|
|
|
|
|
|
return {
|
|
|
|
|
|
"name": name,
|
|
|
|
|
|
"distro": distro,
|
|
|
|
|
|
"version": version,
|
|
|
|
|
|
"arch": arch,
|
|
|
|
|
|
"ram": ram,
|
|
|
|
|
|
"disk": disk,
|
|
|
|
|
|
"vcpus": vcpus,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_split_existing(self, vms, domains):
|
|
|
|
|
|
"""Sépare les VM à créer de celles dont le domaine existe déjà.
|
|
|
|
|
|
`domains` est la liste des noms libvirt, obtenue UNE fois (un seul
|
|
|
|
|
|
sudo) et non par VM. Renvoie (à_créer, noms_existants)."""
|
|
|
|
|
|
known = set(domains)
|
|
|
|
|
|
pending = [vm for vm in vms if vm["name"] not in known]
|
|
|
|
|
|
existing = [vm["name"] for vm in vms if vm["name"] in known]
|
|
|
|
|
|
return pending, existing
|
|
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
def _qemu_ask_ui(self):
|
|
|
|
|
|
"""Interface du déploiement : formulaire TUI ou invites en ligne.
|
|
|
|
|
|
La préférence peut trancher d'avance (menu Configuration) ; « ask »
|
|
|
|
|
|
pose la question."""
|
|
|
|
|
|
pref = todo_prefs.get("qemu_deploy_ui")
|
|
|
|
|
|
if pref in ("tui", "cli"):
|
|
|
|
|
|
return pref
|
|
|
|
|
|
print(f"\n{t('Interface:')}")
|
|
|
|
|
|
print(f" [1] {t('TUI form')} *")
|
|
|
|
|
|
print(f" [2] {t('Classic questions (line by line)')}")
|
|
|
|
|
|
print(f" {t('(change the default in TODO > Configuration)')}")
|
|
|
|
|
|
sel = input(t("Choice (1-2, default 1): ")).strip()
|
|
|
|
|
|
return "cli" if sel == "2" else "tui"
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_form_context(self, mod):
|
|
|
|
|
|
"""Données préchargées pour le formulaire TUI.
|
|
|
|
|
|
|
|
|
|
|
|
TOUT ce qui exige sudo (liste des domaines) ou le réseau (branches)
|
|
|
|
|
|
est fait ICI, pendant que le terminal est encore à nous : une invite
|
|
|
|
|
|
de mot de passe pendant que Textual affiche casserait l'écran."""
|
|
|
|
|
|
native = self._native_arch()
|
|
|
|
|
|
arches = ["amd64", "arm64", "s390x"]
|
|
|
|
|
|
if native not in arches:
|
|
|
|
|
|
arches.insert(0, native)
|
|
|
|
|
|
arches.append("all")
|
|
|
|
|
|
|
|
|
|
|
|
catalog = {}
|
|
|
|
|
|
for a in arches:
|
|
|
|
|
|
distros = list(mod.DISTROS)
|
|
|
|
|
|
if a != "all":
|
|
|
|
|
|
allowed = self._qemu_arch_distros(a)
|
|
|
|
|
|
if allowed is not None:
|
|
|
|
|
|
distros = [d for d in distros if d in allowed]
|
|
|
|
|
|
entries = self._qemu_catalog_entries(mod, distros, a)
|
|
|
|
|
|
for e in entries:
|
|
|
|
|
|
# Le nom est calculé ici : le formulaire reste pure donnée.
|
|
|
|
|
|
e["name"] = self._qemu_infra_name(
|
|
|
|
|
|
e["distro"], e["version"], e["arch"]
|
|
|
|
|
|
)
|
|
|
|
|
|
catalog[a] = entries
|
|
|
|
|
|
|
|
|
|
|
|
print(f"\n{t('Loading (VM list, branches)...')}")
|
|
|
|
|
|
return {
|
|
|
|
|
|
"catalog": catalog,
|
|
|
|
|
|
"arches": arches,
|
|
|
|
|
|
"native": native,
|
|
|
|
|
|
"domains": self._qemu_list_domains(),
|
|
|
|
|
|
"branches": self._qemu_branch_list() or ["master"],
|
|
|
|
|
|
"install_profiles": self._qemu_install_profiles(),
|
|
|
|
|
|
"ssh_key": self._qemu_default_ssh_key(),
|
|
|
|
|
|
"host_cpu": os.cpu_count() or 2,
|
|
|
|
|
|
"free_ram": self._host_free_ram_mb(),
|
|
|
|
|
|
"base_vcpus": self._QEMU_BASE_VCPUS,
|
|
|
|
|
|
"cpu_presets": self._QEMU_CPU_PRESETS,
|
|
|
|
|
|
"ram_presets": self._QEMU_RAM_PRESETS,
|
|
|
|
|
|
"disk_presets": self._QEMU_DISK_PRESETS,
|
|
|
|
|
|
"extra_disk_gb": self.ERPLIBRE_EXTRA_DISK_GB,
|
|
|
|
|
|
"defaults": {
|
|
|
|
|
|
"install": True,
|
|
|
|
|
|
"add_ssh_config": True,
|
|
|
|
|
|
"monitor": True,
|
|
|
|
|
|
"prod": False,
|
|
|
|
|
|
},
|
|
|
|
|
|
# L'aperçu passe par le MÊME constructeur que le déploiement.
|
|
|
|
|
|
"build_command": lambda vm, spec, dry: " ".join(
|
|
|
|
|
|
shlex.quote(p)
|
|
|
|
|
|
for p in self._qemu_deploy_parts_for(vm, spec, dry_run=dry)
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
def _qemu_deploy(self, dry_run=False):
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
"""Déploiement d'un parc de VM, en trois temps : collecte des choix
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
(formulaire TUI ou invites en ligne), aperçu ou exécution. Les deux
|
|
|
|
|
|
interfaces produisent la MÊME spec."""
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
print(f"🚀 {t('Deploy ERPLibre VM(s)!')}")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
try:
|
|
|
|
|
|
mod = self._qemu_import_module()
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
print(f"{t('Cannot load QEMU catalog: ')}{exc}")
|
|
|
|
|
|
return
|
2026-07-29 06:46:21 -04:00
|
|
|
|
# Rappel de la dernière installation enregistrée (si historique).
|
|
|
|
|
|
last = self._qemu_last_run_line()
|
|
|
|
|
|
if last:
|
|
|
|
|
|
print(last)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
if self._qemu_ask_ui() == "tui":
|
|
|
|
|
|
spec = self._qemu_deploy_form(mod, dry_run)
|
|
|
|
|
|
if spec is None:
|
|
|
|
|
|
return
|
|
|
|
|
|
if spec: # None = annulé, {} = repli sur la CLI
|
|
|
|
|
|
self._qemu_run_spec(spec)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
got = self._qemu_collect_vms_cli(mod)
|
|
|
|
|
|
if not got:
|
|
|
|
|
|
return
|
|
|
|
|
|
res_label, vms = got
|
|
|
|
|
|
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
|
self._qemu_print_dry_run(vms)
|
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
spec = self._qemu_collect_options_cli(vms, res_label)
|
|
|
|
|
|
if not spec:
|
|
|
|
|
|
return
|
|
|
|
|
|
self._qemu_run_spec(spec)
|
|
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
def _qemu_deploy_form(self, mod, dry_run):
|
|
|
|
|
|
"""Ouvre le formulaire TUI. Renvoie la spec, None si annulé, ou {}
|
|
|
|
|
|
pour retomber sur les invites en ligne (textual absent)."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from script.todo.qemu_deploy_form import run_deploy_form
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print(t("Install textual for the dashboard (pip)."))
|
|
|
|
|
|
return {}
|
|
|
|
|
|
ctx = self._qemu_form_context(mod)
|
|
|
|
|
|
try:
|
|
|
|
|
|
spec = run_deploy_form(ctx)
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print(t("Install textual for the dashboard (pip)."))
|
|
|
|
|
|
return {}
|
|
|
|
|
|
if not spec:
|
|
|
|
|
|
print(t("Cancelled."))
|
|
|
|
|
|
return None
|
|
|
|
|
|
if dry_run:
|
|
|
|
|
|
# L'entrée « aperçu » du menu ne crée rien, même depuis la TUI.
|
|
|
|
|
|
self._qemu_print_dry_run(spec["vms"])
|
|
|
|
|
|
return None
|
|
|
|
|
|
self._qemu_print_recap(spec, spec.get("existing") or [])
|
|
|
|
|
|
if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")):
|
|
|
|
|
|
print(t("Cancelled."))
|
|
|
|
|
|
return None
|
|
|
|
|
|
return spec
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
def _qemu_print_dry_run(self, vms):
|
|
|
|
|
|
"""Aperçu : les commandes deploy_qemu, sans rien créer (ni sudo, ni
|
|
|
|
|
|
installation). Passe par le point de passage unique, donc montre
|
|
|
|
|
|
exactement ce qui serait lancé."""
|
|
|
|
|
|
spec = {"vms": vms, "ssh_key": self._qemu_default_ssh_key()}
|
|
|
|
|
|
print(f"\n{t('Preview (dry-run):')}")
|
|
|
|
|
|
for vm in vms:
|
|
|
|
|
|
parts = self._qemu_deploy_parts_for(vm, spec, dry_run=True)
|
|
|
|
|
|
print(" " + " ".join(shlex.quote(p) for p in parts))
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_collect_vms_cli(self, mod):
|
|
|
|
|
|
"""Invites en ligne : architecture, catalogue, ressources, noms.
|
|
|
|
|
|
Renvoie (étiquette_de_profil, vms) ou None si rien à faire."""
|
|
|
|
|
|
distros = list(mod.DISTROS)
|
|
|
|
|
|
|
2026-07-28 04:51:45 -04:00
|
|
|
|
# 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."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
2026-07-28 04:51:45 -04:00
|
|
|
|
|
|
|
|
|
|
def arches_for(distro):
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return self._qemu_arches_for(distro, arch)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
|
|
|
|
|
|
# 1) Distributions : multi-sélection, catalogue complet, principal (la
|
|
|
|
|
|
# version par défaut de chaque distro, marquée d'un *), ou granulaire
|
2026-07-28 04:51:45 -04:00
|
|
|
|
# (liste à plat de TOUTES les versions × archis, choix par virgules).
|
|
|
|
|
|
# Avec [all] archis, chaque version se décline en une VM par archi.
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(f"\n{t('Distributions:')}")
|
|
|
|
|
|
for i, d in enumerate(distros, 1):
|
2026-07-28 00:08:38 -04:00
|
|
|
|
default_v = mod.DISTROS[d][1]
|
|
|
|
|
|
vers = ", ".join(
|
|
|
|
|
|
(v + " *" if v == default_v else v) for v in mod.DISTROS[d][0]
|
|
|
|
|
|
)
|
2026-07-29 06:46:21 -04:00
|
|
|
|
print(f" [{i}] {d} ({vers}){self._qemu_stat_avg('distro', d)}")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(f" [all] {t('Whole catalog (every version)')}")
|
2026-07-28 00:08:38 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f" [principal] {t('The main version of each distro (marked *)')}"
|
2026-07-19 02:52:39 -04:00
|
|
|
|
)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f" [granulaire] {t('Pick exact versions (comma-separated list)')}"
|
|
|
|
|
|
)
|
2026-07-28 00:08:38 -04:00
|
|
|
|
raw = (
|
|
|
|
|
|
input(
|
2026-07-28 03:35:25 -04:00
|
|
|
|
t(
|
|
|
|
|
|
"Selection (numbers, 'all', 'principal' or 'granulaire',"
|
|
|
|
|
|
" default: all): "
|
|
|
|
|
|
)
|
2026-07-28 00:08:38 -04:00
|
|
|
|
)
|
|
|
|
|
|
.strip()
|
|
|
|
|
|
.lower()
|
|
|
|
|
|
)
|
|
|
|
|
|
catalog_all = raw in ("", "all", "*")
|
|
|
|
|
|
principal = raw in ("principal", "each", "p")
|
2026-07-28 03:35:25 -04:00
|
|
|
|
granular = raw in ("granulaire", "granular", "g")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
2026-07-28 04:51:45 -04:00
|
|
|
|
selected = [] # (distro, version, ram_mb, disk_str, arch)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
if granular:
|
2026-07-28 04:51:45 -04:00
|
|
|
|
# Liste APLATIE distro + version + ARCHITECTURE : on choisit des
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
# combinaisons précises par numéros séparés de virgules. La liste
|
|
|
|
|
|
# vient du catalogue partagé avec le formulaire TUI.
|
|
|
|
|
|
flat = self._qemu_catalog_entries(mod, distros, arch)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
print(f"\n{t('All versions:')}")
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
for i, e in enumerate(flat, 1):
|
|
|
|
|
|
star = " *" if e["default"] else ""
|
|
|
|
|
|
print(
|
|
|
|
|
|
f" [{i}] {e['distro']} {e['version']}{star} "
|
|
|
|
|
|
f"[{e['arch']}] (RAM≥{e['ram']}Mo, {e['disk']})"
|
|
|
|
|
|
)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
r = (
|
|
|
|
|
|
input(t("Selection (comma-separated numbers): "))
|
|
|
|
|
|
.strip()
|
|
|
|
|
|
.lower()
|
|
|
|
|
|
)
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
for e in self._parse_index_selection(r, flat):
|
|
|
|
|
|
selected.append(
|
|
|
|
|
|
(e["distro"], e["version"], e["ram"], e["disk"], e["arch"])
|
|
|
|
|
|
)
|
2026-07-28 03:35:25 -04:00
|
|
|
|
elif principal:
|
2026-07-28 04:51:45 -04:00
|
|
|
|
# Une VM par distro (version par défaut) × chaque archi supportée.
|
2026-07-28 00:08:38 -04:00
|
|
|
|
for d in distros:
|
|
|
|
|
|
versions_map, default_v = mod.DISTROS[d]
|
|
|
|
|
|
_c, _o, ram, disk = versions_map[default_v]
|
2026-07-28 04:51:45 -04:00
|
|
|
|
for a in arches_for(d):
|
|
|
|
|
|
selected.append((d, default_v, ram, disk, a))
|
2026-07-28 00:08:38 -04:00
|
|
|
|
else:
|
|
|
|
|
|
sel_distros = (
|
|
|
|
|
|
distros
|
|
|
|
|
|
if catalog_all
|
|
|
|
|
|
else self._parse_index_selection(raw, distros)
|
|
|
|
|
|
)
|
|
|
|
|
|
if not sel_distros:
|
|
|
|
|
|
print(t("Nothing selected."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
2026-07-28 00:08:38 -04:00
|
|
|
|
# 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:
|
2026-07-29 07:29:53 -04:00
|
|
|
|
print(f"\n{t('Versions for')} {d.capitalize()} :")
|
2026-07-28 00:08:38 -04:00
|
|
|
|
for i, v in enumerate(vlist, 1):
|
|
|
|
|
|
_c, _o, ram, disk = versions_map[v]
|
2026-07-29 07:29:53 -04:00
|
|
|
|
stat = self._qemu_stat_avg("version", v, d)
|
|
|
|
|
|
print(f" [{i}] {v} (RAM≥{ram}Mo, {disk}){stat}")
|
2026-07-28 00:08:38 -04:00
|
|
|
|
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:
|
2026-07-19 02:52:39 -04:00
|
|
|
|
_c, _o, ram, disk = versions_map[v]
|
2026-07-28 04:51:45 -04:00
|
|
|
|
for a in arches_for(d):
|
|
|
|
|
|
selected.append((d, v, ram, disk, a))
|
2026-07-19 02:52:39 -04:00
|
|
|
|
if not selected:
|
|
|
|
|
|
print(t("Nothing selected."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# 2b) Ressources par VM : multiplicateur x1..x4 ou « Personnalisé ».
|
|
|
|
|
|
# Le profil est CUIT dans `selected`, qui porte dès lors les valeurs
|
|
|
|
|
|
# finales — RAM, disque et vCPU — pour chaque VM.
|
2026-07-28 05:39:59 -04:00
|
|
|
|
host_cpu = os.cpu_count() or 2
|
2026-07-19 02:52:39 -04:00
|
|
|
|
free_ram = self._host_free_ram_mb()
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
res_label, selected = self._qemu_prompt_resources(
|
2026-07-28 05:39:59 -04:00
|
|
|
|
selected, host_cpu, free_ram
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# 2c) Personnalisation par VM : nom, disque, RAM, vCPU (à la demande).
|
|
|
|
|
|
names, selected = self._qemu_customize_vms(selected, host_cpu)
|
[IMP] script todo: fusionner « Déployer VM » et « infra » + renommage granulaire
Le menu QEMU avait deux commandes de déploiement redondantes : [1] « Déployer
une VM » (mono, noms/ressources personnalisés) et [4] « Déployer l'infra »
(multi, matrice distro×version×archi). Elles sont FUSIONNÉES en une seule,
[1] « Déployer une ou plusieurs VM » (_qemu_deploy), qui gère aussi bien 1 VM
que N, avec :
- renommage GRANULAIRE des VM à la demande (_qemu_customize_names) : noms
auto par défaut, on en renomme certains par numéros séparés de virgules ;
- multiplicateur de ressources (x1..x4) déjà présent ;
- aperçu dry-run ([2]) qui passe par le même flux et imprime les commandes
deploy_qemu (--dry-run) via le helper partagé _qemu_build_deploy_parts.
Menu simplifié : [1] Déployer une/plusieurs VM, [2] Aperçu (dry-run),
[3] Télécharger une image, puis Gérer/Catalogue renumérotés. Code mort retiré
(_qemu_deploy_vm, _qemu_prompt_arch, _normalize_disk_size,
_qemu_offer_ssh_config).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-29 04:25:38 -04:00
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
vms = [
|
|
|
|
|
|
self._qemu_make_vm(d, v, a, ram, disk, vcpus, names[i])
|
|
|
|
|
|
for i, (d, v, ram, disk, a, vcpus) in enumerate(selected)
|
|
|
|
|
|
]
|
|
|
|
|
|
self._qemu_print_plan(vms, res_label, host_cpu, free_ram)
|
|
|
|
|
|
return res_label, vms
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_print_plan(self, vms, res_label, host_cpu, free_ram):
|
|
|
|
|
|
"""Plan + estimation des ressources de l'hôte."""
|
|
|
|
|
|
total_ram = sum(vm["ram"] for vm in vms)
|
|
|
|
|
|
total_disk = sum(self._parse_disk_gb(vm["disk"]) for vm in vms)
|
|
|
|
|
|
total_cpu = sum(vm["vcpus"] for vm in vms)
|
|
|
|
|
|
print(f"\n{t('Deployment plan')} ({len(vms)} VM, {res_label}) :")
|
|
|
|
|
|
for vm in vms:
|
2026-07-28 04:51:45 -04:00
|
|
|
|
print(
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
f" - {vm['name']:<30} {vm['distro']} {vm['version']:<7} "
|
|
|
|
|
|
f"[{vm['arch']:<5}] {vm['vcpus']} vCPU RAM {vm['ram']}Mo "
|
|
|
|
|
|
f"{t('disk')} {vm['disk']}"
|
2026-07-28 04:51:45 -04:00
|
|
|
|
)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
cpu_warn = (
|
|
|
|
|
|
f" ⚠ {t('> host cores')} ({host_cpu})"
|
|
|
|
|
|
if (total_cpu > host_cpu)
|
|
|
|
|
|
else ""
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"\n {t('Total vCPU (all running):')} {total_cpu}{cpu_warn}")
|
|
|
|
|
|
print(f" {t('Total RAM (all running):')} {total_ram} Mo")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
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}")
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
def _qemu_collect_options_cli(self, vms, res_label):
|
|
|
|
|
|
"""Invites en ligne : clé SSH, installation ERPLibre, ~/.ssh/config,
|
|
|
|
|
|
parallélisme, puis récapitulatif et confirmation.
|
|
|
|
|
|
Renvoie la spec complète, ou None si l'utilisateur renonce."""
|
2026-07-19 02:52:39 -04:00
|
|
|
|
# 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.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
install = None
|
2026-07-19 03:07:20 -04:00
|
|
|
|
ans = input(
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
t("Install ERPLibre into ~/git/erplibre on each VM? (Y/n): ")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
if self._is_yes_default_yes(ans):
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
branch = self._qemu_pick_branch()
|
|
|
|
|
|
# dev (~/git, SELinux relâché) vs prod (/opt, confiné)
|
|
|
|
|
|
prod = self._qemu_ask_prod()
|
|
|
|
|
|
label, cmd = self._qemu_pick_install_profile()
|
|
|
|
|
|
monitor = self._is_yes_default_yes(
|
2026-07-28 00:27:25 -04:00
|
|
|
|
input(t("Interactive monitoring dashboard? (y/N): "))
|
|
|
|
|
|
)
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
install = {
|
|
|
|
|
|
"branch": branch,
|
|
|
|
|
|
"prod": prod,
|
|
|
|
|
|
"label": label,
|
|
|
|
|
|
"cmd": cmd,
|
|
|
|
|
|
"monitor": monitor,
|
|
|
|
|
|
}
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
add_ssh_config = self._is_yes_default_yes(
|
|
|
|
|
|
input(t("Add each VM to ~/.ssh/config? (Y/n): "))
|
2026-07-19 03:47:33 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-29 04:33:50 -04:00
|
|
|
|
# 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.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
pending, existing = self._qemu_split_existing(
|
|
|
|
|
|
vms, self._qemu_list_domains()
|
|
|
|
|
|
)
|
2026-07-29 04:33:50 -04:00
|
|
|
|
n_jobs = len(pending)
|
|
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# Collisions de noms : une VM déjà définie est ignorée (rien n'est
|
|
|
|
|
|
# écrasé), mais un qcow2 orphelin fera ÉCHOUER deploy_qemu, qui refuse
|
|
|
|
|
|
# d'écraser sans --force. On le dit avant, pas après l'attente.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
if not self._qemu_confirm_collisions(
|
|
|
|
|
|
existing, [vm["name"] for vm in pending]
|
|
|
|
|
|
):
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
print(t("Cancelled."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
if not pending:
|
|
|
|
|
|
print(t("Nothing to create - every VM already exists."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
|
2026-07-29 04:33:50 -04:00
|
|
|
|
# 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
|
2026-07-19 04:15:41 -04:00
|
|
|
|
raw = input(
|
2026-07-29 04:33:50 -04:00
|
|
|
|
f"{t('Parallel deployments (default:')} {default_par}, "
|
|
|
|
|
|
f"{n_jobs} {t('VMs')}): "
|
2026-07-19 04:15:41 -04:00
|
|
|
|
).strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
parallelism = max(1, int(raw)) if raw else default_par
|
|
|
|
|
|
except ValueError:
|
|
|
|
|
|
parallelism = default_par
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
spec = {
|
|
|
|
|
|
"res_label": res_label,
|
|
|
|
|
|
"vms": pending,
|
|
|
|
|
|
"existing": existing,
|
|
|
|
|
|
"ssh_key": ssh_key,
|
|
|
|
|
|
"install": install,
|
|
|
|
|
|
"add_ssh_config": add_ssh_config,
|
|
|
|
|
|
"parallelism": parallelism,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
# 6) Récapitulatif final, puis confirmation. Toutes les réponses
|
|
|
|
|
|
# données jusqu'ici sont rassemblées ici : c'est le dernier point où
|
|
|
|
|
|
# une erreur de saisie se rattrape sans avoir rien créé.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
self._qemu_print_recap(spec, existing)
|
[IMP] qemu deploy: custom resource profile, per-VM vCPU, safer confirmations
Resources per VM offered x1..x4 only — anything else meant deploying with the
wrong sizing and resizing afterwards. « [5] Custom » now asks vCPU, RAM and
disk once for the whole fleet, and vCPU joins disk and RAM in the per-VM
customisation. Presets 1, 2, 4, 6, 8, 16, 24, 32.
The two places ask the SAME three questions, so they are one definition each
(_qemu_ask_disk / _qemu_ask_ram / _qemu_ask_cpu) instead of two copies that
would drift. vCPU is no longer a single global value: it travels per VM
through the whole chain, down to --vcpus.
Overcommit is allowed on an explicit choice — KVM permits more vCPU than
cores — and only warned about. The x1..x4 path still caps at the host core
count: that one is an automatic computation, not a decision.
Three prompts default to yes (install ERPLibre, ~/.ssh/config, deploy): they
are what one wants nearly every time, and typing « o » on each was noise.
Flipping a destructive-by-omission default demanded the guards below.
Before deploying, a final review lists everything that will change — VMs to
create with their effective disk (ERPLibre's +5 G included), VMs left
untouched, install profile and branch, SSH key, ~/.ssh/config, parallelism.
Answering no asks for confirmation rather than dropping every answer given
over a dozen prompts.
Name collisions are now reported BEFORE the wait, with their two very
different consequences: an already-defined VM is skipped and nothing is
overwritten, while a qcow2 left behind by a deleted VM makes deploy_qemu fail,
since it refuses to overwrite without --force. Continuing requires an explicit
yes; the default is no.
Also translated two strings of this flow that had no entry and stayed in
English (« Resolving VM IPs… », « no IP »).
Verified by driving the prompts with scripted answers: custom profile applied
and left intact when blank, per-VM override, 32 vCPU on 28 cores warned but
accepted, review with and without ERPLibre install, no → no → deploy, no →
yes → cancel, and collisions defaulting to no. Rendering checked in fr and en.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 05:37:02 -04:00
|
|
|
|
if not self._confirm_or_discard(t("Deploy these VMs now? (Y/n): ")):
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(t("Cancelled."))
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
return None
|
|
|
|
|
|
return spec
|
|
|
|
|
|
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
def _qemu_deploy_jobs_cli(self, jobs, workers):
|
|
|
|
|
|
"""Déploiement parallèle, sortie texte. Renvoie
|
|
|
|
|
|
[(nom, rc, sortie, durée)] — même contrat que la vue TUI."""
|
|
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
|
|
|
|
|
|
|
|
|
|
def _run(job):
|
|
|
|
|
|
jid, jname, jparts = job
|
|
|
|
|
|
j0 = time.time()
|
|
|
|
|
|
res = subprocess.run(jparts, capture_output=True, text=True)
|
|
|
|
|
|
out = (res.stdout or "") + (res.stderr or "")
|
|
|
|
|
|
return jid, jname, res.returncode, out, time.time() - j0
|
|
|
|
|
|
|
|
|
|
|
|
outcome = []
|
|
|
|
|
|
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
|
|
|
|
futures = [pool.submit(_run, j) for j in jobs]
|
|
|
|
|
|
# done = ordre de COMPLÉTION (les résultats reviennent dans le
|
|
|
|
|
|
# désordre) ; jid = ordre de préparation (stable). Durée par VM.
|
|
|
|
|
|
for done, fut in enumerate(as_completed(futures), 1):
|
|
|
|
|
|
jid, jname, rc, out, secs = fut.result()
|
|
|
|
|
|
mark = "✅" if rc == 0 else "❌"
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"\n[{done}/{len(jobs)}] {mark} [{jid}] {jname} "
|
|
|
|
|
|
f"(rc={rc}, {self._fmt_dur(secs)})"
|
|
|
|
|
|
)
|
|
|
|
|
|
for line in [ln for ln in out.strip().splitlines() if ln][-4:]:
|
|
|
|
|
|
print(f" {line}")
|
|
|
|
|
|
outcome.append((jname, rc, out, secs))
|
|
|
|
|
|
return outcome
|
|
|
|
|
|
|
|
|
|
|
|
def _qemu_deploy_jobs_tui(self, jobs, workers):
|
|
|
|
|
|
"""Même chose, en blocs repliables Textual. Renvoie None si textual
|
|
|
|
|
|
manque, pour que l'appelant retombe sur la sortie texte."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
from script.todo.qemu_deploy_form import run_deploy_progress
|
|
|
|
|
|
|
|
|
|
|
|
return run_deploy_progress(jobs, workers)
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print(f" {t('Install textual for the dashboard (pip).')}")
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
def _qemu_run_spec(self, spec):
|
|
|
|
|
|
"""Exécute une spec de déploiement : création des VM en parallèle,
|
|
|
|
|
|
résolution des IP, ~/.ssh/config, installation ERPLibre.
|
|
|
|
|
|
|
|
|
|
|
|
Ne pose AUCUNE question — tous les choix sont dans la spec, d'où
|
|
|
|
|
|
qu'elle vienne (invites en ligne ou formulaire TUI)."""
|
|
|
|
|
|
pending = spec["vms"]
|
|
|
|
|
|
deployed = list(spec.get("existing") or [])
|
|
|
|
|
|
install = spec.get("install")
|
|
|
|
|
|
install_branch = install["branch"] if install else None
|
|
|
|
|
|
ssh_key = spec.get("ssh_key")
|
|
|
|
|
|
add_ssh_config = spec["add_ssh_config"]
|
|
|
|
|
|
parallelism = spec["parallelism"]
|
|
|
|
|
|
n_jobs = len(pending)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
2026-07-29 04:33:50 -04:00
|
|
|
|
# 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)
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
for k, vm in enumerate(pending, 1):
|
|
|
|
|
|
parts = self._qemu_deploy_parts_for(vm, spec, dry_run=False)
|
|
|
|
|
|
jobs.append((f"{k}/{n_jobs}", vm["name"], parts))
|
2026-07-19 04:15:41 -04:00
|
|
|
|
|
2026-07-29 04:46:48 -04:00
|
|
|
|
deploy_start = time.time()
|
|
|
|
|
|
n_ok = 0
|
2026-07-19 04:15:41 -04:00
|
|
|
|
if jobs:
|
|
|
|
|
|
workers = min(parallelism, len(jobs))
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Deploying')} {len(jobs)} VM "
|
|
|
|
|
|
f"({t('parallel jobs:')} {workers})…"
|
|
|
|
|
|
)
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
if todo_prefs.get("qemu_deploy_progress") == "tui":
|
|
|
|
|
|
outcome = self._qemu_deploy_jobs_tui(jobs, workers)
|
|
|
|
|
|
else:
|
|
|
|
|
|
outcome = None
|
|
|
|
|
|
if outcome is None:
|
|
|
|
|
|
outcome = self._qemu_deploy_jobs_cli(jobs, workers)
|
|
|
|
|
|
for name, rc, _out, _secs in outcome:
|
|
|
|
|
|
if rc == 0:
|
|
|
|
|
|
deployed.append(name)
|
|
|
|
|
|
n_ok += 1
|
2026-07-29 04:46:48 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Deploy summary:')} {n_ok} OK, "
|
|
|
|
|
|
f"{len(jobs) - n_ok} {t('failed')}, "
|
[ADD] qemu deploy: a TUI form, and collapsible progress blocks
Deploying a VM meant answering a dozen questions in a row, never seeing the
whole of one's choices, and starting over to revisit an answer. The first
question is now which interface to use — TUI form or the classic prompts —
defaulting to whatever TODO > Configuration says.
The form shows every setting at once with a plan that recomputes on each
change: names, resources, and the two collisions marked as they arise (a
defined VM is skipped, an orphan qcow2 will make deploy_qemu fail). F2 edits
one VM, F3 previews the commands, F5 deploys, F6/F7/F8 select all / main
versions / none.
Function keys rather than ctrl+letter: ctrl+p is Textual's command palette and
silently swallowed the shortcut, and a bare letter is eaten by whichever input
has focus.
Both interfaces go through _qemu_deploy_parts_for, so the same choices produce
the same command by construction — and a test now drives the CLI prompts and
the form to the same state and compares the argv, which is what keeps them
from drifting.
Nothing privileged or networked runs inside the form. Every virsh call in this
codebase goes through sudo, and a password prompt while Textual owns the
terminal would wreck the display; the domain list and the remote branches are
fetched before the app starts and arrive as plain data.
The progress view is optional (preference: CLI output stays the default, since
it is the easiest to copy from). It gives one collapsible block per VM,
expanded while running, folded on success — and left OPEN on failure, which is
the part worth reading. « c » copies the selected log, « C » all of them.
On copying from a TUI over SSH: copy_to_clipboard emits OSC 52, which the
LOCAL terminal emulator interprets, so it does reach the workstation's
clipboard. Two caveats are handled: the payload is capped at 100 kB keeping
the TAIL (some terminals truncate long OSC 52), and the notification says a
compatible terminal is required — macOS Terminal.app has none, tmux needs
set-clipboard on.
Verified: pure logic (profiles, per-VM overrides surviving a profile change,
totals, statuses) by direct calls; the form headless via run_test — default
selection, F6/F7/F8, profile switch, and the orphan guard demanding a second
F5; parity CLI/TUI on identical argv; the progress view on three fake jobs,
with the failure staying open and the clipboard filled. The semaphore bounding
concurrency was measured: four 0.35 s jobs take 1.63 s at 1 and 0.58 s at 4 —
without it, « 4 in parallel » launched every VM at once.
Also removed a duplicated @staticmethod on _qemu_install_dir, harmless since
Python 3.10 but misleading.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:17:31 -04:00
|
|
|
|
f"{len(jobs)} {t('VMs')}, "
|
|
|
|
|
|
f"{self._fmt_dur(time.time() - deploy_start)}"
|
2026-07-29 04:46:48 -04:00
|
|
|
|
)
|
2026-07-19 04:15:41 -04:00
|
|
|
|
|
2026-07-28 05:22:46 -04:00
|
|
|
|
# 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):
|
2026-07-29 04:33:50 -04:00
|
|
|
|
labels = {
|
2026-08-01 02:42:45 -04:00
|
|
|
|
nm: f"{k}/{len(deployed)}" for k, nm in enumerate(deployed, 1)
|
2026-07-29 04:33:50 -04:00
|
|
|
|
}
|
|
|
|
|
|
ip_map = self._qemu_resolve_ips(deployed, labels)
|
2026-07-28 05:22:46 -04:00
|
|
|
|
|
2026-07-19 04:15:41 -04:00
|
|
|
|
if add_ssh_config:
|
|
|
|
|
|
for name in deployed:
|
2026-07-28 05:22:46 -04:00
|
|
|
|
ip = ip_map.get(name)
|
2026-07-19 03:47:33 -04:00
|
|
|
|
if ip:
|
|
|
|
|
|
self._write_ssh_config_entry(name, "erplibre", ip)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
2026-07-27 23:48:56 -04:00
|
|
|
|
# 7) Installation ERPLibre (clone + make) si demandée.
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
if install:
|
|
|
|
|
|
if install["monitor"]:
|
2026-07-28 00:27:25 -04:00
|
|
|
|
# Installs détachées en parallèle + dashboard Textual.
|
2026-07-28 05:22:46 -04:00
|
|
|
|
self._qemu_install_erplibre_monitored(
|
2026-08-01 02:42:45 -04:00
|
|
|
|
deployed,
|
|
|
|
|
|
install_branch,
|
|
|
|
|
|
ip_map,
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
install["cmd"],
|
|
|
|
|
|
install["prod"],
|
2026-07-28 05:22:46 -04:00
|
|
|
|
)
|
2026-07-28 00:27:25 -04:00
|
|
|
|
else:
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Installing ERPLibre on each VM')} "
|
|
|
|
|
|
f"({install_branch})…"
|
|
|
|
|
|
)
|
|
|
|
|
|
for name in deployed:
|
|
|
|
|
|
self._qemu_install_erplibre_vm(
|
2026-07-29 06:42:02 -04:00
|
|
|
|
name,
|
|
|
|
|
|
ssh_key,
|
|
|
|
|
|
install_branch,
|
|
|
|
|
|
ip_map.get(name),
|
[REF] qemu deploy: split collecting the choices from executing them
_qemu_deploy was ~400 lines mixing a dozen prompts with the parallel
deployment, IP resolution, ~/.ssh/config and the ERPLibre install. A second
interface could not be added to it without duplicating all of that — and the
repository already shows where duplication leads: _qemu_choose_cli_browser
(todo.py) and Monitor._choose_browser (qemu_install_monitor.py) are the same
function twice, and they have already drifted apart.
The function now has three parts around a plain dict, the SPEC:
_qemu_collect_vms_cli arch, catalog, resources, names -> the VM list
_qemu_collect_options_cli SSH key, install, parallelism -> the spec
_qemu_run_spec consumes a spec, asks nothing
_qemu_deploy_parts_for is the single point every command goes through, so two
interfaces producing the same spec necessarily produce the same command —
which makes their divergence testable rather than a matter of discipline.
Pure, I/O-free helpers come out of the body: _qemu_catalog_entries (the flat
distro × version × arch list), _qemu_arches_for, _qemu_make_vm,
_qemu_split_existing and _qemu_orphan_disks. The last two matter beyond
tidiness — the form must recompute collisions on every keystroke, and every
virsh call in this file goes through sudo. Existence is now resolved with ONE
virsh list --all --name (_qemu_list_domains, already present) instead of one
sudo per VM, and the orphan-disk scan needs no privileges at all.
VMs travel as dicts rather than 6-tuples plus a parallel names list. The
tuple-based resource prompts are left untouched and converted at the boundary.
No behaviour change intended, and verified as such: replaying identical
scripted answers against a worktree pinned at the previous commit produces
byte-identical output — including the granular selection spanning three
architectures — once the repository path and the instantaneous free-RAM
reading are normalised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 06:05:18 -04:00
|
|
|
|
install["cmd"],
|
|
|
|
|
|
install["prod"],
|
2026-07-28 00:27:25 -04:00
|
|
|
|
)
|
2026-07-19 02:52:39 -04:00
|
|
|
|
|
2026-07-29 04:46:48 -04:00
|
|
|
|
# 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')}")
|
2026-08-01 02:42:45 -04:00
|
|
|
|
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)}"
|
|
|
|
|
|
)
|
2026-07-29 04:46:48 -04:00
|
|
|
|
print(f"{'═' * 60}")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
|
2026-07-19 03:47:33 -04:00
|
|
|
|
print(f" {t('Default login:')} erplibre / erplibre")
|
2026-07-19 02:52:39 -04:00
|
|
|
|
print(f" {t('Manage with:')} sudo virsh list --all")
|
|
|
|
|
|
|
2026-03-12 05:40:22 -04:00
|
|
|
|
def _deploy_clone_erplibre(self):
|
|
|
|
|
|
default_path = os.path.expanduser("~/erplibre")
|
|
|
|
|
|
target_path = (
|
2026-03-13 15:04:14 -04:00
|
|
|
|
input(t("Target directory path (default: ~/erplibre): ")).strip()
|
2026-03-12 05:40:22 -04:00
|
|
|
|
or default_path
|
|
|
|
|
|
)
|
|
|
|
|
|
target_path = os.path.expanduser(target_path)
|
|
|
|
|
|
if os.path.exists(target_path):
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(f"{t('Directory already exists: ')}{target_path}")
|
2026-03-12 05:40:22 -04:00
|
|
|
|
return
|
|
|
|
|
|
print(t("Cloning ERPLibre..."))
|
|
|
|
|
|
cmd = (
|
|
|
|
|
|
"git clone"
|
|
|
|
|
|
" https://github.com/erplibre/erplibre"
|
|
|
|
|
|
f" {target_path}"
|
|
|
|
|
|
)
|
|
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
|
|
|
|
|
try:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
|
|
|
|
|
print(f"{t('ERPLibre cloned successfully to: ')}" f"{target_path}")
|
2026-03-12 05:40:22 -04:00
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print(f"{t('Error cloning ERPLibre: ')}{e}")
|
|
|
|
|
|
|
2026-04-11 00:13:56 -04:00
|
|
|
|
def _deploy_ntfy_server(self):
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"\n{t('Deploy a local NTFY push notification server (Ubuntu/Arch)')}"
|
|
|
|
|
|
)
|
2026-07-15 07:48:12 -04:00
|
|
|
|
port = input(t("NTFY server port (default: 8080): ")).strip() or "8080"
|
2026-04-11 00:13:56 -04:00
|
|
|
|
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 = (
|
2026-07-15 07:48:12 -04:00
|
|
|
|
input(f"{t('NTFY base URL')} (default: {default_url}): ").strip()
|
2026-04-11 00:13:56 -04:00
|
|
|
|
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}")
|
|
|
|
|
|
|
2026-03-22 22:28:24 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-07-31 00:07:36 -04:00
|
|
|
|
# -o follow_symlinks : sshfs résout les liens symboliques CÔTÉ SERVEUR.
|
|
|
|
|
|
# Indispensable pour les dépôts google-repo (ERPLibre) : leur .git est
|
|
|
|
|
|
# une chaîne de symlinks relatifs profonds (.repo/projects -> project-
|
|
|
|
|
|
# objects) que git ne peut pas traverser sur un montage sshfs par
|
|
|
|
|
|
# défaut (« erreur à la lecture de .git » -> git status/commit KO).
|
|
|
|
|
|
cmd = f"sshfs -o follow_symlinks {target} {mount_point}"
|
2026-03-22 22:28:24 -04:00
|
|
|
|
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}")
|
|
|
|
|
|
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
def _get_ssh_params(self):
|
|
|
|
|
|
"""Prompt for SSH connection parameters. Returns dict or None on cancel."""
|
2026-07-15 07:48:12 -04:00
|
|
|
|
host = click.prompt(
|
|
|
|
|
|
t("Remote host (user@hostname or hostname): ")
|
|
|
|
|
|
).strip()
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
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"
|
2026-07-15 07:48:12 -04:00
|
|
|
|
key = click.prompt(
|
|
|
|
|
|
t("SSH key path (default: ~/.ssh/id_rsa, empty for none): ")
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
path = (
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
click.prompt(
|
2026-07-15 07:48:12 -04:00
|
|
|
|
t("Remote path (default: ~/erplibre_deploy_2): ")
|
[ADD] make: add SSH remote deployment targets and todo.py integration
Enable deploying and managing ERPLibre on remote servers via SSH
directly from make and the interactive todo.py CLI, since only
local deployment was previously supported.
- New conf/make.ssh.Makefile with 11 targets: ssh_check, ssh_push,
ssh_install, ssh_run, ssh_stop, ssh_restart, ssh_status, ssh_logs,
ssh_make, ssh_install_systemd, ssh_install_nginx
- Variables: SSH_HOST (required), SSH_USER, SSH_PORT, SSH_KEY,
SSH_PATH, SSH_TARGET, SSH_DOMAIN, SSH_ADMIN_EMAIL
- Execute > Deploy menu extended with 11 SSH options in todo.py
- All strings translated fr/en in todo_i18n.py
Generated by Claude Code 2.1.101 model claude-sonnet-4-6
Co-Authored-By: Mathieu Benoit <mathben@technolibre.ca>
2026-04-10 22:53:07 -04:00
|
|
|
|
).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
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-05-04 01:02:12 -04:00
|
|
|
|
def prompt_execute_code(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('What do you need for development?')}")
|
2025-05-04 01:02:12 -04:00
|
|
|
|
# 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
|
|
|
|
|
|
# """
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = self.config_file.get_config("code_from_makefile")
|
2026-02-13 04:07:02 -05:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
menu_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Open SHELL"),
|
2026-02-13 04:07:02 -05:00
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(menu_entry)
|
2026-02-13 04:07:02 -05:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
menu_entry = {
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Upgrade Module"),
|
2025-10-31 01:10:54 -04:00
|
|
|
|
}
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(menu_entry)
|
2026-02-13 04:07:02 -05:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices.append(
|
2026-02-28 02:08:04 -05:00
|
|
|
|
{
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"prompt_description": t("Debug"),
|
2026-02-28 02:08:04 -05:00
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-07-30 23:03:48 -04:00
|
|
|
|
# Déplacé depuis le menu Execute : mise à jour de tout le code source
|
|
|
|
|
|
# de dev en staging (sous-menu de mise à jour).
|
|
|
|
|
|
choices.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Update - Update all developed staging source code"
|
|
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-05-04 01:02:12 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif status == str(len(choices)):
|
2026-07-30 23:03:48 -04:00
|
|
|
|
self.prompt_execute_update()
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif status == str(len(choices) - 1):
|
2026-07-30 23:03:48 -04:00
|
|
|
|
self.debug_ide()
|
2026-03-10 03:45:11 -04:00
|
|
|
|
elif status == str(len(choices) - 2):
|
2026-07-30 23:03:48 -04:00
|
|
|
|
self.upgrade_module()
|
|
|
|
|
|
elif status == str(len(choices) - 3):
|
2026-02-13 04:07:02 -05:00
|
|
|
|
self.open_shell_on_database()
|
2025-05-04 01:02:12 -04:00
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
int_cmd = int(status)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if 0 < int_cmd <= len(choices):
|
2025-05-04 01:02:12 -04:00
|
|
|
|
cmd_no_found = False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
|
|
|
|
|
self.execute_from_configuration(instance)
|
2025-05-04 01:02:12 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
2026-03-09 16:34:43 -04:00
|
|
|
|
def prompt_execute_git(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Git management tools!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Local git server")},
|
|
|
|
|
|
{"prompt_description": t("Add a remote to a local repository")},
|
2026-03-09 16:34:43 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
# Append config-driven entries
|
2026-03-10 03:45:11 -04:00
|
|
|
|
config_entries = self.config_file.get_config("git_from_makefile")
|
|
|
|
|
|
if config_entries:
|
|
|
|
|
|
choices.extend(config_entries)
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif status == "1":
|
|
|
|
|
|
self.prompt_execute_git_local_server()
|
2026-03-12 04:04:52 -04:00
|
|
|
|
elif status == "2":
|
|
|
|
|
|
self._git_add_remote()
|
2026-03-09 16:34:43 -04:00
|
|
|
|
else:
|
|
|
|
|
|
cmd_no_found = True
|
|
|
|
|
|
try:
|
|
|
|
|
|
int_cmd = int(status)
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if 0 < int_cmd <= len(choices):
|
2026-03-09 16:34:43 -04:00
|
|
|
|
cmd_no_found = False
|
2026-03-10 03:45:11 -04:00
|
|
|
|
instance = choices[int_cmd - 1]
|
|
|
|
|
|
self.execute_from_configuration(instance)
|
2026-03-09 16:34:43 -04:00
|
|
|
|
except ValueError:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if cmd_no_found:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
2026-03-12 04:04:52 -04:00
|
|
|
|
def _git_add_remote(self):
|
|
|
|
|
|
remote_name = (
|
2026-03-13 15:04:14 -04:00
|
|
|
|
input(t("Remote name (default: localhost): ")).strip()
|
|
|
|
|
|
or "localhost"
|
2026-03-12 04:04:52 -04:00
|
|
|
|
)
|
2026-03-13 15:04:14 -04:00
|
|
|
|
remote_url = input(
|
|
|
|
|
|
t("Repository address (e.g.: git://192.168.1.100/my-repo.git): ")
|
|
|
|
|
|
).strip()
|
2026-03-12 04:04:52 -04:00
|
|
|
|
if not remote_url:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Repository address is required!"))
|
2026-03-12 04:04:52 -04:00
|
|
|
|
return
|
|
|
|
|
|
cmd = f"git remote add {remote_name} {remote_url}"
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Will execute:')} {cmd}")
|
2026-03-12 04:04:52 -04:00
|
|
|
|
try:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Remote added successfully!"))
|
2026-03-12 04:04:52 -04:00
|
|
|
|
except Exception as e:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Error adding remote: ')}{e}")
|
2026-03-12 04:04:52 -04:00
|
|
|
|
|
2026-03-09 16:34:43 -04:00
|
|
|
|
def prompt_execute_git_local_server(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Manage local git repository server!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Deploy a local git server (~/.git-server)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Deploy a production git server (/srv/git, root required)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-09 16:34:43 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
|
|
|
|
|
def _prompt_git_server_actions(self, production_ready=False):
|
|
|
|
|
|
mode = (
|
2026-03-12 05:31:45 -04:00
|
|
|
|
t("Production mode (/srv/git, root required)")
|
2026-03-09 16:34:43 -04:00
|
|
|
|
if production_ready
|
2026-03-12 05:31:45 -04:00
|
|
|
|
else t("Local mode (~/.git-server)")
|
2026-03-09 16:34:43 -04:00
|
|
|
|
)
|
|
|
|
|
|
print(f"🤖 {mode}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Run all (init + remote + push + serve)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"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")},
|
2026-03-09 16:34:43 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
|
|
|
|
|
def _deploy_git_server(self, production_ready=False, action="all"):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Starting git server deployment..."))
|
2026-03-09 16:34:43 -04:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 02:05:53 -04:00
|
|
|
|
def prompt_execute_gpt_code(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('AI assistant tools for development!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Configure Claude Code configurations")},
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Add an automation with Claude in todo.py"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"RTK - CLI proxy to reduce LLM token consumption"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-10 02:05:53 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif status == "1":
|
2026-03-12 04:37:19 -04:00
|
|
|
|
self._prompt_claude_configs()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
elif status == "2":
|
2026-03-12 04:04:52 -04:00
|
|
|
|
self._claude_add_automation()
|
|
|
|
|
|
elif status == "3":
|
2026-03-12 02:51:01 -04:00
|
|
|
|
self.prompt_execute_rtk()
|
2026-03-10 02:05:53 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
2026-03-12 04:37:19 -04:00
|
|
|
|
def _prompt_claude_configs(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Deploy Claude Code commands!')}")
|
2026-03-12 04:37:19 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Commit - OCA/Odoo commit command")},
|
2026-03-12 04:37:19 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"Todo Add Command - Add a command to todo.py menu"
|
2026-03-12 04:37:19 -04:00
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{"prompt_description": t("Show installed custom commands")},
|
2026-03-12 04:37:19 -04:00
|
|
|
|
]
|
|
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
2026-03-12 04:37:19 -04:00
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-12 04:37:19 -04:00
|
|
|
|
|
|
|
|
|
|
def _list_claude_commands(self):
|
|
|
|
|
|
commands_dir = os.path.expanduser("~/.claude/commands")
|
|
|
|
|
|
if not os.path.isdir(commands_dir):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("No custom commands found in ~/.claude/commands/"))
|
2026-03-10 02:05:53 -04:00
|
|
|
|
return
|
2026-03-12 04:37:19 -04:00
|
|
|
|
files = sorted(
|
2026-03-13 15:04:14 -04:00
|
|
|
|
f for f in os.listdir(commands_dir) if f.endswith(".md")
|
2026-03-12 04:37:19 -04:00
|
|
|
|
)
|
|
|
|
|
|
if not files:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("No custom commands found in ~/.claude/commands/"))
|
2026-03-12 04:37:19 -04:00
|
|
|
|
return
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Claude Code custom commands:"))
|
2026-03-12 04:37:19 -04:00
|
|
|
|
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)
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(f"{t('Total:')}" f" {len(files)}")
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
2026-03-12 04:37:19 -04:00
|
|
|
|
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):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('File already exists: ')}{dest_file}")
|
2026-07-19 03:10:55 -04:00
|
|
|
|
overwrite = input(t("Do you want to overwrite the file? (y/Y): "))
|
|
|
|
|
|
if not self._is_yes(overwrite):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Nothing to do."))
|
2026-03-12 04:37:19 -04:00
|
|
|
|
return
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
|
|
|
|
|
template_path = os.path.join(
|
|
|
|
|
|
os.path.dirname(__file__),
|
|
|
|
|
|
"..",
|
|
|
|
|
|
"..",
|
|
|
|
|
|
"conf",
|
2026-03-12 04:37:19 -04:00
|
|
|
|
template_filename,
|
2026-03-10 02:05:53 -04:00
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(template_path) as f:
|
|
|
|
|
|
content = f.read()
|
|
|
|
|
|
|
2026-03-12 04:37:19 -04:00
|
|
|
|
if personalize:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
name = input(t("Enter your full name: ")).strip()
|
|
|
|
|
|
email = input(t("Enter your email: ")).strip()
|
2026-03-12 04:37:19 -04:00
|
|
|
|
content = content.replace(
|
|
|
|
|
|
"Your Name <your@email.com>",
|
|
|
|
|
|
f"{name} <{email}>",
|
|
|
|
|
|
)
|
|
|
|
|
|
content = content.replace(
|
|
|
|
|
|
"Your Name ",
|
|
|
|
|
|
f"{name} ",
|
|
|
|
|
|
)
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
|
|
|
|
|
os.makedirs(dest_dir, exist_ok=True)
|
|
|
|
|
|
with open(dest_file, "w") as f:
|
|
|
|
|
|
f.write(content)
|
|
|
|
|
|
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('File created successfully: ')}{dest_file}")
|
2026-03-10 02:05:53 -04:00
|
|
|
|
except Exception as e:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Error creating file: ')}{e}")
|
2026-03-10 02:05:53 -04:00
|
|
|
|
|
2026-03-12 04:04:52 -04:00
|
|
|
|
def _claude_add_automation(self):
|
2026-03-13 15:04:14 -04:00
|
|
|
|
description = input(t("Description of the command to add: ")).strip()
|
2026-03-12 04:04:52 -04:00
|
|
|
|
if not description:
|
|
|
|
|
|
return
|
2026-03-13 15:04:14 -04:00
|
|
|
|
command = input(t("Bash command to execute: ")).strip()
|
2026-03-12 04:04:52 -04:00
|
|
|
|
if not command:
|
|
|
|
|
|
return
|
|
|
|
|
|
section = (
|
|
|
|
|
|
input(
|
2026-03-12 05:31:45 -04:00
|
|
|
|
t("Menu section (git/code/config/network/process): ")
|
2026-03-12 04:04:52 -04:00
|
|
|
|
).strip()
|
|
|
|
|
|
or "git"
|
|
|
|
|
|
)
|
|
|
|
|
|
section_key = f"{section}_from_makefile"
|
2026-03-13 15:04:14 -04:00
|
|
|
|
config_path = os.path.join(os.path.dirname(__file__), "todo.json")
|
2026-03-12 04:04:52 -04:00
|
|
|
|
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")
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Automation added successfully in todo.json!"))
|
2026-03-12 04:04:52 -04:00
|
|
|
|
except Exception as e:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Error adding automation: ')}{e}")
|
2026-03-12 04:04:52 -04:00
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
def prompt_execute_doc(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Looking for documentation?')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"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")},
|
2025-10-31 01:10:54 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
|
|
|
|
|
def prompt_execute_database(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Make changes to databases!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Backup")},
|
|
|
|
|
|
{"prompt_description": t("Create backup (.zip)")},
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"Download database to create backup (.zip)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Restore")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Restore from backup (.zip)")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Danger zone")},
|
2026-06-25 02:25:40 -04:00
|
|
|
|
{"prompt_description": t("Erase a database")},
|
2025-10-31 01:10:54 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif status == "1":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.db_manager.create_backup_from_database()
|
2025-10-31 01:10:54 -04:00
|
|
|
|
elif status == "2":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.db_manager.download_database_backup_cli()
|
2026-03-02 16:06:30 -05:00
|
|
|
|
elif status == "3":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.db_manager.restore_from_database()
|
2026-06-25 02:25:40 -04:00
|
|
|
|
elif status == "4":
|
|
|
|
|
|
self.db_manager.drop_database()
|
2025-10-31 01:10:54 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-10-31 01:10:54 -04:00
|
|
|
|
|
2025-12-22 04:02:44 -05:00
|
|
|
|
def prompt_execute_process(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Manage execution processes!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Kill Odoo process from actual port")},
|
|
|
|
|
|
{"prompt_description": t("Kill git daemon server process")},
|
2025-12-22 04:02:44 -05:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2025-12-22 04:02:44 -05:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif status == "1":
|
|
|
|
|
|
self.process_kill_from_port()
|
2026-03-09 16:34:43 -04:00
|
|
|
|
elif status == "2":
|
|
|
|
|
|
self.process_kill_git_daemon()
|
2025-12-22 04:02:44 -05:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2025-12-22 04:02:44 -05:00
|
|
|
|
|
2026-03-09 16:34:43 -04:00
|
|
|
|
def process_kill_git_daemon(self):
|
|
|
|
|
|
self.execute.exec_command_live(
|
|
|
|
|
|
"pkill -f 'git daemon'",
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
)
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Git daemon process killed."))
|
2026-03-09 16:34:43 -04:00
|
|
|
|
|
2026-03-12 02:51:01 -04:00
|
|
|
|
def prompt_execute_rtk(self):
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"🤖 {t('Manage RTK (Rust Token Killer) for token optimization!')}"
|
|
|
|
|
|
)
|
2026-03-12 02:51:01 -04:00
|
|
|
|
choices = [
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Setup")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Install RTK")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"prompt_description": t("Initialize global auto-rewrite hook")},
|
|
|
|
|
|
{"section": t("Status")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Check RTK version")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"prompt_description": t("Check RTK status")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Show cumulative token savings")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Optimize")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Discover optimization opportunities")},
|
2026-03-12 02:51:01 -04:00
|
|
|
|
]
|
|
|
|
|
|
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":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.rtk_init_global()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
elif status == "3":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.rtk_check_version()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
elif status == "4":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.rtk_check_status()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
elif status == "5":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.rtk_show_gain()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
elif status == "6":
|
2026-07-19 03:41:00 -04:00
|
|
|
|
self.rtk_discover()
|
2026-03-12 02:51:01 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-12 02:51:01 -04:00
|
|
|
|
|
|
|
|
|
|
def rtk_install(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Installation method:')}")
|
2026-03-12 02:51:01 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("curl - Automatic install script")},
|
|
|
|
|
|
{"prompt_description": t("brew - Homebrew (macOS/Linux)")},
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"cargo - Build from source (Rust required)"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-12 02:51:01 -04:00
|
|
|
|
]
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-12 02:51:01 -04:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("RTK is not installed. Use option 1 to install it."))
|
2026-03-12 02:51:01 -04:00
|
|
|
|
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()
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('RTK is installed, version: ')}{version_output}")
|
2026-03-12 02:51:01 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('RTK is installed, version: ')}?")
|
2026-03-12 02:51:01 -04:00
|
|
|
|
|
|
|
|
|
|
config_path = os.path.expanduser("~/.config/rtk/config.toml")
|
|
|
|
|
|
if os.path.exists(config_path):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Global auto-rewrite hook: active"))
|
2026-03-12 02:51:01 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Global auto-rewrite hook: inactive"))
|
2026-03-12 02:51:01 -04:00
|
|
|
|
|
2026-02-13 03:46:27 -05:00
|
|
|
|
def prompt_execute_config(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Manage ERPLibre and Odoo configuration!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Generate")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"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")},
|
2026-07-19 03:41:00 -04:00
|
|
|
|
{"section": t("Advanced")},
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Setup queue job for parallelism")},
|
2026-02-13 03:46:27 -05:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-02-13 03:46:27 -05:00
|
|
|
|
|
|
|
|
|
|
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()
|
2026-02-28 02:14:38 -05:00
|
|
|
|
elif status == "5":
|
|
|
|
|
|
self.generate_config_queue_job()
|
2026-02-13 03:46:27 -05:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-02-13 03:46:27 -05:00
|
|
|
|
|
2026-02-28 03:24:22 -05:00
|
|
|
|
def prompt_execute_network(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Network tools!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("SSH port-forwarding")},
|
2026-03-07 02:59:07 -05:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
2026-03-12 05:31:45 -04:00
|
|
|
|
"Network performance request per second"
|
2026-03-07 02:59:07 -05:00
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-02-28 03:24:22 -05:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-02-28 03:24:22 -05:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-02-28 03:24:22 -05:00
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-07 01:38:55 -05:00
|
|
|
|
def prompt_execute_security(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Dependency security audit!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-13 15:04:14 -04:00
|
|
|
|
{
|
|
|
|
|
|
"prompt_description": t(
|
|
|
|
|
|
"pip-audit - Check vulnerabilities on Python environments"
|
|
|
|
|
|
)
|
|
|
|
|
|
},
|
2026-03-07 01:38:55 -05:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-07 01:38:55 -05:00
|
|
|
|
|
|
|
|
|
|
while True:
|
|
|
|
|
|
status = click.prompt(help_info)
|
|
|
|
|
|
print()
|
|
|
|
|
|
if status == "0":
|
|
|
|
|
|
return False
|
|
|
|
|
|
elif status == "1":
|
|
|
|
|
|
self.execute_pip_audit()
|
|
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-07 01:38:55 -05:00
|
|
|
|
|
2026-03-08 15:53:51 -04:00
|
|
|
|
def prompt_execute_test(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Test an Odoo module on a temporary database!')}")
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Test a module")},
|
|
|
|
|
|
{"prompt_description": t("Test a module with code coverage")},
|
|
|
|
|
|
{"prompt_description": t("ERPLibre unit tests")},
|
2026-03-08 15:53:51 -04:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-03-08 15:53:51 -04:00
|
|
|
|
|
|
|
|
|
|
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)
|
2026-03-10 01:44:10 -04:00
|
|
|
|
elif status == "3":
|
|
|
|
|
|
self.execute_unit_tests()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-03-08 15:53:51 -04:00
|
|
|
|
|
|
|
|
|
|
def execute_test_module(self, coverage=False):
|
|
|
|
|
|
# Module name
|
2026-03-12 05:31:45 -04:00
|
|
|
|
module_name = input(t("Module name to test: ")).strip()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
if not module_name:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Module name is required!"))
|
2026-03-08 15:53:51 -04:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Database name
|
2026-03-13 15:04:14 -04:00
|
|
|
|
db_name = input(
|
|
|
|
|
|
t("Temporary database name (default: test_todo_tmp): ")
|
|
|
|
|
|
).strip()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
if not db_name:
|
|
|
|
|
|
db_name = "test_todo_tmp"
|
|
|
|
|
|
|
|
|
|
|
|
# Extra modules
|
2026-03-13 15:04:14 -04:00
|
|
|
|
extra_modules = input(
|
|
|
|
|
|
t("Extra modules to install (comma-separated, empty for none): ")
|
|
|
|
|
|
).strip()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
|
|
|
|
|
|
# Log level
|
2026-03-12 05:31:45 -04:00
|
|
|
|
log_level = input(t("Log level (default: test): ")).strip()
|
2026-03-08 15:53:51 -04:00
|
|
|
|
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
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n--- {t('Creating temporary database')} '{db_name}' ---")
|
2026-03-09 16:34:48 -04:00
|
|
|
|
cmd_restore = f"./script/database/db_restore.py --database {db_name}"
|
2026-03-08 15:53:51 -04:00
|
|
|
|
self.execute.exec_command_live(
|
|
|
|
|
|
cmd_restore,
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
single_source_erplibre=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Step 2: Install modules
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(f"\n--- {t('Installing modules')}: {modules_to_install} ---")
|
2026-03-08 15:53:51 -04:00
|
|
|
|
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
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n--- {t('Running tests')}: {module_name} ---")
|
2026-03-08 15:53:51 -04:00
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n✅ {t('Tests completed successfully!')}")
|
2026-03-08 15:53:51 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n❌ {t('Tests failed with return code')} {status_code}")
|
2026-03-08 15:53:51 -04:00
|
|
|
|
|
|
|
|
|
|
# Step 4: Cleanup
|
2026-07-19 03:10:55 -04:00
|
|
|
|
keep_input = input(t("Keep the temporary database? (y/N): "))
|
|
|
|
|
|
keep = self._is_yes(keep_input)
|
2026-03-08 15:53:51 -04:00
|
|
|
|
if keep:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Database kept')}: {db_name}")
|
2026-03-08 15:53:51 -04:00
|
|
|
|
else:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"\n--- {t('Cleaning up temporary database')} '{db_name}' ---"
|
|
|
|
|
|
)
|
2026-03-09 16:34:48 -04:00
|
|
|
|
cmd_drop = f"./odoo_bin.sh db --drop --database {db_name}"
|
2026-03-08 15:53:51 -04:00
|
|
|
|
self.execute.exec_command_live(
|
|
|
|
|
|
cmd_drop,
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
single_source_erplibre=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 01:44:10 -04:00
|
|
|
|
def execute_unit_tests(self):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n--- {t('Running unit tests')} ---")
|
2026-03-10 01:44:10 -04:00
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n✅ {t('All unit tests passed')}")
|
2026-03-10 01:44:10 -04:00
|
|
|
|
else:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"\n❌ {t('Some unit tests failed, exit code')}: {status_code}"
|
|
|
|
|
|
)
|
2026-03-10 01:44:10 -04:00
|
|
|
|
|
2026-03-07 01:38:55 -05:00
|
|
|
|
def execute_pip_audit(self):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
versions, installed_versions, odoo_installed_version = (
|
2026-03-10 03:59:35 -04:00
|
|
|
|
get_odoo_version()
|
2026-03-07 01:38:55 -05:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
# Build list of installed environments
|
2026-03-10 03:45:11 -04:00
|
|
|
|
environments = {}
|
2026-03-07 01:38:55 -05:00
|
|
|
|
key_i = 0
|
2026-03-10 03:45:11 -04:00
|
|
|
|
for version_info in versions[::-1]:
|
|
|
|
|
|
erplibre_version = version_info.get("erplibre_version")
|
2026-03-07 01:38:55 -05:00
|
|
|
|
venv_path = f".venv.{erplibre_version}"
|
|
|
|
|
|
req_path = f"requirement/requirements.{erplibre_version}.txt"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
odoo_version = f"odoo{version_info.get('odoo_version')}"
|
2026-03-07 01:38:55 -05:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
label += f" - {t('Current')}"
|
|
|
|
|
|
if version_info.get("Default"):
|
|
|
|
|
|
label += f" - {t('Default')}"
|
2026-03-07 01:38:55 -05:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
environments[key_s] = {
|
2026-03-07 01:38:55 -05:00
|
|
|
|
"label": label,
|
|
|
|
|
|
"venv_path": venv_path,
|
|
|
|
|
|
"req_path": req_path,
|
|
|
|
|
|
"erplibre_version": erplibre_version,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if not environments:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
t(
|
|
|
|
|
|
"No installed environment found. Install an Odoo version first."
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
2026-03-07 01:38:55 -05:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# Show selection menu
|
|
|
|
|
|
str_input = (
|
2026-03-12 05:31:45 -04:00
|
|
|
|
f"💬 {t('Choose an environment for the audit:')}\n\t"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
+ "\n\t".join([v["label"] for v in environments.values()])
|
2026-03-12 05:31:45 -04:00
|
|
|
|
+ f"\n\t0: {t('Back')}"
|
|
|
|
|
|
+ f"\n{t('Select: ')}"
|
2026-03-07 01:38:55 -05:00
|
|
|
|
)
|
|
|
|
|
|
env_input = ""
|
2026-03-10 03:45:11 -04:00
|
|
|
|
while env_input not in environments and env_input != "0":
|
2026-03-07 01:38:55 -05:00
|
|
|
|
if env_input:
|
2026-03-13 15:04:14 -04:00
|
|
|
|
print(
|
|
|
|
|
|
f"{t('Error, cannot understand value')}" f" '{env_input}'"
|
|
|
|
|
|
)
|
2026-03-07 01:38:55 -05:00
|
|
|
|
env_input = input(str_input).strip()
|
|
|
|
|
|
|
|
|
|
|
|
if env_input == "0":
|
|
|
|
|
|
return
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
selected = environments[env_input]
|
2026-03-07 01:38:55 -05:00
|
|
|
|
venv_path = selected["venv_path"]
|
|
|
|
|
|
req_path = selected["req_path"]
|
|
|
|
|
|
|
|
|
|
|
|
if not os.path.isfile(req_path):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Dependencies file not found: ')}{req_path}")
|
2026-03-07 01:38:55 -05:00
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
|
|
# TODO support bash from parameter if open gnome-terminal
|
|
|
|
|
|
cmd = f"pip-audit -r {req_path} -l;bash"
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"{t('Execution: ')}{cmd}")
|
2026-03-07 01:38:55 -05:00
|
|
|
|
self.execute.exec_command_live(
|
|
|
|
|
|
cmd,
|
|
|
|
|
|
source_erplibre=True,
|
|
|
|
|
|
single_source_erplibre=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-02-13 03:46:27 -05:00
|
|
|
|
def generate_config(self, add_arg=None):
|
|
|
|
|
|
# Repeating to get all item before get group
|
|
|
|
|
|
cmd = (
|
2026-02-16 17:59:13 -05:00
|
|
|
|
f"./script/git/git_merge_repo_manifest.py --output .repo/local_manifests/erplibre_manifest.xml --with_OCA;"
|
2026-02-13 03:46:27 -05:00
|
|
|
|
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):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("base")},
|
|
|
|
|
|
{"prompt_description": t("base + code_generator")},
|
|
|
|
|
|
{"prompt_description": t("base + image_db")},
|
|
|
|
|
|
{"prompt_description": t("all")},
|
2026-02-13 03:46:27 -05:00
|
|
|
|
# {"prompt_description": "base + migration"},
|
|
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-02-13 03:46:27 -05:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-02-13 03:46:27 -05:00
|
|
|
|
|
2026-02-28 02:08:04 -05:00
|
|
|
|
def debug_ide(self):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
choices = [
|
2026-03-12 05:31:45 -04:00
|
|
|
|
{"prompt_description": t("Debug todo.py")},
|
2026-02-28 02:08:04 -05:00
|
|
|
|
]
|
2026-03-10 03:45:11 -04:00
|
|
|
|
help_info = self.fill_help_info(choices)
|
2026-02-28 02:08:04 -05:00
|
|
|
|
|
|
|
|
|
|
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:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Command not found !"))
|
2026-02-28 02:08:04 -05:00
|
|
|
|
|
2026-02-13 03:46:27 -05:00
|
|
|
|
def generate_config_from_backup(self):
|
2026-03-10 03:59:35 -04:00
|
|
|
|
file_name = self.db_manager.open_file_image_db()
|
2026-02-13 03:46:27 -05:00
|
|
|
|
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):
|
2026-03-10 03:59:35 -04:00
|
|
|
|
database_name = self.db_manager.select_database()
|
2026-02-13 04:07:02 -05:00
|
|
|
|
str_arg = f"--database {database_name}"
|
|
|
|
|
|
self.generate_config(add_arg=str_arg)
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
2026-02-28 02:14:38 -05:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-03-10 04:05:04 -04:00
|
|
|
|
def prompt_execute_selenium_and_run_db(
|
|
|
|
|
|
self, db_name, extra_cmd_web_login=""
|
|
|
|
|
|
):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
cmd_server = f"./run.sh -d {db_name};bash"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd_server)
|
2025-10-31 01:10:54 -04:00
|
|
|
|
cmd_client = (
|
|
|
|
|
|
f"sleep 3;./script/selenium/web_login.py{extra_cmd_web_login};bash"
|
2025-04-28 00:32:51 -04:00
|
|
|
|
)
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd_client)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-04-28 00:32:51 -04:00
|
|
|
|
def prompt_execute_selenium(self, command=None, extra_cmd_web_login=""):
|
2026-03-10 03:45:11 -04:00
|
|
|
|
commands = []
|
2025-04-26 17:12:38 -04:00
|
|
|
|
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:
|
2026-03-10 03:45:11 -04:00
|
|
|
|
commands.append(cmd + item)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
else:
|
2026-03-10 03:45:11 -04:00
|
|
|
|
commands.append(cmd + extra_cmd_web_login)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
if len(commands) == 1:
|
|
|
|
|
|
self.execute.exec_command_live(commands[0])
|
|
|
|
|
|
elif len(commands) > 1:
|
2025-04-26 17:12:38 -04:00
|
|
|
|
new_cmd = "parallel ::: "
|
2026-03-10 03:45:11 -04:00
|
|
|
|
for i, cmd in enumerate(commands):
|
2025-04-26 17:12:38 -04:00
|
|
|
|
new_cmd += f' "sleep {1 * i};{cmd}"'
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(new_cmd)
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
2025-08-07 06:09:37 -04:00
|
|
|
|
def crash_diagnostic(self, e):
|
2026-03-10 03:06:07 -04:00
|
|
|
|
# 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
|
2025-08-07 06:09:37 -04:00
|
|
|
|
):
|
|
|
|
|
|
print("Got error : ")
|
|
|
|
|
|
print(e)
|
2026-03-10 03:06:07 -04:00
|
|
|
|
print("Got error at first execution.", ERROR_LOG_PATH)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
try:
|
2026-03-10 03:06:07 -04:00
|
|
|
|
file = open(ERROR_LOG_PATH, "r")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
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)
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=True)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
sys.exit(1)
|
2026-03-10 03:06:07 -04:00
|
|
|
|
if os.path.exists(VENV_ERPLIBRE):
|
2025-08-07 06:09:37 -04:00
|
|
|
|
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(
|
2026-03-10 03:06:07 -04:00
|
|
|
|
# f"You forgot to activate source \nsource ./{VENV_ERPLIBRE}/bin/activate"
|
2025-08-07 06:09:37 -04:00
|
|
|
|
# )
|
|
|
|
|
|
# time.sleep(0.5)
|
|
|
|
|
|
# cmd = "./script/todo/source_todo.sh"
|
|
|
|
|
|
print("Re-execute TODO 🤖 or execute :")
|
|
|
|
|
|
print()
|
2026-03-10 03:06:07 -04:00
|
|
|
|
print(f"source {VENV_ERPLIBRE}/bin/activate;make")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
print()
|
|
|
|
|
|
cmd = "./script/todo/todo.py"
|
|
|
|
|
|
# # self.restart_script(e)
|
|
|
|
|
|
try:
|
|
|
|
|
|
# TODO duplicate
|
|
|
|
|
|
import click
|
|
|
|
|
|
import humanize
|
|
|
|
|
|
import openai
|
2025-10-31 01:10:54 -04:00
|
|
|
|
import urwid
|
2025-08-07 06:09:37 -04:00
|
|
|
|
from pykeepass import PyKeePass
|
|
|
|
|
|
except ImportError:
|
|
|
|
|
|
print("Rerun and exit")
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd, source_erplibre=True)
|
2025-08-07 06:09:37 -04:00
|
|
|
|
sys.exit(1)
|
|
|
|
|
|
print("No error")
|
|
|
|
|
|
else:
|
|
|
|
|
|
self.prompt_install()
|
|
|
|
|
|
|
2026-02-13 04:07:02 -05:00
|
|
|
|
def open_shell_on_database(self):
|
2026-03-10 03:59:35 -04:00
|
|
|
|
database = self.db_manager.select_database()
|
2026-03-07 02:59:07 -05:00
|
|
|
|
if database:
|
|
|
|
|
|
cmd_server = f"./odoo_bin.sh shell -d {database}"
|
2026-03-10 03:45:11 -04:00
|
|
|
|
status, databases = self.execute.exec_command_live(
|
2026-03-07 02:59:07 -05:00
|
|
|
|
cmd_server,
|
|
|
|
|
|
return_status_and_output=True,
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
single_source_erplibre=True,
|
|
|
|
|
|
new_window=True,
|
|
|
|
|
|
)
|
2026-02-13 04:07:02 -05:00
|
|
|
|
|
2026-02-28 02:08:04 -05:00
|
|
|
|
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)
|
|
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
def upgrade_module(self):
|
|
|
|
|
|
upgrade = todo_upgrade.TodoUpgrade(self)
|
|
|
|
|
|
upgrade.execute_module_upgrade()
|
|
|
|
|
|
|
|
|
|
|
|
def upgrade_poetry(self):
|
|
|
|
|
|
# Only show the version to the user
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
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) : "
|
|
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
if self._is_yes(git_repo_update_input):
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-10-31 01:10:54 -04:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-03-10 03:45:11 -04:00
|
|
|
|
def callback_execute_custom_database(self, config):
|
2026-03-10 03:59:35 -04:00
|
|
|
|
database_name = self.db_manager.select_database()
|
2026-02-28 01:35:32 -05:00
|
|
|
|
self.prompt_execute_selenium_and_run_db(database_name)
|
|
|
|
|
|
|
2025-12-22 04:02:44 -05:00
|
|
|
|
def process_kill_from_port(self):
|
|
|
|
|
|
cfg = configparser.ConfigParser()
|
|
|
|
|
|
cfg.read("./config.conf")
|
|
|
|
|
|
http_port = cfg.getint("options", "http_port")
|
|
|
|
|
|
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-12-22 04:02:44 -05:00
|
|
|
|
f"./script/process/kill_process_by_port.py {http_port} --kill-tree --nb_parent 2",
|
|
|
|
|
|
source_erplibre=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-08-07 06:09:37 -04:00
|
|
|
|
def restart_script(self, last_error):
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"🤖 {t('Reboot TODO ...')}")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
# os.execv(sys.executable, ['python'] + sys.argv)
|
|
|
|
|
|
# TODO mettre check que le répertoire est créé, s'il existe, auto-loop à corriger
|
2026-03-10 03:06:07 -04:00
|
|
|
|
if os.path.exists(VENV_ERPLIBRE) and not os.path.exists(
|
|
|
|
|
|
ERROR_LOG_PATH
|
2025-08-07 06:09:37 -04:00
|
|
|
|
):
|
|
|
|
|
|
# TODO mettre check import suivant ne vont pas planter
|
|
|
|
|
|
try:
|
2026-03-10 03:06:07 -04:00
|
|
|
|
with open(ERROR_LOG_PATH, "w") as f_file:
|
2025-08-07 06:09:37 -04:00
|
|
|
|
f_file.write(str(last_error))
|
|
|
|
|
|
pass # The file is created and closed here, no content is written
|
|
|
|
|
|
print(
|
2026-03-10 03:06:07 -04:00
|
|
|
|
f"Try to reopen process with before :\nsource ./{VENV_ERPLIBRE}/bin/activate && exec python "
|
2025-08-07 06:09:37 -04:00
|
|
|
|
+ " ".join(sys.argv)
|
|
|
|
|
|
)
|
|
|
|
|
|
os.execv(
|
|
|
|
|
|
"/bin/bash",
|
|
|
|
|
|
[
|
|
|
|
|
|
"/bin/bash",
|
|
|
|
|
|
"-c",
|
2026-03-10 03:06:07 -04:00
|
|
|
|
f"source ./{VENV_ERPLIBRE}/bin/activate && exec python "
|
2025-08-07 06:09:37 -04:00
|
|
|
|
+ " ".join(sys.argv),
|
|
|
|
|
|
],
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
print("Error detect at first execution.")
|
|
|
|
|
|
print(e)
|
|
|
|
|
|
|
2025-10-31 01:10:54 -04:00
|
|
|
|
def on_dir_selected(self, dir_path):
|
|
|
|
|
|
self.dir_path = dir_path
|
|
|
|
|
|
todo_file_browser.exit_program()
|
|
|
|
|
|
|
2026-03-10 04:18:17 -04:00
|
|
|
|
def callback_make_mobile_home(self, config):
|
2025-12-27 05:23:48 -05:00
|
|
|
|
# Read file
|
2025-10-01 04:53:08 -04:00
|
|
|
|
default_project_name = "ERPLibre"
|
2025-12-27 05:23:48 -05:00
|
|
|
|
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
|
|
|
|
|
|
|
2025-10-01 04:53:08 -04:00
|
|
|
|
default_project_url_name = "https://erplibre.ca"
|
2025-12-27 05:23:48 -05:00
|
|
|
|
# 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"
|
|
|
|
|
|
)
|
2025-12-28 01:40:42 -05:00
|
|
|
|
default_project_note_subject = dotenv.get_key(
|
|
|
|
|
|
dotenv_file, "VITE_LABEL_NOTE"
|
|
|
|
|
|
)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
|
2025-10-01 04:53:08 -04:00
|
|
|
|
default_debug = False
|
|
|
|
|
|
project_name = default_project_name
|
|
|
|
|
|
project_url_name = default_project_url_name
|
2025-12-28 01:40:42 -05:00
|
|
|
|
project_principal_subject = default_project_note_subject
|
2025-12-27 05:23:48 -05:00
|
|
|
|
package_name = default_package_name
|
2025-10-01 04:53:08 -04:00
|
|
|
|
do_debug = default_debug
|
2025-12-27 05:23:48 -05:00
|
|
|
|
do_change_picture_menu = False
|
2025-10-01 04:53:08 -04:00
|
|
|
|
|
|
|
|
|
|
do_personalize = input(
|
|
|
|
|
|
"Do you want to personalize the mobile application (Y) : "
|
|
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
if self._is_yes(do_personalize):
|
2025-10-01 04:53:08 -04:00
|
|
|
|
project_name = (
|
|
|
|
|
|
input(
|
2025-12-27 05:23:48 -05:00
|
|
|
|
f'Your project name (Separate by space in title), default "{default_project_name}" : '
|
2025-10-01 04:53:08 -04:00
|
|
|
|
).strip()
|
|
|
|
|
|
or default_project_name
|
|
|
|
|
|
)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
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
|
|
|
|
|
|
)
|
2025-10-01 04:53:08 -04:00
|
|
|
|
project_url_name = (
|
|
|
|
|
|
input(
|
2025-12-28 01:40:42 -05:00
|
|
|
|
f'Your project url website, default "{default_project_url_name}" : '
|
2025-10-01 04:53:08 -04:00
|
|
|
|
).strip()
|
|
|
|
|
|
or default_project_url_name
|
|
|
|
|
|
)
|
2025-12-28 01:40:42 -05:00
|
|
|
|
project_principal_subject = (
|
|
|
|
|
|
input(
|
|
|
|
|
|
f'Your project subject, default "{default_project_note_subject}" : '
|
|
|
|
|
|
).strip()
|
|
|
|
|
|
or default_project_note_subject
|
|
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
do_debug = self._is_yes(
|
2025-12-27 05:23:48 -05:00
|
|
|
|
input("Compilation with debug information, default No (Y) : ")
|
2025-10-01 04:53:08 -04:00
|
|
|
|
)
|
2026-07-19 03:10:55 -04:00
|
|
|
|
do_change_picture_menu = self._is_yes(
|
2025-12-27 05:23:48 -05:00
|
|
|
|
input(
|
2026-07-19 03:10:55 -04:00
|
|
|
|
"Want to change picture from menu, you need"
|
|
|
|
|
|
" android-studio (Y) : "
|
2025-12-27 05:23:48 -05:00
|
|
|
|
)
|
2025-10-01 04:53:08 -04:00
|
|
|
|
)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
|
|
|
|
|
|
# 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'
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd_client, source_erplibre=False)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
|
2025-10-01 04:53:08 -04:00
|
|
|
|
# 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",
|
|
|
|
|
|
)
|
2025-12-28 01:40:42 -05:00
|
|
|
|
dotenv.set_key(
|
|
|
|
|
|
dotenv_file,
|
|
|
|
|
|
"VITE_LABEL_NOTE",
|
|
|
|
|
|
project_principal_subject,
|
|
|
|
|
|
quote_mode="always",
|
|
|
|
|
|
)
|
2025-10-01 04:53:08 -04:00
|
|
|
|
dotenv.set_key(
|
|
|
|
|
|
dotenv_file,
|
|
|
|
|
|
"VITE_DEBUG_DEV",
|
|
|
|
|
|
"true" if do_debug else "false",
|
|
|
|
|
|
quote_mode="never",
|
|
|
|
|
|
)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
|
|
|
|
|
|
if do_change_picture_menu:
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-12-27 05:23:48 -05:00
|
|
|
|
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 ..."
|
|
|
|
|
|
)
|
2025-12-28 01:40:42 -05:00
|
|
|
|
cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/company_logo.png"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd_client, source_erplibre=False)
|
2025-12-28 01:40:42 -05:00
|
|
|
|
cmd_client = "cp ./mobile/erplibre_home_mobile/android/app/src/main/ic_launcher-playstore.png ./mobile/erplibre_home_mobile/src/assets/imgs/logo.png"
|
2026-02-13 01:27:38 -05:00
|
|
|
|
self.execute.exec_command_live(cmd_client, source_erplibre=False)
|
2025-12-27 05:23:48 -05:00
|
|
|
|
|
2026-02-13 01:27:38 -05:00
|
|
|
|
status = self.execute.exec_command_live(
|
2025-10-01 04:53:08 -04:00
|
|
|
|
"./mobile/compile_and_run.sh", source_erplibre=False
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2025-04-26 17:12:38 -04:00
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
2025-08-07 06:09:37 -04:00
|
|
|
|
start_time = time.time()
|
|
|
|
|
|
try:
|
|
|
|
|
|
todo = TODO()
|
|
|
|
|
|
if ENABLE_CRASH:
|
|
|
|
|
|
todo.crash_diagnostic(CRASH_E)
|
|
|
|
|
|
todo.run()
|
|
|
|
|
|
except KeyboardInterrupt:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(t("Keyboard interrupt"))
|
2025-08-07 06:09:37 -04:00
|
|
|
|
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)
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n{t('TODO execution time')} {humain_time}\n")
|
2025-08-07 06:09:37 -04:00
|
|
|
|
else:
|
2026-03-12 05:31:45 -04:00
|
|
|
|
print(f"\n{t('TODO execution time')} {duration_sec:.2f} sec.\n")
|