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
|
humanize
|
||||||
requests
|
requests
|
||||||
urwid
|
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-dotenv
|
||||||
python-dateutil
|
python-dateutil
|
||||||
unidecode
|
unidecode
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -17,6 +18,41 @@ except ModuleNotFoundError as e:
|
||||||
|
|
||||||
VENV_ERPLIBRE = ".venv.erplibre"
|
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(
|
new_path = os.path.normpath(
|
||||||
os.path.join(os.path.dirname(__file__), "..", "..")
|
os.path.join(os.path.dirname(__file__), "..", "..")
|
||||||
)
|
)
|
||||||
|
|
@ -107,7 +143,8 @@ class Execute:
|
||||||
source_odoo = f.read()
|
source_odoo = f.read()
|
||||||
if not source_odoo:
|
if not source_odoo:
|
||||||
_logger.error(
|
_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
|
return -1
|
||||||
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
|
command = f"source ./.venv.{source_odoo}/bin/activate && {command}"
|
||||||
|
|
@ -116,7 +153,7 @@ class Execute:
|
||||||
|
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print("🏠 ⬇ Execute command :\n")
|
print("🏠 ⬇ Execute command :\n")
|
||||||
print(command)
|
print(redact_secrets(command))
|
||||||
output_lines = []
|
output_lines = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -156,16 +193,10 @@ class Execute:
|
||||||
|
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
if "password" in command:
|
print(f"Error: Command '{redact_secrets(command)}' not found.")
|
||||||
print(
|
|
||||||
f"Error: Command '{command.split(' ')[0]}'[...]"
|
|
||||||
" not found."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
print(f"Error: Command '{command}' not found.")
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"An error occurred: {e}")
|
print(f"An error occurred: {redact_secrets(str(e))}")
|
||||||
process_end_time = time.time()
|
process_end_time = time.time()
|
||||||
duration_sec = process_end_time - process_start_time
|
duration_sec = process_end_time - process_start_time
|
||||||
if humanize:
|
if humanize:
|
||||||
|
|
@ -177,7 +208,7 @@ class Execute:
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n")
|
print(f"🏠 ⬆ Executed ({duration_sec:.2f} sec.) :\n")
|
||||||
if not quiet:
|
if not quiet:
|
||||||
print(command)
|
print(redact_secrets(command))
|
||||||
print()
|
print()
|
||||||
if return_status_and_output_and_command:
|
if return_status_and_output_and_command:
|
||||||
return exit_code, command, output_lines
|
return exit_code, command, output_lines
|
||||||
|
|
|
||||||
|
|
@ -1,68 +1,126 @@
|
||||||
#!/usr/bin/env bash
|
#!/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() {
|
. ./env_var.sh
|
||||||
local package_name=$1
|
|
||||||
|
|
||||||
# Check package is already installed
|
EL_USER=${USER}
|
||||||
if pacman -Qs "$package_name" >/dev/null; then
|
|
||||||
echo "$package_name is already installed."
|
|
||||||
else
|
|
||||||
echo "Installation of package $package_name..."
|
|
||||||
yes | yay -S "$package_name"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Odoo installation
|
# pacman résilient : --needed saute ce qui est déjà là, --noconfirm en non
|
||||||
install_package postgis
|
# interactif.
|
||||||
install_package postgresql
|
PAC="sudo pacman -S --needed --noconfirm"
|
||||||
install_package mariadb
|
# Mise à jour COMPLÈTE obligatoire : Arch est en rolling release et NE SUPPORTE
|
||||||
install_package libev
|
# PAS les mises à jour partielles. Sur une image cloud dont la glibc est
|
||||||
install_package wkhtmltopdf
|
# ancienne, un « pacman -S <paquet récent> » lie le binaire à une glibc plus
|
||||||
install_package freetds
|
# récente que celle installée -> « /usr/bin/postgres: GLIBC_2.44 not found ».
|
||||||
|
# « -Syu » met à jour glibc (et tout le système) pour rester cohérent.
|
||||||
|
sudo pacman -Syu --noconfirm || true
|
||||||
|
|
||||||
echo "Need password to create symbolic link, create postgres user and install npm :"
|
#--------------------------------------------------
|
||||||
sudo ln -fs /usr/lib/libldap.so /usr/lib/libldap_r.so
|
# Outils de compilation — CRITIQUE (build Python via pyenv, extensions Python)
|
||||||
|
# base-devel fournit gcc, make, patch, pkgconf, fakeroot, etc.
|
||||||
|
#--------------------------------------------------
|
||||||
|
echo -e "\n---- Groupe base-devel (compilateur C, make…) ----"
|
||||||
|
${PAC} base-devel
|
||||||
|
retVal=$?
|
||||||
|
if [[ $retVal -ne 0 ]]; then
|
||||||
|
echo "pacman base-devel installation error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
#--------------------------------------------------
|
||||||
|
# Dépendances de build pyenv (compilation de CPython) + outils de base
|
||||||
|
#--------------------------------------------------
|
||||||
|
echo -e "\n---- Dépendances pyenv (compilation Python) + outils ----"
|
||||||
|
${PAC} git wget curl openssl zlib xz tk bzip2 readline sqlite libffi
|
||||||
|
retVal=$?
|
||||||
|
if [[ $retVal -ne 0 ]]; then
|
||||||
|
echo "pacman pyenv dependencies installation error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
#--------------------------------------------------
|
||||||
|
# PostgreSQL (+ PostGIS) — Arch n'initialise pas le cluster automatiquement
|
||||||
|
#--------------------------------------------------
|
||||||
|
echo -e "\n---- Install PostgreSQL Server ----"
|
||||||
|
${PAC} postgresql
|
||||||
|
retVal=$?
|
||||||
|
if [[ $retVal -ne 0 ]]; then
|
||||||
|
echo "pacman postgresql installation error."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# Initialisation du cluster (chemin Arch : /var/lib/postgres/data).
|
||||||
|
if [ ! -f /var/lib/postgres/data/PG_VERSION ]; then
|
||||||
|
echo -e "\n---- Initialisation du cluster PostgreSQL ----"
|
||||||
|
sudo -u postgres initdb --locale=C.UTF-8 --encoding=UTF8 \
|
||||||
|
-D /var/lib/postgres/data || true
|
||||||
|
fi
|
||||||
|
sudo systemctl enable --now postgresql 2>/dev/null || true
|
||||||
|
# PostGIS : optionnel (géospatial), ne bloque pas.
|
||||||
|
${PAC} postgis || echo "PostGIS non installé (optionnel)."
|
||||||
|
|
||||||
|
echo -e "\n---- Creating the ERPLibre PostgreSQL User ----"
|
||||||
sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
|
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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "npm install npm lastest installation error."
|
echo "pacman nodejs 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."
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
sudo npm install -g rtlcss less || echo "npm rtlcss/less: erreur (optionnel)."
|
||||||
|
|
||||||
echo -e "\n---- Test tool ----"
|
echo -e "\n---- Test tool ----"
|
||||||
npm install
|
npm install || echo "npm install (prettier/plugin-xml): erreur (optionnel)."
|
||||||
retVal=$?
|
|
||||||
if [[ $retVal -ne 0 ]]; then
|
# Pour erplibre_devops.
|
||||||
echo "npm install prettier + plugin-xml installation error."
|
${PAC} sshpass || echo "sshpass non installé (optionnel)."
|
||||||
exit 1
|
|
||||||
|
#--------------------------------------------------
|
||||||
|
# nginx (optionnel)
|
||||||
|
#--------------------------------------------------
|
||||||
|
if [ "${EL_INSTALL_NGINX}" = "True" ]; then
|
||||||
|
echo -e "\n---- Installing nginx ----"
|
||||||
|
${PAC} nginx || echo "nginx: erreur (optionnel)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# TODO install nginx
|
#--------------------------------------------------
|
||||||
|
# wkhtmltopdf (optionnel) — uniquement dans l'AUR sur Arch (pas de paquet
|
||||||
# ERPLibre installation
|
# officiel). Sur une image cloud sans helper AUR, on saute proprement.
|
||||||
install_package cmake
|
#--------------------------------------------------
|
||||||
install_package parallel
|
if [ "${EL_INSTALL_WKHTMLTOPDF}" = "True" ]; then
|
||||||
install_package tk
|
if ! command -v wkhtmltopdf >/dev/null 2>&1; then
|
||||||
install_package shfmt
|
echo "wkhtmltopdf : disponible seulement via l'AUR sur Arch, ignoré"
|
||||||
|
echo " (installez-le manuellement avec un helper AUR si nécessaire)."
|
||||||
# For erplibre_devops
|
else
|
||||||
install_package sshpass
|
echo -e "\n---- Already installed wkhtml ----"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "\n---- Arch Linux dependency installation done ----"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
EL_USER=${USER}
|
EL_USER=${USER}
|
||||||
#EL_INSTALL_WKHTMLTOPDF="True"
|
#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
|
### WKHTMLTOPDF download links
|
||||||
## === Ubuntu Focal x64 === (for other distributions please replace these two 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)
|
DEBIAN_VERSION=$(lsb_release -cs)
|
||||||
OS=$(lsb_release -si)
|
OS=$(lsb_release -si)
|
||||||
if [[ "${OS}" == "Ubuntu" ]]; then
|
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
|
if [ "20.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
|
|
||||||
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
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
|
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
|
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
|
fi
|
||||||
elif [[ "${OS}" == "Linuxmint" ]]; then
|
elif [[ "${OS}" == "Linuxmint" ]]; then
|
||||||
if [ "22.3" == "${UBUNTU_VERSION}" ]; 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
|
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.jammy_amd64.deb
|
||||||
fi
|
fi
|
||||||
elif [[ "${OS}" == "Debian" ]]; then
|
elif [[ "${OS}" == "Debian" ]]; then
|
||||||
if [ "bookworm" == "${DEBIAN_VERSION}" ]; then
|
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.bookworm_amd64.deb
|
|
||||||
else
|
|
||||||
WKHTMLTOX_X64=https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-3/wkhtmltox_0.12.6.1-3.bullseye_amd64.deb
|
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
|
fi
|
||||||
elif [[ "${OS}" == *"Ubuntu"* ]]; then
|
elif [[ "${OS}" == *"Ubuntu"* ]]; then
|
||||||
echo "Your version of Ubuntu is not supported, only support 18.04, 20.04 and 22.04"
|
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
|
if [ "18.04" == "${UBUNTU_VERSION}" ]; then
|
||||||
# add-apt-repository can install add-apt-repository Ubuntu 18.x
|
# 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
|
# universe package is for Ubuntu 18.x
|
||||||
sudo add-apt-repository universe
|
sudo add-apt-repository universe
|
||||||
# libpng12-0 dependency for wkhtmltopdf
|
# libpng12-0 dependency for wkhtmltopdf
|
||||||
sudo add-apt-repository "deb http://mirrors.kernel.org/ubuntu/ xenial main"
|
sudo add-apt-repository "deb http://mirrors.kernel.org/ubuntu/ xenial main"
|
||||||
sudo apt-get update
|
${APT_GET} update
|
||||||
sudo apt-get upgrade -y
|
${APT_GET} upgrade -y
|
||||||
fi
|
fi
|
||||||
|
|
||||||
#--------------------------------------------------
|
#--------------------------------------------------
|
||||||
# Install PostgreSQL Server
|
# Install PostgreSQL Server
|
||||||
#--------------------------------------------------
|
#--------------------------------------------------
|
||||||
echo -e "\n---- 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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get install postgresql installation error."
|
echo "apt-get install postgresql installation error."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ----"
|
echo -e "\n---- Creating the ERPLibre PostgreSQL User ----"
|
||||||
sudo su - postgres -c "createuser -s ${EL_USER}" 2>/dev/null || true
|
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
|
# Install Dependencies
|
||||||
#--------------------------------------------------
|
#--------------------------------------------------
|
||||||
echo -e "\n--- Installing debian dependency --"
|
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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get debian tool installation error."
|
echo "apt-get debian tool installation error."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get libmariadb installation error."
|
echo "apt-get libmariadb installation error."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "18.04" == "${UBUNTU_VERSION}" ]; then
|
if [ "18.04" == "${UBUNTU_VERSION}" ]; then
|
||||||
sudo apt-get install libpng12-0 -y
|
${APT_GET} install libpng12-0 -y
|
||||||
retVal=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get libpng installation error."
|
echo "apt-get libpng installation error."
|
||||||
|
|
@ -103,14 +128,14 @@ if [ "18.04" == "${UBUNTU_VERSION}" ]; then
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
# Dependencies for pyenv
|
# 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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get pyenv dependencies installation error."
|
echo "apt-get pyenv dependencies installation error."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
# Dependencies for selenium
|
# 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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "apt-get selenium dependencies installation error."
|
echo "apt-get selenium dependencies installation error."
|
||||||
|
|
@ -118,8 +143,8 @@ if [[ $retVal -ne 0 ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "\n---- Installing nodeJS NPM and rtlcss for LTR support ----"
|
echo -e "\n---- Installing nodeJS NPM and rtlcss for LTR support ----"
|
||||||
sudo apt-get update
|
${APT_GET} update
|
||||||
sudo apt-get install -y ca-certificates curl gnupg
|
${APT_GET} install -y ca-certificates curl gnupg
|
||||||
sudo mkdir -p /etc/apt/keyrings
|
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
|
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
|
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
|
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
|
${APT_GET} update
|
||||||
sudo apt-get install nodejs -y
|
${APT_GET} install nodejs -y
|
||||||
|
|
||||||
sudo npm install npm@latest -g
|
sudo npm install npm@latest -g
|
||||||
retVal=$?
|
retVal=$?
|
||||||
|
|
@ -177,21 +202,36 @@ fi
|
||||||
#--------------------------------------------------
|
#--------------------------------------------------
|
||||||
# Install Wkhtmltopdf if needed
|
# 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 ----"
|
echo -e "\n---- Installing wkhtml ----"
|
||||||
INSTALLED=$(dpkg -s wkhtmltox | grep installed)
|
INSTALLED=$(dpkg -s wkhtmltox | grep installed)
|
||||||
if [ "" == "${INSTALLED}" ]; then
|
if [ "" == "${INSTALLED}" ]; then
|
||||||
echo -e "\n---- Install wkhtml and place shortcuts on correct place ----"
|
echo -e "\n---- Install wkhtml and place shortcuts on correct place ----"
|
||||||
_url=${WKHTMLTOX_X64}
|
_url=${WKHTMLTOX_X64}
|
||||||
sudo wget ${_url}
|
if [ -z "${_url}" ]; then
|
||||||
sudo gdebi --n $(basename ${_url})
|
# Aucune URL (version non mappée) : wkhtmltopdf est OPTIONNEL, on saute
|
||||||
retVal=$?
|
# proprement plutôt que d'appeler gdebi sans fichier (« Usage: gdebi »).
|
||||||
if [[ $retVal -ne 0 ]]; then
|
echo "wkhtmltopdf : aucune URL pour cette version, ignoré (optionnel)."
|
||||||
echo "gdebi install wkhtmltopdf installation error."
|
else
|
||||||
exit 1
|
sudo wget ${_url}
|
||||||
|
sudo gdebi --n $(basename ${_url})
|
||||||
|
retVal=$?
|
||||||
|
if [[ $retVal -ne 0 ]]; then
|
||||||
|
# wkhtmltopdf est OPTIONNEL (rapports PDF). NON bloquant : sur Debian
|
||||||
|
# 13 (trixie) le .deb dépendait de libssl1.1 (absent) et « exit 1 »
|
||||||
|
# faisait échouer TOUT install_os -> Odoo jamais installé. On avertit
|
||||||
|
# et on continue (comme Arch qui poursuit sans wkhtmltopdf).
|
||||||
|
echo "wkhtmltopdf : installation échouée, ignoré (optionnel — pas de PDF)."
|
||||||
|
else
|
||||||
|
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
|
||||||
|
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
sudo ln -fs /usr/local/bin/wkhtmltopdf /usr/bin
|
|
||||||
sudo ln -fs /usr/local/bin/wkhtmltoimage /usr/bin
|
|
||||||
else
|
else
|
||||||
echo -e "\n---- Already installed wkhtml ----"
|
echo -e "\n---- Already installed wkhtml ----"
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@
|
||||||
if [[ "${OSTYPE}" == "linux-gnu" ]]; then
|
if [[ "${OSTYPE}" == "linux-gnu" ]]; then
|
||||||
source /etc/os-release
|
source /etc/os-release
|
||||||
if [[ "${ID}" == "ubuntu" ]]; then
|
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 ----"
|
echo "\n---- linux-gnu installation process started ----"
|
||||||
./script/install/install_debian_dependency.sh
|
./script/install/install_debian_dependency.sh
|
||||||
else
|
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
|
fi
|
||||||
elif [[ "${ID}" == "linuxmint" ]]; then
|
elif [[ "${ID}" == "linuxmint" ]]; then
|
||||||
if [[ "${VERSION_ID}" == "22.3" ]]; then
|
if [[ "${VERSION_ID}" == "22.3" ]]; then
|
||||||
|
|
@ -20,9 +20,12 @@ if [[ "${OSTYPE}" == "linux-gnu" ]]; then
|
||||||
./script/install/install_debian_dependency.sh
|
./script/install/install_debian_dependency.sh
|
||||||
elif [[ "${ID}" == "arch" ]]; then
|
elif [[ "${ID}" == "arch" ]]; then
|
||||||
./script/install/install_arch_linux.sh
|
./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
|
else
|
||||||
./script/install/install_debian_dependency.sh
|
./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
|
fi
|
||||||
elif [[ "${OSTYPE}" == "darwin"* ]]; then
|
elif [[ "${OSTYPE}" == "darwin"* ]]; then
|
||||||
echo "\n---- Darwin installation process started ----"
|
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=$?
|
retVal=$?
|
||||||
if [[ $retVal -ne 0 ]]; then
|
if [[ $retVal -ne 0 ]]; then
|
||||||
echo "Poetry installation error with status ${retVal}"
|
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
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,22 @@ fi
|
||||||
#EL_MANIFEST_PROD="./default.xml"
|
#EL_MANIFEST_PROD="./default.xml"
|
||||||
#EL_MANIFEST_DEV="./manifest/default.dev.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} &
|
git daemon --base-path=. --export-all --reuseaddr --informative-errors ${DAEMON_VERBOSE} &
|
||||||
DAEMON_PID=$!
|
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
|
if [ -L "$EL_MANIFEST_DEV" ]; then
|
||||||
MANIFEST_TARGET=$(readlink -f "$EL_MANIFEST_DEV")
|
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 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}
|
.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
|
self._dir_path = path
|
||||||
|
|
||||||
def select_database(self) -> str | bool:
|
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"
|
cmd_server = "./odoo_bin.sh db --list"
|
||||||
status, databases = self._execute.exec_command_live(
|
status, output = self._execute.exec_command_live(
|
||||||
cmd_server,
|
cmd_server,
|
||||||
return_status_and_output=True,
|
return_status_and_output=True,
|
||||||
|
quiet=True,
|
||||||
source_erplibre=False,
|
source_erplibre=False,
|
||||||
single_source_erplibre=True,
|
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)
|
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:
|
while True:
|
||||||
status = click.prompt(help_info)
|
answer = click.prompt(help_info)
|
||||||
print()
|
print()
|
||||||
if status == "0":
|
if answer == "0":
|
||||||
return False
|
return False
|
||||||
elif status in valid_choices:
|
elif answer in valid_choices:
|
||||||
database_name = databases[int(status) - 1].strip()
|
database_name = databases[int(answer) - 1]
|
||||||
print(database_name)
|
print(database_name)
|
||||||
return database_name
|
return database_name
|
||||||
else:
|
else:
|
||||||
print(t("cmd_not_found"))
|
print(t("Command not found !"))
|
||||||
|
|
||||||
def _confirm_drop(self, message: str) -> bool:
|
def _confirm_drop(self, message: str) -> bool:
|
||||||
"""Ask for an explicit 'oui'/'yes' confirmation, default is no."""
|
"""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",
|
"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\""
|
"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"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5213
script/todo/todo.py
5213
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
|
# 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).
|
# 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:
|
try:
|
||||||
from script.version.erplibre_state import (
|
from script.version.erplibre_state import (
|
||||||
get_version_extra,
|
get_version_extra,
|
||||||
|
|
@ -25,7 +29,18 @@ try:
|
||||||
|
|
||||||
_STATE_AVAILABLE = True
|
_STATE_AVAILABLE = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_STATE_AVAILABLE = False
|
try:
|
||||||
|
from erplibre_state import (
|
||||||
|
get_version_extra,
|
||||||
|
get_version_installed,
|
||||||
|
print_state,
|
||||||
|
set_version_installed,
|
||||||
|
set_version_switched,
|
||||||
|
)
|
||||||
|
|
||||||
|
_STATE_AVAILABLE = True
|
||||||
|
except ImportError:
|
||||||
|
_STATE_AVAILABLE = False
|
||||||
|
|
||||||
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
|
||||||
|
|
||||||
|
|
@ -546,6 +561,61 @@ class Update:
|
||||||
self.execute_log.append("Extra modules (CybroOdoo)")
|
self.execute_log.append("Extra modules (CybroOdoo)")
|
||||||
return os.system("./script/manifest/update_manifest_local_dev.sh")
|
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):
|
def install_system(self):
|
||||||
self.execute_log.append(f"System installation")
|
self.execute_log.append(f"System installation")
|
||||||
status = os.system("./script/install/install_dev.sh")
|
status = os.system("./script/install/install_dev.sh")
|
||||||
|
|
@ -779,6 +849,13 @@ def main():
|
||||||
)
|
)
|
||||||
os.system("./script/generate_config.sh")
|
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
|
return exit_code
|
||||||
# TODO ignore this if installation fail
|
# TODO ignore this if installation fail
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue