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:
commit
e8ee8cc17f
22 changed files with 14419 additions and 345 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
EL_USER=${USER}
|
||||
|
||||
# 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
|
||||
|
||||
#--------------------------------------------------
|
||||
# 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
|
||||
}
|
||||
|
||||
# Odoo installation
|
||||
install_package postgis
|
||||
install_package postgresql
|
||||
install_package mariadb
|
||||
install_package libev
|
||||
install_package wkhtmltopdf
|
||||
install_package freetds
|
||||
#--------------------------------------------------
|
||||
# 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
|
||||
|
||||
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
|
||||
#--------------------------------------------------
|
||||
# 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 ----"
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
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
|
||||
echo "gdebi install wkhtmltopdf installation error."
|
||||
exit 1
|
||||
fi
|
||||
# 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
|
||||
else
|
||||
echo -e "\n---- Already installed wkhtml ----"
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -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 ----"
|
||||
|
|
|
|||
129
script/install/install_fedora_dependency.sh
Executable file
129
script/install/install_fedora_dependency.sh
Executable 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 ----"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
476
script/qemu/README.base.md
Normal 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
270
script/qemu/README.fr.md
Normal 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
259
script/qemu/README.md
Normal 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
2024
script/qemu/deploy_qemu.py
Executable file
File diff suppressed because it is too large
Load diff
|
|
@ -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."""
|
||||
|
|
|
|||
854
script/todo/qemu_deploy_form.py
Normal file
854
script/todo/qemu_deploy_form.py
Normal 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
|
||||
1315
script/todo/qemu_install_monitor.py
Normal file
1315
script/todo/qemu_install_monitor.py
Normal file
File diff suppressed because it is too large
Load diff
98
script/todo/textual_setup.py
Normal file
98
script/todo/textual_setup.py
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
5209
script/todo/todo.py
5209
script/todo/todo.py
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
74
script/todo/todo_prefs.py
Normal 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
|
||||
1245
script/todo/todo_telemetry.py
Normal file
1245
script/todo/todo_telemetry.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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,
|
||||
|
|
@ -23,6 +27,17 @@ try:
|
|||
set_version_switched,
|
||||
)
|
||||
|
||||
_STATE_AVAILABLE = True
|
||||
except ImportError:
|
||||
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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue