Merge branch 'develop_qemu_os_installation'

- Support qemu for ERPLibre OS installation
- New support Fedora
- Improve support arch linux, debian and ubuntu for all supported
  version
- TUI for VM deployement and TODO telemetry for navigation
- Automatic download cloud images for Qemu
- Refactor TODO prompt more readable
This commit is contained in:
Mathieu Benoit 2026-08-07 03:57:30 -04:00
commit e8ee8cc17f
22 changed files with 14419 additions and 345 deletions

View file

@ -24,6 +24,13 @@ openai
humanize
requests
urwid
# Borne recopiée de script/todo/textual_setup.py (TEXTUAL_SPEC) : les écrans TUI
# sont écrits pour Textual 8, qui casse son API entre majeures. Modifier les deux.
textual>=8,<9
# Dépendance directe : les outils d'analyse comparent des arbres XML avec lxml.
# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc
# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame.
lxml
python-dotenv
python-dateutil
unidecode

View file

@ -5,6 +5,7 @@
import datetime
import logging
import os
import re
import shutil
import subprocess
import sys
@ -17,6 +18,41 @@ except ModuleNotFoundError as e:
VENV_ERPLIBRE = ".venv.erplibre"
# Une commande construite ailleurs peut porter un secret en clair : todo.py et
# kdbx_manager.py y mettent « --default_password_auth '<mot de passe KeePass>' »,
# db_restore.py « --master_password=… ». Cette commande est affichée avant et
# après l'exécution, et journalisée en erreur : le secret finissait donc dans le
# terminal, dans les journaux et dans toute sortie CI qui les capture.
#
# On caviarde la VALEUR, jamais le nom de l'option : la commande reste lisible et
# reproductible, il ne manque que ce qui ne doit pas être lu.
_SECRET_OPTION = re.compile(
r"(?P<opt>--?[\w-]*"
r"(?:password|passwd|pwd|secret|token|api[-_]?key)[\w-]*"
r"(?:\s+|=))"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)",
re.IGNORECASE,
)
_SECRET_ENV = re.compile(
r"(?P<var>\b\w*(?:PASSWORD|PASSWD|SECRET|TOKEN)\w*=)"
r"(?P<val>'[^']*'|\"[^\"]*\"|\S+)"
)
def redact_secrets(text):
"""Remplace la valeur des options et variables porteuses de secret.
Appliqué à CHAQUE affichage d'une commande. Filtrer au point d'affichage
plutôt qu'à la construction est ce qui rend la garantie tenable : il n'y a
qu'une poignée de sorties ici, alors que les commandes se construisent
partout dans le dépôt.
"""
if not text:
return text
text = _SECRET_OPTION.sub(lambda m: m.group("opt") + "'***'", text)
return _SECRET_ENV.sub(lambda m: m.group("var") + "'***'", text)
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
)
@ -107,7 +143,8 @@ class Execute:
source_odoo = f.read()
if not source_odoo:
_logger.error(
f"You cannot execute Odoo command if no version is installed. Command : {command}"
"You cannot execute Odoo command if no version is"
f" installed. Command : {redact_secrets(command)}"
)
return -1
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
@ -116,7 +153,7 @@ class Execute:
if not quiet:
print("🏠 ⬇ Execute command :\n")
print(command)
print(redact_secrets(command))
output_lines = []
try:
@ -156,16 +193,10 @@ class Execute:
except FileNotFoundError:
if not quiet:
if "password" in command:
print(
f"Error: Command '{command.split(' ')[0]}'[...]"
" not found."
)
else:
print(f"Error: Command '{command}' not found.")
print(f"Error: Command '{redact_secrets(command)}' not found.")
except Exception as e:
if not quiet:
print(f"An error occurred: {e}")
print(f"An error occurred: {redact_secrets(str(e))}")
process_end_time = time.time()
duration_sec = process_end_time - process_start_time
if humanize:
@ -177,7 +208,7 @@ class Execute:
if not quiet:
print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n")
if not quiet:
print(command)
print(redact_secrets(command))
print()
if return_status_and_output_and_command:
return exit_code, command, output_lines

View file

@ -1,68 +1,126 @@
#!/usr/bin/env bash
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Dépendances système ERPLibre pour Arch Linux (pacman). Réécrit pour les
# images cloud Arch : l'ancien script utilisait « yay » (helper AUR ABSENT
# d'une image cloud et qui refuse de tourner en root) et n'installait jamais
# « base-devel » -> pas de compilateur C -> échec de la compilation de Python
# par pyenv. On passe tout par pacman (dépôts officiels) ; l'AUR (wkhtmltopdf)
# est best-effort et ne bloque pas.
install_package() {
local package_name=$1
. ./env_var.sh
# Check package is already installed
if pacman -Qs "$package_name" >/dev/null; then
echo "$package_name is already installed."
else
echo "Installation of package $package_name..."
yes | yay -S "$package_name"
fi
}
EL_USER=${USER}
# Odoo installation
install_package postgis
install_package postgresql
install_package mariadb
install_package libev
install_package wkhtmltopdf
install_package freetds
# pacman résilient : --needed saute ce qui est déjà là, --noconfirm en non
# interactif.
PAC="sudo pacman -S --needed --noconfirm"
# Mise à jour COMPLÈTE obligatoire : Arch est en rolling release et NE SUPPORTE
# PAS les mises à jour partielles. Sur une image cloud dont la glibc est
# ancienne, un « pacman -S <paquet récent> » lie le binaire à une glibc plus
# récente que celle installée -> « /usr/bin/postgres: GLIBC_2.44 not found ».
# « -Syu » met à jour glibc (et tout le système) pour rester cohérent.
sudo pacman -Syu --noconfirm || true
echo "Need password to create symbolic link, create postgres user and install npm :"
sudo ln -fs /usr/lib/libldap.so /usr/lib/libldap_r.so
#--------------------------------------------------
# Outils de compilation — CRITIQUE (build Python via pyenv, extensions Python)
# base-devel fournit gcc, make, patch, pkgconf, fakeroot, etc.
#--------------------------------------------------
echo -e "\n---- Groupe base-devel (compilateur C, make…) ----"
${PAC} base-devel
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "pacman base-devel installation error."
exit 1
fi
#--------------------------------------------------
# Dépendances de build pyenv (compilation de CPython) + outils de base
#--------------------------------------------------
echo -e "\n---- Dépendances pyenv (compilation Python) + outils ----"
${PAC} git wget curl openssl zlib xz tk bzip2 readline sqlite libffi
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "pacman pyenv dependencies installation error."
exit 1
fi
#--------------------------------------------------
# PostgreSQL (+ PostGIS) — Arch n'initialise pas le cluster automatiquement
#--------------------------------------------------
echo -e "\n---- Install PostgreSQL Server ----"
${PAC} postgresql
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "pacman postgresql installation error."
exit 1
fi
# Initialisation du cluster (chemin Arch : /var/lib/postgres/data).
if [ ! -f /var/lib/postgres/data/PG_VERSION ]; then
echo -e "\n---- Initialisation du cluster PostgreSQL ----"
sudo -u postgres initdb --locale=C.UTF-8 --encoding=UTF8 \
-D /var/lib/postgres/data || true
fi
sudo systemctl enable --now postgresql 2>/dev/null || true
# PostGIS : optionnel (géospatial), ne bloque pas.
${PAC} postgis || echo "PostGIS non installé (optionnel)."
echo -e "\n---- Creating the ERPLibre PostgreSQL User ----"
sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
echo -e "\n---- Update NPM ----"
install_package npm
#--------------------------------------------------
# Dépendances Odoo / ERPLibre (extensions Python, bindings)
#--------------------------------------------------
echo -e "\n---- Installing arch dependency ----"
# best-effort paquet par paquet : pacman refuse TOUTE la transaction si UN
# seul nom est inconnu (pas de --skip-unavailable). Ces deps ne sont pas
# critiques pour le build Python -> on ne bloque pas sur un nom absent.
for _pkg in libxslt libzip libldap libsasl cmake parallel swig portaudio \
cups xmlsec freetds libev mariadb-libs shfmt; do
${PAC} "${_pkg}" || echo " ${_pkg} : non installé (optionnel), on continue."
done
# libldap_r : Odoo/python-ldap cherche parfois libldap_r.so (supprimé des
# versions récentes d'OpenLDAP) -> lien vers libldap.so.
sudo ln -fs /usr/lib/libldap.so /usr/lib/libldap_r.so 2>/dev/null || true
sudo npm install npm@latest -g
#--------------------------------------------------
# Node.js + npm (rtlcss, less)
#--------------------------------------------------
echo -e "\n---- Installing nodeJS NPM and rtlcss ----"
${PAC} nodejs npm
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "npm install npm lastest installation error."
exit 1
fi
sudo npm install -g rtlcss
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "npm install rtlcss installation error."
exit 1
fi
sudo npm install -g less
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "npm install less installation error."
echo "pacman nodejs installation error."
exit 1
fi
sudo npm install -g rtlcss less || echo "npm rtlcss/less: erreur (optionnel)."
echo -e "\n---- Test tool ----"
npm install
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "npm install prettier + plugin-xml installation error."
exit 1
npm install || echo "npm install (prettier/plugin-xml): erreur (optionnel)."
# Pour erplibre_devops.
${PAC} sshpass || echo "sshpass non installé (optionnel)."
#--------------------------------------------------
# nginx (optionnel)
#--------------------------------------------------
if [ "${EL_INSTALL_NGINX}" = "True" ]; then
echo -e "\n---- Installing nginx ----"
${PAC} nginx || echo "nginx: erreur (optionnel)."
fi
# TODO install nginx
# ERPLibre installation
install_package cmake
install_package parallel
install_package tk
install_package shfmt
# For erplibre_devops
install_package sshpass
#--------------------------------------------------
# wkhtmltopdf (optionnel) — uniquement dans l'AUR sur Arch (pas de paquet
# officiel). Sur une image cloud sans helper AUR, on saute proprement.
#--------------------------------------------------
if [ "${EL_INSTALL_WKHTMLTOPDF}" = "True" ]; then
if ! command -v wkhtmltopdf >/dev/null 2>&1; then
echo "wkhtmltopdf : disponible seulement via l'AUR sur Arch, ignoré"
echo " (installez-le manuellement avec un helper AUR si nécessaire)."
else
echo -e "\n---- Already installed wkhtml ----"
fi
fi
echo -e "\n---- Arch Linux dependency installation done ----"

View file

@ -5,6 +5,11 @@
EL_USER=${USER}
#EL_INSTALL_WKHTMLTOPDF="True"
# apt-get qui ATTEND le verrou (jusqu'à 10 min) : sur une image cloud fraîche,
# cloud-init / unattended-upgrades tiennent souvent le verrou apt au 1er boot
# (« Could not get lock » -> échec de l'install). DPkg::Lock::Timeout patiente.
APT_GET="sudo apt-get -o DPkg::Lock::Timeout=600"
##
### WKHTMLTOPDF download links
## === Ubuntu Focal x64 === (for other distributions please replace these two links,
@ -15,22 +20,30 @@ UBUNTU_VERSION=$(lsb_release -rs)
DEBIAN_VERSION=$(lsb_release -cs)
OS=$(lsb_release -si)
if [[ "${OS}" == "Ubuntu" ]]; then
if [ "25.10" == "${UBUNTU_VERSION}" ] || [ "25.04" == "${UBUNTU_VERSION}" ] || [ "24.04" == "${UBUNTU_VERSION}" ] || [ "24.10" == "${UBUNTU_VERSION}" ] || [ "23.10" == "${UBUNTU_VERSION}" ] || [ "23.04" == "${UBUNTU_VERSION}" ] || [ "22.10" == "${UBUNTU_VERSION}" ] || [ "22.04" == "${UBUNTU_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
elif [ "20.04" == "${UBUNTU_VERSION}" ]; then
if [ "20.04" == "${UBUNTU_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
elif [ "18.04" == "${UBUNTU_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.bionic_amd64.deb
else
# 22.04+ (jusqu'à 26.04 et au-delà) : wkhtmltopdf ne publie pas de build
# par version ; le .deb « jammy » est le plus récent et fonctionne. Un
# « else » (au lieu d'énumérer les versions) évite une URL VIDE sur une
# version récente (26.04) -> gdebi appelé sans fichier -> échec.
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
fi
elif [[ "${OS}" == "Linuxmint" ]]; then
if [ "22.3" == "${UBUNTU_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
fi
elif [[ "${OS}" == "Debian" ]]; then
if [ "bookworm" == "${DEBIAN_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
else
if [ "bullseye" == "${DEBIAN_VERSION}" ]; then
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb
else
# bookworm (12), trixie (13) et au-delà : wkhtmltopdf ne publie pas de
# build au-delà de « bookworm » -> on prend bookworm (le plus récent).
# Le build « bullseye » (Debian 11) échouait à s'installer sur trixie
# (gdebi : dépendances incompatibles).
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bookworm_amd64.deb
fi
elif [[ "${OS}" == *"Ubuntu"* ]]; then
echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04"
@ -55,25 +68,30 @@ echo -e "\n---- Update Server ----"
if [ "18.04" == "${UBUNTU_VERSION}" ]; then
# add-apt-repository can install add-apt-repository Ubuntu 18.x
sudo apt-get install software-properties-common curl -y
${APT_GET} install software-properties-common curl -y
# universe package is for Ubuntu 18.x
sudo add-apt-repository universe
# libpng12-0 dependency for wkhtmltopdf
sudo add-apt-repository "deb http://mirrors.kernel.org/ubuntu/ xenial main"
sudo apt-get update
sudo apt-get upgrade -y
${APT_GET} update
${APT_GET} upgrade -y
fi
#--------------------------------------------------
# Install PostgreSQL Server
#--------------------------------------------------
echo -e "\n---- Install PostgreSQL Server ----"
sudo apt-get install postgresql libpq-dev postgis -y
${APT_GET} install postgresql postgresql-contrib libpq-dev -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get install postgresql installation error."
exit 1
fi
# PostGIS : optionnel (géospatial). Le nom du paquet « postgis » n'existe pas
# sur toutes les versions (Ubuntu 24.04) -> best-effort, ne bloque pas.
${APT_GET} install postgis -y \
|| ${APT_GET} install postgresql-postgis -y \
|| echo "PostGIS non installé (optionnel)."
echo -e "\n---- Creating the ERPLibre PostgreSQL User ----"
sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
@ -82,20 +100,27 @@ sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
# Install Dependencies
#--------------------------------------------------
echo -e "\n--- Installing debian dependency --"
sudo apt-get install git build-essential wget libxslt-dev libzip-dev libldap2-dev libsasl2-dev gdebi-core libffi-dev libbz2-dev parallel pysassc swig cmake portaudio19-dev libcups2-dev shfmt xmlsec1 -y
${APT_GET} install git build-essential wget libxslt-dev libzip-dev libldap2-dev libsasl2-dev gdebi-core libffi-dev libbz2-dev parallel pysassc swig cmake portaudio19-dev libcups2-dev xmlsec1 -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get debian tool installation error."
exit 1
fi
sudo apt-get install libmariadbd-dev freetds-dev -y
# shfmt : ABSENT des dépôts Ubuntu < 22.04. Il était dans le lot critique
# ci-dessus -> un seul paquet introuvable faisait échouer TOUT l'apt-get
# (donc pas de build-essential/gcc -> pyenv ne pouvait plus compiler Python).
# C'est un simple formateur shell (dev), non requis pour exécuter ERPLibre :
# on l'installe SÉPARÉMENT et en best-effort (jamais fatal).
${APT_GET} install shfmt -y \
|| echo "shfmt indisponible dans les dépôts (Ubuntu < 22.04 ?) — ignoré."
${APT_GET} install libmariadbd-dev freetds-dev -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get libmariadb installation error."
exit 1
fi
if [ "18.04" == "${UBUNTU_VERSION}" ]; then
sudo apt-get install libpng12-0 -y
${APT_GET} install libpng12-0 -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get libpng installation error."
@ -103,14 +128,14 @@ if [ "18.04" == "${UBUNTU_VERSION}" ]; then
fi
fi
# Dependencies for pyenv
sudo apt-get install make libssl-dev zlib1g-dev libreadline-dev libsqlite3-dev curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev liblzma-dev -y
${APT_GET} install make libssl-dev zlib1g-dev libreadline-dev libsqlite3-dev curl llvm libncurses5-dev libncursesw5-dev xz-utils tk-dev liblzma-dev -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get pyenv dependencies installation error."
exit 1
fi
# Dependencies for selenium
sudo apt-get install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y
${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "apt-get selenium dependencies installation error."
@ -118,8 +143,8 @@ if [[ $retVal -ne 0 ]]; then
fi
echo -e "\n---- Installing nodeJS NPM and rtlcss for LTR support ----"
sudo apt-get update
sudo apt-get install -y ca-certificates curl gnupg
${APT_GET} update
${APT_GET} install -y ca-certificates curl gnupg
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | sudo gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
@ -132,8 +157,8 @@ else
fi
echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | sudo tee /etc/apt/sources.list.d/nodesource.list
sudo apt-get update
sudo apt-get install nodejs -y
${APT_GET} update
${APT_GET} install nodejs -y
sudo npm install npm@latest -g
retVal=$?
@ -177,21 +202,36 @@ fi
#--------------------------------------------------
# Install Wkhtmltopdf if needed
#--------------------------------------------------
if [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then
if [ "$(uname -m)" != "x86_64" ]; then
# WKHTMLTOX_X64 pointe vers un .deb amd64 : sur toute autre architecture
# (s390x, arm64/aarch64…) gdebi échouerait. On saute proprement plutôt que
# d'avorter tout l'install (wkhtmltopdf est optionnel).
echo "wkhtmltopdf : pas de build pour $(uname -m), ignoré (optionnel)."
elif [ ${EL_INSTALL_WKHTMLTOPDF} = "True" ]; then
echo -e "\n---- Installing wkhtml ----"
INSTALLED=$(dpkg -s wkhtmltox | grep installed)
if [ "" == "${INSTALLED}" ]; then
echo -e "\n---- Install wkhtml and place shortcuts on correct place ----"
_url=${WKHTMLTOX_X64}
sudo wget ${_url}
sudo gdebi --n $(basename ${_url})
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "gdebi install wkhtmltopdf installation error."
exit 1
if [ -z "${_url}" ]; then
# Aucune URL (version non mappée) : wkhtmltopdf est OPTIONNEL, on saute
# proprement plutôt que d'appeler gdebi sans fichier (« Usage: gdebi »).
echo "wkhtmltopdf : aucune URL pour cette version, ignoré (optionnel)."
else
sudo wget ${_url}
sudo gdebi --n $(basename ${_url})
retVal=$?
if [[ $retVal -ne 0 ]]; then
# wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian
# 13 (trixie) le .deb dépendait de libssl1.1 (absent) et « exit 1 »
# faisait échouer TOUT install_os -> Odoo jamais installé. On avertit
# et on continue (comme Arch qui poursuit sans wkhtmltopdf).
echo "wkhtmltopdf : installation échouée, ignoré (optionnel — pas de PDF)."
else
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
fi
fi
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
else
echo -e "\n---- Already installed wkhtml ----"
fi

View file

@ -3,11 +3,11 @@
if [[ "${OSTYPE}" == "linux-gnu" ]]; then
source /etc/os-release
if [[ "${ID}" == "ubuntu" ]]; then
if [[ "${VERSION_ID}" == "18.04" || "${VERSION_ID}" == "20.04" || "${VERSION_ID}" == "22.04" || "${VERSION_ID}" == "22.10" || "${VERSION_ID}" == "23.04" || "${VERSION_ID}" == "23.10" || "${VERSION_ID}" == "24.04" || "${VERSION_ID}" == "25.04" || "${VERSION_ID}" == "25.10" ]]; then
if [[ "${VERSION_ID}" == "18.04" || "${VERSION_ID}" == "20.04" || "${VERSION_ID}" == "22.04" || "${VERSION_ID}" == "22.10" || "${VERSION_ID}" == "23.04" || "${VERSION_ID}" == "23.10" || "${VERSION_ID}" == "24.04" || "${VERSION_ID}" == "25.04" || "${VERSION_ID}" == "25.10" || "${VERSION_ID}" == "26.04" ]]; then
echo "\n---- linux-gnu installation process started ----"
./script/install/install_debian_dependency.sh
else
echo "Your version is not supported, only support 18.04, 20.04 and 22.04 - 24.04, 25.04, 25.10 : ${VERSION_ID}"
echo "Your version is not supported, only support 18.04, 20.04 and 22.04 - 24.04, 25.04, 25.10, 26.04 : ${VERSION_ID}"
fi
elif [[ "${ID}" == "linuxmint" ]]; then
if [[ "${VERSION_ID}" == "22.3" ]]; then
@ -20,9 +20,12 @@ if [[ "${OSTYPE}" == "linux-gnu" ]]; then
./script/install/install_debian_dependency.sh
elif [[ "${ID}" == "arch" ]]; then
./script/install/install_arch_linux.sh
elif [[ "${ID}" == "fedora" || "${ID_LIKE}" == *"fedora"* || "${ID_LIKE}" == *"rhel"* ]]; then
echo "\n---- Fedora installation process started ----"
./script/install/install_fedora_dependency.sh
else
./script/install/install_debian_dependency.sh
echo "Your Linux system is not supported, only support Ubuntu 18.04 or Ubuntu 20.04 or Ubuntu 22.04 - Ubuntu 23.10 - Ubuntu 24.04, Ubuntu 25.04, Ubuntu 25.10 ."
echo "Your Linux system is not supported, only support Ubuntu 18.04 or Ubuntu 20.04 or Ubuntu 22.04 - Ubuntu 23.10 - Ubuntu 24.04, Ubuntu 25.04, Ubuntu 25.10, Debian, Fedora, Arch."
fi
elif [[ "${OSTYPE}" == "darwin"* ]]; then
echo "\n---- Darwin installation process started ----"

View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Dépendances système ERPLibre pour Fedora (dnf). Équivalent Fedora de
# install_debian_dependency.sh. « --skip-unavailable » tolère un nom de paquet
# absent (dnf5) au lieu d'échouer sur tout le lot.
. ./env_var.sh
EL_USER=${USER}
# dnf résilient : rafraîchit le cache (évite « checksum doesn't match » /
# signature après un cache périmé) et saute les paquets introuvables.
DNF="sudo dnf install -y --refresh --skip-unavailable"
#--------------------------------------------------
# Outils de compilation (build Python via pyenv, extensions Python)
#--------------------------------------------------
echo -e "\n---- Groupe outils de développement ----"
# Groupes par ID (le nom affiché « C Development Tools... » n'est pas matché).
sudo dnf group install -y --skip-unavailable development-tools c-development \
|| sudo dnf install -y gcc gcc-c++ make automake patch
#--------------------------------------------------
# PostgreSQL
#--------------------------------------------------
echo -e "\n---- Install PostgreSQL Server ----"
${DNF} postgresql-server postgresql-contrib libpq-devel
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "dnf install postgresql installation error."
exit 1
fi
# Initialisation du cluster (Fedora ne le fait pas automatiquement).
if [ ! -f /var/lib/pgsql/data/PG_VERSION ]; then
echo -e "\n---- Initialisation du cluster PostgreSQL ----"
# Nettoie un init partiel et FORCE une locale valide : les images cloud
# Fedora n'ont pas de LANG défini -> « initdb: invalid locale settings ».
sudo rm -rf /var/lib/pgsql/data
sudo PGSETUP_INITDB_OPTIONS="--locale=C.UTF-8 --encoding=UTF8" \
postgresql-setup --initdb || true
fi
sudo systemctl enable --now postgresql 2>/dev/null || true
# PostGIS : optionnel (géospatial), ne bloque pas.
${DNF} postgis || echo "PostGIS non installé (optionnel)."
echo -e "\n---- Creating the ERPLibre PostgreSQL User ----"
sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
#--------------------------------------------------
# Dépendances de build (extensions Python, Odoo)
#--------------------------------------------------
echo -e "\n--- Installing fedora dependency --"
# git-daemon : sur Fedora la sous-commande « git daemon » N'EST PAS dans le
# paquet « git » de base (contrairement à Debian/Ubuntu/Arch). ERPLibre sert
# son manifeste via un « git daemon » local (git://127.0.0.1:9418/) pendant
# « repo sync » -> sans ce paquet : « git: 'daemon' is not a git command »
# puis « Connection refused » et l'échec de la synchro du manifeste.
${DNF} \
git git-daemon wget libxslt-devel libzip-devel openldap-devel \
cyrus-sasl-devel \
libffi-devel bzip2-devel parallel swig cmake portaudio-devel \
cups-devel xmlsec1 xmlsec1-openssl mariadb-connector-c-devel freetds-devel
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "dnf fedora tool installation error."
exit 1
fi
# Dépendances de build pour pyenv (compilation de CPython) — CRITIQUE.
echo -e "\n---- Dépendances pyenv (compilation Python) ----"
${DNF} \
make gcc zlib-devel bzip2 bzip2-devel readline-devel sqlite sqlite-devel \
openssl-devel tk-devel libffi-devel xz-devel patch findutils
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "dnf pyenv dependencies installation error."
exit 1
fi
# Dépendances selenium / bindings.
${DNF} \
cairo-devel python3-devel pkgconf-pkg-config gobject-introspection-devel \
libXt-devel || echo "Dépendances selenium partielles (optionnel)."
#--------------------------------------------------
# Node.js + npm (rtlcss, less)
#--------------------------------------------------
echo -e "\n---- Installing nodeJS NPM and rtlcss ----"
${DNF} nodejs npm
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "dnf nodejs installation error."
exit 1
fi
sudo npm install -g rtlcss less || echo "npm rtlcss/less: erreur (optionnel)."
echo -e "\n---- Test tool ----"
npm install || echo "npm install (prettier/plugin-xml): erreur (optionnel)."
sudo ln -fs /usr/local/bin/lessc /usr/bin/lessc 2>/dev/null || true
#--------------------------------------------------
# nginx (optionnel)
#--------------------------------------------------
if [ "${EL_INSTALL_NGINX}" = "True" ]; then
echo -e "\n---- Installing nginx ----"
${DNF} nginx || echo "nginx: erreur (optionnel)."
fi
#--------------------------------------------------
# wkhtmltopdf (optionnel) — paquet RPM officiel wkhtmltopdf
#--------------------------------------------------
if [ "${EL_INSTALL_WKHTMLTOPDF}" = "True" ]; then
if ! command -v wkhtmltopdf >/dev/null 2>&1; then
echo -e "\n---- Installing wkhtml (best-effort) ----"
# wkhtmltopdf ne publie plus de build « fedora-* » ; le RPM AlmaLinux 9
# (EL9) est compatible Fedora (testé sur F42 : dnf résout les deps).
# Repli AlmaLinux 8 au besoin.
_base="https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3"
sudo dnf install -y "${_base}/wkhtmltox-0.12.6.1-3.almalinux9.x86_64.rpm" \
|| sudo dnf install -y "${_base}/wkhtmltox-0.12.6.1-3.almalinux8.x86_64.rpm" \
|| echo "wkhtmltopdf non installé (optionnel)."
else
echo -e "\n---- Already installed wkhtml ----"
fi
fi
echo -e "\n---- Fedora dependency installation done ----"

View file

@ -96,6 +96,12 @@ if [[ "${EL_PHASE}" != "setup" ]]; then
retVal=$?
if [[ $retVal -ne 0 ]]; then
echo "Poetry installation error with status ${retVal}"
# « -q » masque la CAUSE. On rejoue en « -vvv » (debug) car c'est
# le SEUL niveau où Poetry affiche la sortie des sous-processus
# (git clone/checkout, build pip) — donc l'erreur réelle d'une
# dépendance VCS/build. Capturé dans le log pour diagnostic.
echo "---- Poetry: rejeu -vvv pour diagnostic ----"
poetry install --no-root -vvv 2>&1 || true
exit 1
fi
fi

View file

@ -12,9 +12,22 @@ fi
#EL_MANIFEST_PROD="./default.xml"
#EL_MANIFEST_DEV="./manifest/default.dev.xml"
# Update git-repo
# Update git-repo : local git daemon serving the repo over git://127.0.0.1:9418.
# Kill any leftover daemon from a previous (interrupted) run first: otherwise the
# stale server keeps port 9418, the new daemon fails to bind ("Address already in
# use"), and the cleanup kill below fails on an already-dead PID -> script exit 1.
# Match on the stable arguments, not "git daemon": « git daemon » execs into
# « git-daemon » (hyphen, /usr/lib/git-core/git-daemon), so "git daemon" (space)
# never matches the actual running process.
if pkill -f "daemon --base-path=. --export-all" 2>/dev/null; then
sleep 1 # let the kernel release port 9418 before we rebind
fi
git daemon --base-path=. --export-all --reuseaddr --informative-errors ${DAEMON_VERBOSE} &
DAEMON_PID=$!
# Always stop the daemon we started, whatever happens next (success or error),
# without ever failing the script if it is already gone.
trap 'kill "${DAEMON_PID}" 2>/dev/null || true' EXIT
if [ -L "$EL_MANIFEST_DEV" ]; then
MANIFEST_TARGET=$(readlink -f "$EL_MANIFEST_DEV")
@ -34,4 +47,4 @@ fi
.venv.erplibre/bin/repo init -u git://127.0.0.1:9418/ -b $(git rev-parse --verify HEAD) -m ${MANIFEST_TARGET} "$@"
.venv.erplibre/bin/repo sync -c -j "$JOBS" ${REPO_VERBOSE} -m ${MANIFEST_TARGET}
kill ${DAEMON_PID}
# Daemon cleanup handled by the EXIT trap above (tolerant of an already-dead PID).

476
script/qemu/README.base.md Normal file
View file

@ -0,0 +1,476 @@
<!---------------------------->
<!-- multilingual suffix: en, fr -->
<!-- no suffix: en -->
<!---------------------------->
<!-- [en] -->
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora)
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
1. **Downloads the cloud image by itself** (cached, no double download).
2. Converts it to a dedicated qcow2 working disk and resizes it.
3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init).
4. Runs `virt-install` importing the disk + the seed as a CD-ROM.
5. Waits for the DHCP lease and prints the SSH command.
<!-- [fr] -->
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora)
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
1. **Télécharge lui-même l'image cloud** (mise en cache, sans double
téléchargement).
2. La convertit en un disque de travail qcow2 dédié et le redimensionne.
3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init).
4. Lance `virt-install` en important le disque + le seed en CD-ROM.
5. Attend le bail DHCP et affiche la commande SSH.
<!-- [en] -->
## Prerequisites
- A host with KVM available (bare-metal or nested virtualization enabled).
- `sudo` rights (the deployment writes to `/var/lib/libvirt/images` and drives
libvirt).
## Installation
The script **auto-installs the missing pieces it needs**: on first run it
detects your package manager (apt / dnf / pacman / zypper / brew), lists the
missing components (the client tools, **plus the libvirt daemon and the QEMU
system emulator**), asks for confirmation, installs them with `sudo`, then
enables and starts `libvirtd`. Use `-y` to accept automatically or
`--no-install-deps` to disable this behaviour.
To install everything manually on Ubuntu/Debian (recommended full KVM stack):
<!-- [fr] -->
## Prérequis
- Un hôte disposant de KVM (bare-metal ou virtualisation imbriquée activée).
- Les droits `sudo` (le déploiement écrit dans `/var/lib/libvirt/images` et
pilote libvirt).
## Installation
Le script **installe automatiquement les composants manquants** : au premier
lancement, il détecte votre gestionnaire de paquets (apt / dnf / pacman /
zypper / brew), liste les composants absents (les outils clients, **ainsi que
le démon libvirt et l'émulateur QEMU système**), demande confirmation, les
installe avec `sudo`, puis active et démarre `libvirtd`. Utilisez `-y` pour
accepter automatiquement ou `--no-install-deps` pour désactiver ce
comportement.
Pour tout installer manuellement sur Ubuntu/Debian (pile KVM complète
recommandée) :
<!-- [common] -->
```bash
sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \
libvirt-daemon-system qemu-system-x86
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous
```
<!-- [en] -->
`libvirt-daemon-system` provides the `libvirtd` daemon (and the
`/var/run/libvirt/libvirt-sock` socket) and `qemu-system-x86` the emulator —
without them `virt-install` fails with *"Failed to connect socket to
'/var/run/libvirt/libvirt-sock'"*. The script installs and starts them for
you; this manual command is only needed if you prefer to prepare the host
yourself or run with `--no-install-deps`.
## Usage
Simplest form — the image is downloaded automatically (path derived from
`--version`, cached in `/var/lib/libvirt/images/iso`):
<!-- [fr] -->
`libvirt-daemon-system` fournit le démon `libvirtd` (et le socket
`/var/run/libvirt/libvirt-sock`) et `qemu-system-x86` l'émulateur — sans eux
`virt-install` échoue avec *« Failed to connect socket to
'/var/run/libvirt/libvirt-sock' »*. Le script les installe et les démarre pour
vous ; cette commande manuelle n'est utile que si vous préférez préparer
l'hôte vous-même ou utiliser `--no-install-deps`.
## Utilisation
Forme la plus simple — l'image est téléchargée automatiquement (chemin déduit
de `--version`, mis en cache dans `/var/lib/libvirt/images/iso`) :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub
```
<!-- [en] -->
Download (and verify) an image without creating a VM:
<!-- [fr] -->
Télécharger (et vérifier) une image sans créer de VM :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify
```
<!-- [en] -->
Deploy with an interactive password instead of an SSH key:
<!-- [fr] -->
Déployer avec un mot de passe interactif au lieu d'une clé SSH :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password
```
<!-- [en] -->
Larger VM (8 GB RAM, 8 vCPU, 120 GB disk), overwriting an existing disk:
<!-- [fr] -->
VM plus grande (8 Go RAM, 8 vCPU, disque 120 Go), en écrasant un disque
existant :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--memory 8192 --vcpus 8 --disk-size 120G --ask-password --force
```
<!-- [en] -->
Preview what would happen, without doing anything (no sudo, no download):
<!-- [fr] -->
Prévisualiser ce qui serait fait, sans rien exécuter (sans sudo, sans
téléchargement) :
<!-- [common] -->
```bash
./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run
```
<!-- [en] -->
Non-interactive deployment (accept dependency install automatically):
<!-- [fr] -->
Déploiement non interactif (accepte automatiquement l'installation des
dépendances) :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub -y
```
<!-- [en] -->
Supported Ubuntu versions: `20.04`, `22.04`, `24.04` (default), `24.10`,
`25.04`, `25.10`. Provide an explicit image path as a positional argument to
override the automatic download location.
## After deployment
<!-- [fr] -->
Versions Ubuntu supportées : `20.04`, `22.04`, `24.04` (défaut), `24.10`,
`25.04`, `25.10`. Fournissez un chemin d'image en argument positionnel pour
surcharger l'emplacement de téléchargement automatique.
## Après le déploiement
<!-- [common] -->
```bash
virsh list --all
virsh console test-vm # Ctrl+] to quit / pour quitter
virsh domifaddr test-vm --source lease # find the IP / trouver l'IP
ssh erplibre@<IP>
```
<!-- [en] -->
The default user is `erplibre` (change it with `--user`).
## Via the TODO menu
The script is integrated into the interactive assistant. Run `make todo` (or
`./script/todo/todo.py`), then go to **Execute → Deploy → QEMU/KVM - Deploy an
Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run,
download an image, list VMs and show a VM IP address — the menu asks for the
parameters and builds the command for you.
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).
- `--download-only` — download the image then exit (no VM).
- `--name` — VM name (required for deployment).
- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory`
and `--disk-size` default to the **minimum required by the chosen version**
(libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian
→ 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentication.
- `-y` / `--assume-yes` — auto-accept dependency installation.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
Run `./script/qemu/deploy_qemu.py --help` for the full list.
<!-- [fr] -->
L'utilisateur par défaut est `erplibre` (modifiable avec `--user`).
## Via le menu TODO
Le script est intégré à l'assistant interactif. Lancez `make todo` (ou
`./script/todo/todo.py`), puis allez dans **Execute → Deploy → QEMU/KVM -
Deploy an Ubuntu VM (libvirt)**. De là, vous pouvez déployer une VM,
prévisualiser un dry-run, télécharger une image, lister les VM et afficher
l'IP d'une VM — le menu demande les paramètres et construit la commande pour
vous.
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut
`/var/lib/libvirt/images/iso`).
- `--download-only` — télécharge l'image puis quitte (sans VM).
- `--name` — nom de la VM (requis pour le déploiement).
- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis,
`--memory` et `--disk-size` prennent le **minimum requis par la version**
choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ →
3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus`
vaut 2 par défaut.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentification.
- `-y` / `--assume-yes` — accepte automatiquement l'installation des
dépendances.
- `--no-install-deps` — n'installe jamais les dépendances automatiquement.
- `--dry-run` — affiche les commandes sans rien exécuter.
- `--force` — écrase le disque de travail qcow2 existant.
Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète.
<!-- [en] -->
## Managing VMs
List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images`
is kept unless you delete it):
<!-- [fr] -->
## Gestion des VM
Lister, arrêter et supprimer les VM (le disque qcow2 sous
`/var/lib/libvirt/images` est conservé tant que vous ne le supprimez pas) :
<!-- [common] -->
```bash
sudo virsh list --all # toutes les VM et leur état / all VMs and state
sudo virsh shutdown <nom-vm> # arrêt propre ACPI / graceful shutdown
sudo virsh destroy <nom-vm> # arrêt forcé / force off (pull the plug)
sudo virsh undefine <nom-vm> # supprime la définition / remove definition
sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
```
<!-- [en] -->
`destroy` only powers the VM off (disk kept); `undefine` removes its
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
it first, or redeploy with `--force`.
## SSH access from another machine (ProxyJump)
With the default NAT network the VM is reachable **only from the KVM host**.
To reach it from another machine **without changing the network**, use the
host as a jump host (it already reaches the VM). Get the VM IP with
`sudo virsh domifaddr <nom-vm>`, then from the other machine:
<!-- [fr] -->
`destroy` ne fait qu'éteindre la VM (disque conservé) ; `undefine` supprime sa
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
`undefine` d'abord, ou redéployez avec `--force`.
## Accès SSH depuis une autre machine (ProxyJump)
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte
KVM**. Pour l'atteindre depuis une autre machine **sans toucher au réseau**,
utilisez l'hôte comme rebond (il joint déjà la VM). Récupérez l'IP de la VM
avec `sudo virsh domifaddr <nom-vm>`, puis depuis l'autre machine :
<!-- [common] -->
```bash
# Rebond SSH vers la VM / jump through the KVM host
ssh -J user@<ip-hote> erplibre@<ip-vm>
# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069
ssh -L 8069:<ip-vm>:8069 user@<ip-hote>
```
<!-- [en] -->
To make it permanent, add this to `~/.ssh/config` on the other machine (then
just `ssh myvm`):
<!-- [fr] -->
Pour le rendre permanent, ajoutez ceci à `~/.ssh/config` sur l'autre machine
(ensuite `ssh myvm` suffit) :
<!-- [common] -->
```text
Host myvm
HostName <ip-vm> # ex. 192.168.122.50 (reseau NAT)
User erplibre
ProxyJump user@<ip-hote> # IP LAN de l'hote KVM
```
<!-- [en] -->
This works over Wi-Fi and needs no VM shutdown — the simplest option for
personal access. Prefer a bridge (below) if the VM must be a full server
exposed on the LAN.
## QEMU inside QEMU (nested) & exposing the VM via a bridge
If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only
when **nested virtualization** is enabled on the outer/physical host and the
middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host
(the first command must be non-empty):
<!-- [fr] -->
Ça marche en Wi-Fi et sans arrêter la VM — l'option la plus simple pour un
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
## QEMU dans QEMU (imbriqué) & exposer la VM via un pont
Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne
fonctionne que si la **virtualisation imbriquée** est activée sur l'hôte
physique et que la VM intermédiaire utilise le mode CPU `host-passthrough`.
Vérifiez depuis l'hôte KVM (la première commande doit être non vide) :
<!-- [common] -->
```bash
grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible
# Sur l'hote PHYSIQUE / on the PHYSICAL host:
cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1
cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1
```
<!-- [en] -->
To enable nesting on the physical host (Intel shown; use `kvm_amd` on AMD),
then recreate the middle VM with `host-passthrough`:
<!-- [fr] -->
Pour activer l'imbrication sur l'hôte physique (Intel montré ; `kvm_amd` sur
AMD), puis recréer la VM intermédiaire en `host-passthrough` :
<!-- [common] -->
```bash
echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
<!-- [en] -->
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
works but is slow.
### Bridge for external access
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
reachable by any machine. On the KVM host, create a bridge `br0` over the
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
server) — replace `enp3s0` with your interface:
<!-- [fr] -->
Si l'imbrication est indisponible, QEMU tourne quand même en émulation
logicielle (TCG) — ça marche mais c'est lent.
### Pont pour l'accès externe
Une VM en NAT est isolée ; une VM **pontée** obtient une IP directement sur le
LAN, joignable par n'importe quelle machine. Sur l'hôte KVM, créez un pont
`br0` sur la carte physique (**filaire uniquement** — le Wi-Fi ne se ponte
pas). netplan (Ubuntu serveur) — remplacez `enp3s0` par votre interface :
<!-- [common] -->
```yaml
# /etc/netplan/01-br0.yaml
network:
version: 2
renderer: networkd
ethernets:
enp3s0: {dhcp4: no, dhcp6: no}
bridges:
br0:
interfaces: [enp3s0]
dhcp4: yes
parameters: {stp: false, forward-delay: 0}
```
<!-- [en] -->
Apply safely (auto-reverts if you lose the connection) and verify — or use
NetworkManager (Ubuntu desktop):
<!-- [fr] -->
Appliquez avec filet de sécurité (annulation auto en cas de coupure) et
vérifiez — ou via NetworkManager (Ubuntu bureau) :
<!-- [common] -->
```bash
# netplan
sudo netplan try && sudo netplan apply
ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP
# NetworkManager (alternative)
nmcli con add type bridge ifname br0 con-name br0
nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port
nmcli con modify br0 ipv4.method auto
nmcli con down "Wired connection 1" ; nmcli con up br0
```
<!-- [en] -->
Then attach the VM to the bridge — **either at creation**:
<!-- [fr] -->
Rattachez ensuite la VM au pont — **soit à la création** :
<!-- [common] -->
```bash
sudo ./script/qemu/deploy_qemu.py --name <nom-vm> --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force
```
<!-- [en] -->
**or by editing a VM already created**: stop it, replace its `<interface>`
block (`type='network'` / `<source network='default'/>` → `type='bridge'` /
`<source bridge='br0'/>`), then start it again:
<!-- [fr] -->
**soit par édition d'une VM déjà créée** : arrêtez-la, remplacez son bloc
`<interface>` (`type='network'` / `<source network='default'/>` →
`type='bridge'` / `<source bridge='br0'/>`), puis redémarrez-la :
<!-- [common] -->
```bash
sudo virsh shutdown <nom-vm>
sudo virsh edit <nom-vm> # mettre l'interface en bridge=br0
sudo virsh start <nom-vm>
sudo virsh domifaddr <nom-vm> # nouvelle IP LAN / new LAN IP
```
<!-- [en] -->
The VM now gets a LAN IP from your router, reachable by other machines. From
the Internet you additionally need a port-forward on your router (or a VPN);
in a nested setup the outer host must also forward/expose the middle VM.
<!-- [fr] -->
La VM obtient maintenant une IP LAN de votre routeur, joignable par les autres
machines. Depuis Internet, il faut en plus une redirection de port sur votre
routeur (ou un VPN) ; en configuration imbriquée, l'hôte externe doit aussi
rediriger/exposer la VM intermédiaire.

270
script/qemu/README.fr.md Normal file
View file

@ -0,0 +1,270 @@
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora)
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
1. **Télécharge lui-même l'image cloud** (mise en cache, sans double
téléchargement).
2. La convertit en un disque de travail qcow2 dédié et le redimensionne.
3. Génère `user-data` / `meta-data` et construit le `seed.iso` (cloud-init).
4. Lance `virt-install` en important le disque + le seed en CD-ROM.
5. Attend le bail DHCP et affiche la commande SSH.
## Prérequis
- Un hôte disposant de KVM (bare-metal ou virtualisation imbriquée activée).
- Les droits `sudo` (le déploiement écrit dans `/var/lib/libvirt/images` et
pilote libvirt).
## Installation
Le script **installe automatiquement les composants manquants** : au premier
lancement, il détecte votre gestionnaire de paquets (apt / dnf / pacman /
zypper / brew), liste les composants absents (les outils clients, **ainsi que
le démon libvirt et l'émulateur QEMU système**), demande confirmation, les
installe avec `sudo`, puis active et démarre `libvirtd`. Utilisez `-y` pour
accepter automatiquement ou `--no-install-deps` pour désactiver ce
comportement.
Pour tout installer manuellement sur Ubuntu/Debian (pile KVM complète
recommandée) :
```bash
sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \
libvirt-daemon-system qemu-system-x86
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous
```
`libvirt-daemon-system` fournit le démon `libvirtd` (et le socket
`/var/run/libvirt/libvirt-sock`) et `qemu-system-x86` l'émulateur — sans eux
`virt-install` échoue avec *« Failed to connect socket to
'/var/run/libvirt/libvirt-sock' »*. Le script les installe et les démarre pour
vous ; cette commande manuelle n'est utile que si vous préférez préparer
l'hôte vous-même ou utiliser `--no-install-deps`.
## Utilisation
Forme la plus simple — l'image est téléchargée automatiquement (chemin déduit
de `--version`, mis en cache dans `/var/lib/libvirt/images/iso`) :
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub
```
Télécharger (et vérifier) une image sans créer de VM :
```bash
sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify
```
Déployer avec un mot de passe interactif au lieu d'une clé SSH :
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password
```
VM plus grande (8 Go RAM, 8 vCPU, disque 120 Go), en écrasant un disque
existant :
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--memory 8192 --vcpus 8 --disk-size 120G --ask-password --force
```
Prévisualiser ce qui serait fait, sans rien exécuter (sans sudo, sans
téléchargement) :
```bash
./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run
```
Déploiement non interactif (accepte automatiquement l'installation des
dépendances) :
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub -y
```
Versions Ubuntu supportées : `20.04`, `22.04`, `24.04` (défaut), `24.10`,
`25.04`, `25.10`. Fournissez un chemin d'image en argument positionnel pour
surcharger l'emplacement de téléchargement automatique.
## Après le déploiement
```bash
virsh list --all
virsh console test-vm # Ctrl+] to quit / pour quitter
virsh domifaddr test-vm --source lease # find the IP / trouver l'IP
ssh erplibre@<IP>
```
L'utilisateur par défaut est `erplibre` (modifiable avec `--user`).
## Via le menu TODO
Le script est intégré à l'assistant interactif. Lancez `make todo` (ou
`./script/todo/todo.py`), puis allez dans **Execute → Deploy → QEMU/KVM -
Deploy an Ubuntu VM (libvirt)**. De là, vous pouvez déployer une VM,
prévisualiser un dry-run, télécharger une image, lister les VM et afficher
l'IP d'une VM — le menu demande les paramètres et construit la commande pour
vous.
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut
`/var/lib/libvirt/images/iso`).
- `--download-only` — télécharge l'image puis quitte (sans VM).
- `--name` — nom de la VM (requis pour le déploiement).
- `--memory`, `--vcpus`, `--disk-size` — dimensionnement de la VM. Omis,
`--memory` et `--disk-size` prennent le **minimum requis par la version**
choisie (valeurs libosinfo, voir `--list-images` : Ubuntu 24.04+ →
3072 Mo/20G, Debian → 1024 Mo/10G, Fedora → 2048 Mo/15G) ; `--vcpus`
vaut 2 par défaut.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentification.
- `-y` / `--assume-yes` — accepte automatiquement l'installation des
dépendances.
- `--no-install-deps` — n'installe jamais les dépendances automatiquement.
- `--dry-run` — affiche les commandes sans rien exécuter.
- `--force` — écrase le disque de travail qcow2 existant.
Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète.
## Gestion des VM
Lister, arrêter et supprimer les VM (le disque qcow2 sous
`/var/lib/libvirt/images` est conservé tant que vous ne le supprimez pas) :
```bash
sudo virsh list --all # toutes les VM et leur état / all VMs and state
sudo virsh shutdown <nom-vm> # arrêt propre ACPI / graceful shutdown
sudo virsh destroy <nom-vm> # arrêt forcé / force off (pull the plug)
sudo virsh undefine <nom-vm> # supprime la définition / remove definition
sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
```
`destroy` ne fait qu'éteindre la VM (disque conservé) ; `undefine` supprime sa
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
`undefine` d'abord, ou redéployez avec `--force`.
## Accès SSH depuis une autre machine (ProxyJump)
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte
KVM**. Pour l'atteindre depuis une autre machine **sans toucher au réseau**,
utilisez l'hôte comme rebond (il joint déjà la VM). Récupérez l'IP de la VM
avec `sudo virsh domifaddr <nom-vm>`, puis depuis l'autre machine :
```bash
# Rebond SSH vers la VM / jump through the KVM host
ssh -J user@<ip-hote> erplibre@<ip-vm>
# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069
ssh -L 8069:<ip-vm>:8069 user@<ip-hote>
```
Pour le rendre permanent, ajoutez ceci à `~/.ssh/config` sur l'autre machine
(ensuite `ssh myvm` suffit) :
```text
Host myvm
HostName <ip-vm> # ex. 192.168.122.50 (reseau NAT)
User erplibre
ProxyJump user@<ip-hote> # IP LAN de l'hote KVM
```
Ça marche en Wi-Fi et sans arrêter la VM — l'option la plus simple pour un
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
## QEMU dans QEMU (imbriqué) & exposer la VM via un pont
Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne
fonctionne que si la **virtualisation imbriquée** est activée sur l'hôte
physique et que la VM intermédiaire utilise le mode CPU `host-passthrough`.
Vérifiez depuis l'hôte KVM (la première commande doit être non vide) :
```bash
grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible
# Sur l'hote PHYSIQUE / on the PHYSICAL host:
cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1
cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1
```
Pour activer l'imbrication sur l'hôte physique (Intel montré ; `kvm_amd` sur
AMD), puis recréer la VM intermédiaire en `host-passthrough` :
```bash
echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
Si l'imbrication est indisponible, QEMU tourne quand même en émulation
logicielle (TCG) — ça marche mais c'est lent.
### Pont pour l'accès externe
Une VM en NAT est isolée ; une VM **pontée** obtient une IP directement sur le
LAN, joignable par n'importe quelle machine. Sur l'hôte KVM, créez un pont
`br0` sur la carte physique (**filaire uniquement** — le Wi-Fi ne se ponte
pas). netplan (Ubuntu serveur) — remplacez `enp3s0` par votre interface :
```yaml
# /etc/netplan/01-br0.yaml
network:
version: 2
renderer: networkd
ethernets:
enp3s0: {dhcp4: no, dhcp6: no}
bridges:
br0:
interfaces: [enp3s0]
dhcp4: yes
parameters: {stp: false, forward-delay: 0}
```
Appliquez avec filet de sécurité (annulation auto en cas de coupure) et
vérifiez — ou via NetworkManager (Ubuntu bureau) :
```bash
# netplan
sudo netplan try && sudo netplan apply
ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP
# NetworkManager (alternative)
nmcli con add type bridge ifname br0 con-name br0
nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port
nmcli con modify br0 ipv4.method auto
nmcli con down "Wired connection 1" ; nmcli con up br0
```
Rattachez ensuite la VM au pont — **soit à la création** :
```bash
sudo ./script/qemu/deploy_qemu.py --name <nom-vm> --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force
```
**soit par édition d'une VM déjà créée** : arrêtez-la, remplacez son bloc
`<interface>` (`type='network'` / `<source network='default'/>` →
`type='bridge'` / `<source bridge='br0'/>`), puis redémarrez-la :
```bash
sudo virsh shutdown <nom-vm>
sudo virsh edit <nom-vm> # mettre l'interface en bridge=br0
sudo virsh start <nom-vm>
sudo virsh domifaddr <nom-vm> # nouvelle IP LAN / new LAN IP
```
La VM obtient maintenant une IP LAN de votre routeur, joignable par les autres
machines. Depuis Internet, il faut en plus une redirection de port sur votre
routeur (ou un VPN) ; en configuration imbriquée, l'hôte externe doit aussi
rediriger/exposer la VM intermédiaire.

259
script/qemu/README.md Normal file
View file

@ -0,0 +1,259 @@
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora)
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
1. **Downloads the cloud image by itself** (cached, no double download).
2. Converts it to a dedicated qcow2 working disk and resizes it.
3. Generates `user-data` / `meta-data` and builds the `seed.iso` (cloud-init).
4. Runs `virt-install` importing the disk + the seed as a CD-ROM.
5. Waits for the DHCP lease and prints the SSH command.
## Prerequisites
- A host with KVM available (bare-metal or nested virtualization enabled).
- `sudo` rights (the deployment writes to `/var/lib/libvirt/images` and drives
libvirt).
## Installation
The script **auto-installs the missing pieces it needs**: on first run it
detects your package manager (apt / dnf / pacman / zypper / brew), lists the
missing components (the client tools, **plus the libvirt daemon and the QEMU
system emulator**), asks for confirmation, installs them with `sudo`, then
enables and starts `libvirtd`. Use `-y` to accept automatically or
`--no-install-deps` to disable this behaviour.
To install everything manually on Ubuntu/Debian (recommended full KVM stack):
```bash
sudo apt install qemu-utils virtinst libvirt-clients cloud-image-utils \
libvirt-daemon-system qemu-system-x86
sudo systemctl enable --now libvirtd
sudo usermod -aG libvirt,kvm "$USER" # re-login / reconnectez-vous
```
`libvirt-daemon-system` provides the `libvirtd` daemon (and the
`/var/run/libvirt/libvirt-sock` socket) and `qemu-system-x86` the emulator —
without them `virt-install` fails with *"Failed to connect socket to
'/var/run/libvirt/libvirt-sock'"*. The script installs and starts them for
you; this manual command is only needed if you prefer to prepare the host
yourself or run with `--no-install-deps`.
## Usage
Simplest form — the image is downloaded automatically (path derived from
`--version`, cached in `/var/lib/libvirt/images/iso`):
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub
```
Download (and verify) an image without creating a VM:
```bash
sudo ./script/qemu/deploy_qemu.py --download-only --version 24.04 --verify
```
Deploy with an interactive password instead of an SSH key:
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --ask-password
```
Larger VM (8 GB RAM, 8 vCPU, 120 GB disk), overwriting an existing disk:
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--memory 8192 --vcpus 8 --disk-size 120G --ask-password --force
```
Preview what would happen, without doing anything (no sudo, no download):
```bash
./script/qemu/deploy_qemu.py --name test-vm --version 24.04 --dry-run
```
Non-interactive deployment (accept dependency install automatically):
```bash
sudo ./script/qemu/deploy_qemu.py --name test-vm --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub -y
```
Supported Ubuntu versions: `20.04`, `22.04`, `24.04` (default), `24.10`,
`25.04`, `25.10`. Provide an explicit image path as a positional argument to
override the automatic download location.
## After deployment
```bash
virsh list --all
virsh console test-vm # Ctrl+] to quit / pour quitter
virsh domifaddr test-vm --source lease # find the IP / trouver l'IP
ssh erplibre@<IP>
```
The default user is `erplibre` (change it with `--user`).
## Via the TODO menu
The script is integrated into the interactive assistant. Run `make todo` (or
`./script/todo/todo.py`), then go to **Execute → Deploy → QEMU/KVM - Deploy an
Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run,
download an image, list VMs and show a VM IP address — the menu asks for the
parameters and builds the command for you.
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).
- `--download-only` — download the image then exit (no VM).
- `--name` — VM name (required for deployment).
- `--memory`, `--vcpus`, `--disk-size` — VM sizing. When omitted, `--memory`
and `--disk-size` default to the **minimum required by the chosen version**
(libosinfo values, see `--list-images`: Ubuntu 24.04+ → 3072 MB/20G, Debian
→ 1024 MB/10G, Fedora → 2048 MB/15G); `--vcpus` defaults to 2.
- `--ssh-key`, `--ask-password`, `--password-hash` — authentication.
- `-y` / `--assume-yes` — auto-accept dependency installation.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
Run `./script/qemu/deploy_qemu.py --help` for the full list.
## Managing VMs
List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images`
is kept unless you delete it):
```bash
sudo virsh list --all # toutes les VM et leur état / all VMs and state
sudo virsh shutdown <nom-vm> # arrêt propre ACPI / graceful shutdown
sudo virsh destroy <nom-vm> # arrêt forcé / force off (pull the plug)
sudo virsh undefine <nom-vm> # supprime la définition / remove definition
sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
```
`destroy` only powers the VM off (disk kept); `undefine` removes its
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
it first, or redeploy with `--force`.
## SSH access from another machine (ProxyJump)
With the default NAT network the VM is reachable **only from the KVM host**.
To reach it from another machine **without changing the network**, use the
host as a jump host (it already reaches the VM). Get the VM IP with
`sudo virsh domifaddr <nom-vm>`, then from the other machine:
```bash
# Rebond SSH vers la VM / jump through the KVM host
ssh -J user@<ip-hote> erplibre@<ip-vm>
# Tunnel d'un service, ex. Odoo 8069 / tunnel a service, then http://localhost:8069
ssh -L 8069:<ip-vm>:8069 user@<ip-hote>
```
To make it permanent, add this to `~/.ssh/config` on the other machine (then
just `ssh myvm`):
```text
Host myvm
HostName <ip-vm> # ex. 192.168.122.50 (reseau NAT)
User erplibre
ProxyJump user@<ip-hote> # IP LAN de l'hote KVM
```
This works over Wi-Fi and needs no VM shutdown — the simplest option for
personal access. Prefer a bridge (below) if the VM must be a full server
exposed on the LAN.
## QEMU inside QEMU (nested) & exposing the VM via a bridge
If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only
when **nested virtualization** is enabled on the outer/physical host and the
middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host
(the first command must be non-empty):
```bash
grep -E -o '(vmx|svm)' /proc/cpuinfo | sort -u # extensions visibles / visible
# Sur l'hote PHYSIQUE / on the PHYSICAL host:
cat /sys/module/kvm_intel/parameters/nested # Intel -> Y/1
cat /sys/module/kvm_amd/parameters/nested # AMD -> Y/1
```
To enable nesting on the physical host (Intel shown; use `kvm_amd` on AMD),
then recreate the middle VM with `host-passthrough`:
```bash
echo "options kvm_intel nested=1" | sudo tee /etc/modprobe.d/kvm-nested.conf
sudo modprobe -r kvm_intel && sudo modprobe kvm_intel # ou / or reboot
```
If nesting is unavailable, QEMU still runs via software emulation (TCG) — it
works but is slow.
### Bridge for external access
A NAT VM is isolated; a **bridged** VM gets an IP directly on the LAN,
reachable by any machine. On the KVM host, create a bridge `br0` over the
physical NIC (**wired only** — Wi-Fi cannot be bridged). netplan (Ubuntu
server) — replace `enp3s0` with your interface:
```yaml
# /etc/netplan/01-br0.yaml
network:
version: 2
renderer: networkd
ethernets:
enp3s0: {dhcp4: no, dhcp6: no}
bridges:
br0:
interfaces: [enp3s0]
dhcp4: yes
parameters: {stp: false, forward-delay: 0}
```
Apply safely (auto-reverts if you lose the connection) and verify — or use
NetworkManager (Ubuntu desktop):
```bash
# netplan
sudo netplan try && sudo netplan apply
ip addr show br0 # br0 porte l'IP du LAN / br0 holds the LAN IP
# NetworkManager (alternative)
nmcli con add type bridge ifname br0 con-name br0
nmcli con add type ethernet ifname enp3s0 master br0 con-name br0-port
nmcli con modify br0 ipv4.method auto
nmcli con down "Wired connection 1" ; nmcli con up br0
```
Then attach the VM to the bridge — **either at creation**:
```bash
sudo ./script/qemu/deploy_qemu.py --name <nom-vm> --version 24.04 \
--ssh-key ~/.ssh/id_ed25519.pub --network bridge=br0,model=virtio -y --force
```
**or by editing a VM already created**: stop it, replace its `<interface>`
block (`type='network'` / `<source network='default'/>` → `type='bridge'` /
`<source bridge='br0'/>`), then start it again:
```bash
sudo virsh shutdown <nom-vm>
sudo virsh edit <nom-vm> # mettre l'interface en bridge=br0
sudo virsh start <nom-vm>
sudo virsh domifaddr <nom-vm> # nouvelle IP LAN / new LAN IP
```
The VM now gets a LAN IP from your router, reachable by other machines. From
the Internet you additionally need a port-forward on your router (or a VPN);
in a nested setup the outer host must also forward/expose the middle VM.

2024
script/qemu/deploy_qemu.py Executable file

File diff suppressed because it is too large Load diff

View file

@ -31,28 +31,51 @@ class DatabaseManager:
self._dir_path = path
def select_database(self) -> str | bool:
"""Faire choisir une base parmi celles que PostgreSQL expose.
Le code de retour de « db --list » est vérifié AVANT de construire le
menu. Sans cette vérification, un PostgreSQL injoignable ne se distingue
pas d'une base absente : la sortie et l'erreur sont fusionnées dans le
même flux (`stderr=STDOUT`, execute.py), donc les lignes de la trace
d'appel devenaient les entrées du menu. « Traceback (most recent call
last): » s'affichait comme la base [1], et la choisir renvoyait cette
ligne comme nom de base à l'appelant, qui la passait à sa commande.
"""
cmd_server = "./odoo_bin.sh db --list"
status, databases = self._execute.exec_command_live(
status, output = self._execute.exec_command_live(
cmd_server,
return_status_and_output=True,
quiet=True,
source_erplibre=False,
single_source_erplibre=True,
)
choices = [{"prompt_description": a.strip()} for a in databases]
if status:
print(f"❌ {t('Cannot list the databases (exit code): ')}{status}")
print(f" {t('Is PostgreSQL running?')}")
for line in output[-5:]:
print(f" {line}")
return False
databases = [a.strip() for a in output if a.strip()]
if not databases:
print(f"ℹ️ {t('No database on this PostgreSQL server.')}")
return False
choices = [{"prompt_description": a} for a in databases]
help_info = self._fill_help_info(choices)
valid_choices = [str(a) for a in range(len(choices) + 1) if a]
valid_choices = [str(a + 1) for a in range(len(databases))]
while True:
status = click.prompt(help_info)
answer = click.prompt(help_info)
print()
if status == "0":
if answer == "0":
return False
elif status in valid_choices:
database_name = databases[int(status) - 1].strip()
elif answer in valid_choices:
database_name = databases[int(answer) - 1]
print(database_name)
return database_name
else:
print(t("cmd_not_found"))
print(t("Command not found !"))
def _confirm_drop(self, message: str) -> bool:
"""Ask for an explicit 'oui'/'yes' confirmation, default is no."""

View file

@ -0,0 +1,854 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Formulaire Textual de déploiement QEMU, et vue de progression.
Deux interfaces mènent au même déploiement : les invites en ligne de
`todo.py` et ce formulaire. Toutes deux produisent la MÊME structure — la
« spec » — que `TODO._qemu_run_spec` consomme. Rien n'est décidé ici qui ne
puisse l'être là-bas, et réciproquement.
- build_vms(...) / plan_rows(...) : logique pure, testable sans terminal.
- run_deploy_form(ctx, run_app=True) : le formulaire ; renvoie une spec ou None.
- run_deploy_progress(jobs, ...) : blocs repliables par VM pendant le
déploiement, avec copie du log vers le presse-papiers (OSC 52).
Le formulaire ne lance AUCUNE commande privilégiée ni réseau : tout appel
`virsh` passe par sudo et une invite de mot de passe casserait l'affichage
Textual. Les données coûteuses (domaines existants, branches distantes) sont
préchargées par l'appelant et arrivent dans `ctx`.
"""
from __future__ import annotations
import os
import time
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# --------------------------------------------------------------------------- #
# Logique pure — aucune dépendance à Textual, testable telle quelle
# --------------------------------------------------------------------------- #
def entry_key(entry) -> tuple:
"""Identité stable d'une entrée de catalogue, indépendante de son rang :
les surcharges par VM y survivent quand la sélection change."""
return (entry["distro"], entry["version"], entry["arch"])
def parse_disk(value):
"""« 60 », « 60G », « 1T », « 1,5T » -> « <n>G », ou None si invalide.
Même règle que `TODO._qemu_parse_disk` : tout le reste de la chaîne
raisonne en gigaoctets."""
txt = str(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
def disk_gb(value) -> int:
"""« 60G » -> 60 (best effort), pour les totaux."""
parsed = parse_disk(value)
return int(parsed[:-1]) if parsed else 0
def apply_profile(entries, profile, base_vcpus, host_cpu, custom=None):
"""Applique le profil de ressources aux entrées choisies.
Reproduit à l'identique `TODO._qemu_prompt_resources` : un multiplicateur
monte la RAM minimale du catalogue et les vCPU en se bornant aux cœurs de
l'hôte ; « custom » impose les mêmes valeurs à tout le parc, une valeur
absente gardant celle du catalogue."""
out = []
for e in entries:
if profile == "custom":
cus = custom or {}
ram = cus.get("ram") or e["ram"]
disk = cus.get("disk") or e["disk"]
vcpus = cus.get("vcpus") or base_vcpus
else:
mult = int(profile)
ram = e["ram"] * mult
disk = e["disk"]
vcpus = min(base_vcpus * mult, host_cpu)
out.append(
{
"name": e["name"],
"distro": e["distro"],
"version": e["version"],
"arch": e["arch"],
"ram": ram,
"disk": disk,
"vcpus": vcpus,
}
)
return out
def apply_overrides(vms, entries, overrides):
"""Réapplique les réglages par VM (nom, vCPU, RAM, disque) après un
recalcul du profil. `overrides` est indexé par `entry_key`."""
for vm, e in zip(vms, entries):
for field, value in (overrides.get(entry_key(e)) or {}).items():
vm[field] = value
return vms
def build_vms(entries, profile, base_vcpus, host_cpu, custom, overrides):
"""Catalogue choisi + profil + surcharges -> liste de VM de la spec."""
return apply_overrides(
apply_profile(entries, profile, base_vcpus, host_cpu, custom),
entries,
overrides,
)
def vm_status(name, domains):
"""État d'un nom face à l'existant : ('new'|'exists'|'orphan', message).
Les deux collisions n'ont pas la même gravité — une VM définie est
ignorée, un qcow2 resté seul fait échouer deploy_qemu, qui refuse
d'écraser sans --force."""
if name in domains:
return "exists", t("exists - skipped")
if os.path.exists(f"/var/lib/libvirt/images/{name}.qcow2"):
return "orphan", t("orphan disk - will FAIL")
return "new", ""
def plan_rows(vms, domains, extra_disk_gb=0):
"""Lignes du tableau du plan : une par VM, avec son état."""
rows = []
for vm in vms:
state, note = vm_status(vm["name"], domains)
rows.append(
{
"vm": vm,
"state": state,
"note": note,
"disk_gb": disk_gb(vm["disk"]) + extra_disk_gb,
}
)
return rows
def plan_totals(rows):
"""Totaux des VM RÉELLEMENT créées (les existantes ne consomment rien de
neuf) : (nb, vcpus, ram_mo, disque_go)."""
fresh = [r for r in rows if r["state"] != "exists"]
return (
len(fresh),
sum(r["vm"]["vcpus"] for r in fresh),
sum(r["vm"]["ram"] for r in fresh),
sum(r["disk_gb"] for r in fresh),
)
def build_spec(vms, domains, form):
"""Assemble la spec finale, dans la forme exacte que produit la CLI."""
known = set(domains)
return {
"res_label": form["res_label"],
"vms": [vm for vm in vms if vm["name"] not in known],
"existing": [vm["name"] for vm in vms if vm["name"] in known],
"ssh_key": form["ssh_key"],
"install": form["install"],
"add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"],
}
def fmt_dur(secs) -> str:
mm, ss = divmod(int(secs), 60)
if mm >= 60:
return f"{mm // 60}h{mm % 60:02d}"
return f"{mm}m{ss:02d}" if mm else f"{ss}s"
# Au-delà, un OSC 52 est tronqué par certains terminaux (xterm notamment).
# On copie alors la FIN du log — la partie qui porte l'erreur.
CLIP_LIMIT = 100_000
def clip_payload(text, limit=CLIP_LIMIT):
"""(texte_à_copier, tronqué?) — on garde la fin, pas le début."""
if len(text) <= limit:
return text, False
return text[-limit:], True
# --------------------------------------------------------------------------- #
# Formulaire Textual
# --------------------------------------------------------------------------- #
def run_deploy_form(ctx, run_app: bool = True):
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import (
Button,
Checkbox,
DataTable,
Footer,
Header,
Input,
Label,
RadioButton,
RadioSet,
Select,
SelectionList,
Static,
)
catalog = ctx["catalog"]
arches = ctx["arches"]
domains = set(ctx.get("domains") or [])
profiles = ctx.get("install_profiles") or []
branches = ctx.get("branches") or ["master"]
host_cpu = ctx.get("host_cpu") or 2
free_ram = ctx.get("free_ram") or 0
base_vcpus = ctx.get("base_vcpus") or 2
extra_disk = ctx.get("extra_disk_gb") or 0
defaults = ctx.get("defaults") or {}
result = {"spec": None}
AUTO = "__auto__"
def entry_label(e):
star = " *" if e.get("default") else ""
return (
f"{e['distro']} {e['version']}{star} [{e['arch']}] "
f"RAM≥{e['ram']}Mo {e['disk']}"
)
class EditVMScreen(ModalScreen):
"""Réglages d'UNE VM. Un champ vide garde la valeur courante."""
BINDINGS = [("escape", "cancel", t("Cancel"))]
def __init__(self, vm):
super().__init__()
self._vm = vm
def compose(self) -> ComposeResult:
with Vertical(id="editbox"):
yield Static(f" {self._vm['name']}", id="edittitle")
yield Label(t("Name"))
yield Input(value=self._vm["name"], id="e_name")
yield Label(t("vCPU"))
yield Input(value=str(self._vm["vcpus"]), id="e_vcpus")
yield Label(t("RAM (MB)"))
yield Input(value=str(self._vm["ram"]), id="e_ram")
yield Label(t("Disk"))
yield Input(value=str(self._vm["disk"]), id="e_disk")
with Horizontal(id="editbtns"):
yield Button(t("Apply"), variant="primary", id="e_ok")
yield Button(t("Cancel"), id="e_cancel")
def on_button_pressed(self, event) -> None:
if event.button.id != "e_ok":
self.dismiss(None)
return
out = {}
name = self.query_one("#e_name", Input).value.strip()
if name:
out["name"] = name
for field, wid in (("vcpus", "#e_vcpus"), ("ram", "#e_ram")):
raw = self.query_one(wid, Input).value.strip()
if raw.isdigit() and int(raw) > 0:
out[field] = int(raw)
disk = parse_disk(self.query_one("#e_disk", Input).value)
if disk:
out["disk"] = disk
self.dismiss(out)
def action_cancel(self) -> None:
self.dismiss(None)
class PreviewScreen(ModalScreen):
"""Aperçu des commandes qui seraient lancées (aucune exécution)."""
BINDINGS = [
("escape", "close", t("Close")),
("q", "close", t("Close")),
]
def __init__(self, lines):
super().__init__()
self._lines = lines
def compose(self) -> ComposeResult:
with Vertical(id="prevbox"):
yield Static(
f" {t('Preview (dry-run):')} ({t('Esc to close')})",
id="prevtitle",
)
yield Static("\n\n".join(self._lines), id="prevbody")
def action_close(self) -> None:
self.dismiss()
class DeployForm(App):
CSS = """
#body { height: 1fr; }
#fields { width: 62; border: solid $accent; overflow-y: auto; }
#right { width: 1fr; }
#plan { height: 1fr; border: solid $accent; }
#totals { height: auto; color: $text-muted; padding: 0 1; }
.grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; }
SelectionList { height: 10; border: solid $panel; }
RadioSet { height: auto; layout: horizontal; }
#reslabel { color: $text-muted; }
EditVMScreen { align: center middle; }
#editbox {
width: 56; height: auto; padding: 1 2;
border: thick $accent; background: $surface;
}
#edittitle { color: $accent; text-style: bold; }
#editbtns { height: auto; padding-top: 1; }
PreviewScreen { align: center middle; }
#prevbox {
width: 90%; height: 70%; padding: 1 2;
border: thick $accent; background: $surface;
}
#prevtitle { height: 1; color: $accent; text-style: bold; }
#prevbody { height: 1fr; overflow-y: auto; }
"""
# Touches de fonction plutôt que ctrl+lettre : ctrl+p est pris par la
# palette de commandes de Textual, et une lettre seule serait avalée
# par le champ de saisie qui a le focus.
BINDINGS = [
("f5", "deploy", t("Deploy")),
("f2", "edit_vm", t("Edit VM")),
("f3", "preview", t("Preview")),
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("escape", "cancel", t("Cancel")),
]
def __init__(self):
super().__init__()
self.arch = ctx.get("native") or arches[0]
self.profile = "1"
self.custom = {}
self.overrides = {}
self.vms = []
self.rows = []
# -- construction de l'écran ----------------------------------- #
def compose(self) -> ComposeResult:
yield Header()
with Horizontal(id="body"):
with VerticalScroll(id="fields"):
yield Static(t("Architecture"), classes="grouptitle")
with RadioSet(id="f_arch"):
for a in arches:
label = a if a != "all" else t("all archs")
yield RadioButton(label, value=a == self.arch)
yield Static(t("Catalog"), classes="grouptitle")
yield SelectionList(id="f_catalog")
yield Static(t("Resources per VM"), classes="grouptitle")
with RadioSet(id="f_profile"):
for label in ("x1", "x2", "x3", "x4"):
yield RadioButton(label, value=label == "x1")
yield RadioButton(t("custom"))
yield Select(
[(str(c), c) for c in ctx["cpu_presets"]],
prompt=t("vCPU"),
id="f_vcpus",
disabled=True,
)
yield Select(
[
(f"{m} ({m // 1024}G)", m)
for m in ctx["ram_presets"]
],
prompt=t("RAM (MB)"),
id="f_ram",
disabled=True,
)
yield Select(
[(d, d) for d in ctx["disk_presets"]],
prompt=t("Disk"),
id="f_disk",
disabled=True,
)
yield Static("ERPLibre", classes="grouptitle")
yield Checkbox(
t("Install ERPLibre"),
value=defaults.get("install", True),
id="f_install",
)
yield Select(
[(b, b) for b in branches],
value=(
"develop" if "develop" in branches else branches[0]
),
allow_blank=False,
id="f_branch",
)
yield Select(
[(lbl, i) for i, (lbl, _c) in enumerate(profiles)],
value=0 if profiles else Select.BLANK,
allow_blank=not profiles,
id="f_profile_install",
)
yield Checkbox(
t("Production (/opt, confined)"),
value=defaults.get("prod", False),
id="f_prod",
)
yield Checkbox(
t("Monitoring dashboard"),
value=defaults.get("monitor", True),
id="f_monitor",
)
yield Static("SSH", classes="grouptitle")
yield Input(
value=ctx.get("ssh_key") or "",
placeholder=t("SSH public key path"),
id="f_key",
)
yield Checkbox(
t("Add each VM to ~/.ssh/config"),
value=defaults.get("add_ssh_config", True),
id="f_sshcfg",
)
yield Static(t("Parallelism"), classes="grouptitle")
yield Select(
[(str(n), n) for n in range(1, host_cpu + 1)],
value=min(4, host_cpu),
allow_blank=False,
id="f_par",
)
with Vertical(id="right"):
yield DataTable(id="plan")
yield Static("", id="totals")
yield Footer()
def on_mount(self) -> None:
self.title = t("Deploy ERPLibre VM(s)!")
table = self.query_one("#plan", DataTable)
table.cursor_type = "row"
table.add_columns(
t("Name"),
t("Distro"),
t("Version"),
t("Arch"),
"vCPU",
"RAM",
t("Disk"),
t("Status"),
)
self._reload_catalog(first_load=True)
# -- catalogue et recalcul ------------------------------------- #
def _entries(self):
return catalog.get(self.arch, [])
def _reload_catalog(self, first_load=False):
"""(Re)charge la liste à cocher.
RIEN n'est coché d'avance : déployer coûte cher, et une case
pré-cochée ferait créer une VM que personne n'a demandée. Le « * »
marque toujours la version principale, et F7 les coche toutes.
Après un changement d'architecture, les cases déjà cochées sont
conservées quand l'entrée existe encore — l'identité est
(distro, version, archi), pas le rang dans la liste."""
widget = self.query_one("#f_catalog", SelectionList)
keep = (
set()
if first_load
else {
entry_key(self._entries_before[i])
for i in widget.selected
if i < len(self._entries_before)
}
)
widget.clear_options()
entries = self._entries()
for i, e in enumerate(entries):
widget.add_option((entry_label(e), i, entry_key(e) in keep))
self._entries_before = entries
self._recompute()
def _selected_entries(self):
widget = self.query_one("#f_catalog", SelectionList)
entries = self._entries()
return [entries[i] for i in sorted(widget.selected)]
def _recompute(self):
entries = self._selected_entries()
self.vms = build_vms(
entries,
self.profile,
base_vcpus,
host_cpu,
self.custom,
self.overrides,
)
self.rows = plan_rows(
self.vms,
domains,
(
extra_disk
if self.query_one("#f_install", Checkbox).value
else 0
),
)
self._render_plan()
def _render_plan(self):
table = self.query_one("#plan", DataTable)
table.clear()
for r in self.rows:
vm = r["vm"]
icon = {"new": "", "exists": "⏭ ", "orphan": "❌ "}[r["state"]]
table.add_row(
vm["name"],
vm["distro"],
vm["version"],
vm["arch"],
str(vm["vcpus"]),
f"{vm['ram']}Mo",
f"{r['disk_gb']}G",
f"{icon}{r['note']}",
)
if not self.rows:
# Rien de coché : un total à zéro n'apprend rien, on dit
# plutôt comment remplir la liste.
self.query_one("#totals", Static).update(
f" {t('Tick what to deploy')} — "
f"{t('F7 main versions · F6 all')}"
)
return
n, cpus, ram, disk = plan_totals(self.rows)
warn = ""
if free_ram and ram > free_ram:
warn = f" ⚠ {t('> host free RAM')}"
elif cpus > host_cpu:
warn = f" ⚠ {t('> host cores')} ({host_cpu})"
dupes = len({vm["name"] for vm in self.vms}) != len(self.vms)
dup_txt = (
f"\n ⚠ {t('Duplicate names detected; keeping as entered.')}"
if dupes
else ""
)
self.query_one("#totals", Static).update(
f" {n} {t('VMs')} · {cpus} vCPU · {ram} Mo · ~{disk} G"
f"{warn}{dup_txt}"
)
# -- réactions aux champs -------------------------------------- #
def on_radio_set_changed(self, event) -> None:
if event.radio_set.id == "f_arch":
self.arch = arches[event.radio_set.pressed_index]
self._reload_catalog()
elif event.radio_set.id == "f_profile":
index = event.radio_set.pressed_index
self.profile = "custom" if index == 4 else str(index + 1)
custom = self.profile == "custom"
for wid in ("#f_vcpus", "#f_ram", "#f_disk"):
self.query_one(wid, Select).disabled = not custom
self._recompute()
def on_selection_list_selected_changed(self, event) -> None:
self._recompute()
def on_select_changed(self, event) -> None:
mapping = {"f_vcpus": "vcpus", "f_ram": "ram", "f_disk": "disk"}
field = mapping.get(event.select.id)
if field:
if event.value is not Select.BLANK:
self.custom[field] = event.value
self._recompute()
def on_checkbox_changed(self, event) -> None:
if event.checkbox.id == "f_install":
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
# -- actions ---------------------------------------------------- #
def action_select_all(self) -> None:
self.query_one("#f_catalog", SelectionList).select_all()
def action_select_none(self) -> None:
self.query_one("#f_catalog", SelectionList).deselect_all()
def action_select_main(self) -> None:
"""Une VM par distro : la version marquée par défaut."""
widget = self.query_one("#f_catalog", SelectionList)
widget.deselect_all()
for i, e in enumerate(self._entries()):
if e.get("default"):
widget.select(i)
def action_edit_vm(self) -> None:
table = self.query_one("#plan", DataTable)
index = table.cursor_row
if not (0 <= index < len(self.vms)):
return
entries = self._selected_entries()
key = entry_key(entries[index])
def apply(changes):
if changes:
self.overrides.setdefault(key, {}).update(changes)
self._recompute()
self.push_screen(EditVMScreen(dict(self.vms[index])), apply)
def _form_values(self):
install = None
if self.query_one("#f_install", Checkbox).value and profiles:
index = self.query_one("#f_profile_install", Select).value
label, cmd = profiles[index if isinstance(index, int) else 0]
install = {
"branch": self.query_one("#f_branch", Select).value,
"prod": self.query_one("#f_prod", Checkbox).value,
"label": label,
"cmd": cmd,
"monitor": self.query_one("#f_monitor", Checkbox).value,
}
key = self.query_one("#f_key", Input).value.strip()
return {
"res_label": (
t("custom")
if self.profile == "custom"
else f"x{self.profile}"
),
"ssh_key": os.path.expanduser(key) if key else "",
"install": install,
"add_ssh_config": self.query_one("#f_sshcfg", Checkbox).value,
"parallelism": self.query_one("#f_par", Select).value,
}
def action_preview(self) -> None:
spec = build_spec(self.vms, domains, self._form_values())
build = ctx.get("build_command")
if not build:
return
lines = [build(vm, spec, True) for vm in spec["vms"]]
self.push_screen(PreviewScreen(lines or [t("Nothing selected.")]))
def action_deploy(self) -> None:
if not self.vms:
self.notify(t("Nothing selected."), severity="warning")
return
spec = build_spec(self.vms, domains, self._form_values())
if not spec["vms"]:
self.notify(
t("Nothing to create - every VM already exists."),
severity="warning",
)
return
orphans = [r for r in self.rows if r["state"] == "orphan"]
if orphans and not getattr(self, "_orphan_ack", False):
# Un qcow2 orphelin fait échouer deploy_qemu : on prévient une
# première fois, F5 à nouveau vaut confirmation.
self._orphan_ack = True
self.notify(
t("orphan disk - will FAIL")
+ f" ({len(orphans)}) — "
+ t("press F5 again to confirm"),
severity="error",
timeout=10,
)
return
result["spec"] = spec
self.exit()
def action_cancel(self) -> None:
self.exit()
app = DeployForm()
# Exposé pour les tests headless (run_app=False), qui pilotent l'app
# eux-mêmes et ont besoin de lire la spec produite.
app._result = result
if not run_app:
return app
app.run()
return result["spec"]
# --------------------------------------------------------------------------- #
# Vue de progression : un bloc repliable par VM
# --------------------------------------------------------------------------- #
def run_deploy_progress(jobs, parallelism, run_app: bool = True):
"""Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par
VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app.
Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit
— et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire."""
import subprocess
import threading
from textual.app import App, ComposeResult
from textual.containers import Vertical, VerticalScroll
from textual.widgets import (
Button,
Collapsible,
Footer,
Header,
RichLog,
Static,
)
results = []
def slug(name):
"""Identifiant de widget : Textual n'accepte ni point ni tiret en
tête, et les noms de VM en contiennent."""
return "vm_" + "".join(c if c.isalnum() else "_" for c in name)
class Progress(App):
CSS = """
#blocks { height: 1fr; }
RichLog { height: 14; border: solid $panel; }
#summary { height: auto; color: $accent; padding: 0 1; }
#hint { height: auto; color: $text-muted; padding: 0 1; }
"""
BINDINGS = [
("c", "copy_current", t("Copy log")),
("C", "copy_all", t("Copy all logs")),
("q", "quit", t("Quit")),
]
def __init__(self):
super().__init__()
self._out = {name: "" for _jid, name, _p in jobs}
self._done = 0
self._t0 = time.time()
self._slots = threading.Semaphore(max(1, parallelism))
def compose(self) -> ComposeResult:
yield Header()
with VerticalScroll(id="blocks"):
for jid, name, _parts in jobs:
with Collapsible(
title=f"⏳ [{jid}] {name}",
collapsed=False,
id=slug(name),
):
yield RichLog(
id=f"log_{slug(name)}",
highlight=False,
markup=False,
wrap=True,
)
with Vertical():
yield Static("", id="summary")
yield Static(
f" {t('c copy log · C copy all · q quit')}", id="hint"
)
yield Button(t("Copy all logs"), id="copyall")
yield Footer()
def on_mount(self) -> None:
self.title = t("Deploying")
self._refresh_summary()
for jid, name, parts in jobs:
self.run_job(jid, name, parts)
def _refresh_summary(self):
self.query_one("#summary", Static).update(
f" {self._done}/{len(jobs)} — "
f"{fmt_dur(time.time() - self._t0)}"
)
# `thread=True` : subprocess.run est bloquant ; le faire dans un
# thread garde la boucle d'événements Textual fluide. Le sémaphore
# borne les déploiements SIMULTANÉS — sans lui, demander « 4 en
# parallèle » en lancerait autant que de VM.
def run_job(self, jid, name, parts):
def _job() -> None:
with self._slots:
t0 = time.time()
try:
res = subprocess.run(
parts, capture_output=True, text=True
)
rc = res.returncode
out = (res.stdout or "") + (res.stderr or "")
except (OSError, subprocess.SubprocessError) as exc:
rc, out = 1, str(exc)
self.call_from_thread(
self._finish, jid, name, rc, out, time.time() - t0
)
self.run_worker(_job, thread=True, group="deploy", exclusive=False)
def _finish(self, jid, name, rc, out, secs):
self._out[name] = out
results.append((name, rc, out, secs))
self._done += 1
log = self.query_one(f"#log_{slug(name)}", RichLog)
for line in out.strip().splitlines():
log.write(line)
block = self.query_one(f"#{slug(name)}", Collapsible)
mark = "✅" if rc == 0 else "❌"
block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + (
"" if rc == 0 else f" · rc={rc}"
)
# Un succès se replie (il n'y a plus rien à y lire) ; un échec
# reste ouvert.
block.collapsed = rc == 0
self._refresh_summary()
# -- presse-papiers (OSC 52 : traverse SSH) --------------------- #
def _copy(self, text, what):
payload, cut = clip_payload(text)
if not payload.strip():
self.notify(t("Nothing to copy."), severity="warning")
return
self.copy_to_clipboard(payload)
note = f"{what} — {len(payload)} {t('chars')}"
if cut:
note += f" ({t('tail only, log was truncated')})"
self.notify(
note + "\n" + t("Needs an OSC 52 capable terminal."),
title=t("Clipboard"),
timeout=8,
)
def action_copy_current(self) -> None:
focused = self.focused
for _jid, name, _p in jobs:
node = focused
while node is not None:
if getattr(node, "id", None) == slug(name):
self._copy(self._out[name], name)
return
node = node.parent
self.action_copy_all()
def action_copy_all(self) -> None:
blob = "\n".join(
f"───── {name} ─────\n{self._out[name]}"
for _jid, name, _p in jobs
)
self._copy(blob, t("all logs"))
def on_button_pressed(self, event) -> None:
if event.button.id == "copyall":
self.action_copy_all()
app = Progress()
app._results = results # lecture par les tests headless
if not run_app:
return app
app.run()
return results

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,98 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Disponibilité de Textual, et installation à la demande.
Les écrans TUI du CLI TODO (télémétrie, dashboard d'installation, formulaire
de déploiement, reprise de migration) dépendent tous de Textual. Sans lui, ils
se contentaient d'un « Installez textual (pip) » qui laissait l'utilisateur
chercher la bonne commande pour son système.
`ensure(...)` répond à la question à sa place : Textual est-il là, et sinon
veut-on l'installer maintenant ?
Module à part, et non une méthode de `TODO` : `todo_upgrade` en a besoin
aussi, et il est importé PAR `todo` — le mettre là créerait un cycle.
"""
from __future__ import annotations
import importlib
import importlib.util # « import importlib » seul n'expose PAS .util
import subprocess
import sys
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Borne de version, à UN seul endroit. Les écrans TUI du dépôt sont écrits pour
# Textual 8 ; la bibliothèque casse son API entre majeures. « pip install
# textual » sans borne installerait la majeure suivante et casserait les écrans
# sans prévenir, alors même que requirement/erplibre_require-ments.txt la borne.
# Les deux chemins d'installation doivent dire la même chose : ce littéral est
# recopié dans le fichier de requirements, avec un commentaire qui pointe ici.
TEXTUAL_SPEC = "textual>=8,<9"
def available() -> bool:
"""Textual est-il importable maintenant ?"""
return importlib.util.find_spec("textual") is not None
def in_venv() -> bool:
"""Vrai si l'interpréteur courant est dans un environnement virtuel."""
return sys.prefix != getattr(sys, "base_prefix", sys.prefix)
def install_command():
"""Commande d'installation adaptée à l'interpréteur QUI TOURNE.
C'est lui qui devra importer Textual, pas le python du système : viser
`sys.executable` évite d'installer un paquet distribution que le venv ne
verrait jamais. Hors venv, « --user » contourne le refus des
distributions dont l'environnement est « externally managed » (PEP 668).
"""
cmd = [sys.executable, "-m", "pip", "install", TEXTUAL_SPEC]
if not in_venv():
cmd.insert(4, "--user")
return cmd
def ensure(prompt=True, ask=input):
"""Textual disponible ? Sinon proposer de l'installer. Renvoie un booléen.
`prompt=False` se contente de constater — pour les appels qui ne peuvent
pas poser de question. `ask` est injectable pour les tests.
"""
if available():
return True
print(f"\n⚠ {t('Textual is required for this screen.')}")
if not prompt:
return False
answer = ask(t("Install it now? (Y/n): ")).strip().lower()
if answer and answer not in ("y", "yes", "o", "oui"):
return False
cmd = install_command()
print(f" {t('Will execute:')} {' '.join(cmd)}")
try:
status = subprocess.run(cmd).returncode
except (OSError, subprocess.SubprocessError) as exc:
print(f" ⚠ {exc}")
return False
# Un import négatif est mémorisé : sans purge du cache, Textual resterait
# « absent » pour ce processus alors qu'il vient d'être installé.
importlib.invalidate_caches()
if available():
print(f"✅ {t('Textual is installed.')}")
return True
print(f" ⚠ {t('Installation finished but textual is still missing.')}")
if status:
print(f" {t('pip exited with')} {status}")
print(f" {t('Your distribution may package it as python3-textual.')}")
return False

View file

@ -61,5 +61,11 @@
"prompt_description_key": "Generate git patch to /tmp",
"bash_command": "PATCH=\"/tmp/patch_$(date +%Y%m%d_%H%M%S).patch\"; git -C \"$(pwd)\" diff HEAD > \"$PATCH\" && printf \"\\n✓ Patch created: %s\\n\\n=== Guide to apply the patch ===\\n Check compatibility : git apply --check %s\\n Apply (git) : git apply %s\\n Apply (patch) : patch -p1 < %s\\n Revert (git) : git apply -R %s\\n\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\" \"$PATCH\""
}
],
"qemu_from_makefile": [
{
"prompt_description_key": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
"bash_command": "./script/qemu/deploy_qemu.py --name demo-vm --version 24.04 --dry-run"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

74
script/todo/todo_prefs.py Normal file
View file

@ -0,0 +1,74 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Préférences persistantes du CLI TODO.
Réglages qui survivent d'une session à l'autre et qui appartiennent à
l'UTILISATEUR, pas au dépôt : ils vivent donc dans ~/.erplibre (comme la
télémétrie de navigation) et non dans un fichier versionné.
- get(key, default) / set(key, value) : accès unitaire.
- reset() : efface tout et revient aux défauts.
Tout est best-effort : une préférence illisible ou un disque plein ne doivent
JAMAIS empêcher le CLI de démarrer.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
# Clés connues et leur valeur par défaut. Une clé absente de ce dictionnaire
# reste lisible/écrivable, mais n'apparaît pas dans l'écran de configuration.
DEFAULTS = {
# Interface du déploiement QEMU : "ask" pose la question à chaque fois,
# "tui" ouvre le formulaire directement, "cli" garde les invites en ligne.
"qemu_deploy_ui": "ask",
# Affichage pendant le déploiement : "cli" (sortie texte, facile à copier
# depuis le terminal) ou "tui" (blocs repliables + copie OSC 52).
"qemu_deploy_progress": "cli",
# Interface de la migration Odoo : "ask" / "tui" / "cli".
"migration_ui": "ask",
}
def _path() -> Path:
base = Path(os.path.expanduser("~/.erplibre"))
base.mkdir(parents=True, exist_ok=True)
return base / "todo_prefs.json"
def load() -> dict:
try:
data = json.loads(_path().read_text())
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _save(data: dict) -> None:
try:
_path().write_text(json.dumps(data, ensure_ascii=False, indent=2))
except OSError:
pass
def get(key: str, default=None):
"""Valeur d'une préférence : fichier, puis DEFAULTS, puis `default`."""
if default is None:
default = DEFAULTS.get(key)
return load().get(key, default)
def set(key: str, value) -> None: # noqa: A001 - API voulue : prefs.set(...)
data = load()
data[key] = value
_save(data)
def reset() -> int:
"""Efface toutes les préférences. Renvoie le nombre de clés effacées."""
count = len(load())
_save({})
return count

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,10 @@ import time
# erplibre_state is in the same directory; import lazily to avoid hard failure
# when the script runs outside the project root (e.g. during bare install).
# Two import styles are needed: when this file is imported as a package module
# the repo root is on sys.path (package import works); when it is executed
# directly as ./script/version/update_env_version.py, sys.path[0] is
# script/version/ and only the sibling import resolves.
try:
from script.version.erplibre_state import (
get_version_extra,
@ -25,7 +29,18 @@ try:
_STATE_AVAILABLE = True
except ImportError:
_STATE_AVAILABLE = False
try:
from erplibre_state import (
get_version_extra,
get_version_installed,
print_state,
set_version_installed,
set_version_switched,
)
_STATE_AVAILABLE = True
except ImportError:
_STATE_AVAILABLE = False
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
@ -546,6 +561,61 @@ class Update:
self.execute_log.append("Extra modules (CybroOdoo)")
return os.system("./script/manifest/update_manifest_local_dev.sh")
def add_extra_to_config_conf(self):
"""Append the extra addons (CybroOdoo, ...) to config.conf addons_path.
generate_config.sh rewrites config.conf from a fixed addons list that
does NOT include the extra modules, so this must run AFTER it (last
writer wins). The paths come from the source-of-truth extra manifest
(projects tagged with the "extra" group). Already-present paths are
skipped, so the call is idempotent."""
import xml.etree.ElementTree as ET
ver = self.new_version_odoo # e.g. "18.0"
manifest = os.path.join(
"manifest", f"git_manifest_extra_odoo{ver}.xml"
)
config_path = "config.conf"
if not os.path.isfile(manifest) or not os.path.isfile(config_path):
_logger.warning(
"Cannot add extra modules to config.conf:"
f" missing {manifest} or {config_path}."
)
return
home = os.getcwd()
prefix = f"odoo{ver}/addons/"
extra_paths = []
for project in ET.parse(manifest).getroot().findall("project"):
groups = project.get("groups", "").split(",")
path = project.get("path", "")
if "extra" in groups and path.startswith(prefix):
extra_paths.append(os.path.join(home, path))
if not extra_paths:
return
with open(config_path, "r", encoding="utf-8") as f:
lines = f.readlines()
changed = False
for i, line in enumerate(lines):
if not line.startswith("addons_path"):
continue
value = line.rstrip("\n").split("=", 1)[1] if "=" in line else ""
existing = [p.strip() for p in value.split(",") if p.strip()]
missing = [p for p in extra_paths if p not in existing]
if missing:
lines[i] = "addons_path = " + ",".join(existing + missing) + "\n"
changed = True
break
if changed:
with open(config_path, "w", encoding="utf-8") as f:
f.writelines(lines)
_logger.info(
"Added extra addons to config.conf: "
+ ", ".join(extra_paths)
)
else:
_logger.info("Extra addons already present in config.conf.")
def install_system(self):
self.execute_log.append(f"System installation")
status = os.system("./script/install/install_dev.sh")
@ -779,6 +849,13 @@ def main():
)
os.system("./script/generate_config.sh")
# config.conf : generate_config.sh (ci-dessus) réécrit le fichier à partir
# d'une liste d'addons figée SANS les modules extra ; on ajoute donc les
# chemins extra APRÈS coup (dernier writer). Idempotent, sans effet si
# --with_extra n'est pas demandé.
if update.config.with_extra and exit_code == 0:
update.add_extra_to_config_conf()
return exit_code
# TODO ignore this if installation fail