diff --git a/Makefile b/Makefile
index 6b7e893..6ac6eff 100644
--- a/Makefile
+++ b/Makefile
@@ -384,4 +384,9 @@ pycharm_open:
.PHONY: pycharm_configure
pycharm_configure:
- ./script/ide/pycharm_configuration.py --init
+# Le venv du dépôt, et non le python système : le script importe xmltodict, qui
+# vit dans .venv.erplibre. Appelé directement, il s'arrête sur
+# « ModuleNotFoundError: No module named 'xmltodict' » — et c'est cette cible
+# que le message d'échec de l'installation recommande. update_env_version.
+# pycharm_update() l'appelle déjà ainsi : une seule autorité.
+ ./.venv.erplibre/bin/python ./script/ide/pycharm_configuration.py --init
diff --git a/manifest/git_manifest_mobile_home.xml b/manifest/git_manifest_mobile_home.xml
index 54d42ec..e91fffa 100644
--- a/manifest/git_manifest_mobile_home.xml
+++ b/manifest/git_manifest_mobile_home.xml
@@ -12,12 +12,20 @@
groups="mobile"
/>
-
+
diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh
new file mode 100755
index 0000000..d2f873b
--- /dev/null
+++ b/script/forgejo/install_forgejo.sh
@@ -0,0 +1,354 @@
+#!/usr/bin/env bash
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+#
+# Installe Forgejo — https://forgejo.org — depuis le binaire statique officiel
+# publié sur https://codeberg.org/forgejo/forgejo.
+#
+# Le binaire est statique et sans dépendance : le même fichier sert Debian,
+# Ubuntu, AlmaLinux, Rocky, openSUSE et Arch. Ce script ne touche donc à AUCUN
+# gestionnaire de paquets — c'est ce qui le rend portable sur les plateformes
+# ERPLibre sans une branche par distribution.
+#
+# Il n'appelle PAS env_var.sh, à la différence des scripts d'installation
+# ERPLibre : Forgejo ne dépend ni du dépôt ni de son venv, et le script doit
+# rester utilisable hors d'un checkout.
+#
+# Réglages, tous par variables d'environnement :
+# FORGEJO_VERSION version à poser (défaut : la dernière publiée)
+# FORGEJO_HTTP_PORT port web (défaut : 3000)
+# FORGEJO_SSH_PORT port SSH interne de Forgejo (défaut : 2222)
+# FORGEJO_ADMIN_USER compte administrateur créé (défaut : erplibre)
+# FORGEJO_ADMIN_PASSWORD son mot de passe (défaut : erplibre)
+# FORGEJO_ADMIN_EMAIL son courriel (défaut : admin@erplibre.local)
+# FORGEJO_USER compte système propriétaire (défaut : git)
+# FORGEJO_SKIP_ADMIN à 1, ne crée aucun compte (installateur web)
+set -euo pipefail
+
+Red='\033[0;31m'
+Green='\033[0;32m'
+Yellow='\033[0;33m'
+Color_Off='\033[0m'
+
+VERSION="${FORGEJO_VERSION:-}"
+HTTP_PORT="${FORGEJO_HTTP_PORT:-3000}"
+SSH_PORT="${FORGEJO_SSH_PORT:-2222}"
+# « admin » est REFUSÉ par Forgejo — « CreateUser: name is reserved », mesuré.
+# La liste des noms réservés couvre aussi api, assets, avatars, explore, user…
+ADMIN_USER="${FORGEJO_ADMIN_USER:-erplibre}"
+ADMIN_PASSWORD="${FORGEJO_ADMIN_PASSWORD:-erplibre}"
+ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}"
+RUN_USER="${FORGEJO_USER:-git}"
+SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}"
+
+# Ce qui a changé sur le disque pendant ce passage. Le service ne redémarre que
+# si quelque chose a bougé : rejouer le script sur une forge saine ne doit pas
+# l'interrompre, même deux secondes.
+CHANGED=0
+
+BIN=/usr/local/bin/forgejo
+CONF_DIR=/etc/forgejo
+CONF="$CONF_DIR/app.ini"
+DATA=/var/lib/forgejo
+UNIT=/etc/systemd/system/forgejo.service
+API=https://codeberg.org/api/v1/repos/forgejo/forgejo/releases
+DL=https://codeberg.org/forgejo/forgejo/releases/download
+
+usage() {
+ sed -n '5,26p' "$0" | sed 's/^# \?//'
+ exit 0
+}
+case "${1:-}" in
+ -h|--help) usage ;;
+esac
+
+say() { echo -e " $*"; }
+die() { echo -e " ${Red}✗ $*${Color_Off}" >&2; exit 1; }
+
+# Adresse par laquelle la machine est joignable, pour ROOT_URL et SSH_DOMAIN.
+#
+# « hostname -I » d'abord, mais PAS seulement : ce drapeau vient de net-tools et
+# l'inetutils d'Arch ne le connaît pas. « ip route get » le remplace partout où
+# iproute2 est là, c'est-à-dire partout. « localhost » ferme la marche : une
+# forge joignable en local vaut mieux qu'un script qui s'arrête.
+host_address() {
+ local h=""
+ # Chaque candidat est VALIDÉ comme adresse IPv4 avant d'être retenu : un
+ # « hostname » qui ne connaît pas -I peut rendre le nom de la machine, et
+ # une ROOT_URL bâtie sur un nom non résolvable est pire qu'un repli.
+ for h in \
+ "$(hostname -I 2>/dev/null | awk '{print $1}')" \
+ "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \
+ "$(ip -4 -o addr show scope global 2>/dev/null \
+ | awk '{split($4, a, "/"); print a[1]; exit}')"
+ do
+ case "$h" in
+ [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$h"; return 0 ;;
+ esac
+ done
+ echo localhost
+}
+
+# --- 1. Architecture -------------------------------------------------------
+# Forgejo publie amd64, arm64 et arm-6. PAS de s390x : sur cette architecture
+# il faudrait le bâtir depuis les sources en Go, ce que ce script ne fait pas —
+# il le dit plutôt que de télécharger un binaire qui ne s'exécutera pas.
+case "$(uname -m)" in
+ x86_64) ARCH=amd64 ;;
+ aarch64|arm64) ARCH=arm64 ;;
+ armv6l|armv7l) ARCH=arm-6 ;;
+ *) die "Forgejo ne publie pas de binaire pour $(uname -m)" \
+ "(amd64, arm64 et arm-6 seulement)." ;;
+esac
+
+# --- 2. Version ------------------------------------------------------------
+if [ -z "$VERSION" ]; then
+ # La liste des versions est en JSON : on la lit avec python3, présent dans
+ # toutes les images cloud visées. Sans lui, on retombe sur grep — mieux
+ # qu'un abandon, et le motif est celui d'un champ JSON, pas d'une page web.
+ if command -v python3 >/dev/null 2>&1; then
+ VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \
+ | python3 -c 'import json,sys
+try:
+ print(json.load(sys.stdin)[0]["tag_name"].lstrip("v"))
+except Exception:
+ pass' || true)
+ else
+ VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \
+ | grep -o '"tag_name":"v[^"]*"' | head -1 \
+ | sed 's/.*"v//;s/"//' || true)
+ fi
+fi
+[ -n "$VERSION" ] || die "Version de Forgejo introuvable (réseau ? $API)"
+say "Forgejo $VERSION pour $ARCH"
+
+# --- 3. Déjà posé ? --------------------------------------------------------
+# Rejouer une installation est le cas normal. Comparer la version évite de
+# retélécharger 34 Mo pour rien, et de redémarrer un service qui va bien.
+if [ -x "$BIN" ] && "$BIN" --version 2>/dev/null | grep -q "version $VERSION"; then
+ say "${Green}binaire déjà en version $VERSION, téléchargement évité${Color_Off}"
+else
+ # L'archive .xz pèse 34 Mo contre 114 Mo pour le binaire nu. On la prend
+ # quand xz est là, sans jamais l'installer : le binaire nu est le repli.
+ tmp=$(mktemp -d)
+ trap 'rm -rf "$tmp"' EXIT
+ if command -v xz >/dev/null 2>&1; then
+ asset="forgejo-$VERSION-linux-$ARCH.xz"
+ else
+ asset="forgejo-$VERSION-linux-$ARCH"
+ fi
+ say "téléchargement de $asset"
+ curl -fsSL --retry 3 --max-time 900 "$DL/v$VERSION/$asset" \
+ -o "$tmp/$asset" || die "téléchargement impossible : $DL/v$VERSION/$asset"
+ # Somme de contrôle publiée à côté du fichier : une archive tronquée par une
+ # coupure réseau donne un binaire qui ne s'exécute pas, et l'erreur arrive
+ # alors dix étapes plus loin.
+ if curl -fsSL --max-time 60 "$DL/v$VERSION/$asset.sha256" \
+ -o "$tmp/$asset.sha256" 2>/dev/null; then
+ (cd "$tmp" && sha256sum -c "$asset.sha256" >/dev/null) \
+ || die "somme de contrôle invalide pour $asset"
+ say "somme de contrôle vérifiée"
+ else
+ say "${Yellow}⚠ somme de contrôle indisponible, non vérifiée${Color_Off}"
+ fi
+ case "$asset" in
+ *.xz) xz -d "$tmp/$asset"; src="$tmp/${asset%.xz}" ;;
+ *) src="$tmp/$asset" ;;
+ esac
+ chmod +x "$src"
+ sudo install -m 0755 "$src" "$BIN"
+ CHANGED=1
+ say "${Green}binaire posé : $BIN${Color_Off}"
+ rm -rf "$tmp"
+ trap - EXIT
+fi
+
+# --- 4. Compte système et répertoires --------------------------------------
+if ! id "$RUN_USER" >/dev/null 2>&1; then
+ sudo useradd --system --create-home --home-dir "/home/$RUN_USER" \
+ --shell /bin/bash --comment "Forgejo" "$RUN_USER"
+ say "compte système créé : $RUN_USER"
+fi
+sudo mkdir -p "$DATA"/{custom,data,log} "$CONF_DIR"
+sudo chown -R "$RUN_USER:$RUN_USER" "$DATA"
+sudo chmod 750 "$DATA"
+# Le fichier de configuration appartient à root et se LIT par le groupe : le
+# service en a besoin, et Forgejo y écrit ses secrets au premier démarrage si
+# on ne les pose pas soi-même — ce que fait l'étape suivante.
+sudo chown root:"$RUN_USER" "$CONF_DIR"
+sudo chmod 770 "$CONF_DIR"
+
+# --- 5. Configuration ------------------------------------------------------
+# JAMAIS réécrite si elle existe : elle porte les secrets, et un utilisateur a
+# pu l'ajuster. C'est aussi ce qui rend ce script rejouable.
+# « sudo test », et non « [ -f ] » : /etc/forgejo appartient à root:git en 770,
+# donc l'utilisateur qui lance le script ne peut même pas y statuer un fichier.
+# Le test échouait toujours, et CHAQUE passage réécrivait la configuration avec
+# des secrets neufs — ce qui invalide les sessions et les jetons existants.
+if sudo test -f "$CONF"; then
+ say "configuration conservée : $CONF"
+else
+ host=$(host_address)
+ # Les QUATRE secrets, et pas seulement les deux évidents. Vécu : sans
+ # « oauth2.JWT_SECRET », Forgejo tente de l'écrire dans app.ini au
+ # démarrage, n'y arrive pas — le fichier appartient à root — et s'arrête
+ # sur « [F] save oauth2.JWT_SECRET failed ». Le service redémarrait en
+ # boucle, 25 fois, sans jamais écouter le port.
+ #
+ # Les poser ici garde app.ini NON inscriptible par le service : c'est la
+ # bonne posture, et ça évite un fichier de configuration qui se réécrit
+ # tout seul.
+ secret=$("$BIN" generate secret SECRET_KEY)
+ token=$("$BIN" generate secret INTERNAL_TOKEN)
+ jwt=$("$BIN" generate secret JWT_SECRET)
+ lfs_jwt=$("$BIN" generate secret JWT_SECRET)
+ # « INSTALL_LOCK = true » verrouille l'installateur web : la machine est
+ # utilisable sans passer par un formulaire, ce qui est tout l'intérêt d'une
+ # option cochée au déploiement. SQLite, pour ne pas disputer PostgreSQL à
+ # Odoo, qui vit sur la même VM.
+ sudo tee "$CONF" >/dev/null < "$unit_tmp" </dev/null; then
+ sudo install -m 0644 "$unit_tmp" "$UNIT"
+ sudo systemctl daemon-reload
+ CHANGED=1
+ say "service défini : $UNIT"
+fi
+rm -f "$unit_tmp"
+
+sudo systemctl enable forgejo.service >/dev/null 2>&1 || true
+# « restart » et NON « enable --now » quand quelque chose a changé : « --now »
+# ne touche pas à un service déjà actif, qui garde alors sa configuration en
+# MÉMOIRE. Vécu, et le symptôme ne désigne pas la cause : le serveur comparait
+# son ancien INTERNAL_TOKEN à celui que le hook venait de lire sur le disque, et
+# répondait 403 à son propre hook. Tout push finissait sur « Forgejo: Internal
+# Server Error Decoding Failed », le hook ne sachant pas décoder un 403.
+if [ "$CHANGED" = 1 ]; then
+ sudo systemctl restart forgejo.service \
+ || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40"
+ say "service redémarré (configuration ou binaire modifié)"
+elif ! systemctl is-active --quiet forgejo.service; then
+ sudo systemctl start forgejo.service \
+ || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40"
+fi
+
+# --- 7. Attendre qu'il RÉPONDE --------------------------------------------
+# Une requête HTTP, pas un « systemctl is-active » : le service est « active »
+# bien avant d'écouter, et « activating » en boucle de redémarrage ressemble à
+# un démarrage en cours. /api/v1/version prouve que l'application SERT — la
+# création du compte administrateur qui suit a besoin de la base migrée.
+#
+# Et surtout pas « exec 3<>/dev/tcp/... » : « exec » est un builtin spécial, et
+# une redirection qui échoue termine le shell. Le script mourait donc en
+# silence, au premier tour de la boucle, code 1 sans un mot — vécu.
+ready=0
+for i in $(seq 1 60); do
+ # « -fs » sans « -S » : dans une boucle de réessai, le message de curl est
+ # du bruit — « Failed to connect » au premier tour est normal, le service
+ # vient de redémarrer. C'est le die final qui parle si rien ne répond.
+ if curl -fs -o /dev/null --max-time 3 \
+ "http://127.0.0.1:$HTTP_PORT/api/v1/version"; then
+ ready=1
+ break
+ fi
+ sleep 2
+done
+[ "$ready" = 1 ] || die "aucune réponse sur le port $HTTP_PORT après 120 s" \
+ "(sudo journalctl -u forgejo -n 40)"
+
+# --- 8. Compte administrateur ---------------------------------------------
+# Créé seulement s'il n'y a AUCUN compte : rejouer le script ne doit pas
+# échouer sur « user already exists », ni écraser un mot de passe choisi.
+if [ "$SKIP_ADMIN" = 1 ]; then
+ say "aucun compte créé (FORGEJO_SKIP_ADMIN=1)"
+elif sudo -u "$RUN_USER" "$BIN" admin user list --config "$CONF" 2>/dev/null \
+ | tail -n +2 | grep -q .; then
+ say "comptes déjà présents, administrateur non recréé"
+else
+ sudo -u "$RUN_USER" "$BIN" admin user create --admin \
+ --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" \
+ --email "$ADMIN_EMAIL" --must-change-password=false \
+ --config "$CONF" >/dev/null \
+ || die "création de l'administrateur impossible"
+ say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}"
+fi
+
+# --- 9. Résumé -------------------------------------------------------------
+host=$(host_address)
+version=$("$BIN" --version 2>/dev/null | head -1)
+say "${Green}Forgejo prêt${Color_Off} : http://$host:$HTTP_PORT/"
+say " $version"
+say " git par SSH : port $SSH_PORT (serveur interne de Forgejo)"
+say " service : sudo systemctl status forgejo"
+say " journal : sudo journalctl -u forgejo -f"
diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh
index 61e759b..52e1b4e 100755
--- a/script/install/install_debian_dependency.sh
+++ b/script/install/install_debian_dependency.sh
@@ -18,9 +18,30 @@ APT_GET="sudo apt-get -o DPkg::Lock::Timeout=600"
## in order to have correct version of wkhtmltopdf installed, for a danger note refer to
## https://github.com/odoo/odoo/wiki/Wkhtmltopdf ):
# Ubuntu 20.04
-UBUNTU_VERSION=$(lsb_release -rs)
-DEBIAN_VERSION=$(lsb_release -cs)
-OS=$(lsb_release -si)
+# /etc/os-release D'ABORD, lsb_release seulement en repli.
+#
+# « lsb_release » vient du paquet lsb-release, qui arrive avec la tâche
+# « standard ». Les images cloud l'ont ; une Debian posée par
+# debian-installer, non. Les trois variables devenaient alors VIDES, et le
+# script concluait « Your version of Ubuntu is not supported » sur une Debian
+# — vécu sur s390x, la seule architecture qui passe par l'installateur.
+#
+# /etc/os-release, lui, appartient à systemd et est toujours là. Il donne
+# ID=debian, VERSION_ID=13 et VERSION_CODENAME=trixie sans rien installer.
+if [[ -r /etc/os-release ]]; then
+ # Sous-shell : « source » importerait NAME, PRETTY_NAME et le reste dans
+ # un script qui n'en veut pas.
+ UBUNTU_VERSION=$(. /etc/os-release && echo "${VERSION_ID}")
+ DEBIAN_VERSION=$(. /etc/os-release && echo "${VERSION_CODENAME}")
+ OS=$(. /etc/os-release && echo "${ID}")
+ # lsb_release rend « Ubuntu » et « Debian » ; os-release rend « ubuntu » et
+ # « debian ». Les comparaisons plus bas attendent la première forme.
+ OS="${OS^}"
+else
+ UBUNTU_VERSION=$(lsb_release -rs)
+ DEBIAN_VERSION=$(lsb_release -cs)
+ OS=$(lsb_release -si)
+fi
# Ubuntu 18.04, 20.04 et 22.04 ne sont plus supportées, sur AUCUNE
# architecture. Le mur le plus net est pikepdf, qui réclame qpdf >= 12.2,
@@ -268,6 +289,18 @@ if [[ $retVal -ne 0 ]]; then
echo "apt-get pyenv dependencies installation error."
exit 1
fi
+# python3-venv : le venv d'OUTILS (.venv.erplibre) est bâti avec le python du
+# SYSTÈME, et sur Debian et Ubuntu « python3 -m venv » n'embarque pas ensurepip
+# sans ce paquet. Sans lui le venv naît infirme — bin/python existe, ni pip ni
+# activate — et tout ce qui en dépend tombe : « repo », la fusion du manifeste
+# (ModuleNotFoundError: No module named 'git'), la configuration PyCharm, la
+# compilation mobile. Mesuré sur une VM Ubuntu 24.04 fraîche.
+${APT_GET} install python3-venv -y
+retVal=$?
+if [[ $retVal -ne 0 ]]; then
+ echo "apt-get python3-venv installation error."
+ exit 1
+fi
# Dependencies for selenium
${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y
retVal=$?
diff --git a/script/mobile/__init__.py b/script/mobile/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py
new file mode 100755
index 0000000..44e8c96
--- /dev/null
+++ b/script/mobile/check_bundle_transfer.py
@@ -0,0 +1,167 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ?
+
+L'application embarque le code des dépôts du manifeste pour les parcourir hors
+ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535
+entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source
+faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc
+un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve,
+à quel offset et sur quelle longueur.
+
+Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par
+l'installation comme à la main :
+
+ ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile]
+
+Il échoue quand le transfert est vide, quand une tranche manque, ou quand un
+index promet des octets que sa tranche n'a pas — trois pannes qu'un simple
+« la compilation a réussi » ne dit pas.
+"""
+
+import argparse
+import json
+import random
+import sys
+from pathlib import Path
+
+# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris
+# pour bon. Le seul dépôt odoo en porte près de 40 000 à lui seul.
+MIN_FILES = 1000
+# Échantillon relu octet pour octet. Tout relire prendrait des minutes pour ne
+# rien apprendre de plus : une tranche fausse l'est dès le premier extrait.
+SAMPLE = 20
+SEED = 7
+
+
+def _read_json(path: Path):
+ return json.loads(path.read_text(encoding="utf-8"))
+
+
+def pack_path(repo_dir: Path, chunk: int) -> Path:
+ """Nom de tranche, tel que l'écrit le plugin de compilation."""
+ return repo_dir / ("pack-%03d.bin" % chunk)
+
+
+def read_from_pack(repo_dir: Path, entry: dict) -> bytes:
+ """Relit un fichier depuis sa tranche. Lève si l'index et le pack mentent."""
+ chunk = entry["chunk"]
+ path = pack_path(repo_dir, chunk)
+ size = entry.get("size", 0)
+ offset = entry.get("offset", 0)
+ if not path.is_file():
+ raise FileNotFoundError(f"tranche absente : {path.name}")
+ if offset + size > path.stat().st_size:
+ raise ValueError(
+ f"{path.name} fait {path.stat().st_size} o, l'index y demande"
+ f" {size} o à {offset}"
+ )
+ with open(path, "rb") as fh:
+ fh.seek(offset)
+ data = fh.read(size)
+ if len(data) != size:
+ raise ValueError(f"{path.name} : {len(data)} o lus au lieu de {size}")
+ return data
+
+
+def check(
+ mobile_root: Path, workspace: Path = None, min_files: int = None
+) -> dict:
+ """Vérifie le transfert et rend un compte-rendu.
+
+ `workspace` : racine du checkout ERPLibre. Fournie, un échantillon est
+ comparé OCTET POUR OCTET à la source — c'est la seule vérification qui
+ prouve un transfert fidèle, et non seulement cohérent.
+ """
+ # Résolu à l'APPEL, et non dans la signature : un défaut lié à la
+ # définition ne suit pas la constante si un appelant la change.
+ min_files = MIN_FILES if min_files is None else min_files
+ base = mobile_root / "dist" / "repos"
+ manifest = base / "manifest.json"
+ if not manifest.is_file():
+ raise FileNotFoundError(
+ f"aucun transfert : {manifest} est absent (npm run build ?)"
+ )
+ repos = _read_json(manifest)
+ report = {
+ "repos": len(repos),
+ "files": 0,
+ "packs": 0,
+ "checked": 0,
+ "compared": 0,
+ }
+ sample_pool = []
+ for proj in repos:
+ repo_dir = base / proj["slug"]
+ index = repo_dir / "index.json"
+ if not index.is_file():
+ raise FileNotFoundError(f"{proj['slug']} : index.json absent")
+ entries = _read_json(index)
+ files = [
+ e
+ for e in entries
+ if e.get("type") == "file" and e.get("chunk") is not None
+ ]
+ report["files"] += len(files)
+ report["packs"] += len(list(repo_dir.glob("pack-*.bin")))
+ sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")]
+
+ if report["files"] < min_files:
+ raise ValueError(
+ f"transfert trop maigre : {report['files']} fichiers pour"
+ f" {report['repos']} dépôts (au moins {min_files} attendus)"
+ )
+
+ random.seed(SEED)
+ for proj, repo_dir, entry in random.sample(
+ sample_pool, min(SAMPLE, len(sample_pool))
+ ):
+ data = read_from_pack(repo_dir, entry)
+ report["checked"] += 1
+ if workspace is None:
+ continue
+ src = workspace / proj["path"] / entry["path"]
+ if not src.is_file():
+ continue
+ if data != src.read_bytes():
+ raise ValueError(
+ f"{proj['slug']} : {entry['path']} diffère de la source"
+ )
+ report["compared"] += 1
+ return report
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument(
+ "mobile_root",
+ nargs="?",
+ default="mobile/erplibre_home_mobile",
+ help="racine du dépôt mobile (défaut : mobile/erplibre_home_mobile)",
+ )
+ parser.add_argument(
+ "--workspace",
+ default="",
+ help="racine ERPLibre, pour comparer un échantillon à la source",
+ )
+ args = parser.parse_args()
+ root = Path(args.mobile_root)
+ ws = Path(args.workspace) if args.workspace else None
+ try:
+ rep = check(root, ws)
+ except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
+ print(f" ⚠ transfert des dépôts : {exc}")
+ return 1
+ extra = (
+ f", {rep['compared']} comparés à la source" if rep["compared"] else ""
+ )
+ print(
+ f" {rep['repos']} dépôts, {rep['files']} fichiers en"
+ f" {rep['packs']} tranches ({rep['checked']} relus{extra})"
+ )
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py
index 501fe76..dd80601 100755
--- a/script/odoo/migration/smoke_public_url.py
+++ b/script/odoo/migration/smoke_public_url.py
@@ -138,6 +138,15 @@ def start_server(database, port, config_path="./config.conf", log_path=None):
nomme la vue fautive faisait partie des absentes. Un fichier se relit
entièrement, quand on veut.
"""
+ # Garder l'exécution PRÉCÉDENTE. Le journal était ouvert en « w » :
+ # relancer le test effaçait la trace de l'échec qu'on venait de voir,
+ # et il ne restait plus rien à examiner. Une seule génération suffit —
+ # c'est celle d'avant qu'on vient chercher.
+ if log_path and os.path.isfile(log_path):
+ try:
+ os.replace(log_path, log_path + ".1")
+ except OSError:
+ pass
handle = open(log_path, "w", encoding="utf-8") if log_path else None
server = subprocess.Popen(
[
@@ -179,23 +188,36 @@ def read_log(log_path):
def fetch(url, timeout=30):
- """(statut, corps). Statut 0 quand la connexion elle-même échoue."""
+ """(statut, corps, url finale). Statut 0 si la connexion échoue.
+
+ L'URL FINALE, pas seulement celle qu'on a demandée. Sur ce site
+ chaque page traverse deux ou trois redirections — mesuré, 146 pour
+ 55 pages — et quand la dernière rend 500, l'outil nommait la
+ première. On allait vérifier une page saine et l'on concluait que le
+ test se trompait.
+ """
try:
with urllib.request.urlopen(url, timeout=timeout) as answer:
- return answer.getcode(), answer.read().decode(
- "utf-8", errors="replace"
+ return (
+ answer.getcode(),
+ answer.read().decode("utf-8", errors="replace"),
+ answer.geturl(),
)
except urllib.error.HTTPError as exc:
- return exc.code, exc.read().decode("utf-8", errors="replace")
+ return (
+ exc.code,
+ exc.read().decode("utf-8", errors="replace"),
+ exc.url or url,
+ )
except Exception:
- return 0, ""
+ return 0, "", url
def wait_ready(base_url, timeout=180, sleep=2):
"""Attendre que le serveur réponde. False s'il n'est jamais venu."""
deadline = time.time() + timeout
while time.time() < deadline:
- status, _body = fetch(base_url + "/web/login", timeout=5)
+ status, _body, _fin = fetch(base_url + "/web/login", timeout=5)
if status:
return True
time.sleep(sleep)
@@ -209,7 +231,7 @@ def sitemap_urls(base_url):
servie en local. Garder le domaine ferait interroger la production —
c'est le genre d'erreur qui ne se voit qu'après.
"""
- status, body = fetch(base_url + "/sitemap.xml")
+ status, body, _fin = fetch(base_url + "/sitemap.xml")
if not status or status >= 400:
return [], status
lst_loc = RE_LOC.findall(body)
@@ -217,7 +239,7 @@ def sitemap_urls(base_url):
if "= 400:
- lst_failure.append((url, status, []))
+ lst_failure.append((url, status, [], finale))
return lst_failure
@@ -324,7 +349,7 @@ def culprit_keys(database, lst_failure):
exactement la recopie où l'on se trompe.
"""
lst_id = []
- for _url, _status, lst_parent in lst_failure:
+ for _url, _status, lst_parent, _finale in lst_failure:
for parent_id in lst_parent:
if parent_id not in lst_id:
lst_id.append(parent_id)
@@ -355,9 +380,14 @@ def render(lst_url, lst_failure, lst_key=None):
f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}"
f" {t('public URL(s) failed')} :"
]
- for url, status, lst_parent in lst_failure:
+ for url, status, lst_parent, finale in lst_failure:
label = status or t("no answer")
lines.append(f" [{label}] {url}")
+ # L'URL du sitemap n'est pas celle qui a échoué quand une
+ # redirection s'est interposée. Ne montrer que la première
+ # envoyait vérifier une page saine.
+ if finale and finale != url:
+ lines.append(f" → {t('failed at')} {finale}")
if lst_parent:
lines.append(
f" {t('parent view(s) in cause')} :"
@@ -747,7 +777,7 @@ def recheck_after_reset(
f"{t('The server never answered on')} {base_url}"
)
lst_again = check_urls(
- [url for url, _s, _p in lst_failure], timeout=timeout
+ [echec[0] for echec in lst_failure], timeout=timeout
)
if internal_needs_retry(internal_report):
reprise = internal_phase(
@@ -906,7 +936,7 @@ def main(argv=None):
f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
f" {t('failing URL(s) after the reset')} :"
)
- print(render([url for url, _s, _p in lst_failure], lst_again, None))
+ print(render([echec[0] for echec in lst_failure], lst_again, None))
return 1 if (lst_again or internal_failed) else 0
diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md
index b031ad7..cf6668a 100644
--- a/script/qemu/README.base.md
+++ b/script/qemu/README.base.md
@@ -262,6 +262,117 @@ 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.
+When a VM is graphical, the menu also offers a **check list of development
+tools**: PyCharm Community (installed from the official
+JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre
+checkout — the Community line, because the unified 2025.3 build stops on a
+licence screen and never opens a project), Android
+Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64
+only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions.
+
+The extension packages of the distribution are installed but left disabled —
+their UUID is not reliably known, and the Extension Manager is there to pick
+from. Three extensions named by UUID are installed **and enabled**, straight
+from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive
+is fetched for the GNOME Shell version actually running in the VM — the same
+endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL
+would install a build made for another release. A mismatched build is never
+loaded by GNOME anyway: it compares `metadata.json` with its own version and
+shows the extension as outdated rather than breaking the session.
+
+The tools are installed **before** the clone and the ERPLibre install, and
+the order matters: PyCharm writes the repository's `.idea/` the first time it
+opens the project, and the install that follows runs
+`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`,
+which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under
+Xvfb — a virtual framebuffer inside the guest, so the orchestrating host
+needs no graphics at all — with the trust, privacy and data-sharing dialogs
+answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is
+written in 195 s, and the install then adds its exclusions to the `.iml`.
+When Xvfb is unavailable or the IDE does not get there in five minutes, the
+log says so and the install carries on.
+
+A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds
+the mobile repository to the manifest (which is additive, so it coexists with
+an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17,
+command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece
+— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`,
+and finally `npm test`. **A failed build fails the VM**: the exit code reaches
+the dashboard, and the log names the probable cause instead of leaving a
+40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle
+mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the
+machine's RAM, swap and oom-kill count, because a memory cause is proven and
+not assumed), or too many asset files for one APK. The heavy output goes to
+`~/erplibre-mobile-build.log` inside the VM so the install log stays readable.
+
+That last cause is fixed rather than avoided. The app carries the manifest
+repositories so their code can be browsed offline, and an APK is a ZIP capped at
+65535 entries — one file per source asked for 123 678 and the build stopped
+there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per
+repository saying which slice holds a file, at which offset and length. The
+reader asks for a byte range, and falls back to the whole slice when the WebView
+server ignores `Range` — 4 MB at worst, which is why the slices are bounded.
+Raster images are left out: addon screenshots, in a browser that shows text.
+
+Images are packed too, and a packed file has no URL of its own: the reader turns
+its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped —
+41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that
+Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them
+back, `BUNDLE_SKIP_IMG=1` drops the images.
+
+Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB
+with **2 844 entries**, and 20 files read back from the packs identical byte for
+byte to their source. The APK does not follow the payload — text compresses,
+PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The
+install verifies the transfer with `script/mobile/check_bundle_transfer.py`,
+which also runs on its own, and a failed transfer fails the VM — an app that
+does not carry the code it is meant to show is not the app that was asked for.
+
+It is bounded to apt-based distributions, because that upstream installer
+starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio
+— a plain server VM builds the APK — and when Android Studio is also ticked
+they share one SDK through `ANDROID_HOME`. Without Android, the same app runs
+in a browser: `npm start`.
+
+A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the
+QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator
+without a window and hands you the adb tunnel and the scrcpy command. Prefer
+that to a window over X11 — scrcpy receives H.264 encoded by the device, where
+`ssh -X` ships every frame as raw pixels in software rendering. If you do want
+the window, the path must be absolute, because `ssh host 'command'` reads
+neither `~/.profile` nor `~/.bashrc`:
+`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`.
+
+It needs no desktop in the VM, but it does need KVM inside the guest, so
+nested virtualisation on the host; the log says so when `/dev/kvm` is missing.
+The device is not frozen: the SDK is asked for its profiles and the newest
+plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet).
+Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a
+black screen, and `swiftshader_indirect` no longer exists, the emulator
+answering `Selected GPU option ... is not valid`.
+
+A sixth, **Forgejo**, installs a self-hosted git forge — the software behind
+Codeberg — from the project's official static binary, and leaves it serving on
+port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop,
+and unlike it no package family is excluded: the binary is static, so the same
+file serves apt, dnf, pacman and zypper. That is what makes it portable across
+the ERPLibre platforms without a branch per distribution. Architectures follow
+upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on
+s390x rather than dropping a binary that cannot run.
+
+The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for
+an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the
+published checksum, writes all four secrets itself so the service never needs
+to rewrite its own configuration, and stores its data in SQLite so it does not
+dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe —
+1.5 s measured with everything in place: it skips a binary already at the right
+version, never overwrites an existing `app.ini`, and does not recreate the
+administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
+a few others tune it; `--help` lists them.
+
+Each tool is filtered per VM — by architecture, desktop flavour and package
+family — and its disk cost is added to the plan before anything is created.
+
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
@@ -279,9 +390,41 @@ parameters and builds the command for you.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
+- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the
+ host has a render node), `on` (force), `off` (software rendering).
+- `--gpu-node` — which render node to use, on a multi-GPU host.
+- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The
+ TODO menu passes its own language.
+- `--erplibre-dir` — where ERPLibre will live in the VM
+ (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre
+ section to the login guide; omitted, that section is left out.
+- `--erplibre-make` — the make target that installed the VM
+ (e.g. `install_odoo_18`), shown in the guide as the way to update it.
+- `--no-git-identity` — do not copy the host's `user.name`, `user.email`
+ and `core.editor` into the VM's `~/.gitconfig`.
Run `./script/qemu/deploy_qemu.py --help` for the full list.
+## Login guide (`/etc/motd`)
+
+Every VM greets you, at each interactive SSH login, with the commands of
+**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the
+ERPLibre ones (edit the server, restart it, update modules, update Odoo,
+inspect the instance, open the TODO menu). It is written by cloud-init, so
+it is there from the first boot: before ERPLibre is installed, and still
+there if that installation fails, which is exactly when you log in by hand.
+
+`--dry-run` prints the generated guide along with the rest of the user-data.
+The guide is not shown to `ssh host 'command'`, so it never pollutes an
+installation log.
+
+The host's git identity travels with it, into the VM's `~/.gitconfig`: a
+commit made in the VM then carries your name instead of
+`erplibre@`. The editor follows the same route — `core.editor`, the
+`config.conf` line of the guide, and the package installed in the VM all
+come from one table, so the guide never names a command the VM does not
+have.
+
L'utilisateur par défaut est `erplibre` (modifiable avec `--user`).
@@ -294,6 +437,127 @@ 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.
+Quand une VM est graphique, le menu propose en plus une **liste à cocher
+d'outils de développement** : PyCharm Community (posé depuis l'archive
+officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt
+ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un
+écran de licence et n'ouvre jamais de projet),
+Android Studio (`/opt/android-studio`, commande `studio` ou
+`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux
+aarch64) et un jeu
+d'extensions GNOME suggérées.
+
+Les extensions empaquetées par la distribution sont installées sans être
+activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire
+d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont,
+elles, installées **et activées**, directement depuis extensions.gnome.org :
+**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de
+GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile
+v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une
+version faite pour une autre release. Une archive mal appariée n'est de toute
+façon jamais chargée par GNOME : il compare `metadata.json` à sa propre
+version et affiche l'extension comme obsolète plutôt que de casser la session.
+
+Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et
+l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du
+projet, et l'installation qui suit y lance `pycharm_configuration.py`
+(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de
+`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous
+Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte
+qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres
+de confiance, de confidentialité et de partage de données répondues d'avance.
+Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et
+l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou
+si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation
+continue.
+
+Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il
+ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une
+installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17,
+outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et
+sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`,
+`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait
+échouer la VM** : le code de sortie remonte au tableau de bord, et le journal
+NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire :
+disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non
+acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de
+l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer),
+ou trop de fichiers d'assets pour un APK. Le détail va dans
+`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation
+reste lisible.
+
+Cette dernière cause est corrigée, et non contournée. L'application embarque les
+dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP
+borné à 65535 entrées — un fichier par source en réclamait 123 678, et la
+compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** :
+des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche
+se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande
+un intervalle d'octets, et retombe sur la tranche entière quand le serveur du
+WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont
+bornées. Les images matricielles restent dehors : des captures d'écran
+d'addons, dans un navigateur qui montre du texte.
+
+Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre :
+le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont
+écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un
+contenu que Weblate maintient et que personne ne lit sur un téléphone.
+`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images.
+
+Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de
+354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques
+octet pour octet à leur source. L'APK ne suit pas la charge — le texte se
+compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo
+d'APK. L'installation vérifie le transfert avec
+`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un
+transfert manqué fait échouer la VM — une application qui ne porte pas le code
+qu'elle est censée montrer n'est pas l'application demandée.
+
+Il est borné aux distributions apt, parce que cet installateur amont commence
+par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une
+simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les
+deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même
+application tourne dans un navigateur : `npm start`.
+
+Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis
+le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre
+l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy.
+Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR
+l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en
+rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car
+`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` :
+`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`.
+
+Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc
+la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm`
+manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le
+plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette).
+Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un
+écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant
+`Selected GPU option ... is not valid`.
+
+Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel
+derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse
+en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation
+mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de
+paquets n'est exclue : le binaire est statique, donc le même fichier sert apt,
+dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes
+ERPLibre sans une branche par distribution. Les architectures suivent l'amont,
+qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de
+poser un binaire qui ne s'exécutera pas.
+
+Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une
+machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme
+de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait
+jamais à réécrire sa propre configuration, et garde ses données en SQLite pour
+ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et
+bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la
+bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
+l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
+et quelques autres le règlent ; `--help` les énumère.
+
+Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
+de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.
+
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
@@ -314,9 +578,40 @@ vous.
- `--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.
+- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si
+ l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel).
+- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes.
+- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou
+ `en`. Le menu TODO passe la sienne.
+- `--erplibre-dir` — où ERPLibre sera installé dans la VM
+ (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section
+ ERPLibre au guide de connexion ; omis, cette section est laissée de côté.
+- `--erplibre-make` — la cible make qui a installé la VM
+ (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour.
+- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et
+ `core.editor` de l'hôte dans le `~/.gitconfig` de la VM.
Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète.
+## Guide de connexion (`/etc/motd`)
+
+Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa**
+distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre :
+éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour
+Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init,
+donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et
+encore là si elle échoue — le moment où l'on se connecte justement à la main.
+
+`--dry-run` affiche le guide généré avec le reste du user-data. Il ne
+s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation
+restent nets.
+
+L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM :
+un commit fait dans la VM porte alors votre nom plutôt que
+`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la
+ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une
+seule table, de sorte que le guide ne nomme jamais une commande absente.
+
## Managing VMs
@@ -392,6 +687,51 @@ 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.
+## 3D acceleration (host GPU)
+
+A graphical VM without acceleration renders everything on the CPU — the
+desktop, and the Android emulator running inside it. The deployment therefore
+takes the host GPU **by default** (`--gpu auto`): when the host exposes a
+render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless`
+display that carries the OpenGL context **beside** the VNC console — it opens
+no port and replaces nothing. No render node, no 3D, and the deployment says
+why instead of quietly falling back.
+
+```bash
+ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D
+sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless"
+```
+
+An existing VM is adjusted from the TODO menu **while it is shut off**:
+libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
+[2] Change the state`, then either accept *Adjust hardware before starting*,
+or take `[3] Adjust hardware only`. In a form when Textual is available, in
+prompts otherwise, it sets:
+
+- **vCPU, RAM, autostart** — the plain sizing knobs.
+- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU
+ instructions over as they are: that is what makes nested virtualization
+ possible *inside* the VM. `host-model` describes an equivalent model,
+ migratable to another machine.
+- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the
+ QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU
+ libvirt writes it into the XML and QEMU never receives it (check with
+ `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram.
+- **Network** — the libvirt networks and the host bridges, the latter to put
+ the VM on the LAN (see the bridge section below). Switching keeps the MAC
+ address and the PCI slot, so the guest finds *its* card again — same
+ interface name, same DHCP lease.
+
+Two things worth knowing:
+
+- A host that is **itself a VM** has no render node unless a GPU was handed
+ down to it. Nested without passthrough, 3D is out of reach: the Android
+ emulator then runs on SwiftShader, and no option changes that.
+- Once the VM does have 3D, the emulator can be tried with `-gpu host`
+ instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`.
+ It stays a manual test — an emulator whose GL context fails hangs instead
+ of falling back, so `swangle` remains the default.
+
## 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
@@ -404,6 +744,53 @@ middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
+## Accélération 3D (GPU de l'hôte)
+
+Une VM graphique sans accélération rend tout par le processeur — le bureau
+comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU
+de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu,
+la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui
+porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port
+et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit
+pourquoi au lieu de retomber en silence.
+
+```bash
+ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D
+sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless"
+```
+
+Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est
+éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM ›
+Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de
+démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
+Textual est présent, en invites sinon, il règle :
+
+- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire.
+- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du
+ processeur hôte telles quelles : c'est lui qui rend la virtualisation
+ imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent,
+ migrable vers une autre machine.
+- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la
+ ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu,
+ libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec
+ `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la
+ consomme.
+- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour
+ poser la VM sur le LAN (voir la section du pont plus bas). Le basculement
+ garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte,
+ donc son nom d'interface et son bail DHCP.
+
+Deux choses à savoir :
+
+- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU
+ lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte :
+ l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y
+ change rien.
+- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt
+ qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça
+ reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu
+ au lieu de retomber, d'où `swangle` par défaut.
+
## 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
diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md
index af7b826..fcff7ef 100644
--- a/script/qemu/README.fr.md
+++ b/script/qemu/README.fr.md
@@ -146,6 +146,127 @@ 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.
+Quand une VM est graphique, le menu propose en plus une **liste à cocher
+d'outils de développement** : PyCharm Community (posé depuis l'archive
+officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt
+ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un
+écran de licence et n'ouvre jamais de projet),
+Android Studio (`/opt/android-studio`, commande `studio` ou
+`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux
+aarch64) et un jeu
+d'extensions GNOME suggérées.
+
+Les extensions empaquetées par la distribution sont installées sans être
+activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire
+d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont,
+elles, installées **et activées**, directement depuis extensions.gnome.org :
+**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de
+GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile
+v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une
+version faite pour une autre release. Une archive mal appariée n'est de toute
+façon jamais chargée par GNOME : il compare `metadata.json` à sa propre
+version et affiche l'extension comme obsolète plutôt que de casser la session.
+
+Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et
+l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du
+projet, et l'installation qui suit y lance `pycharm_configuration.py`
+(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de
+`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous
+Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte
+qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres
+de confiance, de confidentialité et de partage de données répondues d'avance.
+Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et
+l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou
+si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation
+continue.
+
+Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il
+ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une
+installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17,
+outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et
+sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`,
+`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait
+échouer la VM** : le code de sortie remonte au tableau de bord, et le journal
+NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire :
+disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non
+acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de
+l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer),
+ou trop de fichiers d'assets pour un APK. Le détail va dans
+`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation
+reste lisible.
+
+Cette dernière cause est corrigée, et non contournée. L'application embarque les
+dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP
+borné à 65535 entrées — un fichier par source en réclamait 123 678, et la
+compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** :
+des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche
+se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande
+un intervalle d'octets, et retombe sur la tranche entière quand le serveur du
+WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont
+bornées. Les images matricielles restent dehors : des captures d'écran
+d'addons, dans un navigateur qui montre du texte.
+
+Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre :
+le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont
+écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un
+contenu que Weblate maintient et que personne ne lit sur un téléphone.
+`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images.
+
+Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de
+354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques
+octet pour octet à leur source. L'APK ne suit pas la charge — le texte se
+compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo
+d'APK. L'installation vérifie le transfert avec
+`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un
+transfert manqué fait échouer la VM — une application qui ne porte pas le code
+qu'elle est censée montrer n'est pas l'application demandée.
+
+Il est borné aux distributions apt, parce que cet installateur amont commence
+par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une
+simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les
+deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même
+application tourne dans un navigateur : `npm start`.
+
+Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis
+le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre
+l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy.
+Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR
+l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en
+rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car
+`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` :
+`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`.
+
+Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc
+la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm`
+manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le
+plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette).
+Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un
+écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant
+`Selected GPU option ... is not valid`.
+
+Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel
+derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse
+en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation
+mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de
+paquets n'est exclue : le binaire est statique, donc le même fichier sert apt,
+dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes
+ERPLibre sans une branche par distribution. Les architectures suivent l'amont,
+qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de
+poser un binaire qui ne s'exécutera pas.
+
+Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une
+machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme
+de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait
+jamais à réécrire sa propre configuration, et garde ses données en SQLite pour
+ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et
+bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la
+bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
+l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
+et quelques autres le règlent ; `--help` les énumère.
+
+Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
+de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.
+
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
@@ -166,9 +287,40 @@ vous.
- `--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.
+- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si
+ l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel).
+- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes.
+- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou
+ `en`. Le menu TODO passe la sienne.
+- `--erplibre-dir` — où ERPLibre sera installé dans la VM
+ (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section
+ ERPLibre au guide de connexion ; omis, cette section est laissée de côté.
+- `--erplibre-make` — la cible make qui a installé la VM
+ (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour.
+- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et
+ `core.editor` de l'hôte dans le `~/.gitconfig` de la VM.
Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète.
+## Guide de connexion (`/etc/motd`)
+
+Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa**
+distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre :
+éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour
+Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init,
+donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et
+encore là si elle échoue — le moment où l'on se connecte justement à la main.
+
+`--dry-run` affiche le guide généré avec le reste du user-data. Il ne
+s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation
+restent nets.
+
+L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM :
+un commit fait dans la VM porte alors votre nom plutôt que
+`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la
+ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une
+seule table, de sorte que le guide ne nomme jamais une commande absente.
+
## Gestion des VM
Lister, arrêter et supprimer les VM (le disque qcow2 sous
@@ -215,6 +367,53 @@ Host myvm
accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur
à part entière exposé sur le LAN.
+## Accélération 3D (GPU de l'hôte)
+
+Une VM graphique sans accélération rend tout par le processeur — le bureau
+comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU
+de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu,
+la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui
+porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port
+et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit
+pourquoi au lieu de retomber en silence.
+
+```bash
+ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D
+sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless"
+```
+
+Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est
+éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM ›
+Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de
+démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si
+Textual est présent, en invites sinon, il règle :
+
+- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire.
+- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du
+ processeur hôte telles quelles : c'est lui qui rend la virtualisation
+ imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent,
+ migrable vers une autre machine.
+- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la
+ ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu,
+ libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec
+ `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la
+ consomme.
+- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour
+ poser la VM sur le LAN (voir la section du pont plus bas). Le basculement
+ garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte,
+ donc son nom d'interface et son bail DHCP.
+
+Deux choses à savoir :
+
+- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU
+ lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte :
+ l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y
+ change rien.
+- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt
+ qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça
+ reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu
+ au lieu de retomber, d'où `swangle` par défaut.
+
## 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
diff --git a/script/qemu/README.md b/script/qemu/README.md
index 473a847..38cf1e2 100644
--- a/script/qemu/README.md
+++ b/script/qemu/README.md
@@ -136,6 +136,117 @@ 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.
+When a VM is graphical, the menu also offers a **check list of development
+tools**: PyCharm Community (installed from the official
+JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre
+checkout — the Community line, because the unified 2025.3 build stops on a
+licence screen and never opens a project), Android
+Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64
+only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions.
+
+The extension packages of the distribution are installed but left disabled —
+their UUID is not reliably known, and the Extension Manager is there to pick
+from. Three extensions named by UUID are installed **and enabled**, straight
+from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive
+is fetched for the GNOME Shell version actually running in the VM — the same
+endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL
+would install a build made for another release. A mismatched build is never
+loaded by GNOME anyway: it compares `metadata.json` with its own version and
+shows the extension as outdated rather than breaking the session.
+
+The tools are installed **before** the clone and the ERPLibre install, and
+the order matters: PyCharm writes the repository's `.idea/` the first time it
+opens the project, and the install that follows runs
+`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`,
+which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under
+Xvfb — a virtual framebuffer inside the guest, so the orchestrating host
+needs no graphics at all — with the trust, privacy and data-sharing dialogs
+answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is
+written in 195 s, and the install then adds its exclusions to the `.iml`.
+When Xvfb is unavailable or the IDE does not get there in five minutes, the
+log says so and the install carries on.
+
+A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds
+the mobile repository to the manifest (which is additive, so it coexists with
+an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17,
+command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece
+— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`,
+and finally `npm test`. **A failed build fails the VM**: the exit code reaches
+the dashboard, and the log names the probable cause instead of leaving a
+40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle
+mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the
+machine's RAM, swap and oom-kill count, because a memory cause is proven and
+not assumed), or too many asset files for one APK. The heavy output goes to
+`~/erplibre-mobile-build.log` inside the VM so the install log stays readable.
+
+That last cause is fixed rather than avoided. The app carries the manifest
+repositories so their code can be browsed offline, and an APK is a ZIP capped at
+65535 entries — one file per source asked for 123 678 and the build stopped
+there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per
+repository saying which slice holds a file, at which offset and length. The
+reader asks for a byte range, and falls back to the whole slice when the WebView
+server ignores `Range` — 4 MB at worst, which is why the slices are bounded.
+Raster images are left out: addon screenshots, in a browser that shows text.
+
+Images are packed too, and a packed file has no URL of its own: the reader turns
+its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped —
+41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that
+Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them
+back, `BUNDLE_SKIP_IMG=1` drops the images.
+
+Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB
+with **2 844 entries**, and 20 files read back from the packs identical byte for
+byte to their source. The APK does not follow the payload — text compresses,
+PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The
+install verifies the transfer with `script/mobile/check_bundle_transfer.py`,
+which also runs on its own, and a failed transfer fails the VM — an app that
+does not carry the code it is meant to show is not the app that was asked for.
+
+It is bounded to apt-based distributions, because that upstream installer
+starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio
+— a plain server VM builds the APK — and when Android Studio is also ticked
+they share one SDK through `ANDROID_HOME`. Without Android, the same app runs
+in a browser: `npm start`.
+
+A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the
+QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator
+without a window and hands you the adb tunnel and the scrcpy command. Prefer
+that to a window over X11 — scrcpy receives H.264 encoded by the device, where
+`ssh -X` ships every frame as raw pixels in software rendering. If you do want
+the window, the path must be absolute, because `ssh host 'command'` reads
+neither `~/.profile` nor `~/.bashrc`:
+`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`.
+
+It needs no desktop in the VM, but it does need KVM inside the guest, so
+nested virtualisation on the host; the log says so when `/dev/kvm` is missing.
+The device is not frozen: the SDK is asked for its profiles and the newest
+plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet).
+Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a
+black screen, and `swiftshader_indirect` no longer exists, the emulator
+answering `Selected GPU option ... is not valid`.
+
+A sixth, **Forgejo**, installs a self-hosted git forge — the software behind
+Codeberg — from the project's official static binary, and leaves it serving on
+port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop,
+and unlike it no package family is excluded: the binary is static, so the same
+file serves apt, dnf, pacman and zypper. That is what makes it portable across
+the ERPLibre platforms without a branch per distribution. Architectures follow
+upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on
+s390x rather than dropping a binary that cannot run.
+
+The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for
+an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the
+published checksum, writes all four secrets itself so the service never needs
+to rewrite its own configuration, and stores its data in SQLite so it does not
+dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe —
+1.5 s measured with everything in place: it skips a binary already at the right
+version, never overwrites an existing `app.ini`, and does not recreate the
+administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
+a few others tune it; `--help` lists them.
+
+Each tool is filtered per VM — by architecture, desktop flavour and package
+family — and its disk cost is added to the plan before anything is created.
+
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
@@ -153,9 +264,41 @@ parameters and builds the command for you.
- `--no-install-deps` — never auto-install dependencies.
- `--dry-run` — show the commands without executing anything.
- `--force` — overwrite the existing working qcow2 disk.
+- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the
+ host has a render node), `on` (force), `off` (software rendering).
+- `--gpu-node` — which render node to use, on a multi-GPU host.
+- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The
+ TODO menu passes its own language.
+- `--erplibre-dir` — where ERPLibre will live in the VM
+ (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre
+ section to the login guide; omitted, that section is left out.
+- `--erplibre-make` — the make target that installed the VM
+ (e.g. `install_odoo_18`), shown in the guide as the way to update it.
+- `--no-git-identity` — do not copy the host's `user.name`, `user.email`
+ and `core.editor` into the VM's `~/.gitconfig`.
Run `./script/qemu/deploy_qemu.py --help` for the full list.
+## Login guide (`/etc/motd`)
+
+Every VM greets you, at each interactive SSH login, with the commands of
+**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the
+ERPLibre ones (edit the server, restart it, update modules, update Odoo,
+inspect the instance, open the TODO menu). It is written by cloud-init, so
+it is there from the first boot: before ERPLibre is installed, and still
+there if that installation fails, which is exactly when you log in by hand.
+
+`--dry-run` prints the generated guide along with the rest of the user-data.
+The guide is not shown to `ssh host 'command'`, so it never pollutes an
+installation log.
+
+The host's git identity travels with it, into the VM's `~/.gitconfig`: a
+commit made in the VM then carries your name instead of
+`erplibre@`. The editor follows the same route — `core.editor`, the
+`config.conf` line of the guide, and the package installed in the VM all
+come from one table, so the guide never names a command the VM does not
+have.
+
## Managing VMs
List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images`
@@ -202,6 +345,51 @@ 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.
+## 3D acceleration (host GPU)
+
+A graphical VM without acceleration renders everything on the CPU — the
+desktop, and the Android emulator running inside it. The deployment therefore
+takes the host GPU **by default** (`--gpu auto`): when the host exposes a
+render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless`
+display that carries the OpenGL context **beside** the VNC console — it opens
+no port and replaces nothing. No render node, no 3D, and the deployment says
+why instead of quietly falling back.
+
+```bash
+ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D
+sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless"
+```
+
+An existing VM is adjusted from the TODO menu **while it is shut off**:
+libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs ›
+[2] Change the state`, then either accept *Adjust hardware before starting*,
+or take `[3] Adjust hardware only`. In a form when Textual is available, in
+prompts otherwise, it sets:
+
+- **vCPU, RAM, autostart** — the plain sizing knobs.
+- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU
+ instructions over as they are: that is what makes nested virtualization
+ possible *inside* the VM. `host-model` describes an equivalent model,
+ migratable to another machine.
+- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the
+ QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU
+ libvirt writes it into the XML and QEMU never receives it (check with
+ `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram.
+- **Network** — the libvirt networks and the host bridges, the latter to put
+ the VM on the LAN (see the bridge section below). Switching keeps the MAC
+ address and the PCI slot, so the guest finds *its* card again — same
+ interface name, same DHCP lease.
+
+Two things worth knowing:
+
+- A host that is **itself a VM** has no render node unless a GPU was handed
+ down to it. Nested without passthrough, 3D is out of reach: the Android
+ emulator then runs on SwiftShader, and no option changes that.
+- Once the VM does have 3D, the emulator can be tried with `-gpu host`
+ instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`.
+ It stays a manual test — an emulator whose GL context fails hangs instead
+ of falling back, so `swangle` remains the default.
+
## 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
diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py
index 4f483de..c253bf0 100755
--- a/script/qemu/deploy_qemu.py
+++ b/script/qemu/deploy_qemu.py
@@ -46,9 +46,12 @@ Exemples
from __future__ import annotations
import argparse
+import base64
import getpass
import grp
+import gzip
import hashlib
+import zlib
import os
import re
import shutil
@@ -173,6 +176,13 @@ S390X_DISTROS: tuple[str, ...] = (
"rocky",
"fedora",
"opensuse",
+ # Debian n'a PAS d'image cloud s390x, et n'en aura pas par cette voie :
+ # vérifié sur cloud.debian.org, les arborescences bookworm et trixie ne
+ # publient que amd64, arm64, ppc64el et riscv64. Le port s390x existe
+ # pourtant — « binary-s390x » répond 200 — et debian-installer livre
+ # kernel + initrd pour les deux versions. On y passe donc par
+ # l'INSTALLATEUR au lieu d'un qcow2 tout fait : voir uses_installer().
+ "debian",
)
# Une distro peut ne publier qu'une PARTIE de ses versions sur une
@@ -184,9 +194,33 @@ S390X_DISTROS: tuple[str, ...] = (
# (404 sur le miroir maître), la 44 n'est pour l'instant que sur certains
# miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié.
ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = {
- "s390x": {"fedora": ("43",)},
+ # Debian sur s390x passe par debian-installer, dont les images sont
+ # publiées pour bookworm et trixie — vérifié. bullseye est écartée : elle
+ # est en fin de vie et son installateur n'a pas été éprouvé ici.
+ "s390x": {"fedora": ("43",), "debian": ("12", "13")},
}
+# Distros installées par debian-installer plutôt que depuis une image cloud.
+# La différence n'est pas cosmétique : pas de qcow2 à convertir, pas de seed
+# cloud-init, un disque VIERGE et un amorçage kernel+initrd.
+INSTALLER_COMBOS: tuple[tuple[str, str], ...] = (("debian", "s390x"),)
+
+# Plancher mémoire de l'installateur : il déplie un système de fichiers entier
+# en RAM, là où une image cloud arrive déjà installée.
+INSTALLER_MIN_RAM = 2048
+
+# kernel.debian / initrd.debian du port s390x. « current » suit les mises à
+# jour de l'installateur sans figer un numéro qui périmerait.
+INSTALLER_URL = (
+ "https://deb.debian.org/debian/dists/{code}/main/installer-s390x"
+ "/current/images/generic/{fichier}"
+)
+
+
+def uses_installer(distro: str, arch: str) -> bool:
+ """Vrai si cette combinaison s'installe par debian-installer."""
+ return (distro, arch) in INSTALLER_COMBOS
+
def arch_versions(distro: str, arch: str, versions) -> list[str]:
"""Versions de `distro` réellement publiées pour `arch`."""
@@ -226,6 +260,103 @@ def host_arch() -> str:
}.get(machine, "amd64")
+# Nœud de rendu DRM : le fichier que le processus QEMU ouvre pour créer un
+# contexte OpenGL (virgl) et donner la 3D à la VM. Un hôte sans GPU — ou
+# lui-même virtualisé sans GPU transmis — n'expose AUCUN « renderD* », et
+# aucune option de ligne de commande ne peut y suppléer : la VM retombe alors
+# sur le rendu logiciel. On teste donc la présence du nœud, pas nos droits
+# dessus : l'accès est accordé par libvirt au démarrage du domaine (cgroup +
+# étiquette), et un test de lecture sous notre propre compte rejetterait à
+# tort un hôte où seul le groupe « render » entre.
+HOST_DRI_DIR = Path("/dev/dri")
+
+
+def host_render_nodes(directory=HOST_DRI_DIR) -> list[str]:
+ """Nœuds de rendu de l'hôte, triés (ex. ['/dev/dri/renderD128'])."""
+ directory = Path(directory)
+ try:
+ names = sorted(p.name for p in directory.iterdir())
+ except OSError:
+ return []
+ return [str(directory / n) for n in names if n.startswith("renderD")]
+
+
+def host_gpu_node(directory=HOST_DRI_DIR) -> str:
+ """Nœud de rendu à confier à QEMU, ou '' si l'hôte n'a pas de GPU.
+
+ Le premier de la liste : sur une machine à plusieurs cartes, renderD128
+ est le nœud du GPU primaire. --gpu-node force un autre choix.
+ """
+ nodes = host_render_nodes(directory)
+ return nodes[0] if nodes else ""
+
+
+def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]:
+ """(3D activée, message à dire) pour un mode --gpu et un nœud donnés.
+
+ Séparée du reste pour être vérifiable sans hôte : c'est ici que se décide
+ « par défaut avec GPU s'il existe », et le silence n'est pas une option —
+ une VM en rendu logiciel doit dire pourquoi.
+ """
+ mode = (mode or "auto").lower()
+ if mode == "off":
+ return False, ""
+ if not screen:
+ # Sans écran virtuel, la 3D n'a rien à accélérer. Le dire seulement
+ # quand elle a été demandée explicitement.
+ if mode == "on":
+ return (
+ False,
+ " GPU : pas d'écran virtuel sur cette VM, 3D ignorée.",
+ )
+ return False, ""
+ if not node:
+ if mode == "on":
+ return (
+ False,
+ " ⚠ GPU demandé mais l'hôte n'a aucun nœud de rendu"
+ " (/dev/dri/renderD*) : la VM démarrerait sans écran."
+ " Rendu logiciel.",
+ )
+ return (
+ False,
+ " GPU : aucun sur l'hôte, rendu logiciel (virgl absent).",
+ )
+ return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)."
+
+
+def gpu_apply(
+ video: list, mode: str, node: str, screen: bool
+) -> tuple[list, list, str]:
+ """(video, arguments 3D, message) — pour virt-install.
+
+ Renvoie le `--video` à garder : celui de la 3D REMPLACE le simple
+ « --video virtio », il ne s'y ajoute pas. Deux --video donneraient deux
+ écrans à la VM, et l'invité n'afficherait le bureau que sur un seul.
+ """
+ use_gpu, message = gpu_decision(mode, node, screen)
+ if not use_gpu:
+ return video, [], message
+ return [], gpu_install_args(node), message
+
+
+def gpu_install_args(node: str) -> list[str]:
+ """Arguments virt-install qui donnent la 3D à la VM.
+
+ Deux pièces indissociables : l'accélération sur le virtio-gpu, et un
+ affichage capable de contexte GL. « egl-headless » joue ce second rôle
+ SANS remplacer la console VNC — il n'ouvre aucun port, il n'existe que
+ pour porter le contexte OpenGL. C'est la recette documentée pour associer
+ 3D et VNC, là où ne vaut que pour SPICE.
+ """
+ return [
+ "--video",
+ "model.type=virtio,model.acceleration.accel3d=on",
+ "--graphics",
+ f"type=egl-headless,gl.rendernode={node}",
+ ]
+
+
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
@@ -1449,6 +1580,589 @@ def user_groups(distro: str) -> str:
return "users, wheel"
+# --------------------------------------------------------------------------- #
+# Guide de connexion (/etc/motd) et identité git de la VM
+# --------------------------------------------------------------------------- #
+# Le catalogue couvre quatre gestionnaires de paquets, et l'opérateur change de
+# distribution d'un déploiement à l'autre. Le guide met SOUS LES YEUX, à la
+# connexion, les commandes de la machine où l'on vient d'entrer : apt là où
+# c'est apt, zypper là où c'est zypper.
+#
+# Le mécanisme est /etc/motd, et il est le même partout. Vérifié dans les images
+# cloud elles-mêmes, montées en lecture seule : sshd y est en « PrintMotd no »
+# et c'est pam_motd qui affiche le fichier. Trois conséquences tenues pour
+# acquises ici :
+# - il suffit d'ÉCRIRE /etc/motd. Ajouter « PrintMotd yes » afficherait le
+# guide DEUX FOIS — sshd lit /etc/motd en dur, PAM le lit aussi ;
+# - « ssh hôte 'commande' » ne l'affiche PAS (openssh coupe les deux chemins
+# dès qu'une commande est passée), donc le suivi d'installation reste net.
+# Un « ssh hôte < script » l'afficherait, lui : aucun n'est utilisé ici ;
+# - openSUSE ajoute son « Have a lot of fun... » APRÈS le guide : il vient de
+# /usr/lib/motd.d/welcome, que pam_motd lit après le fichier. On le laisse.
+#
+# Ubuntu n'a PAS de /etc/motd (son postinst base-files ne le crée pas, à la
+# différence de Debian) : le fichier est donc créé, pas remplacé. Sur Debian il
+# écrase les cinq lignes de base-files, ce qui ne fâche pas dpkg — /etc/motd n'y
+# est ni un conffile ni même un fichier du paquet.
+
+# Étiquette lisible d'une distribution. openSUSE livre DEUX produits sous un
+# seul nom de distro (Leap, numéroté ; Tumbleweed, rolling) : la version tranche.
+DISTRO_LABELS: dict[str, str] = {
+ "ubuntu": "Ubuntu",
+ "debian": "Debian",
+ "fedora": "Fedora",
+ "almalinux": "AlmaLinux",
+ "rocky": "Rocky Linux",
+ "opensuse": "openSUSE",
+ "arch": "Arch Linux",
+}
+
+# Gestionnaire de paquets de chaque distribution du catalogue.
+DISTRO_PKG: dict[str, str] = {
+ "ubuntu": "apt",
+ "debian": "apt",
+ "fedora": "dnf",
+ "almalinux": "dnf",
+ "rocky": "dnf",
+ "opensuse": "zypper",
+ "arch": "pacman",
+}
+
+
+def distro_label(distro: str, version: str) -> str:
+ """« Ubuntu 24.04 », « openSUSE Leap 16.0 », « Arch Linux »…"""
+ name = DISTRO_LABELS.get(distro, distro)
+ if distro == "opensuse":
+ if version == "tumbleweed":
+ return f"{name} Tumbleweed"
+ return f"{name} Leap {version}"
+ if distro == "arch":
+ # Rolling release : « latest » n'apprend rien à personne.
+ return name
+ return f"{name} {version}"
+
+
+# Aide-mémoire par gestionnaire de paquets : (commande, glose fr, glose en).
+# Chaque ligne vient du manuel amont de l'outil, pas de mémoire, et doit
+# fonctionner TELLE QUELLE — c'est un guide, pas une piste à vérifier.
+#
+# dnf : les formes écrites ici valent pour dnf4 (AlmaLinux/Rocky 9 ET 10, tous
+# deux en dnf 4.x) comme pour dnf5 (Fedora 41+). Les raccourcis de dnf4 ont
+# disparu de dnf5 : « dnf history » seul, « grouplist », « whatprovides »,
+# « list installed » sans tirets y échouent tous. Les formes longues passent
+# partout, et ne coûtent rien.
+PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
+ "apt": (
+ ("sudo apt update", "rafraîchir l'index", "refresh the index"),
+ (
+ "sudo apt upgrade",
+ "mettre à jour le système",
+ "upgrade the system",
+ ),
+ ("sudo apt install ", "installer", "install"),
+ ("sudo apt remove ", "retirer", "remove"),
+ ("apt search ", "chercher", "search"),
+ ("apt show ", "détails d'un paquet", "package details"),
+ ("apt list --installed", "lister l'installé", "list installed"),
+ ("sudo apt autoremove", "purger les orphelins", "purge orphans"),
+ ),
+ "dnf": (
+ (
+ "sudo dnf upgrade",
+ "mettre à jour le système",
+ "upgrade the system",
+ ),
+ ("sudo dnf install ", "installer", "install"),
+ ("sudo dnf remove ", "retirer", "remove"),
+ ("dnf check-update", "mises à jour disponibles", "available updates"),
+ ("dnf search ", "chercher", "search"),
+ ("dnf info ", "détails d'un paquet", "package details"),
+ ("dnf list --installed", "lister l'installé", "list installed"),
+ ("dnf history list", "journal des opérations", "transaction log"),
+ ),
+ "pacman": (
+ (
+ "sudo pacman -Syu",
+ "mettre à jour le système",
+ "upgrade the system",
+ ),
+ # Jamais « -Sy » seul : la base de paquets serait à jour et le système
+ # non, donc une installation tirerait des binaires liés à des
+ # bibliothèques absentes. Arch ne supporte que la mise à jour complète,
+ # d'où la forme « -Syu » pour installer.
+ (
+ "sudo pacman -Syu ",
+ "installer (jamais « -Sy » seul)",
+ "install (never a bare « -Sy »)",
+ ),
+ ("sudo pacman -Rns ", "retirer", "remove"),
+ ("pacman -Ss ", "chercher", "search"),
+ ("pacman -Si ", "détails d'un paquet", "package details"),
+ ("pacman -Q", "lister l'installé", "list installed"),
+ ("pacman -Qdtq", "orphelins", "orphans"),
+ ("sudo pacman -Sc", "nettoyer le cache", "clean the cache"),
+ ),
+}
+
+
+def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]:
+ """Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap.
+
+ La ligne de mise à jour n'est PAS la même, et ce n'est pas une préférence de
+ style : « up » sur Leap, dont la version est figée, et « dup » sur
+ Tumbleweed, où chaque mise à jour est un instantané complet de la
+ distribution. La doc amont est catégorique — « on Tumbleweed you will never
+ have to use zypper-up » — et « up » y laisse traîner des paquets retirés des
+ dépôts, donc des dépendances bancales. Le déploiement sait laquelle des deux
+ il installe : autant que le guide le sache aussi.
+ """
+ upgrade = (
+ ("sudo zypper dup", "mettre à jour (rolling)", "upgrade (rolling)")
+ if rolling
+ else (
+ "sudo zypper up",
+ "mettre à jour le système",
+ "upgrade the system",
+ )
+ )
+ return (
+ ("sudo zypper ref", "rafraîchir les dépôts", "refresh the repos"),
+ upgrade,
+ ("sudo zypper in ", "installer", "install"),
+ ("sudo zypper rm ", "retirer", "remove"),
+ ("zypper se ", "chercher", "search"),
+ ("zypper info ", "détails d'un paquet", "package details"),
+ ("zypper se -i", "lister l'installé", "list installed"),
+ ("zypper lu", "mises à jour disponibles", "available updates"),
+ (
+ "sudo zypper ps -s",
+ "à redémarrer après MAJ",
+ "restart after upgrade",
+ ),
+ )
+
+
+# Lignes valables partout, quelle que soit la distribution.
+SYSTEM_GUIDE: tuple[tuple[str, str, str], ...] = (
+ ("hostname -I", "adresse IP de la VM", "the VM's IP address"),
+ ("df -h /", "espace disque", "disk space"),
+ ("free -h", "mémoire", "memory"),
+)
+# Ajoutées SEULEMENT quand il n'y a pas de section ERPLibre : celle-ci montre
+# déjà les deux commandes, sur un service qui existe vraiment. Sur une VM
+# déployée sans ERPLibre, elles manqueraient.
+SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = (
+ ("systemctl status ", "état d'un service", "a service's state"),
+ ("journalctl -u -f", "suivre son journal", "follow its log"),
+)
+
+
+# N'apparaît que sur une VM déployée AVEC un bureau. Vécu : GNOME installé,
+# gdm3 installé, cible graphique par défaut… et la console restait en mode texte.
+# graphical.target était déjà atteinte quand le paquet est arrivé, et une cible
+# active ne rattrape pas un service ajouté après coup. « enable » seul n'y change
+# rien sur Debian et Ubuntu — l'unité n'a pas de WantedBy, seulement un alias —
+# d'où le « --now », qui démarre.
+DESKTOP_GUIDE: tuple[tuple[str, str, str], ...] = (
+ (
+ "systemctl status display-manager",
+ "état du bureau graphique",
+ "graphical desktop state",
+ ),
+ (
+ "sudo systemctl enable --now gdm",
+ "le démarrer (« --now » : enable seul ne suffit pas)",
+ 'start it ("--now": enable alone does nothing)',
+ ),
+)
+
+
+def erplibre_guide(
+ el_dir: str, el_make: str = "", editor: str = ""
+) -> tuple[tuple[str, str, str], ...]:
+ """Commandes ERPLibre de la VM.
+
+ `el_dir` : racine de l'installation (~/git/erplibre en développement,
+ /opt/erplibre en production). Toutes les autres lignes sont relatives à ce
+ répertoire, d'où le « cd » en tête.
+
+ `el_make` : cible make qui a installé la VM, réutilisée pour la mettre à
+ jour. Vide, le guide s'arrête à « git pull » plutôt que d'annoncer une cible
+ qui n'est pas celle du profil retenu.
+
+ `editor` : éditeur de l'hôte, quand il a pu être déterminé. Sans lui on
+ nomme le fichier de configuration sans nommer d'éditeur — « vi » n'est pas
+ garanti sur toutes les images cloud, et un guide qui propose une commande
+ absente est pire que muet.
+ """
+ rows = [
+ (f"cd {el_dir}", "aller au dépôt", "go to the checkout"),
+ ("make todo", "menu ERPLibre (TODO)", "ERPLibre menu (TODO)"),
+ ]
+ if editor:
+ rows.append(
+ (
+ f"{editor} config.conf",
+ "éditer le serveur",
+ "edit the server",
+ )
+ )
+ else:
+ rows.append(
+ (
+ "config.conf",
+ "configuration du serveur",
+ "the server's config",
+ )
+ )
+ rows += [
+ (
+ "sudo systemctl restart erplibre",
+ "redémarrer le serveur",
+ "restart the server",
+ ),
+ ("systemctl status erplibre", "état du serveur", "server state"),
+ ("journalctl -u erplibre -f", "suivre son journal", "follow its log"),
+ ("./run.sh -d ", "lancer à la main", "run it by hand"),
+ (
+ "./script/addons/update_addons_all.sh ",
+ "mise à jour des modules",
+ "update the modules",
+ ),
+ (
+ f"git pull && make {el_make}" if el_make else "git pull",
+ (
+ "mise à jour ERPLibre/Odoo"
+ if el_make
+ else "mettre à jour le dépôt"
+ ),
+ "update ERPLibre/Odoo" if el_make else "update the checkout",
+ ),
+ ("http://:8069", "interface web", "web interface"),
+ ]
+ return tuple(rows)
+
+
+# Plancher de largeur de l'encadré. Au-dessus, il SUIT le contenu : un cadre
+# plus étroit que ce qu'il encadre serait pire qu'un cadre large. C'est au
+# contenu de rester sous 80 colonnes — un guide qui se replie sur un terminal
+# standard est illisible, et un test le vérifie pour les sept distributions.
+MOTD_MIN_WIDTH = 62
+
+
+def _pick(pair: tuple[str, str], lang: str) -> str:
+ """Membre fr ou en d'un couple de libellés."""
+ return pair[1] if lang == "en" else pair[0]
+
+
+def gloss_col(*blocks: tuple[tuple[str, str, str], ...]) -> int:
+ """Colonne où commencent les gloses de ces blocs : la commande la plus
+ longue, plus deux espaces."""
+ return max(len(cmd) for rows in blocks for cmd, _fr, _en in rows) + 2
+
+
+def motd_block(
+ title: str, rows: tuple[tuple[str, str, str], ...], lang: str, col: int
+) -> list[str]:
+ """Un bloc du guide : un titre, puis « commande glose » alignées.
+
+ `col` est donné plutôt que déduit du bloc : les blocs de commandes courtes
+ partagent une colonne commune, sans quoi le bloc « système » se tasserait à
+ treize caractères là où celui des paquets en occupe trente. Le bloc
+ ERPLibre, lui, garde la sienne — sa commande la plus longue fait
+ 43 caractères, et l'imposer au guide entier ferait déborder les lignes de
+ 80 colonnes.
+ """
+ out = [f" {title}"]
+ for cmd, gloss_fr, gloss_en in rows:
+ out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}")
+ return out
+
+
+def build_motd(
+ distro: str,
+ version: str,
+ arch: str,
+ lang: str = "fr",
+ el_dir: str = "",
+ el_make: str = "",
+ editor: str = "",
+ desktop: bool = False,
+) -> str:
+ """Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable.
+
+ La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans
+ installation ne doit pas annoncer un dépôt et un service qui n'existent pas.
+ Le bloc « Bureau » suit la même règle avec `desktop` : sur un serveur, ces
+ deux commandes ne mèneraient à aucune unité.
+ """
+ body: list[str] = []
+ mgr = DISTRO_PKG.get(distro, "")
+ if mgr == "zypper":
+ pkg_rows = zypper_guide(version == "tumbleweed")
+ else:
+ pkg_rows = PKG_GUIDE.get(mgr, ())
+ sys_rows = SYSTEM_GUIDE if el_dir else SYSTEM_GUIDE + SERVICE_GUIDE
+ narrow = gloss_col(pkg_rows or sys_rows, sys_rows)
+ if pkg_rows:
+ body += motd_block(
+ f"{_pick(('Paquets', 'Packages'), lang)} — {mgr}",
+ pkg_rows,
+ lang,
+ narrow,
+ )
+ if el_dir:
+ body.append("")
+ el_rows = erplibre_guide(el_dir, el_make, editor)
+ body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows))
+ if desktop:
+ body.append("")
+ body += motd_block(
+ _pick(("Bureau", "Desktop"), lang),
+ DESKTOP_GUIDE,
+ lang,
+ gloss_col(DESKTOP_GUIDE),
+ )
+ body.append("")
+ body += motd_block(
+ _pick(("Système", "System"), lang), sys_rows, lang, narrow
+ )
+ title = f"ERPLibre · {distro_label(distro, version)} · {arch}"
+ width = max(
+ MOTD_MIN_WIDTH, max([len(line) for line in body] + [len(title)]) + 4
+ )
+ head = [
+ "╭" + "─" * (width - 2) + "╮",
+ "│ " + title.ljust(width - 4) + " │",
+ "╰" + "─" * (width - 2) + "╯",
+ ]
+ foot = [
+ "",
+ " "
+ + _pick(
+ (
+ "Guide écrit au déploiement par",
+ "Guide written at deploy time by",
+ ),
+ lang,
+ )
+ + " script/qemu/deploy_qemu.py",
+ ]
+ return "\n".join(head + [""] + body + foot) + "\n"
+
+
+def invoking_home() -> Path:
+ """Foyer de l'utilisateur qui a lancé le script, sudo compris.
+
+ Le script tourne sous sudo : `Path.home()` y renvoie /root, où il n'y a
+ aucune configuration git à reprendre.
+ """
+ try:
+ return Path(os.path.expanduser(f"~{invoking_user()}"))
+ except (KeyError, RuntimeError):
+ return Path.home()
+
+
+def _git_global(key: str, home: Path) -> str:
+ """Valeur d'une clé de la configuration git GLOBALE de `home`.
+
+ HOME est forcé plutôt que de lire ~/.gitconfig à la main : git accepte DEUX
+ emplacements pour sa configuration globale (~/.gitconfig et
+ ~/.config/git/config), et lui poser la question évite de trancher à sa place.
+ """
+ try:
+ res = subprocess.run(
+ ["git", "config", "--global", "--get", key],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ env=dict(os.environ, HOME=str(home)),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return ""
+ return res.stdout.strip() if res.returncode == 0 else ""
+
+
+def host_editor(home: Path) -> str:
+ """Éditeur que l'hôte utilise, dans l'ordre où git le résout lui-même.
+
+ core.editor, puis $VISUAL/$EDITOR, puis /usr/bin/editor — le lien des
+ alternatives Debian, qui est LA réponse à « quel éditeur ce système
+ utilise-t-il » quand rien n'est configuré. Ailleurs ce lien n'existe pas et
+ on ne devine pas : mieux vaut ne rien écrire que d'imposer un éditeur.
+
+ GIT_EDITOR est volontairement IGNORÉ. Les outils qui appellent git sans
+ interaction le posent à « true » pour empêcher toute ouverture d'éditeur ;
+ le recopier dans la VM y désactiverait silencieusement l'éditeur de git.
+ """
+ editor = _git_global("core.editor", home)
+ if not editor:
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
+ if not editor:
+ try:
+ editor = Path("/usr/bin/editor").resolve(strict=True).name
+ except OSError:
+ editor = ""
+ return editor.strip()
+
+
+def editor_binary(editor: str) -> str:
+ """Binaire seul d'une commande d'éditeur (« code --wait » -> « code »).
+
+ Le guide affiche le binaire, pas la commande complète : les options de git
+ (attente de fermeture, fichier temporaire) n'ont pas de sens pour ouvrir un
+ fichier de configuration à la main.
+ """
+ if not editor.strip():
+ return ""
+ return editor.split()[0].rsplit("/", 1)[-1]
+
+
+# Éditeurs que la VM sait se donner : binaire de l'hôte -> (paquet, binaire dans
+# la VM). Le nom du paquet est le MÊME sur apt, dnf, zypper et pacman pour ces
+# trois-là — vérifié pour chacun ; « vi » est fourni par vim, et le paquet
+# neovim installe « nvim ».
+#
+# Cette table est la SEULE autorité, et elle décide de trois choses à la fois :
+# le paquet que l'installation ajoute, la commande que le guide affiche, et la
+# valeur de core.editor dans la VM. Les tenir liées est le point : un
+# « core.editor = code » pointant un binaire absent fait échouer « git commit »
+# (« cannot run code »), et un guide qui nomme une commande absente est pire que
+# muet. Un éditeur hors de cette table est donc ignoré — pas deviné.
+EDITOR_PACKAGES: dict[str, tuple[str, str]] = {
+ "vim": ("vim", "vim"),
+ "vi": ("vim", "vim"),
+ "nvim": ("neovim", "nvim"),
+ "neovim": ("neovim", "nvim"),
+ "nano": ("nano", "nano"),
+}
+
+
+def vm_editor(home: Path) -> tuple[str, str]:
+ """(paquet, binaire) de l'éditeur à donner à la VM, ou deux chaînes vides."""
+ return EDITOR_PACKAGES.get(editor_binary(host_editor(home)), ("", ""))
+
+
+def build_gitconfig(name: str, email: str, editor: str) -> str:
+ """~/.gitconfig de la VM. Chaîne vide si l'hôte n'a rien à transmettre.
+
+ Une VM de développement sert à produire des commits, et un commit sans
+ identité est refusé par git (« Please tell me who you are ») : reprendre
+ celle de l'hôte évite de la retaper sur chaque machine, et surtout évite les
+ commits signés d'un « erplibre@ » que personne ne reconnaît.
+
+ INDENTATION EN ESPACES, jamais en tabulation. git accepte les deux, mais ce
+ texte part dans un scalaire bloc YAML où une tabulation en tête de ligne est
+ une erreur FATALE : cloud-init rejette alors le user-data en entier et la VM
+ démarre sans utilisateur ni clé SSH, donc inaccessible.
+ """
+ lines: list[str] = []
+ if name or email:
+ lines.append("[user]")
+ if name:
+ lines.append(f" name = {name}")
+ if email:
+ lines.append(f" email = {email}")
+ if editor:
+ lines += ["[core]", f" editor = {editor}"]
+ return "\n".join(lines) + "\n" if lines else ""
+
+
+def write_files_lines(
+ entries: list[tuple[str, str, str, str]],
+) -> list[str]:
+ """Bloc « write_files » de cloud-init pour des fichiers TEXTE.
+
+ entries : (chemin, mode, contenu, propriétaire) ; propriétaire vide = root.
+
+ Deux règles YAML dont le non-respect coûte TOUTE la configuration — une
+ erreur de syntaxe fait rejeter le user-data en ENTIER, sans message sur la
+ console : la VM démarre nue, sans utilisateur ni clé SSH, inaccessible.
+ - Le mode est une CHAÎNE, entre guillemets. « permissions: 644 » non quoté
+ est lu comme 644 DÉCIMAL et appliqué tel quel, soit 0o1204 soit le bit
+ setuid allumé et des droits absurdes, sans le moindre avertissement.
+ - Le contenu est un scalaire bloc « | » indenté de six espaces, dont la
+ PREMIÈRE ligne non vide fixe l'indentation de référence : les suivantes
+ doivent être au moins aussi indentées. Les caractères d'encadrement
+ UTF-8 passent sans échappement.
+
+ Un propriétaire impose « defer: true » : write_files tourne à l'étape init,
+ AVANT la création des utilisateurs, donc le chown vers le compte de la VM
+ échouerait. Reporté à l'étape finale, il passe — et le suivi d'installation
+ attend de toute façon la fin de cloud-init avant de se connecter.
+ """
+ out = ["write_files:"]
+ for path, mode, content, owner in entries:
+ out.append(f" - path: {path}")
+ out.append(f" permissions: '{mode}'")
+ if owner:
+ out.append(f" owner: {owner}:{owner}")
+ out.append(" defer: true")
+ out.append(" content: |")
+ # textwrap.indent laisse les lignes vides VIDES : six espaces résiduels
+ # survivraient au scalaire bloc et se retrouveraient dans le fichier,
+ # invisibles en revue et bien présents à l'écran.
+ out += textwrap.indent(content.rstrip("\n"), " ").split("\n")
+ return out
+
+
+# Préfixe des fichiers d'accueil embarqués dans l'initrd de l'installateur.
+# Un préfixe, et non un répertoire : le cpio est déplié séquentiellement et les
+# répertoires parents manquants ne sont pas créés — une entrée
+# « erplibre/etc-motd » sans entrée « erplibre » ferait échouer le dépliage de
+# l'initrd ENTIER, donc l'installation. Les fichiers restent à la racine.
+INSTALLER_GUIDE_PREFIX = "erplibre-"
+
+
+def installer_guide_name(path: str) -> str:
+ """Nom dans l'initrd du fichier destiné au chemin `path` de la VM.
+
+ « /etc/motd » -> « erplibre-etc-motd ». Un nom PLAT, dérivé du chemin : les
+ deux fonctions qui s'en servent (le preseed qui copie, l'initrd qui range)
+ le calculent de la même façon, donc elles ne peuvent pas diverger.
+ """
+ return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-")
+
+
+def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
+ """Fichiers d'accueil de la VM : le guide de connexion, l'identité git.
+
+ Une seule source pour les deux voies de déploiement — cloud-init l'écrit
+ par write_files, l'installateur Debian par son late_command.
+ """
+ home = invoking_home()
+ editor = "" if args.no_git_identity else vm_editor(home)[1]
+ files = [
+ (
+ "/etc/motd",
+ "0644",
+ build_motd(
+ args.distro,
+ args.version,
+ args.arch,
+ args.lang,
+ args.erplibre_dir,
+ args.erplibre_make,
+ editor,
+ bool(args.desktop),
+ ),
+ "",
+ )
+ ]
+ if args.no_git_identity:
+ return files
+ gitconfig = build_gitconfig(
+ _git_global("user.name", home),
+ _git_global("user.email", home),
+ editor,
+ )
+ if gitconfig:
+ files.append(
+ (f"/home/{args.user}/.gitconfig", "0644", gitconfig, args.user)
+ )
+ return files
+
+
def build_cloud_config(
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
) -> str:
@@ -1490,6 +2204,11 @@ def build_cloud_config(
f" layout: {args.keyboard_layout}",
f" variant: {args.keyboard_variant}",
]
+ # Guide de connexion et identité git : posés par cloud-init, donc présents
+ # dès le PREMIER boot. C'est le point : ils sont là avant l'installation
+ # d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte
+ # justement à la main.
+ lines += write_files_lines(guide_files(args))
# apt update/upgrade désactivés par défaut : sur un réseau lent/instable
# ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH
# est déjà présent dans les images cloud ; on l'active via runcmd sans apt.
@@ -1679,6 +2398,376 @@ def prepare_disk(
runner.run(["qemu-img", "resize", str(disk), size], privileged=True)
+def _ip_taken(ip: str) -> bool:
+ """Adresse déjà occupée, même par une machine qui ne parle pas SSH.
+
+ Un simple essai sur le port 22 ne suffit pas : il laisse passer toute
+ machine éteinte au moment du choix, ou dont sshd est filtré. Vécu — une
+ adresse attribuée à une VM Debian neuve appartenait déjà à une machine du
+ parc, et l'installation ERPLibre s'est déroulée SUR CETTE DERNIÈRE. Le
+ journal ne le disait qu'à demi-mot : « git is already the newest
+ version », impossible sur un système que d-i vient de poser.
+
+ On interroge donc trois choses : le voisinage ARP de l'hôte, qui connaît
+ ce qui a parlé récemment ; ICMP, qui répond même sans service ; puis SSH.
+ """
+ try:
+ neigh = subprocess.run(
+ ["ip", "neigh", "show", ip],
+ capture_output=True,
+ text=True,
+ timeout=5,
+ ).stdout
+ # « FAILED » signifie justement que personne n'a répondu.
+ if ip in neigh and "FAILED" not in neigh:
+ return True
+ except (OSError, subprocess.SubprocessError):
+ pass
+ try:
+ if subprocess.run(
+ ["ping", "-c", "1", "-W", "1", ip],
+ capture_output=True,
+ timeout=5,
+ ).returncode == 0:
+ return True
+ except (OSError, subprocess.SubprocessError):
+ pass
+ return _ip_reachable(ip, port=22, timeout=1.5)
+
+
+def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None:
+ """Adresse fixe libre pour une VM installée par debian-installer.
+
+ L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i
+ montre « Menu item 'netcfg-static' selected », jamais netcfg-dhcp, puis
+ « Taking down interface enc1 ». Aucun DHCP n'est tenté — c'est la
+ convention IBM Z, où la configuration réseau se donne au parmfile. Il
+ faut donc fournir une adresse, et elle doit être libre.
+
+ On la prend en HAUT de la plage : dnsmasq attribue depuis le bas, donc
+ les collisions avec un bail futur sont les plus improbables là.
+ """
+ if not net:
+ return None
+ cmd = ["virsh", "-c", LIBVIRT_URI, "net-dumpxml", net]
+ if use_sudo:
+ cmd.insert(0, "sudo")
+ try:
+ xml = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=20
+ ).stdout
+ except (OSError, subprocess.SubprocessError):
+ return None
+ m = re.search(r" str:
+ """Preseed debian-installer équivalent au cloud-config des autres distros.
+
+ Il doit couvrir EXACTEMENT ce que cloud-init fait ailleurs : nom d'hôte,
+ utilisateur, clés SSH, sudo sans mot de passe, fuseau, paquets de base.
+ Tout ce qui manque ici devient une question posée à l'écran, et
+ l'installation s'arrête sur une console que personne ne regarde.
+
+ « priority=critical » suffit à ne pas poser les questions restantes ; il
+ ne dispense PAS de répondre à celles qui n'ont pas de défaut, d'où le
+ partitionnement et le miroir écrits explicitement.
+ """
+ user = args.user
+ # Sans mot de passe utilisable, d-i s'arrête sur la création du compte :
+ # « ! » est un hachage volontairement invalide — la connexion se fera par
+ # clé, comme le cloud-config le prévoit lui aussi.
+ crypted = pw_hash or "!"
+ lines = [
+ "d-i debian-installer/locale string en_US.UTF-8",
+ "d-i keyboard-configuration/xkb-keymap select us",
+ # Question propre à s390x, posée par le udeb « s390-netdevice » et
+ # inexistante ailleurs : le matériel Z offre ctc, qeth, iucv ou
+ # virtio, et d-i ne devine pas. Elle n'a AUCUNE valeur par défaut,
+ # donc « priority=critical » ne la saute pas — l'installation se
+ # figeait dessus, sur le premier choix de la liste (ctc), en
+ # n'affichant rien d'autre qu'un écran bleu. Mesuré.
+ "d-i s390-netdevice/choose_networktype select virtio",
+ # « auto » évite la question du choix d'interface : sous virtio-ccw
+ # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable.
+ f"d-i netcfg/get_hostname string {args.hostname}",
+ # Adresse fixe : sans elle, netcfg-static pose la question a l'ecran
+ # et l'installation s'arrete la, indefiniment.
+ *(
+ [
+ "d-i netcfg/disable_autoconfig boolean true",
+ "d-i netcfg/disable_dhcp boolean true",
+ f"d-i netcfg/get_ipaddress string {static['ip']}",
+ f"d-i netcfg/get_netmask string {static['netmask']}",
+ f"d-i netcfg/get_gateway string {static['gateway']}",
+ f"d-i netcfg/get_nameservers string {static['dns']}",
+ "d-i netcfg/confirm_static boolean true",
+ ]
+ if static
+ else []
+ ),
+ "d-i netcfg/get_domain string localdomain",
+ "d-i netcfg/hostname string " + args.hostname,
+ "d-i mirror/country string manual",
+ "d-i mirror/http/hostname string deb.debian.org",
+ "d-i mirror/http/directory string /debian",
+ "d-i mirror/http/proxy string",
+ "d-i passwd/root-login boolean false",
+ "d-i passwd/user-fullname string ERPLibre",
+ f"d-i passwd/username string {user}",
+ f"d-i passwd/user-password-crypted password {crypted}",
+ # network-console : sur IBM Z, d-i propose systématiquement de
+ # poursuivre par SSH — la console y est historiquement limitée. Il
+ # refuse un mot de passe vide et bloque l'installation non assistée.
+ # Ce secret ne vit QUE le temps de l'installateur, sur le réseau
+ # libvirt, et disparaît avec lui : il ne donne accès à rien ensuite.
+ "d-i network-console/password password erplibre",
+ "d-i network-console/password-again password erplibre",
+ "d-i clock-setup/utc boolean true",
+ f"d-i time/zone string {args.timezone}",
+ "d-i clock-setup/ntp boolean true",
+ # Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais
+ # d-i pose quand même la question quand rien ne le désigne.
+ # Ce que partman voit reellement, ecrit sur la console : « No root
+ # file system is defined » ne distingue pas « disque absent » de
+ # « recette non appliquee », et les deux se corrigent differemment.
+ # Toute commande preseedee DOIT rendre 0 : d-i bloque sur « Failed to
+ # run preseeded command » sinon, et le diagnostic devient le blocage.
+ # Vecu — un « ls /dev/dasd* » sans correspondance suffisait.
+ "d-i partman/early_command string cat /proc/partitions > /dev/console"
+ " ; ls /lib/partman/automatically_partition/ > /dev/console 2>&1"
+ " ; true",
+ # partman-auto RECLAME explicitement : il n'est pas tire d'office sur
+ # s390x, ou la voie attendue est le partitionnement DASD manuel.
+ # Mesure dans l'installateur : « /lib/partman/automatically_partition/
+ # No such file or directory », et la liste des udebs recuperes montre
+ # partman-base, -utils, -partitioning, -target… mais jamais -auto.
+ # Sans lui, aucune recette ne s'applique et partman s'arrete sur
+ # « No root file system is defined ».
+ "d-i anna/choose_modules string partman-auto",
+ "d-i partman-auto/disk string /dev/vda",
+ "d-i partman-auto/method string regular",
+ "d-i partman-auto/choose_recipe select atomic",
+ "d-i partman/default_filesystem string ext4",
+ "d-i partman-partitioning/confirm_write_new_label boolean true",
+ "d-i partman/choose_partition select finish",
+ "d-i partman/confirm boolean true",
+ "d-i partman/confirm_nooverwrite boolean true",
+ "tasksel tasksel/first multiselect ssh-server",
+ "d-i pkgsel/include string openssh-server sudo python3"
+ " qemu-guest-agent ca-certificates",
+ "d-i pkgsel/upgrade select none",
+ "popularity-contest popularity-contest/participate boolean false",
+ "d-i finish-install/reboot_in_progress note",
+ ]
+ # late_command : tout ce que le preseed ne sait pas exprimer. « in-target »
+ # exécute DANS le système installé ; les redirections, elles, restent dans
+ # l'installateur et doivent donc viser /target.
+ post = [
+ f"in-target usermod -aG sudo {user}",
+ f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}",
+ f"chmod 440 /target/etc/sudoers.d/{user}",
+ ]
+ if ssh_keys:
+ post.append(f"mkdir -p /target/home/{user}/.ssh")
+ for key in ssh_keys:
+ post.append(
+ f"echo '{key}' >> /target/home/{user}/.ssh/authorized_keys"
+ )
+ post += [
+ f"in-target chown -R {user}:{user} /home/{user}/.ssh",
+ f"chmod 700 /target/home/{user}/.ssh",
+ f"chmod 600 /target/home/{user}/.ssh/authorized_keys",
+ ]
+ # Guide de connexion et identité git : les mêmes fichiers que sur les autres
+ # distributions, mais ici il n'y a pas de cloud-init pour les écrire. Ils
+ # voyagent DANS l'initrd, à côté du preseed, et le late_command ne fait que
+ # les copier.
+ #
+ # Pourquoi pas leur contenu dans le preseed : la valeur d'une question tient
+ # sur UNE ligne, et celle-ci fait déjà 1165 caractères avec une clé RSA-4096.
+ # Y ajouter 1,2 Kio de guide — encodé ou en trente echo, la longueur est la
+ # même — doublerait une ligne dont aucune limite n'est documentée pour
+ # cdebconf. Et une troncature ne coûterait pas le guide : elle couperait le
+ # late_command au milieu, donc ni sudoers ni clé SSH, donc une VM
+ # inaccessible.
+ for path, mode, _content, owner in guide_files(args):
+ src = "/" + installer_guide_name(path)
+ post.append(f"cp {src} /target{path} || true")
+ post.append(f"chmod {mode} /target{path} || true")
+ if owner:
+ post.append(f"in-target chown {owner}:{owner} {path} || true")
+ # Le code de sortie du late_command est celui de sa DERNIÈRE commande, et
+ # d-i s'arrête sur « Failed to run preseeded command » dès qu'il n'est pas
+ # nul. Sans ce « true », un chmod qui échoue bloque l'installation sur un
+ # écran que personne ne regarde — c'est déjà la garde de
+ # partman/early_command, quelques lignes plus haut.
+ post.append("true")
+ # Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide.
+ # netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage
+ # statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on
+ # ne peut lire qu'en ouvrant un shell à la main. Ces quelques lignes
+ # atterrissent, elles, dans le journal de console — donc dans un fichier
+ # qu'il suffit de lire après coup. La sonde DHCP est celle de busybox,
+ # bornée à trois essais, et ne configure rien de durable.
+ early = [
+ # LE correctif, pas un diagnostic : on ALLUME la carte.
+ #
+ # Mesuré dans l'installateur : « enc1: …
+ # qdisc noop » — ni UP ni LOWER_UP — alors qu'un udhcpc manuel
+ # obtenait un bail en deux secondes. Le réseau n'a jamais été en
+ # cause ; netcfg teste l'état du lien AVANT d'essayer, ne le voit
+ # pas, saute le DHCP et demande une adresse statique.
+ #
+ # Sur s390x c'est le udeb s390-netdevice qui active le périphérique.
+ # En preseedant sa question pour qu'il ne s'affiche plus, on
+ # court-circuite aussi cette activation. On la refait donc ici, avant
+ # que netcfg ne décide.
+ "ip link set enc1 up > /dev/console 2>&1",
+ "echo '=== EL: enc1 activee avant netcfg ===' > /dev/console",
+ "ip -o link show enc1 > /dev/console 2>&1",
+ # Même garde que partman/early_command : « ip -o link show » rend 1
+ # quand l'interface n'existe pas, et le code de sortie du early_command
+ # est celui de sa dernière commande. Une ligne de DIAGNOSTIC bloquait
+ # donc l'installation qu'elle devait servir à comprendre.
+ "true",
+ ]
+ lines.append("d-i preseed/early_command string " + " ; ".join(early))
+ lines.append("d-i preseed/late_command string " + " ; ".join(post))
+ return "\n".join(lines) + "\n"
+
+
+def build_installer_initrd(
+ preseed: str,
+ initrd_src: Path,
+ out: Path,
+ runner: Runner,
+ guide: list[tuple[str, str, str, str]] | None = None,
+) -> None:
+ """Glisse le preseed DANS l'initrd de l'installateur.
+
+ Servir le preseed en HTTP est l'autre voie documentée, mais elle ajoute un
+ serveur à faire vivre pendant toute l'installation et une dépendance à
+ l'ordre d'obtention de l'adresse. Embarquer le fichier ne dépend de rien :
+ d-i lit « /preseed.cfg » à la racine de l'initrd avant même le réseau.
+
+ La méthode est celle de la documentation Debian — décompresser, ajouter le
+ fichier au cpio, recompresser — et non une concaténation d'archives, que
+ le noyau accepte mais que d-i ne parcourt pas de la même façon.
+
+ `guide` : les fichiers d'accueil de la VM (guide de connexion, identité
+ git), rangés à côté du preseed. L'initrd EST le système de fichiers de
+ l'installateur : le late_command n'a plus qu'à les copier vers /target,
+ sans avoir à transporter leur contenu dans une valeur de preseed.
+ """
+ if runner.dry_run:
+ print(f"[dry-run] preseed -> {out}")
+ for path, _mode, _content, _owner in guide or []:
+ print(f"[dry-run] + {installer_guide_name(path)} -> {path}")
+ return
+ if not shutil.which("cpio"):
+ sys.exit(
+ "cpio est requis pour embarquer le preseed dans l'initrd.\n"
+ " Debian/Ubuntu : sudo apt-get install cpio"
+ )
+ with tempfile.TemporaryDirectory() as tmp:
+ work = Path(tmp)
+ (work / "preseed.cfg").write_text(preseed, encoding="utf-8")
+ members = ["preseed.cfg"]
+ for path, _mode, content, _owner in guide or []:
+ name = installer_guide_name(path)
+ (work / name).write_text(content, encoding="utf-8")
+ members.append(name)
+ # network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela.
+ #
+ # Sur IBM Z, d-i propose de poursuivre par SSH — la console y est
+ # historiquement limitée. Ce n'est pas une question à laquelle
+ # répondre : le composant démarre sshd puis ATTEND une connexion de
+ # l'utilisateur « installer », indéfiniment. Preseeder son mot de
+ # passe le fait avancer d'un écran, pas davantage — mesuré.
+ #
+ # Un composant dont « .isinstallable » sort en erreur est retiré du
+ # menu. L'original le fait déjà quand sshd tourne ; on le remplace par
+ # un refus inconditionnel. Le fichier ajouté APRÈS l'original prend sa
+ # place : le noyau déplie le cpio séquentiellement et le dernier
+ # écrit gagne.
+ gate = work / "var/lib/dpkg/info"
+ gate.mkdir(parents=True, exist_ok=True)
+ target = gate / "network-console.isinstallable"
+ target.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8")
+ target.chmod(0o755)
+ plain = work / "initrd"
+ with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst:
+ shutil.copyfileobj(src, dst)
+ members.append("var/lib/dpkg/info/network-console.isinstallable")
+ subprocess.run(
+ ["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)],
+ input="\n".join(members) + "\n",
+ text=True,
+ cwd=work,
+ check=True,
+ capture_output=True,
+ )
+ out.parent.mkdir(parents=True, exist_ok=True)
+ with open(plain, "rb") as src, gzip.open(out, "wb") as dst:
+ shutil.copyfileobj(src, dst)
+ print(f" preseed embarqué dans {out} ({out.stat().st_size} octets)")
+
+
+def create_blank_disk(
+ disk: Path, size: str, runner: Runner, force: bool
+) -> None:
+ """Disque VIERGE : l'installateur écrit tout, il n'y a rien à convertir."""
+ if disk.exists() and not force:
+ sys.exit(
+ f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser."
+ )
+ runner.run(
+ ["qemu-img", "create", "-f", "qcow2", str(disk), size],
+ privileged=True,
+ )
+
+
def network_name(network_arg: str) -> str | None:
"""Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc."""
for part in network_arg.split(","):
@@ -1749,6 +2838,7 @@ def virt_install(
seed: Path,
osinfo: str,
runner: Runner,
+ installer: tuple[Path, Path] | None = None,
) -> None:
# Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte.
# Deux causes d'émulation, à ne pas confondre : une architecture étrangère
@@ -1773,6 +2863,7 @@ def virt_install(
# s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non
# ttyS0. Ailleurs (x86/arm64), console série classique.
console_target = "sclp" if args.arch == "s390x" else "serial"
+ console_log = f"/var/log/libvirt/qemu/{args.name}-console.log"
# Écran virtuel pour une VM graphique. s390x en est écarté : QEMU y expose
# bien « virtio-gpu-ccw », mais rien ne garantit que le noyau s390x de la
# distribution embarque le pilote DRM virtio-gpu — la VM démarrerait alors
@@ -1804,6 +2895,16 @@ def virt_install(
# d'avant : --graphics spice,listen=none
graphics = "vnc,listen=127.0.0.1"
video = ["--video", "virtio"]
+ # 3D : allumée d'office quand l'hôte a un GPU (--gpu auto). Une VM
+ # graphique sans accélération rend tout par le processeur — le bureau
+ # comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus
+ # coûteux qu'on puisse laisser en place sans le dire.
+ gpu_node = args.gpu_node or host_gpu_node()
+ video, gpu_args, gpu_msg = gpu_apply(
+ video, args.gpu, gpu_node, graphics != "none"
+ )
+ if gpu_msg:
+ print(gpu_msg)
cmd = [
"virt-install",
# Sans --connect, un utilisateur non root vise qemu:///session : le
@@ -1817,17 +2918,45 @@ def virt_install(
str(args.memory),
"--vcpus",
str(args.vcpus),
- "--import",
+ ]
+ if installer:
+ kernel, initrd = installer
+ # « --install » et non « --boot » : virt-install écrit alors DEUX
+ # configurations — celle de l'installation, transitoire, et celle du
+ # système installé. Avec « --boot kernel=… » la VM repartirait sur
+ # l'installateur à chaque démarrage, indéfiniment.
+ #
+ # console=ttysclp0 : s390x n'a pas de port série ISA. Sans cet
+ # argument l'installateur tourne sur une console invisible, et un
+ # échec ne laisse aucune trace lisible.
+ cmd += [
+ "--install",
+ f"kernel={kernel},initrd={initrd},"
+ # PAS de « auto=true » : il vise le preseed par URL et réordonne
+ # l'installation pour monter le réseau AVANT tout le reste. Le
+ # nôtre est local, il n'y a rien à aller chercher — et c'est
+ # précisément là que netcfg dérapait.
+ "kernel_args=priority=critical "
+ "preseed/file=/preseed.cfg console=ttysclp0",
+ ]
+ else:
+ cmd.append("--import")
+ cmd += [
"--disk",
f"path={disk},format=qcow2,bus=virtio",
+ ]
+ # Le seed n'existe QUE sur la voie image cloud. Sous debian-installer, le
+ # preseed voyage dans l'initrd et un second disque ne ferait qu'ajouter un
+ # /dev/vdb dont partman-auto devrait être protégé.
+ if not installer:
# Seed cloud-init attaché comme DISQUE virtio en lecture seule (et non
# en CD-ROM) : le pilote virtio-blk est dans l'initramfs, donc le
# volume « cidata » est visible dès init-local et cloud-init le lit.
# En CD-ROM, l'initramfs Debian ne charge pas sr_mod à temps -> le
# seed n'est pas vu et rien ne s'applique (Ubuntu, lui, tolère le CD).
# Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt.
- "--disk",
- f"path={seed},readonly=on,bus=virtio",
+ cmd += ["--disk", f"path={seed},readonly=on,bus=virtio"]
+ cmd += [
"--osinfo",
osinfo,
"--network",
@@ -1835,14 +2964,21 @@ def virt_install(
"--graphics",
graphics,
"--console",
- f"pty,target_type={console_target}",
+ # Journal de console pour la voie installateur. Une console « pty »
+ # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une
+ # VM que personne ne regarde, et il ne reste RIEN à lire ensuite —
+ # exactement « l'installation a échoué, pas de sortie pertinente ».
+ # Le fichier, lui, survit à l'arrêt du domaine.
+ f"pty,target_type={console_target},log.file={console_log}"
+ if installer
+ else f"pty,target_type={console_target}",
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
# un redimensionnement de disque). Inoffensif si l'agent est absent.
"--channel",
"unix,target.type=virtio,target.name=org.qemu.guest_agent.0",
]
- cmd += video
+ cmd += video + gpu_args
if args.arch == "s390x":
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
# disque (ni BIOS ni UEFI/OVMF -> aucun --boot).
@@ -1881,6 +3017,13 @@ def virt_install(
cmd += ["--virt-type", "qemu"]
if not args.attach_console:
cmd.append("--noautoconsole")
+ if installer:
+ # Sans « --wait 0 », virt-install RESTE au premier plan jusqu'à la
+ # fin de l'installation. Sous émulation s390x elle se compte en
+ # heures, et le déploiement parallèle attendrait chaque VM l'une
+ # après l'autre. La configuration finale est déjà écrite : rendre
+ # la main n'abandonne rien.
+ cmd += ["--wait", "0"]
# virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous
# sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python
# déverse un « Logging error » (le pavé « Fetched capabilities … »). On
@@ -1902,6 +3045,54 @@ def virt_install(
runner.run(log_env + cmd, privileged=True)
+def watch_and_restart(name: str, runner: Runner) -> None:
+ """Rallume la VM quand debian-installer a fini et l'a éteinte.
+
+ virt-install mène l'installation en DEUX temps : un amorçage transitoire
+ sur kernel+initrd, puis la configuration définitive, qui démarre sur le
+ disque. Le passage de l'un à l'autre se fait par un arrêt — l'installateur
+ redémarre, libvirt détruit le domaine transitoire — et c'est virt-install
+ qui rallume ensuite. Avec « --wait 0 » il est déjà parti : le domaine
+ reste « shut off », disque installé et XML correct, mais éteint.
+
+ On ne peut pas pour autant laisser virt-install attendre : sous émulation
+ l'installation dure des heures, et le déploiement rendrait la main à ce
+ rythme-là. Un veilleur détaché fait donc le dernier geste.
+ """
+ if runner.dry_run:
+ print(f"[dry-run] veilleur de redémarrage pour {name}")
+ return
+ sudo = "sudo " if runner.use_sudo else ""
+ # 6 h de garde : bien au-delà d'une installation émulée, et le veilleur
+ # meurt de lui-même si quelque chose a mal tourné.
+ script = (
+ f"for i in $(seq 1 720); do "
+ f" s=$({sudo}virsh -c {LIBVIRT_URI} domstate {name} 2>/dev/null); "
+ f' if [ "$s" = "shut off" ]; then '
+ f" {sudo}virsh -c {LIBVIRT_URI} start {name} >/dev/null 2>&1; "
+ f" exit 0; "
+ f" fi; "
+ f" sleep 30; "
+ f"done"
+ )
+ try:
+ subprocess.Popen(
+ ["/bin/sh", "-c", script],
+ stdin=subprocess.DEVNULL,
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ start_new_session=True,
+ )
+ except (OSError, subprocess.SubprocessError) as exc:
+ print(f" ⚠ veilleur non lancé ({exc}) ; démarrer à la main :")
+ print(f" sudo virsh start {name}")
+ return
+ print(
+ f" Veilleur lancé : {name} sera rallumée dès que l'installateur"
+ " l'aura éteinte."
+ )
+
+
def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool:
"""Vrai si le port SSH répond (distingue le bail actif du bail périmé)."""
try:
@@ -2064,6 +3255,19 @@ def build_parser() -> argparse.ArgumentParser:
"permet. Les paquets GNOME sont posés par la commande d'installation, "
"pas ici.",
)
+ g_vm.add_argument(
+ "--gpu",
+ choices=("auto", "on", "off"),
+ default="auto",
+ help="Accélération 3D par le GPU de l'hôte : auto (défaut, activée "
+ "si un /dev/dri/renderD* existe), on (forcer), off (rendu logiciel).",
+ )
+ g_vm.add_argument(
+ "--gpu-node",
+ default="",
+ help="Nœud de rendu à utiliser (défaut : le premier trouvé). Utile "
+ "sur un hôte à plusieurs cartes.",
+ )
g_vm.add_argument(
"--osinfo", help="Force la valeur --osinfo (sinon déduite)."
)
@@ -2149,6 +3353,36 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="N'exécute pas package_upgrade au premier boot.",
)
+ g_cloud.add_argument(
+ "--lang",
+ choices=("fr", "en"),
+ default="fr",
+ help="Langue du guide affiché à la connexion SSH (défaut : fr). "
+ "todo.py passe la langue de son menu.",
+ )
+ g_cloud.add_argument(
+ "--erplibre-dir",
+ default="",
+ metavar="CHEMIN",
+ help="Racine d'ERPLibre dans la VM (~/git/erplibre en dev, "
+ "/opt/erplibre en prod). Ajoute la section ERPLibre au guide de "
+ "connexion. Vide, elle est omise : une VM déployée sans installation "
+ "n'annonce pas un dépôt et un service qui n'existent pas.",
+ )
+ g_cloud.add_argument(
+ "--erplibre-make",
+ default="",
+ metavar="CIBLE",
+ help="Cible make qui a installé la VM (ex. install_odoo_18), reprise "
+ "dans le guide pour la mettre à jour. Vide : le guide s'arrête à "
+ "« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.",
+ )
+ g_cloud.add_argument(
+ "--no-git-identity",
+ action="store_true",
+ help="N'injecte pas l'identité git de l'hôte (user.name, user.email, "
+ "core.editor) dans le ~/.gitconfig de la VM.",
+ )
g_cloud.add_argument(
"--apt-update",
action="store_true",
@@ -2382,22 +3616,80 @@ def main() -> None:
"nettement plus lents que l'architecture native."
)
- print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==")
- download_image(urls, args.image_path, args.dry_run)
- if do_verify:
- verify_sha256(url, args.image_path, args.dry_run)
+ installer: tuple[Path, Path] | None = None
+ if uses_installer(args.distro, args.arch):
+ # Voie debian-installer : aucune image cloud n'existe pour cette
+ # combinaison, on télécharge l'installateur et on part d'un disque nu.
+ cache = args.image_path.parent
+ kernel = cache / f"debian-{args.version}-s390x-kernel"
+ initrd_src = cache / f"debian-{args.version}-s390x-initrd.gz"
+ initrd = cache / f"{args.name}-initrd.gz"
+ # Le dimensionnement du catalogue vient des images cloud, où le
+ # système est DÉJÀ installé. debian-installer, lui, déplie un système
+ # de fichiers complet en mémoire avant d'écrire quoi que ce soit :
+ # 1024 Mio est le plancher annoncé par Debian, sans marge, et un
+ # manque de mémoire s'y manifeste par un écran figé sans message.
+ # On relève le plancher, en le disant — un réglage explicite plus haut
+ # n'est jamais abaissé.
+ if args.memory < INSTALLER_MIN_RAM:
+ print(
+ f" Mémoire portée à {INSTALLER_MIN_RAM} Mio pour"
+ f" l'installateur (catalogue : {args.memory})."
+ )
+ args.memory = INSTALLER_MIN_RAM
+ print(f"\n== 1/5 Installateur Debian {args.version} ({code}) s390x ==")
+ download_image(
+ [INSTALLER_URL.format(code=code, fichier="kernel.debian")],
+ kernel,
+ args.dry_run,
+ )
+ download_image(
+ [INSTALLER_URL.format(code=code, fichier="initrd.debian")],
+ initrd_src,
+ args.dry_run,
+ )
- print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==")
- prepare_disk(args.image_path, disk, args.disk_size, runner, args.force)
+ print(f"\n== 2-3/5 Disque vierge {disk} ({args.disk_size}) ==")
+ create_blank_disk(disk, args.disk_size, runner, args.force)
- print(f"\n== 4/5 Seed cloud-init {seed} ==")
- cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
- build_seed(cloud_cfg, args.hostname, seed, runner)
+ print(f"\n== 4/5 Preseed embarqué dans l'initrd ==")
+ static = static_net_plan(
+ network_name(args.network), not args.dry_run, args.name
+ )
+ if static:
+ print(f" Adresse fixe retenue : {static['ip']}"
+ f" (passerelle {static['gateway']})")
+ else:
+ print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera"
+ " la question à l'écran et l'installation s'arrêtera.")
+ build_installer_initrd(
+ build_preseed(args, pw_hash, ssh_keys, static),
+ initrd_src,
+ initrd,
+ runner,
+ )
+ installer = (kernel, initrd)
+ else:
+ print(
+ f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) =="
+ )
+ download_image(urls, args.image_path, args.dry_run)
+ if do_verify:
+ verify_sha256(url, args.image_path, args.dry_run)
+
+ print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==")
+ prepare_disk(args.image_path, disk, args.disk_size, runner, args.force)
+
+ print(f"\n== 4/5 Seed cloud-init {seed} ==")
+ cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
+ build_seed(cloud_cfg, args.hostname, seed, runner)
resolved_osinfo = osinfo_arg(osinfo, args.distro)
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
ensure_network(network_name(args.network), runner)
- virt_install(args, disk, seed, resolved_osinfo, runner)
+ virt_install(args, disk, seed, resolved_osinfo, runner, installer)
+ if installer:
+ watch_and_restart(args.name, runner)
has_key = bool(ssh_keys)
print("\nTerminé. Suivi :")
diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py
index d86f5e7..6e0d3f4 100644
--- a/script/todo/qemu_deploy_form.py
+++ b/script/todo/qemu_deploy_form.py
@@ -294,6 +294,7 @@ def build_spec(vms, domains, form):
"ssh_key": form["ssh_key"],
"timezone": form.get("timezone", ""),
"desktop": form.get("desktop", ""),
+ "vm_tools": tuple(form.get("vm_tools") or ()),
"python_provider": form.get("python_provider", ""),
"app_store": form.get("app_store", "deb"),
"install": form["install"],
@@ -364,6 +365,16 @@ def run_deploy_form(ctx, run_app: bool = True):
# {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les
# saveurs — on ne le redéfinit pas ici.
desktop_suffixes = dict(ctx.get("desktop_suffixes") or {})
+ # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et
+ # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait
+ # que les afficher et rendre les cases cochées.
+ vm_tools = list(ctx.get("vm_tools") or [])
+ tool_disk = dict(ctx.get("vm_tool_disk") or {})
+ tool_arches = dict(ctx.get("vm_tool_arches") or {})
+ tool_desktops = dict(ctx.get("vm_tool_desktops") or {})
+ tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {})
+ tool_families = dict(ctx.get("vm_tool_families") or {})
+ distro_family = dict(ctx.get("distro_family") or {})
# Architectures pour lesquelles mise publie un binaire.
mise_arches = set(ctx.get("mise_arches") or ())
# [(clé, libellé)] des magasins d'applications, et les distributions qui
@@ -655,6 +666,20 @@ def run_deploy_form(ctx, run_app: bool = True):
for i, (_k, label) in enumerate(app_stores):
yield RadioButton(label, value=i == 0)
yield Static("", id="storewarn")
+ if vm_tools:
+ # Une case par outil, et non une liste déroulante : ils
+ # sont indépendants, et chacun se prend ou se laisse.
+ yield Static(
+ t("Development tools:"), classes="grouptitle"
+ )
+ for key, label, hint in vm_tools:
+ gb = tool_disk.get(key, 0)
+ yield Checkbox(
+ f"{label} +{gb} Go — {hint}",
+ value=key in (defaults.get("tools") or ()),
+ id=f"f_tool_{key}",
+ )
+ yield Static("", id="toolwarn")
yield Static("ERPLibre", classes="grouptitle")
yield Checkbox(
t("Install ERPLibre"),
@@ -816,9 +841,19 @@ def run_deploy_form(ctx, run_app: bool = True):
# Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle
# seule : un supplément commun mentait dès que les types
# différaient d'une machine à l'autre.
+ tools = self._vm_tools()
for row in self.rows:
if row["vm"].get("desktop"):
row["disk_gb"] += desktop_disk
+ # Même règle pour les outils, et pour la même raison : ils ne
+ # pèsent que sur les VM qui les reçoivent réellement. Android
+ # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de
+ # sens que sous GNOME — une VM qui ne les aura pas ne doit pas
+ # se voir gonfler son disque.
+ row["disk_gb"] += sum(
+ tool_disk.get(k, 0)
+ for k in self._tools_for_vm(row["vm"], tools)
+ )
# Le plan doit MONTRER qu'une VM a été personnalisée : sans marque,
# deux lignes aux ressources différentes n'ont aucune explication à
# l'écran, et la surcharge est oubliée à la relecture. Le drapeau
@@ -831,6 +866,73 @@ def run_deploy_form(ctx, run_app: bool = True):
self._render_plan()
self._render_mise()
self._render_store()
+ self._render_tools()
+
+ def _vm_tools(self):
+ """Clés des outils cochés, dans l'ordre de la liste."""
+ picked = []
+ for key, _label, _hint in vm_tools:
+ try:
+ if self.query_one(f"#f_tool_{key}", Checkbox).value:
+ picked.append(key)
+ except Exception:
+ continue
+ return tuple(picked)
+
+ def _tools_for_vm(self, vm, tools):
+ """Outils qu'une VM donnée recevra vraiment.
+
+ Même filtre que todo.py côté déploiement : une VM ARM ne verra
+ jamais Android Studio, une VM Cinnamon jamais les extensions GNOME,
+ un serveur aucun des IDE — mais un serveur reçoit bien la
+ compilation mobile, qui n'a rien à afficher, et une distribution
+ sans apt ne la reçoit pas, son installateur n'existant que là."""
+ out = []
+ for key in tools:
+ arches = tool_arches.get(key) or ()
+ desks = tool_desktops.get(key) or ()
+ fams = tool_families.get(key) or ()
+ if tool_needs_desktop.get(key) and not vm.get("desktop"):
+ continue
+ if arches and vm["arch"] not in arches:
+ continue
+ if desks and vm.get("desktop") not in desks:
+ continue
+ if fams and distro_family.get(vm["distro"], "") not in fams:
+ continue
+ out.append(key)
+ return out
+
+ def _render_tools(self):
+ """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et
+ NOMME ce qui sera écarté.
+
+ Une case par outil, et non un blocage en bloc : sur un parc de
+ serveurs les IDE se grisent, la compilation mobile reste offerte.
+ Cocher Android Studio sur un parc ARM ne produit rien — le dire ici
+ évite de le découvrir dans le journal d'installation."""
+ if not vm_tools:
+ return
+ for key, _label, _hint in vm_tools:
+ usable = any(
+ self._tools_for_vm(vm, (key,)) for vm in self.vms
+ )
+ self.query_one(f"#f_tool_{key}", Checkbox).disabled = not usable
+ picked = self._vm_tools()
+ skipped = sorted(
+ {
+ vm["name"]
+ for vm in self.vms
+ for k in picked
+ if k not in self._tools_for_vm(vm, picked)
+ }
+ )
+ self.query_one("#toolwarn", Static).update(
+ f" ⚠ {t('Partly skipped (arch or desktop):')} "
+ f"{', '.join(skipped)}"
+ if skipped
+ else ""
+ )
def _render_mise(self):
"""Grise le choix quand aucune VM retenue n'est servie par mise,
@@ -1676,6 +1778,10 @@ def run_deploy_form(ctx, run_app: bool = True):
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
+ elif str(event.checkbox.id or "").startswith("f_tool_"):
+ # Un IDE de plus, c'est un disque plus grand : le plan doit le
+ # montrer AVANT de déployer, pas après une heure d'installation.
+ self._recompute()
# -- actions ---------------------------------------------------- #
def action_select_all(self) -> None:
@@ -1734,6 +1840,7 @@ def run_deploy_form(ctx, run_app: bool = True):
or ctx.get("timezone")
or "",
"desktop": self._default_desktop(),
+ "vm_tools": self._vm_tools(),
"python_provider": self._python_provider(),
"app_store": self._app_store(),
"install": install,
diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py
new file mode 100644
index 0000000..700cfc4
--- /dev/null
+++ b/script/todo/qemu_hardware.py
@@ -0,0 +1,690 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Ressources matérielles d'une VM libvirt : lecture, et plan de modification.
+
+Le menu d'état de `todo.py` démarre des VM. C'est le seul moment où leur
+matériel est modifiable : libvirt refuse de changer vCPU, RAM ou écran d'un
+domaine allumé, et l'accélération 3D n'est lue qu'au démarrage de QEMU. Ce
+module tient la logique pure de ce moment-là.
+
+- hw_state(xml) : ce que la VM a aujourd'hui, lu dans son XML.
+- hw_plan(...) : les commandes qui l'amènent à l'état voulu, et RIEN de plus
+ — un plan vide quand rien ne change, et une ligne « skip »
+ expliquée quand une demande n'a pas d'objet.
+- build_want(...) / run_hardware_form(...) : le formulaire Textual.
+
+Aucune commande n'est lancée ici : `todo.py` les exécute, sous sudo, et les
+affiche avant. Le plan reste donc vérifiable sans hyperviseur.
+
+Deux pièges, appris sur l'hôte :
+
+- « virt-xml --memory N » ne change que , la cible du ballon.
+ Élever la RAM au-delà du maximum exige les deux champs à la fois, sinon la
+ VM plafonne en silence à son ancien maximum.
+- « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé
+ deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan.
+"""
+from __future__ import annotations
+
+import xml.etree.ElementTree as ET
+
+from script.todo.qemu_deploy_form import parse_ram, positive_int
+
+try:
+ from script.todo.todo_i18n import t
+except Exception: # pragma: no cover - repli si i18n indisponible
+
+ def t(key: str) -> str:
+ return key
+
+
+# URI système : sous sudo, root y va de lui-même, mais l'expliciter écarte le
+# piège documenté dans deploy_qemu.py — un appel non root visant
+# qemu:///session, où les domaines du parc n'existent pas.
+CONNECT = "qemu:///system"
+
+# Le virtio-gpu est le SEUL modèle qui porte virgl. Poser accel3d sur un qxl
+# ou un cirrus est accepté par le schéma et ne fait rien.
+GPU_VIDEO_MODEL = "virtio"
+
+# Modes CPU proposés. « host-passthrough » donne les instructions du
+# processeur hôte telles quelles — c'est ce qui rend la virtualisation
+# IMBRIQUÉE possible dans la VM, et ce que virt-install pose par défaut ;
+# « host-model » décrit un modèle équivalent, migrable vers une autre machine.
+# Les attributs check/migratable accompagnent le passthrough, comme
+# virt-install les écrit : sans eux libvirt vérifie un modèle qu'il n'a pas
+# calculé.
+CPU_MODES = ("host-passthrough", "host-model")
+CPU_EXTRA = {"host-passthrough": ",check=none,migratable=on"}
+
+# « vram » n'est PAS proposé : sur un virtio-gpu, libvirt l'écrit dans le XML
+# et QEMU ne le reçoit jamais — vérifié par « virsh domxml-to-native », qui
+# ne montre que « max_outputs » (les écrans). Ce serait un bouton sans effet.
+# Seul qxl consomme vram, et le parc n'utilise pas qxl.
+
+# Affichages qui donnent un écran à la VM. « egl-headless » n'en est pas un :
+# il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL.
+SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop")
+EGL = "egl-headless"
+
+# libvirt écrit KiB pour la mémoire, mais le schéma autorise les deux systèmes
+# d'unités — KB vaut mille octets, KiB en vaut 1024. On convertit en octets
+# d'abord : une table « à peu près » ferait dériver l'affichage de la VM.
+_UNIT_BYTES = {
+ "b": 1,
+ "bytes": 1,
+ "kb": 1000,
+ "k": 1024,
+ "kib": 1024,
+ "mb": 1000**2,
+ "m": 1024**2,
+ "mib": 1024**2,
+ "gb": 1000**3,
+ "g": 1024**3,
+ "gib": 1024**3,
+ "tb": 1000**4,
+ "t": 1024**4,
+ "tib": 1024**4,
+}
+
+
+def _mib(node) -> int:
+ """Valeur d'un élément mémoire libvirt, en mébioctets."""
+ if node is None:
+ return 0
+ factor = _UNIT_BYTES.get((node.get("unit") or "KiB").lower(), 1024)
+ try:
+ return int(float((node.text or "0").strip()) * factor) // (1024 * 1024)
+ except ValueError:
+ return 0
+
+
+def ram_field(mib) -> str:
+ """Valeur du champ RAM : « 32G » plutôt que « 32768 ».
+
+ parse_ram relit les deux, mais cinq chiffres ne tiennent pas dans le
+ champ : la VM de 32 Go y affichait « 3276 », et un nombre tronqué qu'on
+ valide sans regarder rétrécit la machine.
+ """
+ try:
+ mib = int(mib or 0)
+ except (TypeError, ValueError):
+ return ""
+ if mib <= 0:
+ return ""
+ return f"{mib // 1024}G" if mib % 1024 == 0 else str(mib)
+
+
+def fmt_mib(mib) -> str:
+ """1024 -> « 1 Go », 3072 -> « 3 Go », 1536 -> « 1,5 Go », 512 -> « 512 Mo »."""
+ try:
+ mib = int(mib)
+ except (TypeError, ValueError):
+ return "?"
+ if mib <= 0:
+ return "?"
+ if mib % 1024 == 0:
+ return f"{mib // 1024} Go"
+ if mib > 1024:
+ return f"{mib / 1024:.1f}".replace(".", ",") + " Go"
+ return f"{mib} Mo"
+
+
+def hw_state(xml: str, autostart=None) -> dict:
+ """État matériel lu dans le XML du domaine.
+
+ `autostart` n'est pas dans le XML — il vit dans un lien symbolique côté
+ libvirt — donc l'appelant le passe (via « virsh dominfo »).
+ """
+ state = {
+ "name": "",
+ "vcpus": 0,
+ "mem_mib": 0,
+ "max_mem_mib": 0,
+ "video": "",
+ "accel3d": False,
+ "egl": False,
+ "render": "",
+ "screen": False,
+ "heads": 1,
+ "cpu": "",
+ # PREMIÈRE interface seulement : c'est celle que « virt-xml --edit
+ # --network » modifie, et une VM du parc n'en a qu'une.
+ "net": "",
+ "autostart": bool(autostart),
+ }
+ try:
+ root = ET.fromstring(xml or "")
+ except ET.ParseError:
+ return state
+ state["name"] = (root.findtext("name") or "").strip()
+ try:
+ state["vcpus"] = int((root.findtext("vcpu") or "0").strip())
+ except ValueError:
+ state["vcpus"] = 0
+ state["max_mem_mib"] = _mib(root.find("memory"))
+ state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"]
+ cpu = root.find("cpu")
+ if cpu is not None:
+ state["cpu"] = cpu.get("mode") or ""
+ iface = root.find("./devices/interface")
+ if iface is not None:
+ source = iface.find("source")
+ state["net"] = net_token(
+ iface.get("type") or "",
+ source if source is not None else None,
+ )
+ model = root.find("./devices/video/model")
+ if model is not None:
+ state["video"] = model.get("type") or ""
+ try:
+ state["heads"] = int(model.get("heads") or 1)
+ except ValueError:
+ state["heads"] = 1
+ accel = model.find("acceleration")
+ state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
+ for graphics in root.findall("./devices/graphics"):
+ kind = graphics.get("type") or ""
+ if kind in SCREEN_TYPES:
+ state["screen"] = True
+ elif kind == EGL:
+ state["egl"] = True
+ gl = graphics.find("gl")
+ state["render"] = (
+ (gl.get("rendernode") or "") if gl is not None else ""
+ )
+ return state
+
+
+def net_token(kind: str, source) -> str:
+ """Identité du réseau d'une interface : « network:default », « bridge:br0 ».
+
+ Un seul jeton pour comparer, choisir et appliquer : le type et le nom vont
+ toujours ensemble — « br0 » ne veut rien dire sans savoir que c'est un
+ pont, et libvirt refuse type='network' avec un pont pour source.
+ """
+ if not kind:
+ return ""
+ name = ""
+ if source is not None:
+ name = (
+ source.get("network")
+ or source.get("bridge")
+ or source.get("dev")
+ or ""
+ )
+ return f"{kind}:{name}" if name else kind
+
+
+def net_label(token: str) -> str:
+ """« network:default » -> « default », « bridge:br0 » -> « br0 (pont) ».
+
+ Le réseau libvirt ne porte pas de suffixe : c'est le cas ordinaire, et le
+ nommer allongeait le libellé au-delà de la liste déroulante. Seul le pont
+ est marqué, parce que c'est lui qui change le comportement de la VM.
+ """
+ if not token:
+ return "—"
+ kind, _, name = token.partition(":")
+ if kind == "network":
+ return name
+ if kind == "bridge":
+ return f"{name} ({t('bridge')})"
+ return token
+
+
+# Les modes CPU s'affichent en court : la valeur écrite dans le XML reste
+# entière, seul le libellé raccourcit.
+CPU_LABELS = {"host-passthrough": "passthrough", "host-model": "model"}
+
+
+def cpu_label(mode: str) -> str:
+ return CPU_LABELS.get(mode, mode or "—")
+
+
+def net_spec(token: str) -> str:
+ """Jeton -> argument « --network » de virt-xml."""
+ kind, _, name = token.partition(":")
+ if kind == "network":
+ return f"network={name}"
+ if kind == "bridge":
+ return f"bridge={name}"
+ return token
+
+
+def _virt_xml(name: str, *args) -> list:
+ """Commande virt-xml qui ÉCRIT la définition persistante du domaine.
+
+ « --define » explicite : sans lui, virt-xml interroge l'utilisateur quand
+ le domaine tourne, et une invite dans un menu piloté par un script bloque
+ sans rien dire.
+ """
+ return ["virt-xml", "--connect", CONNECT, name, "--define", *args]
+
+
+def hw_plan(state: dict, want: dict, node: str = "") -> list:
+ """Commandes menant de `state` à `want`, dans l'ordre.
+
+ Chaque entrée est un dict : {'what': …, 'cmd': [...]} pour ce qui sera
+ lancé, {'what': …, 'skip': raison} pour ce qui est demandé mais sans
+ objet. Rien à changer -> liste vide, et l'appelant n'exécute rien.
+ """
+ name = state.get("name") or want.get("name") or ""
+ plan = []
+ if not name:
+ return plan
+
+ vcpus = positive_int(want.get("vcpus"), 0)
+ if vcpus and vcpus != state.get("vcpus"):
+ plan.append(
+ {
+ "what": f"vCPU {state.get('vcpus')} → {vcpus}",
+ "cmd": _virt_xml(name, "--edit", "--vcpus", str(vcpus)),
+ }
+ )
+
+ ram = positive_int(want.get("ram"), 0)
+ if ram and ram != state.get("mem_mib"):
+ plan.append(
+ {
+ "what": f"RAM {fmt_mib(state.get('mem_mib'))} → {fmt_mib(ram)}",
+ "cmd": _virt_xml(
+ name,
+ "--edit",
+ "--memory",
+ f"memory={ram},currentMemory={ram}",
+ ),
+ }
+ )
+
+ cpu = (want.get("cpu") or "").strip()
+ if cpu and cpu != state.get("cpu"):
+ plan.append(
+ {
+ "what": f"CPU {state.get('cpu') or '—'} → {cpu}",
+ "cmd": _virt_xml(
+ name, "--edit", "--cpu", cpu + CPU_EXTRA.get(cpu, "")
+ ),
+ }
+ )
+
+ heads = positive_int(want.get("heads"), 0)
+ if heads and heads != state.get("heads"):
+ if not state.get("video"):
+ # « --edit --video » n'a aucun périphérique à modifier : virt-xml
+ # sortirait en erreur au milieu du lot.
+ plan.append(
+ {
+ "what": t("Screens"),
+ "skip": t("this VM has no virtual screen"),
+ }
+ )
+ else:
+ plan.append(
+ {
+ "what": f"{t('Screens')} {state.get('heads')} → {heads}",
+ "cmd": _virt_xml(
+ name, "--edit", "--video", f"model.heads={heads}"
+ ),
+ }
+ )
+
+ net = (want.get("net") or "").strip()
+ if net and net != state.get("net"):
+ if not state.get("net"):
+ plan.append(
+ {"what": t("Network"), "skip": t("this VM has no interface")}
+ )
+ else:
+ plan.append(
+ {
+ "what": f"{t('Network')} {net_label(state.get('net'))}"
+ f" → {net_label(net)}",
+ "cmd": _virt_xml(
+ name, "--edit", "--network", net_spec(net)
+ ),
+ }
+ )
+
+ gpu = want.get("gpu")
+ if gpu is not None:
+ plan += _gpu_plan(name, state, bool(gpu), node)
+
+ auto = want.get("autostart")
+ if auto is not None and bool(auto) != bool(state.get("autostart")):
+ args = ["virsh", "--connect", CONNECT, "autostart"]
+ if not auto:
+ args.append("--disable")
+ plan.append(
+ {
+ "what": t("Autostart") + (" : on" if auto else " : off"),
+ "cmd": args + [name],
+ }
+ )
+ return plan
+
+
+def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list:
+ """Volet 3D du plan : accélération sur l'écran, et contexte GL."""
+ plan = []
+ if gpu:
+ if not state.get("screen"):
+ return [
+ {
+ "what": t("3D acceleration (host GPU)"),
+ "skip": t("this VM has no virtual screen"),
+ }
+ ]
+ if not node:
+ return [
+ {
+ "what": t("3D acceleration (host GPU)"),
+ "skip": t("no render node on the host"),
+ }
+ ]
+ if not state.get("accel3d") or state.get("video") != GPU_VIDEO_MODEL:
+ plan.append(
+ {
+ "what": t("3D acceleration (host GPU)") + " : on",
+ "cmd": _virt_xml(
+ name,
+ "--edit",
+ "--video",
+ f"model.type={GPU_VIDEO_MODEL}"
+ ",model.acceleration.accel3d=on",
+ ),
+ }
+ )
+ if state.get("render") != node:
+ if state.get("egl"):
+ # Déjà un affichage GL, mais sur un autre nœud : le corriger
+ # en place. L'ajouter une seconde fois en poserait DEUX.
+ plan.append(
+ {
+ "what": f"{t('Render node')} → {node}",
+ "cmd": _virt_xml(
+ name,
+ "--edit",
+ f"type={EGL}",
+ "--graphics",
+ f"gl.rendernode={node}",
+ ),
+ }
+ )
+ else:
+ plan.append(
+ {
+ "what": f"{t('Render node')} : {node}",
+ "cmd": _virt_xml(
+ name,
+ "--add-device",
+ "--graphics",
+ f"type={EGL},gl.rendernode={node}",
+ ),
+ }
+ )
+ return plan
+ if state.get("accel3d"):
+ plan.append(
+ {
+ "what": t("3D acceleration (host GPU)") + " : off",
+ "cmd": _virt_xml(
+ name,
+ "--edit",
+ "--video",
+ f"model.type={state.get('video') or GPU_VIDEO_MODEL}"
+ ",model.acceleration.accel3d=off",
+ ),
+ }
+ )
+ if state.get("egl"):
+ # Ciblé par type : la console VNC de la VM, elle, doit survivre.
+ plan.append(
+ {
+ "what": t("Render node") + " : —",
+ "cmd": _virt_xml(
+ name, "--remove-device", "--graphics", f"type={EGL}"
+ ),
+ }
+ )
+ return plan
+
+
+def build_want(
+ state: dict, vcpus, ram, gpu, autostart, cpu="", heads="", net=""
+) -> dict:
+ """Valeurs de widgets -> intention, en retombant sur l'état actuel.
+
+ Un champ vidé ou illisible ne veut pas dire « zéro vCPU » : il veut dire
+ « n'y touche pas ». Sans ce repli, valider le formulaire sans rien saisir
+ proposerait de rétrécir la VM à néant.
+ """
+ return {
+ "name": state.get("name") or "",
+ "vcpus": positive_int(vcpus, state.get("vcpus") or 0),
+ "ram": parse_ram(ram) or state.get("mem_mib") or 0,
+ "gpu": bool(gpu),
+ "autostart": bool(autostart),
+ "cpu": (cpu or state.get("cpu") or "").strip(),
+ "heads": positive_int(heads, state.get("heads") or 1),
+ "net": (net or state.get("net") or "").strip(),
+ }
+
+
+def gpu_allowed(state: dict, node: str) -> str:
+ """'' si la 3D est proposable pour cette VM, sinon la raison du refus."""
+ if not node:
+ return t("no render node on the host")
+ if not state.get("screen"):
+ return t("this VM has no virtual screen")
+ return ""
+
+
+def hw_summary(state: dict) -> str:
+ """Ligne d'état lisible : « 8 vCPU, 32 Go, 3D on (renderD128) »."""
+ bits = [f"{state.get('vcpus') or '?'} vCPU", fmt_mib(state.get("mem_mib"))]
+ if state.get("accel3d") or state.get("render"):
+ node = state.get("render") or "?"
+ bits.append(f"3D {node.rsplit('/', 1)[-1]}")
+ elif state.get("screen"):
+ bits.append(t("software rendering"))
+ if (state.get("heads") or 1) > 1:
+ bits.append(f"{state['heads']} {t('Screens').lower()}")
+ if state.get("net"):
+ bits.append(net_label(state["net"]))
+ # Le mode CPU n'est dit que s'il n'est PAS le passthrough : c'est le défaut
+ # du parc, et une ligne de résumé ne doit porter que l'inattendu.
+ if state.get("cpu") and state["cpu"] != "host-passthrough":
+ bits.append(f"CPU {state['cpu']}")
+ return ", ".join(bits)
+
+
+def cpu_choices(states) -> list:
+ """Modes CPU à proposer, le mode courant compris s'il sort de la liste.
+
+ Une VM en mode « custom » ne doit pas voir son réglage disparaître d'une
+ liste qui l'ignore : la liste déroulante afficherait alors un autre mode
+ que le sien, et valider le formulaire le changerait sans le dire.
+ """
+ modes = list(CPU_MODES)
+ for state in states or ():
+ mode = (state or {}).get("cpu")
+ if mode and mode not in modes:
+ modes.append(mode)
+ return modes
+
+
+def net_choices(states, nets=None) -> list:
+ """[(jeton, libellé)] des réseaux proposables, courants inclus."""
+ tokens = []
+ for token in list(nets or ()):
+ if token and token not in tokens:
+ tokens.append(token)
+ for state in states or ():
+ token = (state or {}).get("net")
+ if token and token not in tokens:
+ tokens.append(token)
+ return [(tok, net_label(tok)) for tok in tokens]
+
+
+def run_hardware_form(rows, node: str = "", nets=None, run_app: bool = True):
+ """Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None.
+
+ `rows` est une liste d'états (hw_state), `nets` les réseaux que l'hôte
+ peut offrir. `run_app=False` renvoie l'instance sans la lancer — c'est
+ ainsi que les tests l'inspectent.
+ """
+ from textual.app import App, ComposeResult
+ from textual.containers import Horizontal, VerticalScroll
+ from textual.widgets import (
+ Button,
+ Checkbox,
+ Footer,
+ Header,
+ Input,
+ Select,
+ Static,
+ )
+
+ states = [dict(r) for r in rows if r]
+ cpus = cpu_choices(states)
+ reseaux = net_choices(states, nets)
+
+ class HardwareForm(App):
+ TITLE = t("VM hardware")
+ # L'intention se lit SUR l'instance, pas dans une fermeture : c'est
+ # ainsi que les tests montent le formulaire et vérifient ce qu'il
+ # rend, sans passer par un terminal.
+ want = None
+ CSS = """
+ #rows { height: 1fr; }
+ #host { padding: 0 1; }
+ .vm { padding: 1 1 0 1; text-style: bold; }
+ .row { height: auto; padding: 0 1; }
+ .lbl { width: 7; height: 3; content-align: right middle; }
+ .num { width: 10; }
+ /* « auto » plutôt qu'une largeur fixe : le libellé traduit change de
+ longueur, et une case tronquée ne dit plus ce qu'elle coche. */
+ .cb3d { width: auto; margin: 0 2 0 1; }
+ .cbauto { width: auto; }
+ .warn { padding: 0 3; }
+ .sel { width: 22; }
+ /* Le réseau porte un suffixe (« br0 (pont) ») : deux colonnes de plus
+ que le mode CPU, qui s'affiche en un mot. */
+ .selnet { width: 24; }
+ .lbl2 { width: 8; height: 3; content-align: right middle; }
+ /* 8 et pas 5 : sous cette largeur, Textual dessine le cadre du champ
+ mais PAS son contenu — la valeur devient invisible, ce qui est pire
+ qu'une troncature (on valide un champ qu'on croit vide). */
+ .heads { width: 8; }
+ #bar { height: auto; padding: 1; }
+ """
+ BINDINGS = [
+ ("ctrl+s", "apply", t("Apply")),
+ ("escape", "quit", t("Cancel")),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Header()
+ head = t("Host GPU:") + " "
+ head += node if node else t("none (software rendering)")
+ yield Static(head, id="host")
+ with VerticalScroll(id="rows"):
+ for i, st in enumerate(states):
+ reason = gpu_allowed(st, node)
+ # Le nom sur SA ligne, avec l'état actuel : les noms du
+ # parc font trente caractères, en colonne ils seraient
+ # tronqués — et c'est le nom qui dit quelle VM on règle.
+ yield Static(
+ f"{st.get('name', '')} — {hw_summary(st)}",
+ classes="vm",
+ )
+ with Horizontal(classes="row"):
+ yield Static("vCPU", classes="lbl")
+ yield Input(
+ value=str(st.get("vcpus") or ""),
+ id=f"vcpus{i}",
+ classes="num",
+ )
+ yield Static("RAM", classes="lbl")
+ yield Input(
+ value=ram_field(st.get("mem_mib")),
+ id=f"ram{i}",
+ classes="num",
+ placeholder="Mo ou G",
+ )
+ yield Checkbox(
+ t("3D"),
+ value=bool(st.get("accel3d")),
+ id=f"gpu{i}",
+ disabled=bool(reason),
+ classes="cb3d",
+ )
+ yield Checkbox(
+ t("Autostart"),
+ value=bool(st.get("autostart")),
+ id=f"auto{i}",
+ classes="cbauto",
+ )
+ with Horizontal(classes="row"):
+ yield Static("CPU", classes="lbl2")
+ yield Select(
+ [(cpu_label(m), m) for m in cpus],
+ value=st.get("cpu") or cpus[0],
+ allow_blank=False,
+ id=f"cpu{i}",
+ classes="sel",
+ )
+ yield Static(t("Screens"), classes="lbl2")
+ yield Input(
+ value=str(st.get("heads") or 1),
+ id=f"heads{i}",
+ classes="heads",
+ )
+ if reseaux:
+ yield Static(t("Network"), classes="lbl2")
+ yield Select(
+ [(lab, tok) for tok, lab in reseaux],
+ value=st.get("net") or reseaux[0][0],
+ allow_blank=False,
+ id=f"net{i}",
+ classes="selnet",
+ )
+ if reason:
+ yield Static(f"⚠ {reason}", classes="warn")
+ with Horizontal(id="bar"):
+ yield Button(t("Apply"), variant="primary", id="apply")
+ yield Button(t("Cancel"), id="cancel")
+ yield Footer()
+
+ def action_apply(self) -> None:
+ want = {}
+ for i, st in enumerate(states):
+ net = st.get("net") or ""
+ if reseaux:
+ net = self.query_one(f"#net{i}", Select).value or net
+ want[st.get("name", "")] = build_want(
+ st,
+ self.query_one(f"#vcpus{i}", Input).value,
+ self.query_one(f"#ram{i}", Input).value,
+ self.query_one(f"#gpu{i}", Checkbox).value,
+ self.query_one(f"#auto{i}", Checkbox).value,
+ cpu=self.query_one(f"#cpu{i}", Select).value or "",
+ heads=self.query_one(f"#heads{i}", Input).value,
+ net=net,
+ )
+ self.want = want
+ self.exit()
+
+ def on_button_pressed(self, event) -> None:
+ if event.button.id == "apply":
+ self.action_apply()
+ else:
+ self.exit()
+
+ app = HardwareForm()
+ if not run_app:
+ return app
+ app.run()
+ return app.want
diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py
index eb63c5c..a00420c 100644
--- a/script/todo/qemu_install_monitor.py
+++ b/script/todo/qemu_install_monitor.py
@@ -332,6 +332,41 @@ def read_status(log_path: str) -> tuple[str, int | None]:
return "running", None
+# Au-delà de ce silence, la colonne d'état le DIT. Ce n'est pas un verdict mais
+# un chiffre : plusieurs étapes sont légitimement muettes, leur sortie partant
+# ailleurs. Mesuré sur une installation réelle : le téléchargement d'Android
+# Studio tient ~5 min sans une ligne, et l'étape « APK debug » davantage — son
+# détail va dans le journal de la VM. Dix minutes passent donc au-dessus du
+# premier sans attendre le second, qui reste bruyant par nature.
+#
+# À 48 minutes, le chiffre est accablant : une installation est morte ainsi,
+# session ssh emportée, et le sablier tournait toujours.
+IDLE_HINT_SECS = 600
+
+
+def log_idle(log_path: str) -> float:
+ """Secondes depuis la dernière écriture dans le journal. -1 s'il manque.
+
+ La date de modification du fichier, et non un compte de lignes : c'est la
+ seule mesure qui distingue « rien n'avance » de « rien ne s'affiche »."""
+ try:
+ return max(0.0, time.time() - os.path.getmtime(log_path))
+ except OSError:
+ return -1.0
+
+
+def state_mark(icon: str, idle: float) -> str:
+ """Icône d'état, suivie du silence du journal quand il dépasse le seuil.
+
+ Le silence est une INFORMATION, pas un diagnostic : plusieurs étapes sont
+ muettes longtemps sans rien avoir de cassé. Mais le sablier seul ne
+ distingue pas une installation qui travaille d'une qui est morte, et c'est
+ arrivé — 48 minutes de sablier sur une session ssh déjà emportée."""
+ if idle > IDLE_HINT_SECS:
+ return f"{icon} {t('silent')} {_fmt_secs(idle)}"
+ return icon
+
+
def run_progress(run: dict) -> dict:
"""Avancement d'un run : combien de VM tournent encore, et depuis quand
plus rien n'a été écrit. `idle` sert à distinguer une install vivante d'un
@@ -395,6 +430,133 @@ _LST_IGNORE_ERROR = (
)
+# Signaux d'échec qui ne contiennent NI « error » NI « warning ». Sans eux, le
+# scan par sous-chaîne rate des installations franchement ratées : le journal de
+# la VM erplibre-ubuntu-2604-gnome, dont la compilation de l'APK a été tuée par
+# le noyau, ne portait AUCUNE ligne « error » — mesuré, 0 sur 8765 lignes —
+# pendant que « ⚠ ÉCHEC : APK debug (gradle) », « FAILURE: Build failed » et
+# « daemon disappeared unexpectedly » y étaient. Le détail des erreurs annonçait
+# donc « aucune erreur détectée » sur une installation en échec.
+#
+# Chaque motif est là parce qu'il est apparu dans un vrai journal, pas par
+# précaution : Gradle dit « FAILURE », Python « Traceback », git « fatal: », apt
+# « Unable to locate package », le noyau « Killed » ou « Cannot allocate
+# memory », et nos propres étapes « ⚠ ÉCHEC ».
+_LST_HARD_MARKERS = (
+ "⚠ échec",
+ "failed:",
+ "failure",
+ "traceback (most recent call last)",
+ "fatal:",
+ "command not found",
+ # PAS « no such file or directory » : sur le journal de référence, 5 de ses
+ # 7 occurrences étaient des sondes bénignes (« cat: .odoo-version »), et le
+ # bruit dilue un résumé dont l'intérêt est justement d'être court. Un
+ # fichier vraiment manquant fait échouer une ÉTAPE, elle-même captée.
+ "permission denied",
+ "unable to locate package",
+ "disappeared unexpectedly",
+ "outofmemory",
+ "cannot allocate memory",
+ "segmentation fault",
+ "core dumped",
+ "killed process",
+)
+# Étape en échec, telle que la pose « mstep » : « ⚠ ÉCHEC : ». C'est
+# le signal AUTORITAIRE — il nomme l'étape, là où « FAILURE » ne nomme que
+# l'outil.
+_RE_FAILED_STEP = re.compile(r"⚠\s*(?:ÉCHEC|FAILED)\s*:?\s*(.+)")
+# Début d'une autre étape ou d'une section : borne du diagnostic qui suit.
+_RE_STEP_BOUND = re.compile(r"^\s*(?:->|==)\s")
+
+
+def _is_hard_signal(line: str) -> bool:
+ low = line.lower()
+ return any(m in low for m in _LST_HARD_MARKERS)
+
+
+def _error_signature(line: str) -> str:
+ """Ligne réduite à sa FORME, pour regrouper les répétitions.
+
+ Un journal d'installation répète la même erreur des centaines de fois avec
+ un chemin ou un numéro qui change. Regrouper sur cette forme donne « ×342 »
+ au lieu de 342 lignes à faire défiler."""
+ sig = re.sub(r"\d+", "#", line)
+ sig = re.sub(r"0x[0-9a-fA-F]+", "#", sig)
+ sig = re.sub(r"/\S+", "/…", sig)
+ return re.sub(r"\s+", " ", sig).strip()[:160]
+
+
+def scan_log_summary(log_path: str, diag_cap: int = 14) -> dict:
+ """Résumé d'un journal d'installation : ce qui a échoué, puis le reste.
+
+ Rend {steps, hard, groups, nerr, nwarn} où « steps » liste les étapes en
+ échec AVEC leur diagnostic, « hard » les autres signaux durs dédupliqués, et
+ « groups » les lignes « error »/« warning » regroupées par forme et comptées.
+
+ L'ordre n'est pas cosmétique : une étape en échec nommée vaut mille lignes,
+ et c'est elle qu'on veut lire d'abord."""
+ try:
+ lines = Path(log_path).read_text(errors="replace").splitlines()
+ except OSError:
+ return {"steps": [], "hard": [], "groups": [], "nerr": 0, "nwarn": 0}
+
+ steps, hard, groups = [], {}, {}
+ nerr = nwarn = 0
+ for i, line in enumerate(lines, 1):
+ if EXIT_MARKER in line:
+ continue
+ low = line.lower()
+ match = _RE_FAILED_STEP.search(line)
+ if match:
+ # Le diagnostic suit l'échec, jusqu'à l'étape suivante : c'est lui
+ # qui porte la cause, l'échec ne portant que le nom.
+ diag = []
+ for nxt in lines[i : i + 60]:
+ if _RE_STEP_BOUND.match(nxt) or _RE_FAILED_STEP.search(nxt):
+ break
+ if EXIT_MARKER in nxt:
+ continue
+ if nxt.strip() and len(diag) < diag_cap:
+ diag.append(nxt.rstrip())
+ steps.append(
+ {"line": i, "label": match.group(1).strip(), "diag": diag}
+ )
+ continue
+ if _is_hard_signal(line):
+ sig = _error_signature(line)
+ entry = hard.setdefault(
+ sig, {"line": i, "text": line.strip(), "count": 0}
+ )
+ entry["count"] += 1
+ continue
+ if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
+ nerr += 1
+ key = ("error", _error_signature(line))
+ groups.setdefault(
+ key, {"line": i, "text": line.strip(), "count": 0}
+ )["count"] += 1
+ if "warning" in low and not any(
+ ig in line for ig in _LST_IGNORE_WARNING
+ ):
+ nwarn += 1
+ key = ("warning", _error_signature(line))
+ groups.setdefault(
+ key, {"line": i, "text": line.strip(), "count": 0}
+ )["count"] += 1
+ ordered = sorted(
+ ({"kind": k[0], **v} for k, v in groups.items()),
+ key=lambda g: (-g["count"], g["line"]),
+ )
+ return {
+ "steps": steps,
+ "hard": sorted(hard.values(), key=lambda h: h["line"]),
+ "groups": ordered,
+ "nerr": nerr,
+ "nwarn": nwarn,
+ }
+
+
def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]:
"""(lignes_erreur, lignes_avertissement) d'un log, même détection que
scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les
@@ -408,6 +570,9 @@ def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]:
if EXIT_MARKER in line:
continue
low = line.lower()
+ if _is_hard_signal(line) and len(errs) < cap:
+ errs.append(f"{i}: {line}")
+ continue
if (
"error" in low
and not any(ig in line for ig in _LST_IGNORE_ERROR)
@@ -438,6 +603,13 @@ def scan_log_errors(log_path: str) -> tuple[int, int]:
low = line.lower()
if EXIT_MARKER in line:
continue
+ # Un échec d'étape EST une erreur, même sans le mot « error » : sinon le
+ # tableau de bord affiche « 0 erreur » sur une installation ratée —
+ # mesuré sur erplibre-ubuntu-2604-gnome, 0 ligne « error » pour un APK
+ # tué par le noyau.
+ if _is_hard_signal(line):
+ nerr += 1
+ continue
if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR):
nerr += 1
if "warning" in low and not any(
@@ -677,6 +849,54 @@ def _fmt_size(nbytes) -> str:
return f"{nbytes // 1024}K"
+def _host_mem() -> tuple:
+ """(total, disponible, swap_total, swap_libre) en octets, lus dans /proc.
+
+ /proc/meminfo plutôt qu'une dépendance : psutil n'est pas garanti dans le
+ venv d'outils, et ce suivi tourne sur l'hyperviseur — donc sous Linux, d'où
+ viennent déjà getloadavg() et libvirt.
+
+ « MemAvailable » et non « MemFree » : le noyau y répond ce qu'il peut
+ rendre sans échanger, cache réclamable compris. MemFree seul affiche
+ presque rien sur une machine qui travaille, et alarmerait pour rien.
+ """
+ wanted = ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree")
+ vals = {}
+ try:
+ with open("/proc/meminfo", encoding="utf-8") as fh:
+ for line in fh:
+ key, _, rest = line.partition(":")
+ if key in wanted:
+ vals[key] = int(rest.split()[0]) * 1024
+ except (OSError, ValueError, IndexError):
+ return (0, 0, 0, 0)
+ return tuple(vals.get(k, 0) for k in wanted)
+
+
+def _mem_tele(total, avail, sw_total, sw_free) -> str:
+ """Segment « RAM » de la barre de télémétrie. Vide si /proc n'a rien dit.
+
+ Le swap n'apparaît que s'il existe : l'afficher à « 0/0 » sur une machine
+ qui n'en a pas occupe une place pour ne rien dire. Quand il existe, il est
+ montré même à zéro — une VM qui a commencé à échanger explique une lenteur,
+ et c'est précisément ce qu'on cherche dans un suivi d'installation.
+ """
+ if not total:
+ return ""
+ used = max(0, total - avail)
+ out = (
+ f"🧠 RAM {_fmt_size(used)}/{_fmt_size(total)}"
+ f" ({int(used / total * 100)}%)"
+ f" · {t('free space')} {_fmt_size(avail)}"
+ )
+ if sw_total:
+ out += (
+ f" · swap {_fmt_size(max(0, sw_total - sw_free))}"
+ f"/{_fmt_size(sw_total)}"
+ )
+ return out
+
+
def _fmt_secs(secs) -> str:
"""Secondes -> « 45s » / « 12m » / « 1h05 »."""
secs = int(secs)
@@ -984,26 +1204,64 @@ def run_monitor(manifest_path: str, run_app: bool = True):
("q", "dismiss", "Fermer"),
]
- def __init__(self, vm_name, errs, warns):
+ def __init__(self, vm_name, errs, warns, summary=None):
super().__init__()
self._vm = vm_name
self._errs = errs
self._warns = warns
+ self._sum = summary or {}
def compose(self) -> ComposeResult:
- with Vertical(id="errbox"):
- yield Static(
- f" {self._vm} — ⚠ {len(self._errs)} "
+ nsteps = len(self._sum.get("steps", []))
+ head = (
+ f" {self._vm} — ⚠ {len(self._errs)} "
+ f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}"
+ )
+ # Le nombre d'étapes en échec passe DEVANT : c'est la seule ligne du
+ # bandeau qui dise si l'installation a abouti.
+ if nsteps:
+ head = (
+ f" {self._vm} — 🛑 {nsteps} "
+ f"{t('failed steps')} · ⚠ {len(self._errs)} "
f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}"
- f" ({t('Esc to close')})",
- id="errtitle",
)
+ with Vertical(id="errbox"):
+ yield Static(f"{head} ({t('Esc to close')})", id="errtitle")
yield RichLog(
id="errlog", highlight=False, markup=False, wrap=True
)
def on_mount(self) -> None:
log = self.query_one("#errlog", RichLog)
+ steps = self._sum.get("steps", [])
+ hard = self._sum.get("hard", [])
+ groups = self._sum.get("groups", [])
+
+ # -- Le résumé, d'abord. Une étape nommée vaut mille lignes.
+ if steps:
+ log.write(f"── {t('Failed steps')} ──")
+ for st in steps:
+ log.write(f"🛑 {st['label']} ({t('line')} {st['line']})")
+ for line in st["diag"]:
+ log.write(f" {line.strip()}")
+ log.write("")
+ if hard:
+ log.write(f"── {t('Hard signals')} ──")
+ for h in hard:
+ mult = f" ×{h['count']}" if h["count"] > 1 else ""
+ log.write(f"{h['line']}:{mult} {h['text']}")
+ log.write("")
+ if groups:
+ # Regroupé par FORME : un journal répète la même erreur des
+ # centaines de fois avec un chemin qui change.
+ log.write(f"── {t('Grouped by shape')} ──")
+ for g in groups[:60]:
+ mark = "⚠" if g["kind"] == "error" else "⚡"
+ mult = f" ×{g['count']}" if g["count"] > 1 else ""
+ log.write(f"{mark} {g['line']}:{mult} {g['text']}")
+ log.write("")
+
+ # -- Puis le détail brut, pour qui veut tout lire.
if self._errs:
log.write(f"── {t('errors').capitalize()} ──")
for line in self._errs:
@@ -1012,7 +1270,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
log.write(f"── {t('warnings').capitalize()} ──")
for line in self._warns:
log.write(line)
- if not self._errs and not self._warns:
+ if not (steps or hard or self._errs or self._warns):
log.write(t("No error detected."))
def action_dismiss(self) -> None:
@@ -1334,12 +1592,18 @@ def run_monitor(manifest_path: str, run_app: bool = True):
load1 = os.getloadavg()[0]
du = shutil.disk_usage(self._disk_dir)
used_pct = int(du.used / du.total * 100) if du.total else 0
+ # La RAM va entre le CPU et le disque : c'est la ressource dont
+ # l'épuisement ne se voit nulle part ailleurs. Une compilation
+ # mobile a été tuée par le noyau sur une VM de 12 Go sans swap,
+ # et ce suivi n'en montrait rien.
+ mem = _mem_tele(*_host_mem())
return (
f" ⚙ CPU {min(999, int(load1 / ncpu * 100))}% "
- f"(charge {load1:.1f}/{ncpu}) "
- f"💽 {self._disk_dir}: {_fmt_size(du.used)}/"
+ f"({t('load')} {load1:.1f}/{ncpu}) "
+ + (f"{mem} " if mem else "")
+ + f"💽 {self._disk_dir}: {_fmt_size(du.used)}/"
f"{_fmt_size(du.total)} ({used_pct}%) · "
- f"libre {_fmt_size(du.free)}"
+ f"{t('free space')} {_fmt_size(du.free)}"
)
except Exception:
return ""
@@ -1453,7 +1717,12 @@ def run_monitor(manifest_path: str, run_app: bool = True):
table, name, "state", f"⏸ {t('paused')}"
)
else:
- self._set_cell(table, name, "state", ICON[state])
+ self._set_cell(
+ table,
+ name,
+ "state",
+ state_mark(ICON[state], log_idle(vm["log"])),
+ )
ref = eta_reference(self._stats, vm.get("arch"))
if ref is not None:
remaining.append(max(0, ref - (now - started)))
@@ -1782,7 +2051,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
if not vm:
return
errs, warns = scan_log_error_lines(vm["log"])
- self.push_screen(ErrorLinesScreen(vm["name"], errs, warns))
+ summary = scan_log_summary(vm["log"])
+ self.push_screen(
+ ErrorLinesScreen(vm["name"], errs, warns, summary)
+ )
def on_click(self, event) -> None:
# Clic sur le sommaire de stats -> déplie / replie le détail.
diff --git a/script/todo/todo.py b/script/todo/todo.py
index 4614ffa..07ab2f4 100755
--- a/script/todo/todo.py
+++ b/script/todo/todo.py
@@ -1087,14 +1087,19 @@ class TODO:
print(f"{t('Invalid selection, using')} {default}")
return default
- # Distros publiant des images cloud par architecture (cohérent avec
- # S390X_DISTROS / ARM64_DISTROS de deploy_qemu.py). amd64 : toutes.
+ # Repli SEULEMENT : la table qui fait autorité est ARCH_DISTRO_SUPPORT de
+ # deploy_qemu.py, lue par _qemu_arch_distros. Ces tuples ont longtemps été
+ # une copie à la main, avec le commentaire « cohérent avec deploy_qemu » en
+ # guise de garantie — et la cohérence a rompu à la première évolution :
+ # Debian a gagné s390x là-bas sans l'obtenir ici, donc l'écran ne le
+ # proposait pas. On ne les garde que pour le cas où l'import échoue.
_QEMU_S390X_DISTROS = (
"ubuntu",
"almalinux",
"rocky",
"fedora",
"opensuse",
+ "debian",
)
_QEMU_ARM64_DISTROS = (
"ubuntu",
@@ -1124,12 +1129,24 @@ class TODO:
}.get(machine, "amd64")
def _qemu_arch_distros(self, arch):
- """Distros supportant `arch` (None = toutes, cas amd64)."""
- if arch == "s390x":
- return self._QEMU_S390X_DISTROS
- if arch == "arm64":
- return self._QEMU_ARM64_DISTROS
- return None
+ """Distros supportant `arch` (None = toutes, cas amd64).
+
+ Lu dans deploy_qemu.py, qui refuse aussi les combinaisons qu'il
+ n'annonce pas : une seule table, donc aucun écran ne peut proposer un
+ choix rejeté ensuite. « amd64 » n'y figure pas et rend None, ce qui
+ veut bien dire « toutes » — c'est le contrat attendu ici.
+ """
+ try:
+ table = getattr(self._qemu_import_module(), "ARCH_DISTRO_SUPPORT")
+ except Exception:
+ # Repli sur les copies locales : mieux vaut un catalogue figé
+ # qu'un écran vide si deploy_qemu.py est absent ou cassé.
+ if arch == "s390x":
+ return self._QEMU_S390X_DISTROS
+ if arch == "arm64":
+ return self._QEMU_ARM64_DISTROS
+ return None
+ return table.get(arch)
def _qemu_last_run_line(self):
"""Ligne « dernière install » (distro version [arch] en durée), depuis
@@ -1298,6 +1315,11 @@ class TODO:
"Remote desktop tunnel (VNC/RDP through SSH)"
)
},
+ {
+ "prompt_description": t(
+ "Android emulator (start, tunnel, scrcpy)"
+ )
+ },
{"section": t("Catalog")},
{"prompt_description": t("List available images and specs")},
]
@@ -1340,6 +1362,8 @@ class TODO:
elif status == "14":
self._qemu_tunnel_menu()
elif status == "15":
+ self._qemu_emulator_menu()
+ elif status == "16":
self._qemu_list_images()
else:
cmd_no_found = True
@@ -1552,10 +1576,20 @@ class TODO:
print(f"\n {t('Remote desktop kind:')}")
print(f" [1] RDP 3389 (xrdp) *")
print(f" [2] VNC 5901 (TigerVNC, Arch)")
- print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}")
+ print(
+ f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}"
+ )
+ print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}")
+ print(f" [5] {t('Graphical console (virt-viewer, built-in tunnel)')}")
kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1"
if kind_answer == "3":
- self._qemu_console_tunnel()
+ self._qemu_console_tunnel(name, src)
+ return
+ if kind_answer == "4":
+ self._qemu_scrcpy_tunnel(name, src)
+ return
+ if kind_answer == "5":
+ self._qemu_virt_viewer(name, src)
return
port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP")
local = port + 1
@@ -1584,7 +1618,455 @@ class TODO:
)
print(f" {t('The tunnel stays open as long as that ssh runs.')}")
- def _qemu_console_tunnel(self):
+ @staticmethod
+ def _qemu_ssh_opts(src):
+ """Options ssh selon la provenance de la cible.
+
+ Une VM libvirt locale est jointe par son IP, et son IP est recyclée d'un
+ déploiement à l'autre : sa clé d'hôte change sous le même adresse, et
+ ssh refuse alors de se connecter — « Host key verification failed »,
+ vécu. C'est la raison pour laquelle le suivi d'installation et l'attente
+ de sshd emploient déjà ces deux options.
+
+ Un hôte de ~/.ssh/config, lui, est une machine que l'utilisateur a
+ configurée : on ne touche PAS à sa politique de clés. Sa clé est un
+ garde-fou qui lui appartient."""
+ if src == "ssh_config":
+ return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"]
+ return [
+ "-o",
+ "BatchMode=yes",
+ "-o",
+ "ConnectTimeout=8",
+ "-o",
+ "StrictHostKeyChecking=no",
+ "-o",
+ "UserKnownHostsFile=/dev/null",
+ ]
+
+ def _qemu_ssh_target(self, name, src):
+ """Destination ssh d'une cible du menu, selon sa provenance.
+
+ Un hôte de ~/.ssh/config se nomme tel quel — c'est lui qui porte le
+ ProxyJump, et le réécrire à la main reviendrait à le deviner. Un domaine
+ libvirt local, lui, n'a qu'une IP, et l'utilisateur des VM ERPLibre est
+ « erplibre ». Renvoie une chaîne vide quand l'IP manque."""
+ if src == "ssh_config":
+ return name
+ ip = self._qemu_resolve_ips([name]).get(name)
+ return f"erplibre@{ip}" if ip else ""
+
+ # Commande de l'émulateur dans la VM. Le chemin est ABSOLU : un
+ # « ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc.
+ _QEMU_EMULATOR_BIN = "$HOME/android/emulator/emulator"
+
+ # Drapeaux passés à CHAQUE lancement, et non écrits dans le config.ini de
+ # l'AVD : l'émulateur réécrit ce fichier depuis le profil du téléphone au
+ # premier démarrage, et les hw.lcd.* y étaient effacés — l'AVD repartait en
+ # 1080x2400 densité 420, quatre fois les pixels voulus. Mesuré.
+ #
+ # La résolution et la DENSITÉ vont ensemble, et c'est contre-intuitif :
+ # 540x1140 en densité 420 est PIRE que le plein écran — 81 ms de médiane
+ # contre 40, et 57 % d'images en retard contre 37, tout étant rendu énorme.
+ # Avec la densité 240, la queue s'effondre : 99e centile à 250 ms contre
+ # 950, et 32 % d'images en retard.
+ #
+ # « -no-snapshot-save » : sans lui, un émulateur tué par pkill — ce que ce
+ # menu propose lui-même — laisse un instantané en cours, et le lancement
+ # SUIVANT meurt sur « A snapshot operation is pending and timeout has
+ # expired ». Vécu, et le message ne dit pas quoi faire.
+ # « -gpu » reste sur swangle par DÉFAUT, même quand la VM a la 3D : un
+ # « -gpu host » qui échoue ne rend pas la main, l'émulateur reste pendu, et
+ # ce n'est pas un défaut à imposer sans l'avoir mesuré sur la machine.
+ # EL_EMULATOR_GPU permet de l'essayer sans toucher au code, une fois le
+ # nœud de rendu présent dans l'invité (voir script/qemu/README).
+ _QEMU_EMULATOR_GPU = os.environ.get("EL_EMULATOR_GPU") or "swangle"
+ _QEMU_EMULATOR_FLAGS = (
+ "-no-audio -no-boot-anim -no-snapshot-save"
+ f" -gpu {_QEMU_EMULATOR_GPU}"
+ " -skin 540x1140 -prop qemu.sf.lcd_density=240"
+ )
+ _QEMU_AVD_NAME = "erplibre"
+
+ def _qemu_emulator_running(self, target, src="virsh"):
+ """Nombre d'émulateurs en cours dans la VM.
+
+ Deux sur le même AVD, et le second s'arrête sur « Running multiple
+ emulators with the same AVD is an experimental feature ». Le savoir
+ AVANT de lancer évite de lire cette phrase sans la comprendre — vécu,
+ deux fois."""
+ try:
+ res = subprocess.run(
+ ["ssh"]
+ + self._qemu_ssh_opts(src)
+ + [target, "pgrep -c qemu-system 2>/dev/null || echo 0"],
+ capture_output=True,
+ text=True,
+ timeout=25,
+ )
+ return int((res.stdout or "0").strip().splitlines()[-1])
+ except (OSError, subprocess.SubprocessError, ValueError, IndexError):
+ return -1
+
+ def _qemu_emulator_ready(self, target, src="virsh"):
+ """La VM a-t-elle de quoi émuler ? Rend (prêt, raison).
+
+ Le binaire ET l'AVD, en une seule lecture : sans cette vérification le
+ démarrage détaché rendait 0 sur une VM sans SDK, et le menu annonçait
+ « Démarré » quand le journal disait « not found ». Une VM déployée sans
+ cocher l'outil Émulateur Android est le cas normal, pas une panne."""
+ probe = (
+ f"test -x {self._QEMU_EMULATOR_BIN} || echo NO_SDK; "
+ f"test -d $HOME/.android/avd/{self._QEMU_AVD_NAME}.avd"
+ " || echo NO_AVD"
+ )
+ try:
+ res = subprocess.run(
+ ["ssh"] + self._qemu_ssh_opts(src) + [target, probe],
+ capture_output=True,
+ text=True,
+ timeout=25,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False, t("Cannot reach this VM.")
+ out = res.stdout or ""
+ if "NO_SDK" in out:
+ return False, t("No Android SDK in this VM: no emulator binary.")
+ if "NO_AVD" in out:
+ return False, t("No AVD named erplibre in this VM.")
+ return True, ""
+
+ def _qemu_emulator_menu(self):
+ """Démarre l'émulateur Android d'une VM, et donne la suite qui va avec.
+
+ La question qui décide de tout est celle de la FENÊTRE :
+ - avec fenêtre, l'écran voyage en pixels bruts par X11, et la commande
+ doit partir du poste qui possède l'affichage — donc pas d'ici ;
+ - sans fenêtre, on peut la lancer d'ici, détachée, et l'image arrive
+ ensuite par scrcpy en H.264. C'est la voie fluide.
+ """
+ print(f"\n📱 {t('Android emulator')}")
+ targets = [(h, "ssh_config") for h in self._ssh_config_hosts()]
+ if not targets:
+ targets = [(n, "virsh") for n in self._qemu_list_domains()]
+ if not targets:
+ print(f" {t('No host in ~/.ssh/config and no local VM.')}")
+ return
+ for i, (nm, sr) in enumerate(targets, 1):
+ mark = "" if sr == "ssh_config" else f" ({t('local VM')})"
+ print(f" [{i}] {nm}{mark}")
+ answer = input(f"{t('Which VM?')} [1]: ").strip() or "1"
+ if not answer.isdigit() or not (1 <= int(answer) <= len(targets)):
+ print(t("Cancelled."))
+ return
+ name, src = targets[int(answer) - 1]
+ target = self._qemu_ssh_target(name, src)
+ if not target:
+ print(f" {t('No IP for this VM; is it running?')}")
+ return
+
+ running = self._qemu_emulator_running(target, src)
+ if running > 0:
+ print(f"\n ⚠ {t('An emulator is already running on this VM.')}")
+ print(f" {t('Only one per AVD; close it first:')}")
+ print(f"\n ssh {target} 'pkill -f \"[q]emu-system-x86_64\"'\n")
+ if not self._is_yes(input(t("Close it now? (y/N): "))):
+ return
+ subprocess.run(
+ ["ssh"]
+ + self._qemu_ssh_opts(src)
+ + [target, 'pkill -f "[q]emu-system-x86_64"'],
+ capture_output=True,
+ timeout=30,
+ )
+ print(f" {t('Closed.')}")
+
+ ready, why = self._qemu_emulator_ready(target, src)
+ if not ready:
+ print(f"\n ⚠ {why}")
+ print(f" {t('Tick the Android emulator tool when deploying.')}")
+ return
+
+ print(f"\n {t('Show a window?')}")
+ print(f" [1] {t('No window - stream with scrcpy (smoother)')} *")
+ print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}")
+ kind = input(f"{t('Choice')} [1]: ").strip() or "1"
+ # Sans cette validation, TOUT ce qui n'est pas « 2 » démarrait
+ # l'émulateur : une frappe de travers (« n ») lançait le démarrage,
+ # observé. Un menu à deux crans n'a pas de troisième réponse.
+ if kind not in ("1", "2"):
+ print(t("Cancelled."))
+ return
+ emu = self._QEMU_EMULATOR_BIN
+ avd = self._QEMU_AVD_NAME
+
+ if kind == "2":
+ # L'affichage appartient au POSTE : cette commande ne peut pas
+ # partir d'ici, où il n'y a pas d'écran à lui donner.
+ print(f"\n {t('Run this on YOUR workstation:')}")
+ print(
+ f"\n ssh -XC {target} '{emu} -avd {avd} "
+ f"{self._QEMU_EMULATOR_FLAGS}'\n"
+ )
+ print(
+ f" {t('X11 compression is on (-XC); the screen is 540x1140.')}"
+ )
+ return
+
+ print(f"\n {t('Starting the emulator without a window...')}")
+ # « sg kvm » : l'appartenance au groupe est posée à l'installation, mais
+ # une VM créée avant ce correctif ne l'a pas dans sa session — sans KVM
+ # l'émulateur refuse de démarrer. setsid le détache, pour qu'il survive
+ # à la fermeture de ce ssh.
+ start = (
+ f'setsid -f sg kvm -c "{emu} -avd {avd} -no-window '
+ f"{self._QEMU_EMULATOR_FLAGS}"
+ ' > /tmp/erplibre-emulator.log 2>&1"'
+ )
+ res = subprocess.run(
+ ["ssh"] + self._qemu_ssh_opts(src) + [target, start],
+ capture_output=True,
+ text=True,
+ timeout=60,
+ )
+ if res.returncode:
+ print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}")
+ return
+ # « setsid » détache : le code de retour ne dit RIEN de l'émulateur.
+ # Le menu annonçait « Démarré » pendant que le journal de la VM disait
+ # « not found » — mesuré sur une VM sans SDK. On attend donc de voir le
+ # processus, et à défaut on rapporte le journal.
+ for _ in range(5):
+ if self._qemu_emulator_running(target, src) > 0:
+ break
+ time.sleep(2)
+ else:
+ print(f" ⚠ {t('It did not start; the VM log says:')}")
+ log = subprocess.run(
+ ["ssh"]
+ + self._qemu_ssh_opts(src)
+ + [target, "tail -5 /tmp/erplibre-emulator.log 2>/dev/null"],
+ capture_output=True,
+ text=True,
+ timeout=25,
+ )
+ for line in (log.stdout or "").strip().splitlines():
+ print(f" {line}")
+ return
+ print(
+ f" {t('Started. Boot takes about a minute; log in the VM:')}"
+ " /tmp/erplibre-emulator.log"
+ )
+ self._qemu_scrcpy_tunnel(name, src, started=True)
+
+ def _qemu_scrcpy_tunnel(self, name, src, started=False):
+ """Tunnel adb vers l'émulateur Android d'une VM, pour scrcpy.
+
+ Pourquoi cette voie plutôt que « ssh -X » : par X11, chaque image de
+ l'écran traverse le réseau en pixels bruts — 0,62 Mpixel par image même
+ après réduction, en rendu logiciel. scrcpy, lui, reçoit un flux H.264
+ encodé PAR l'appareil et le décode sur le poste. L'émulateur tourne
+ alors SANS fenêtre : plus de X11 du tout, ni sur l'hôte ni dans la VM.
+
+ Le port est celui de l'émulateur, pas celui du serveur adb. Un émulateur
+ écoute sur 5554 (console) et 5555 (adb), tous deux sur le localhost de
+ la VM — vérifié par « ss -ltn ». C'est 5555 qu'il faut, et non 5037 :
+ tunneler le serveur adb obligerait à tuer celui du poste, qui occupe le
+ même port.
+
+ Vérifié de bout en bout à travers le tunnel : une poignée de main adb
+ (paquet CNXN) reçoit « device::ro.product.name=sdk_gphone64_x86 » de
+ l'émulateur lui-même — c'est exactement ce que fait « adb connect ».
+ """
+ port = 5555
+ target = self._qemu_ssh_target(name, src)
+ if not target:
+ print(f" {t('No IP for this VM; is it running?')}")
+ return
+ print(f"\n 📱 {t('Android emulator over adb + scrcpy')}")
+ if started:
+ # Inutile de redire comment le démarrer : on vient de le faire.
+ print(f"\n {t('1. Emulator started, without a window.')}")
+ else:
+ print(
+ f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}"
+ )
+ print(
+ f"\n ssh {target} '{self._QEMU_EMULATOR_BIN} "
+ f"-avd {self._QEMU_AVD_NAME} -no-window "
+ f"{self._QEMU_EMULATOR_FLAGS}'\n"
+ )
+ print(f" {t('2. Open the tunnel from YOUR workstation:')}")
+ if src == "ssh_config":
+ # « localhost » est résolu par le DERNIER saut, donc par la VM
+ # elle-même : le ProxyJump de ssh_config traverse les niveaux.
+ print(f"\n ssh -N -L {port}:localhost:{port} {name}\n")
+ print(f" {t('(through the ProxyJump already in ~/.ssh/config)')}")
+ else:
+ host, from_ssh = self._qemu_self_address()
+ user = os.environ.get("USER", "user")
+ vm_ip = target.split("@")[-1]
+ if not from_ssh:
+ print(
+ f" ⚠ {t('Not in an SSH session: check the host address.')}"
+ )
+ # DEUX sauts, et non un seul vers l'hyperviseur : l'émulateur
+ # n'écoute que sur le 127.0.0.1 de la VM — « ss -ltn » le montre, et
+ # l'hyperviseur reçoit un refus sur IP_VM:5555. Or « localhost » se
+ # résout sur le DERNIER hôte de la chaîne : la VM doit donc être ce
+ # dernier saut, l'hyperviseur n'étant que le relais (-J).
+ print(
+ f"\n ssh -N -L {port}:localhost:{port}"
+ f" -J {user}@{host} erplibre@{vm_ip}\n"
+ )
+ print(
+ f" {t('(the hypervisor only relays; -J puts the VM last)')}"
+ )
+ print(f" {t('3. Then, still on your workstation:')}")
+ print(f"\n adb connect localhost:{port}")
+ print(f" scrcpy -s localhost:{port}\n")
+ print(f" {t('The tunnel stays open as long as that ssh runs.')}")
+ print(f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy adb")
+
+ # Ouvrir le tunnel D'ICI n'a de sens que si scrcpy tournera ici : le
+ # port ressort sur CETTE machine. On le propose donc en le disant,
+ # plutôt que de le faire d'office depuis un hyperviseur sans écran.
+ print(f"\n {t('If scrcpy will run on THIS machine, I can open it.')}")
+ if not self._is_yes(input(t("Open the tunnel now? (y/N): "))):
+ return
+ if self._port_in_use(port):
+ print(f" ⚠ {t('Port already in use here:')} {port}")
+ print(
+ f" {t('Close the other tunnel first:')}"
+ f' pkill -f "{port}:localhost:{port}"'
+ )
+ return
+ # « ExitOnForwardFailure » : sans lui, un ssh détaché rend 0 alors que
+ # la redirection a échoué — un succès annoncé pour un tunnel absent.
+ cmd = (
+ ["ssh", "-f", "-N", "-o", "ExitOnForwardFailure=yes"]
+ + self._qemu_ssh_opts(src)
+ + ["-L", f"{port}:localhost:{port}", target]
+ )
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=40)
+ if res.returncode:
+ print(f" ⚠ {t('Tunnel failed:')} {res.stderr.strip()[:200]}")
+ return
+ print(f" ✅ {t('Tunnel open on localhost:')}{port}")
+ print(
+ f" {t('Then:')} adb connect localhost:{port}"
+ f" && scrcpy -s localhost:{port}"
+ )
+ print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"')
+
+ @staticmethod
+ def _port_in_use(port):
+ """Le port est-il déjà pris sur CETTE machine ?
+
+ Un second tunnel sur le même port échouerait, et le message d'ssh
+ (« bind: Address already in use ») se perd en mode détaché."""
+ with socket.socket() as sock:
+ sock.settimeout(1)
+ return sock.connect_ex(("127.0.0.1", port)) == 0
+
+ # Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui
+ # est rare et bienvenu : seule la commande d'installation change.
+ _QEMU_VIRT_VIEWER_INSTALL = (
+ ("apt-get", "sudo apt-get install -y virt-viewer"),
+ ("dnf", "sudo dnf install -y virt-viewer"),
+ ("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"),
+ ("zypper", "sudo zypper --non-interactive install virt-viewer"),
+ )
+
+ def _qemu_ensure_virt_viewer(self):
+ """virt-viewer sur CETTE machine, installé s'il manque.
+
+ Installé seulement là où il va SERVIR : sur un hyperviseur sans écran,
+ poser un client graphique ne rendrait service à personne. C'est
+ l'appelant qui a vérifié l'affichage."""
+ if shutil.which("virt-viewer"):
+ return True
+ print(f"\n {t('virt-viewer is missing here; installing it.')}")
+ for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL:
+ if shutil.which(tool):
+ print(f" {t('Will execute:')} {cmd}")
+ self.execute.exec_command_live(cmd, source_erplibre=False)
+ break
+ else:
+ print(f" ⚠ {t('no known package manager here.')}")
+ return False
+ if shutil.which("virt-viewer"):
+ print(f" ✅ virt-viewer")
+ return True
+ print(f" ⚠ {t('virt-viewer still missing after the install.')}")
+ return False
+
+ def _qemu_virt_viewer(self, name, src):
+ """Ouvre l'écran d'une VM avec virt-viewer, qui monte SON tunnel.
+
+ C'est la voie la plus courte : virt-viewer parle à libvirt par
+ « qemu+ssh:// » et n'a besoin d'aucun « ssh -L » à tenir ouvert. Il lit
+ aussi le port de l'écran par libvirt, donc rien à deviner.
+
+ La seule question qui compte est celle de l'AFFICHAGE. virt-viewer
+ ouvre une fenêtre : il doit tourner là où il y a un écran. Deux cas, et
+ c'est l'environnement qui tranche, pas une question de plus :
+ - un affichage est là (poste de travail, ou « ssh -X ») : on installe
+ virt-viewer au besoin et on le lance, détaché ;
+ - aucun affichage : on donne la commande à lancer sur le poste, sous
+ la forme qemu+ssh, avec l'adresse par laquelle cette machine a été
+ jointe.
+ """
+ domain = name.rsplit("+", 1)[-1] if src == "ssh_config" else name
+ display = os.environ.get("DISPLAY") or os.environ.get(
+ "WAYLAND_DISPLAY"
+ )
+ if src == "ssh_config":
+ # L'hyperviseur est le ProxyJump déclaré : c'est lui qui fait
+ # tourner le QEMU de cette VM, pas la VM elle-même.
+ jump = self._ssh_proxyjump(name)
+ if not jump:
+ print(
+ f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}"
+ )
+ print(f" {t('Cannot tell which machine runs its QEMU.')}")
+ return
+ uri = f"qemu+ssh://{jump}/system"
+ else:
+ uri = "qemu:///system"
+
+ if display:
+ if not self._qemu_ensure_virt_viewer():
+ return
+ cmd = ["virt-viewer", "-c", uri, domain]
+ print(f"\n {t('Opening')} : {' '.join(cmd)}")
+ try:
+ with open("/tmp/erplibre-virt-viewer.log", "ab") as log:
+ subprocess.Popen(
+ cmd,
+ stdout=log,
+ stderr=log,
+ start_new_session=True,
+ )
+ except OSError as exc:
+ print(f" ⚠ {t('Could not start it:')} {exc}")
+ return
+ print(f" {t('Window opening on your display')} ({display}).")
+ print(f" {t('Log:')} /tmp/erplibre-virt-viewer.log")
+ return
+
+ host, from_ssh = self._qemu_self_address()
+ user = os.environ.get("USER", "user")
+ print(f"\n {t('No display here; run this on YOUR workstation:')}")
+ print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n")
+ if not from_ssh:
+ print(f" ⚠ {t('Not in an SSH session: check the host address.')}")
+ print(f" {t('A ~/.ssh/config alias works there too.')}")
+ print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}")
+ print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman"
+ " / zypper")
+
+ def _qemu_console_tunnel(self, name, src):
"""Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité.
Les deux autres choix du menu supposent un service DANS l'invité —
@@ -1597,52 +2079,102 @@ class TODO:
« listen=none » QEMU n'ouvre AUCUN socket, et aucun tunnel n'y peut
rien tant que le domaine n'est pas redéfini.
"""
- names = self._qemu_list_domains()
- if not names:
- print(f" {t('No local VM.')}")
- return
- for i, name in enumerate(names, 1):
- print(f" [{i}] {name}")
- raw = input(f"{t('Which VM?')} [1]: ").strip() or "1"
- if not raw.isdigit() or not (1 <= int(raw) <= len(names)):
- print(t("Cancelled."))
- return
- name = names[int(raw) - 1]
- try:
- res = subprocess.run(
- ["sudo", "virsh", "vncdisplay", name],
- capture_output=True,
- text=True,
- timeout=15,
- )
- except (OSError, subprocess.SubprocessError):
- res = None
- # « 127.0.0.1:0 » désigne le port 5900, « :1 » le 5901, etc.
- port = None
- if res and res.returncode == 0:
- disp = res.stdout.strip().rsplit(":", 1)
- if len(disp) == 2 and disp[1].isdigit():
- port = 5900 + int(disp[1])
+ if src != "ssh_config":
+ jump, domain = "", name
+ else:
+ # L'écran VNC appartient à QEMU, donc à l'HYPERVISEUR — pas à
+ # l'invité. Tunneler vers la VM elle-même ne trouve rien : le
+ # socket n'existe pas de ce côté. Vécu, et c'est aussi ce qui
+ # rendait le premier jet de ce menu inutile hors machine locale.
+ #
+ # L'hyperviseur est le ProxyJump déclaré dans ssh_config, lu par
+ # « ssh -G » : c'est la seule lecture qui couvre toutes les formes
+ # d'écriture (Host, Match, wildcards, includes). Le nom composé
+ # « saut+vm » n'est qu'un libellé, il ne fait pas autorité.
+ jump = self._ssh_proxyjump(name)
+ domain = name.rsplit("+", 1)[-1]
+ if not jump:
+ print(f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}")
+ print(f" {t('Cannot tell which machine runs its QEMU.')}")
+ return
+ port = self._qemu_vnc_port(domain, jump)
+ # Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe
+ # évite de les copier sur la mauvaise machine, l'erreur naturelle ici.
+ pre = f"ssh {jump} " if jump else ""
if not port:
print(f"\n ⚠ {t('This VM exposes no VNC port.')}")
print(f" {t('Its display is likely spice with listen=none:')}")
- print(f" sudo virsh dumpxml {name} | grep -A2 '")
- print(f" sudo virsh start {name}")
+ print(f" {pre}sudo virsh start {domain}")
print(f"\n {t('New VMs get this by default; see deploy_qemu.')}")
return
- host, from_ssh = self._qemu_self_address()
- user = os.environ.get("USER", "user")
- if not from_ssh:
- print(f" ⚠ {t('Not in an SSH session: check the host address.')}")
+ if jump:
+ target = jump
+ else:
+ host, from_ssh = self._qemu_self_address()
+ user = os.environ.get("USER", "user")
+ if not from_ssh:
+ print(f" ⚠ {t('Not in an SSH session: check the host address.')}")
+ target = f"{user}@{host}"
print(f"\n {t('Run this on YOUR workstation:')}")
- print(f"\n ssh -N -L {port}:127.0.0.1:{port} {user}@{host}\n")
+ print(f"\n ssh -N -L {port}:127.0.0.1:{port} {target}\n")
+ if jump:
+ print(f" {t('Target is the hypervisor')} ({jump}), "
+ f"{t('not the VM: the socket is QEMU-side.')}")
print(f" {t('then point your VNC client at')} localhost:{port}")
print(f" {t('The tunnel stays open as long as that ssh runs.')}")
+ @staticmethod
+ def _ssh_proxyjump(host):
+ """ProxyJump effectif d'un hôte, tel que ssh le calcule lui-même.
+
+ « ssh -G » rend la configuration RÉSOLUE : Match, wildcards et Include
+ compris. Relire ~/.ssh/config à la main raterait tout cela.
+ """
+ try:
+ res = subprocess.run(
+ ["ssh", "-G", host], capture_output=True, text=True, timeout=10
+ )
+ except (OSError, subprocess.SubprocessError):
+ return ""
+ for line in res.stdout.splitlines():
+ if line.startswith("proxyjump "):
+ value = line.split(None, 1)[1].strip()
+ return "" if value.lower() == "none" else value
+ return ""
+
+ @staticmethod
+ def _qemu_vnc_port(domain, jump=""):
+ """Port VNC réel d'un domaine, localement ou sur un hyperviseur distant.
+
+ Il ne se devine pas : libvirt l'attribue au démarrage. « virsh
+ vncdisplay » rend « 127.0.0.1:0 », où le suffixe est le NUMÉRO d'écran
+ — 0 vaut 5900, 1 vaut 5901.
+
+ Sans sudo d'abord : l'appartenance au groupe libvirt suffit souvent, et
+ « sudo -n » distant échouerait sur l'absence de TTY. On ne retombe sur
+ « sudo -n » que si le premier essai n'a rien donné.
+ """
+ base = ["virsh", "--connect", "qemu:///system", "vncdisplay", domain]
+ for argv in (base, ["sudo", "-n"] + base):
+ cmd = (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv
+ try:
+ res = subprocess.run(
+ cmd, capture_output=True, text=True, timeout=25
+ )
+ except (OSError, subprocess.SubprocessError):
+ continue
+ if res.returncode != 0:
+ continue
+ disp = res.stdout.strip().rsplit(":", 1)
+ if len(disp) == 2 and disp[1].isdigit():
+ return 5900 + int(disp[1])
+ return 0
+
def _qemu_ssh_config_menu(self):
"""Écrit les entrées ~/.ssh/config du parc QEMU.
@@ -2356,14 +2888,25 @@ class TODO:
print(f"\n{t('Target state:')}")
print(f" [1] {t('Open (start)')}")
print(f" [2] {t('Close (shut down)')}")
+ print(f" [3] {t('Adjust hardware only (vCPU, RAM, 3D)')}")
st = input(t("Choice: ")).strip()
if st == "1":
action, verb = "start", t("start")
elif st == "2":
action, verb = "shutdown", t("shut down")
+ elif st == "3":
+ self._qemu_adjust_hardware(resolved)
+ return
else:
print(t("Cancelled."))
return
+ # Le matériel d'une VM ne se règle QUE pendant qu'elle est éteinte :
+ # démarrer est donc le dernier moment pour le faire, et le seul où la
+ # question tombe juste.
+ if action == "start" and self._is_yes(
+ input(f"\n{t('Adjust hardware before starting? (y/N): ')}")
+ ):
+ self._qemu_adjust_hardware(resolved)
# DOUBLE validation avant d'appliquer.
summary = f"{verb} -> {', '.join(resolved)}"
if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")):
@@ -2377,6 +2920,252 @@ class TODO:
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
+ @staticmethod
+ def _qemu_dumpxml(name):
+ """XML PERSISTANT du domaine, ou '' — source de son état matériel.
+
+ « --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml »
+ rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage
+ (portid du réseau, vnetN, alias). C'est la définition persistante que
+ virt-xml modifie, et c'est donc elle qu'il faut lire.
+ """
+ try:
+ res = subprocess.run(
+ ["sudo", "virsh", "dumpxml", "--inactive", name],
+ capture_output=True,
+ text=True,
+ timeout=20,
+ env=TODO._qemu_c_env(),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return ""
+ return res.stdout if res.returncode == 0 else ""
+
+ @staticmethod
+ def _qemu_autostart(name):
+ """Démarrage automatique activé ? (absent du XML : virsh seul le sait)"""
+ try:
+ res = subprocess.run(
+ ["sudo", "virsh", "dominfo", name],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ env=TODO._qemu_c_env(),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return False
+ for line in res.stdout.splitlines():
+ if line.startswith("Autostart:"):
+ return line.split(":", 1)[1].strip() == "enable"
+ return False
+
+ def _qemu_ask_bool(self, prompt, default):
+ """Question fermée dont le DÉFAUT est l'état actuel de la VM.
+
+ Une réponse vide — ou incompréhensible — laisse la VM telle quelle :
+ sur un formulaire de matériel, le silence ne doit rien modifier.
+ """
+ ans = input(prompt).strip()
+ if self._is_yes(ans):
+ return True
+ if self._is_no(ans):
+ return False
+ return default
+
+ def _qemu_host_gpu_node(self):
+ """Nœud de rendu de l'hôte, vu par deploy_qemu (source unique), ou ''."""
+ try:
+ return self._qemu_import_module().host_gpu_node()
+ except (OSError, AttributeError, ImportError):
+ return ""
+
+ def _qemu_net_choices(self):
+ """Réseaux proposables : réseaux libvirt, puis ponts de l'hôte.
+
+ Les ponts appartenant à un réseau libvirt (virbr0 pour « default »)
+ sont écartés : les proposer offrirait DEUX fois le même chemin, dont
+ un qui contourne la gestion du réseau par libvirt.
+ """
+ tokens = []
+ nets = self._qemu_cmd_lines(
+ ["sudo", "virsh", "net-list", "--all", "--name"]
+ )
+ owned = set()
+ for net in nets:
+ tokens.append(f"network:{net}")
+ for line in self._qemu_cmd_lines(
+ ["sudo", "virsh", "net-info", net]
+ ):
+ if line.startswith("Bridge:"):
+ owned.add(line.split(":", 1)[1].strip())
+ for line in self._qemu_cmd_lines(
+ ["ip", "-o", "link", "show", "type", "bridge"]
+ ):
+ # « 3: br0: br0
+ parts = line.split(":")
+ bridge = parts[1].strip() if len(parts) > 1 else ""
+ if bridge and bridge not in owned:
+ tokens.append(f"bridge:{bridge}")
+ return tokens
+
+ @staticmethod
+ def _qemu_cmd_lines(cmd):
+ """Lignes non vides d'une commande, ou [] si elle échoue."""
+ try:
+ res = subprocess.run(
+ cmd,
+ capture_output=True,
+ text=True,
+ timeout=15,
+ env=TODO._qemu_c_env(),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return []
+ if res.returncode != 0:
+ return []
+ return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
+
+ def _qemu_adjust_hardware(self, names):
+ """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
+
+ Les VM allumées sont écartées, en le disant : virt-xml y écrirait une
+ définition qui ne prendrait effet qu'au prochain démarrage — un
+ réglage qui paraît appliqué et ne l'est pas.
+ """
+ from script.todo import qemu_hardware as hw
+
+ off, busy = [], []
+ for name in names:
+ state = self._qemu_domstate(name)
+ (off if state == "shut off" else busy).append(name)
+ if busy:
+ print(
+ f"\n ⚠ {t('Not shut off, hardware left untouched:')}"
+ f" {', '.join(busy)}"
+ )
+ if not off:
+ return
+ node = self._qemu_host_gpu_node()
+ gpu_txt = node or t("none (software rendering)")
+ print(f"\n{t('Host GPU:')} {gpu_txt}")
+ rows = [
+ r
+ for r in (
+ hw.hw_state(self._qemu_dumpxml(n), self._qemu_autostart(n))
+ for n in off
+ )
+ if r.get("name")
+ ]
+ if not rows:
+ print(f" ⚠ {t('Unreadable VM definition.')}")
+ return
+ for r in rows:
+ print(f" {r['name']:<30} {hw.hw_summary(r)}")
+ nets = self._qemu_net_choices()
+ want = self._qemu_hw_form(rows, node, nets)
+ if want is None:
+ print(t("Cancelled."))
+ return
+ if not want:
+ want = self._qemu_hw_prompts(rows, node, nets)
+ if not want:
+ print(t("Cancelled."))
+ return
+ plan = []
+ for r in rows:
+ plan += hw.hw_plan(r, want.get(r["name"]) or {}, node)
+ for entry in plan:
+ if entry.get("skip"):
+ print(f" ⚠ {entry['what']} : {entry['skip']}")
+ cmds = [e for e in plan if e.get("cmd")]
+ if not cmds:
+ print(f"\n{t('Nothing to change.')}")
+ return
+ print(f"\n{t('Changes:')}")
+ for entry in cmds:
+ print(f" - {entry['what']}")
+ if not self._is_yes(input(t("Apply these changes? (y/N): "))):
+ print(t("Cancelled."))
+ return
+ for entry in cmds:
+ cmd = "sudo " + " ".join(shlex.quote(c) for c in entry["cmd"])
+ print(f"\n{t('Will execute:')} {cmd}")
+ self.execute.exec_command_live(cmd, source_erplibre=False)
+
+ def _qemu_hw_form(self, rows, node, nets=None):
+ """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
+ retomber sur les invites en ligne (textual absent), None si annulé."""
+ from script.todo import textual_setup
+
+ if not textual_setup.ensure():
+ return {}
+ try:
+ from script.todo.qemu_hardware import run_hardware_form
+
+ return run_hardware_form(rows, node, nets)
+ except ImportError:
+ return {}
+
+ def _qemu_pick(self, title, values, current, labels=None):
+ """Liste numérotée dont le DÉFAUT est la valeur actuelle.
+
+ Rendre la valeur actuelle sur une réponse vide, et sur une réponse
+ illisible : dans un formulaire de matériel, ne rien comprendre ne doit
+ rien changer.
+ """
+ labels = labels or values
+ print(f"{title} :")
+ for i, (val, lab) in enumerate(zip(values, labels), 1):
+ mark = " ←" if val == current else ""
+ print(f" [{i}] {lab}{mark}")
+ ans = input(" " + t("Choice: ")).strip()
+ if not ans.isdigit():
+ return current
+ idx = int(ans)
+ return values[idx - 1] if 1 <= idx <= len(values) else current
+
+ def _qemu_hw_prompts(self, rows, node, nets=None):
+ """Même ajustement, en invites, quand Textual n'est pas disponible."""
+ from script.todo import qemu_hardware as hw
+
+ cpus = hw.cpu_choices(rows)
+ reseaux = hw.net_choices(rows, nets)
+ want = {}
+ for r in rows:
+ print(f"\n {r['name']} — {hw.hw_summary(r)}")
+ vcpus = input(f" vCPU [{r.get('vcpus')}] : ")
+ ram = input(f" RAM [{hw.fmt_mib(r.get('mem_mib'))}] : ")
+ reason = hw.gpu_allowed(r, node)
+ if reason:
+ print(f" ⚠ {t('3D acceleration (host GPU)')} : {reason}")
+ gpu = False
+ else:
+ gpu = self._qemu_ask_bool(
+ f" {t('3D acceleration (host GPU)')} ? (o/N) : ",
+ bool(r.get("accel3d")),
+ )
+ auto = self._qemu_ask_bool(
+ f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart"))
+ )
+ cpu = self._qemu_pick(f" {t('CPU mode')}", cpus, r.get("cpu"))
+ heads = ""
+ if r.get("video"):
+ heads = input(f" {t('Screens')} [{r.get('heads') or 1}] : ")
+ net = r.get("net") or ""
+ # Une seule possibilité : rien à demander. C'est le cas d'un hôte
+ # sans pont, où le réseau libvirt est la seule voie.
+ if len(reseaux) > 1:
+ net = self._qemu_pick(
+ f" {t('Network')}",
+ [tok for tok, _lab in reseaux],
+ net,
+ labels=[lab for _tok, lab in reseaux],
+ )
+ want[r["name"]] = hw.build_want(
+ r, vcpus, ram, gpu, auto, cpu=cpu, heads=heads, net=net
+ )
+ return want
+
@staticmethod
def _qemu_dominfo(name):
"""(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0)."""
@@ -2424,6 +3213,107 @@ class TODO:
except (OSError, subprocess.SubprocessError, ValueError):
return 0, 0
+ @staticmethod
+ def _fmt_uptime(secs):
+ """Durée depuis le démarrage, en six caractères au plus.
+
+ « _fmt_dur » s'arrête aux minutes — bon pour une installation, illisible
+ pour une VM debout depuis trois jours. Ici la précision décroît avec la
+ durée : personne ne lit les secondes d'un uptime de 19 heures."""
+ secs = int(secs)
+ if secs < 60:
+ return f"{secs}s"
+ if secs < 3600:
+ return f"{secs // 60}m"
+ if secs < 86400:
+ return f"{secs // 3600}h{(secs % 3600) // 60:02d}"
+ days = secs // 86400
+ # Au-delà de 99 jours, les heures ne rentrent plus dans la colonne — et
+ # personne ne les lit sur une machine debout depuis un an.
+ if days >= 100:
+ return f"{days}j"
+ return f"{days}j{(secs % 86400) // 3600:02d}h"
+
+ @staticmethod
+ def _qemu_domain_uptime(name):
+ """Secondes depuis le démarrage du domaine, ou None.
+
+ libvirt n'expose pas l'uptime d'un invité : ni dominfo, ni domstats, ni
+ l'agent. Mais le processus QEMU du domaine est né avec lui, et son âge
+ est donc exactement celui de la VM. « guest=, » est le motif que
+ libvirt met dans sa ligne de commande — la virgule évite qu'un nom
+ préfixe d'un autre matche à sa place."""
+ try:
+ res = subprocess.run(
+ ["pgrep", "-f", f"guest={name},"],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ pid = (res.stdout or "").split()[0]
+ age = subprocess.run(
+ ["ps", "-o", "etimes=", "-p", pid],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ )
+ return int((age.stdout or "").strip())
+ except (OSError, subprocess.SubprocessError, ValueError, IndexError):
+ return None
+
+ @staticmethod
+ def _qemu_dommemstat(name):
+ """(utilisée, totale) en KiO vues par l'INVITÉ, ou (0, 0).
+
+ « available » est ce que l'invité voit, « usable » ce qu'il peut encore
+ rendre : leur différence est son « used », à quelques mégaoctets près —
+ calibré contre le « free » de deux VM (1186 contre 1216, 4831 contre
+ 4838). « unused » ne convient pas : il ignore le cache, et donnait
+ 10,8 Go d'« utilisé » sur une VM qui en occupait 1,2.
+
+ La période de collecte est posée d'abord, et c'est indispensable : sans
+ elle le ballon ne rafraîchit rien, et une VM qui occupait 4,8 Go en
+ annonçait 490 Mo — vécu. « --live » ne touche pas le XML : le réglage
+ disparaît au prochain démarrage du domaine."""
+ try:
+ subprocess.run(
+ [
+ "sudo",
+ "virsh",
+ "dommemstat",
+ name,
+ "--period",
+ "5",
+ "--live",
+ ],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ env=TODO._qemu_c_env(),
+ )
+ res = subprocess.run(
+ ["sudo", "virsh", "dommemstat", name],
+ capture_output=True,
+ text=True,
+ timeout=15,
+ env=TODO._qemu_c_env(),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return 0, 0
+ stat = {}
+ for line in (res.stdout or "").splitlines():
+ parts = line.split()
+ if len(parts) == 2:
+ try:
+ stat[parts[0]] = int(parts[1])
+ except ValueError:
+ continue
+ total = stat.get("available", 0)
+ usable = stat.get("usable", 0)
+ if not total or not usable:
+ return 0, total
+ return max(0, total - usable), total
+
def _qemu_list_vms_advanced(self):
"""Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel
+ réel), plus l'espace total disponible du stockage des images."""
@@ -2432,9 +3322,12 @@ class TODO:
print(f"\n{t('No VM found.')}")
return
g = 1 << 30
+ # Largeurs serrées pour que la ligne tienne en 80 colonnes AVEC le nom
+ # entier : c'est lui qui distingue les machines, et « erplibre-ubuntu-
+ # 2604-gno » tronqué ne distingue plus rien.
header = (
- f"\n{'VM':<28} {'État':<10} {'vCPU':>4} {'RAM':>8} "
- f"{'Disque':>9} {'Réel':>9}"
+ f"\n{'VM':<26} {'État':<8} {'vCPU':>4} {'RAM':>10} "
+ f"{'Disque':>7} {'Réel':>7} {'Uptime':>6}"
)
print(header)
print("─" * len(header.strip()))
@@ -2447,9 +3340,27 @@ class TODO:
if disk:
disk_dirs.add(os.path.dirname(disk))
ram_g = (mem_kib * 1024) / g if mem_kib else 0
+ # « RAM » dit désormais l'USAGE et non la seule allocation : sur un
+ # hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide
+ # s'il reste de la place pour la suivante. Deux nombres dans une
+ # colonne plutôt que deux colonnes — le tableau tient encore sur
+ # une ligne de terminal.
+ used_kib, _total_kib = self._qemu_dommemstat(name)
+ # Le total sans décimale quand il est entier — une allocation
+ # vaut 8, 12 ou 32 Go, jamais 32,0.
+ alloc = f"{ram_g:.0f}" if ram_g == int(ram_g) else f"{ram_g:.1f}"
+ ram = (
+ f"{used_kib * 1024 / g:.1f}G/{alloc}G"
+ if used_kib
+ else f"-/{alloc}G"
+ )
+ # L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose
+ # nulle part, et ce processus est né avec le domaine.
+ up = self._qemu_domain_uptime(name)
print(
- f"{name:<28.28} {state:<10.10} {vcpus:>4} "
- f"{ram_g:>7.1f}G {virt / g:>8.1f}G {actual / g:>8.1f}G"
+ f"{name:<26.26} {state:<8.8} {vcpus:>4} "
+ f"{ram:>10} {virt / g:>6.1f}G {actual / g:>6.1f}G "
+ f"{self._fmt_uptime(up) if up else '-':>6}"
)
# Espace total disponible sur le(s) stockage(s) des disques.
for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]:
@@ -3957,13 +4868,22 @@ class TODO:
ERPLIBRE_GIT_URL = "https://github.com/erplibre/erplibre"
def _qemu_import_module(self):
- """Importe deploy_qemu.py comme module (source de vérité des specs)."""
+ """Importe deploy_qemu.py comme module (source de vérité des specs).
+
+ Mémorisé : le catalogue interroge cette source une fois par couple
+ (distro, version), et réexécuter un fichier de 2 700 lignes à chaque
+ passage se voyait à l'écran.
+ """
+ cached = getattr(self, "_qemu_mod_cache", None)
+ if cached is not None:
+ return cached
import importlib.util
path = self._qemu_script_path()
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
+ self._qemu_mod_cache = mod
return mod
@classmethod
@@ -4432,6 +5352,30 @@ class TODO:
~/git/erplibre (dev)."""
return "/opt/erplibre" if prod else "$HOME/git/erplibre"
+ @staticmethod
+ def _qemu_guide_dir(prod):
+ """Répertoire d'ERPLibre tel que le GUIDE de connexion l'annonce.
+
+ « ~/git/erplibre » plutôt que « $HOME/git/erplibre » : ce chemin n'est
+ pas exécuté par un script, il est lu par quelqu'un qui recopie la ligne
+ dans son shell — où les deux marchent — et le tilde est la forme qu'il
+ reconnaît. En production le chemin est absolu et la question ne se pose
+ pas."""
+ return "/opt/erplibre" if prod else "~/git/erplibre"
+
+ @staticmethod
+ def _qemu_make_target(install_cmd):
+ """Cible make qui installe Odoo dans `install_cmd`, pour le guide.
+
+ Les profils s'écrivent « make install_os && make install_odoo_18 » : la
+ cible utile est la SECONDE, celle qui installe Odoo, et c'est aussi
+ celle qu'on relance après un « git pull ». Les profils qui n'en ont pas
+ (« ERPLibre seul », « mobile », « Déploiement ») rendent une chaîne
+ vide : le guide s'arrête alors à « git pull » plutôt que d'annoncer une
+ cible qui n'est pas celle de cette VM."""
+ found = re.findall(r"make\s+(install_odoo\S*)", install_cmd or "")
+ return found[-1] if found else ""
+
def _qemu_odoo_service_cmd(self, prod=False):
"""Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme
service systemd puis l'active. N'est ajouté QUE pour les profils Odoo.
@@ -4897,6 +5841,25 @@ class TODO:
# images cloud démarrent en multi-user.target.
"sudo systemctl set-default graphical.target || true; "
f"sudo systemctl enable {de['service']} >/dev/null 2>&1 || true; "
+ # Et il faut le DÉMARRER, pas seulement l'activer. Deux raisons,
+ # toutes deux mesurées sur erplibre-ubuntu-2604-gnome :
+ #
+ # - graphical.target était DÉJÀ atteinte quand le paquet est
+ # arrivé, et une cible active ne rattrape pas un service ajouté
+ # après coup : display-manager.service est resté inactif ;
+ # - sur Debian et Ubuntu, « systemctl enable gdm » rend 0 sans
+ # rien faire — l'unité n'a pas de « WantedBy », seulement
+ # « Alias=display-manager.service » que le paquet a déjà posé.
+ #
+ # Résultat : GNOME installé, gdm3 installé, cible graphique par
+ # défaut… et la console de la VM restait en mode texte jusqu'au
+ # premier redémarrage. L'écran, c'est justement ce qu'on est venu
+ # chercher sur une VM graphique.
+ "if sudo systemctl start display-manager.service 2>/dev/null || "
+ f"sudo systemctl start {de['service']} 2>/dev/null; then "
+ f'echo " {t("graphical session started")}"; '
+ f'else echo " ⚠ {t("graphical session not started; reboot the VM")}"; '
+ "fi; "
# xrdp là où il existe ; sur Arch c'est TigerVNC, qui se configure
# par utilisateur et n'a pas de service à activer d'office.
"if command -v xrdp >/dev/null 2>&1; then "
@@ -4916,6 +5879,1269 @@ class TODO:
+ "fi; "
)
+ # ------------------------------------------------------------------ #
+ # Outils de développement d'une VM graphique
+ # ------------------------------------------------------------------ #
+ # Chacun est une case à cocher, indépendante des autres, et chacun pèse sur
+ # le disque — le plan l'annonce AVANT de déployer, sinon l'installation se
+ # termine sur un disque plein après une heure d'attente.
+ #
+ # « disk_gb » compte le PIC, pas l'installé : l'archive téléchargée vit sur
+ # le disque le temps de l'extraction. PyCharm, c'est 1,2 Go d'archive et
+ # ~3 Go déplié ; Android Studio 1,5 Go et 3,5 Go, plus la place du premier
+ # SDK que l'utilisateur téléchargera.
+ #
+ # « arches » n'est pas une précaution : Google ne publie Android Studio
+ # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404,
+ # et le product-info.json de l'archive ne déclare qu'une cible
+ # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64.
+ _QEMU_VM_TOOLS = {
+ "pycharm": {
+ "label": "PyCharm",
+ "hint": "Python IDE, opens the ERPLibre checkout",
+ "disk_gb": 5,
+ "arches": ("amd64", "arm64"),
+ "desktops": (),
+ "needs_desktop": True,
+ "families": (),
+ "phase": "before",
+ },
+ "android": {
+ "label": "Android Studio",
+ "hint": "ERPLibre mobile development (x86_64 only)",
+ "disk_gb": 8,
+ "arches": ("amd64",),
+ "desktops": (),
+ "needs_desktop": True,
+ "families": (),
+ "phase": "before",
+ },
+ "gnome_ext": {
+ "label": "GNOME extensions",
+ "hint": "suggested extensions + extension manager",
+ "disk_gb": 1,
+ "arches": (),
+ "desktops": ("gnome",),
+ "needs_desktop": True,
+ "families": (),
+ "phase": "before",
+ },
+ # Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche
+ # rien. Une VM serveur le prend, une VM graphique aussi — et sur
+ # celle-ci le SDK est partagé avec Android Studio plutôt que doublé.
+ #
+ # « families » le borne à apt, et ce n'est pas un choix : l'installateur
+ # du dépôt mobile, install-android.sh, commence par
+ # « sudo apt install openjdk-17-jdk ». Ailleurs il s'arrête là. Lever
+ # cette limite se fait dans CE script-là, pas ici.
+ #
+ # Disque : ~1,5 Go de SDK et plateformes, ~2,5 Go de NDK, whisper.cpp
+ # et sentencepiece clonés, node_modules, et les artefacts Gradle.
+ "mobile": {
+ "label": "ERPLibre mobile (build)",
+ "hint": "APK debug + Vitest, validates the VM",
+ "disk_gb": 12,
+ "arches": ("amd64",),
+ "desktops": (),
+ "needs_desktop": False,
+ "families": ("apt",),
+ # APRÈS l'installation : le build a besoin du dépôt mobile, que le
+ # manifeste ajoute, et du venv d'outils pour le synchroniser.
+ "phase": "after",
+ },
+ # Forgejo est un SERVICE, pas un outil de bureau : une VM serveur le
+ # prend aussi bien qu'une VM graphique. Son binaire est STATIQUE — le
+ # même fichier sur apt, dnf, pacman et zypper — donc aucune famille de
+ # paquets n'est exclue, et c'est ce qui le rend portable sur toutes les
+ # plateformes ERPLibre sans une branche par distribution.
+ #
+ # Les architectures, elles, sont bornées par l'amont : Forgejo publie
+ # amd64, arm64 et arm-6, et RIEN pour s390x. Sur celle-là il faudrait le
+ # bâtir en Go ; la case se grise plutôt que de poser un binaire qui ne
+ # s'exécute pas.
+ #
+ # Disque : ~115 Mo de binaire (34 Mo téléchargés en .xz), la base SQLite
+ # et les dépôts que l'utilisateur y poussera.
+ "forgejo": {
+ "label": "Forgejo (git forge)",
+ "hint": "self-hosted git forge on :3000, SQLite",
+ "disk_gb": 2,
+ "arches": ("amd64", "arm64"),
+ "desktops": (),
+ "needs_desktop": False,
+ "families": (),
+ # APRÈS l'installation : le script vit dans le dépôt, donc après le
+ # clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv
+ # ni d'Odoo.
+ "phase": "after",
+ },
+ # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
+ # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
+ # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
+ # bloc vérifie et annonce plutôt que de laisser découvrir.
+ #
+ # Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
+ # l'émulateur lui-même.
+ "avd": {
+ "label": "Android emulator (Pixel)",
+ "hint": "AVD viewable over ssh -X",
+ "disk_gb": 6,
+ "arches": ("amd64",),
+ "desktops": (),
+ "needs_desktop": False,
+ "families": ("apt",),
+ "phase": "after",
+ },
+ }
+
+ # Famille de paquets de chaque distribution, pour borner un outil à ce qui
+ # sait l'installer.
+ _QEMU_DISTRO_FAMILY = {
+ "ubuntu": "apt",
+ "debian": "apt",
+ "fedora": "dnf",
+ "almalinux": "dnf",
+ "rocky": "dnf",
+ "opensuse": "zypper",
+ "arch": "pacman",
+ }
+
+ @classmethod
+ def _qemu_vm_tool_choices(cls):
+ """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne."""
+ return [
+ (key, t(spec["label"]), t(spec["hint"]))
+ for key, spec in cls._QEMU_VM_TOOLS.items()
+ ]
+
+ @classmethod
+ def _qemu_tools_for(cls, tools, arch, desktop, distro="", phase=""):
+ """Outils RÉELLEMENT applicables à cette VM.
+
+ Un outil demandé pour tout le parc ne convient pas forcément à chaque
+ machine : Android Studio n'existe qu'en x86_64, les extensions GNOME
+ n'ont pas de sens sous Cinnamon, et la compilation mobile ne sait
+ s'installer que sur les distributions apt. Filtrer ici plutôt que dans
+ la commande distante évite d'annoncer une installation qui ne se fera
+ pas.
+
+ `phase` restreint au moment d'exécution : « before » avant le clone,
+ « after » après l'installation. Vide, les deux sont rendus."""
+ out = []
+ for key in tools or ():
+ spec = cls._QEMU_VM_TOOLS.get(key)
+ if not spec:
+ continue
+ if spec["needs_desktop"] and not desktop:
+ continue
+ if spec["arches"] and arch not in spec["arches"]:
+ continue
+ if spec["desktops"] and desktop not in spec["desktops"]:
+ continue
+ family = cls._QEMU_DISTRO_FAMILY.get(distro, "")
+ if spec["families"] and distro and family not in spec["families"]:
+ continue
+ if phase and spec["phase"] != phase:
+ continue
+ out.append(key)
+ return out
+
+ @classmethod
+ def _qemu_tools_disk_gb(cls, tools, arch, desktop, distro=""):
+ """Go à ajouter au disque pour les outils applicables à cette VM."""
+ return sum(
+ cls._QEMU_VM_TOOLS[k]["disk_gb"]
+ for k in cls._qemu_tools_for(tools, arch, desktop, distro)
+ )
+
+ # Archive officielle JetBrains, et non un paquet de distribution : aucun ne
+ # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne
+ # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout).
+ #
+ # La ligne COMMUNITY, et non le produit unifié. Mesuré dans une VM :
+ # « code=PCC&latest » sert maintenant pycharm-2025.3, le build unifié, qui
+ # s'arrête sur sa licence — son journal dit « NoValidIdeLicense » puis
+ # « Get licenses: request requires authentication », et le projet ne
+ # s'ouvre jamais. Aucune ouverture, donc aucun .idea, donc rien à
+ # configurer ensuite. Community ne demande aucun compte, et elle est
+ # toujours publiée et corrigée : 2025.2.6.2 date du 2026-07-29.
+ #
+ # Aucun numéro figé ici : on prend la plus récente archive
+ # « pycharm-community- » du flux officiel des versions, pour
+ # l'architecture de la VM.
+ _QEMU_PYCHARM_FEED = (
+ "https://data.services.jetbrains.com/products/releases"
+ "?code=PCC&type=release"
+ )
+ # Repli quand le flux est injoignable : la redirection « dernière version ».
+ # Elle sert le build unifié, donc on le DIT — l'utilisateur devra ouvrir un
+ # compte JetBrains, et mieux vaut l'apprendre dans le journal qu'au premier
+ # lancement.
+ _QEMU_PYCHARM_URL = (
+ "https://download.jetbrains.com/product?code=PCC&latest&distribution="
+ )
+
+ # Android Studio n'a PAS d'URL « latest » : le répertoire de version
+ # (2026.1.3.8) et le nom de fichier (quail3-patch1) sont deux jetons
+ # INDÉPENDANTS, l'un ne se déduit pas de l'autre, et le flux updates.xml de
+ # Google ne publie ni l'un ni l'autre. On lit donc l'URL sur la page
+ # officielle, qui la porte en clair, et on retombe sur celle-ci si la page
+ # change de forme. Relevée et vérifiée (HTTP 200) le 2026-08-17.
+ _QEMU_ANDROID_URL = (
+ "https://dl.google.com/dl/android/studio/ide-zips/2026.1.3.8/"
+ "android-studio-quail3-patch1-linux.tar.gz"
+ )
+ _QEMU_ANDROID_PAGE = "https://developer.android.com/studio"
+
+ @staticmethod
+ def _qemu_desktop_entry_cmd(name, label, exec_cmd, icon, categories):
+ """Écrit un lanceur .desktop. Sans lui, un outil déplié dans /opt
+ n'existe pas pour le bureau : il ne se lance qu'en tapant son chemin.
+ """
+ return (
+ f"sudo tee /usr/share/applications/{name}.desktop >/dev/null </dev/null "
+ "| head -1); "
+ f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{link}; '
+ + (
+ f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{alias}; '
+ if alias
+ else ""
+ )
+ )
+
+ def _qemu_pycharm_remote_cmd(self, prod=False):
+ """Installe PyCharm et lui donne le dépôt ERPLibre comme projet.
+
+ « Configuré sur git/erplibre » veut dire deux choses, et les deux sont
+ faites ici : le lanceur du bureau OUVRE ce dépôt, et
+ pycharm_configuration.py y écrit le .idea/ du projet (interpréteur,
+ configurations d'exécution, dossiers exclus) — la même chose que
+ « make pycharm_configure », mais avec le python du venv d'outils, seul à
+ disposer de xmltodict.
+
+ Tout le bloc est gardé : un IDE qui ne s'installe pas ne doit pas faire
+ échouer l'installation d'ERPLibre, qui elle a duré une heure."""
+ el_dir = self._qemu_install_dir(prod)
+ return (
+ f'echo "== {t("Installing PyCharm (long)")} =="; '
+ "{ "
+ 'case "$(uname -m)" in x86_64) jb=linux;; '
+ 'aarch64|arm64) jb=linuxARM64;; *) jb="";; esac; '
+ # if/else et non « || { …; false; } » : dans un groupe, un échec
+ # n'interrompt PAS la suite (set -e est suspendu à gauche d'un
+ # « && »), et l'architecture non servie partait quand même
+ # télécharger une URL sans valeur de distribution.
+ 'if [ -z "$jb" ]; then '
+ f'echo " {t("no JetBrains build for")} $(uname -m)"; false; '
+ "else "
+ # Déjà posé ? On ne retélécharge pas. Rejouer une
+ # installation est le cas NORMAL — une qui est morte, un outil
+ # ajouté après coup — et le téléchargement en est la partie
+ # longue : mesuré, ~5 min pour Android Studio, autant pour
+ # PyCharm. Le reste de l'étape (lanceur, alias, raccourci)
+ # rejoue de toute façon, lui est idempotent et bon marché.
+ "if [ -x /opt/pycharm/bin/pycharm.sh ]; then "
+ f'echo " {t("already there, download skipped")}"; '
+ "else "
+ # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en
+ # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go.
+ # Le flux dit quelle archive Community prendre pour cette
+ # architecture. En python plutôt qu'en shell : il fait la requête,
+ # lit le JSON et rend une ligne — sans jq, absent des images cloud.
+ "url=$(python3 - \"$jb\" <<'ELPYJB'\n"
+ "import json, sys, urllib.request\n"
+ "key = sys.argv[1]\n"
+ "try:\n"
+ f' with urllib.request.urlopen("{self._QEMU_PYCHARM_FEED}",\n'
+ " timeout=30) as fh:\n"
+ " data = json.load(fh)\n"
+ "except Exception:\n"
+ " sys.exit(0)\n"
+ 'for rel in data.get("PCC", []):\n'
+ ' link = (rel.get("downloads") or {}).get(key, {}).get("link", "")\n'
+ ' if "pycharm-community-" in link:\n'
+ " print(link)\n"
+ " break\n"
+ "ELPYJB\n"
+ "); "
+ 'if [ -z "$url" ]; then '
+ f'url="{self._QEMU_PYCHARM_URL}$jb"; '
+ f'echo " {t("release feed unreachable: unified build, it will ask for a JetBrains account")}"; '
+ "fi; "
+ "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && "
+ 'curl -fsSL "$url" -o "$tmp" && '
+ "sudo mkdir -p /opt/pycharm && "
+ 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; '
+ 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { '
+ + self._qemu_jetbrains_launcher_cmd("/opt/pycharm", "pycharm")
+ + self._qemu_desktop_entry_cmd(
+ "pycharm",
+ "PyCharm (ERPLibre)",
+ f"/usr/local/bin/pycharm {el_dir}",
+ "/opt/pycharm/bin/pycharm.svg",
+ "Development;IDE;",
+ )
+ # AUCUN appel à pycharm_configuration.py ici : l'installation
+ # ERPLibre le fait déjà. update_env_version.pycharm_update() teste
+ # « os.path.exists('.idea') » puis lance le script — une seule
+ # autorité, et elle sait se taire quand le projet n'existe pas
+ # encore. Doubler l'appel ne configurait rien de plus : ça écrivait
+ # « Missing ./.idea path » dans le journal d'une VM neuve, où
+ # PyCharm n'a évidemment jamais ouvert le dépôt.
+ + f'echo " {t("PyCharm installed:")} /opt/pycharm '
+ f'({t("command")} pycharm, {t("project")} {el_dir})"; '
+ f'echo " {t("open the project once and close PyCharm; the .idea "
+ "it writes is what the install configures")}"; '
+ f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; '
+ )
+
+ # Serveur X virtuel, par gestionnaire de paquets. Les noms ne se
+ # ressemblent pas d'une famille à l'autre — relevés dans chaque dépôt, pas
+ # devinés.
+ _QEMU_XVFB_PKG = {
+ "apt": "xvfb",
+ "dnf": "xorg-x11-server-Xvfb",
+ "zypper": "xorg-x11-server-Xvfb",
+ "pacman": "xorg-server-xvfb",
+ }
+
+ # Attente maximale du .idea, en tours de 5 s — cinq minutes. Mesuré sur une
+ # VM Ubuntu 26.04 à 16 Go : le projet est écrit en 195 s, indexation du
+ # dépôt en cours. On n'attend donc PAS la fin de cette indexation, qui dure
+ # bien plus et dont personne n'a besoin ici : pycharm_configuration.py ne
+ # réclame que le .iml et misc.xml.
+ _QEMU_PYCHARM_OPEN_TRIES = 60
+
+ def _qemu_xvfb_install_cmd(self):
+ """Pose Xvfb avec le gestionnaire de paquets présent, sans bruit."""
+ x = self._QEMU_XVFB_PKG
+ return (
+ "if command -v apt-get >/dev/null 2>&1; then "
+ "sudo DEBIAN_FRONTEND=noninteractive apt-get "
+ f"-o DPkg::Lock::Timeout=600 install -y {x['apt']} "
+ ">/dev/null 2>&1 || true; "
+ "elif command -v dnf >/dev/null 2>&1; then "
+ f"sudo dnf install -y {x['dnf']} >/dev/null 2>&1 || true; "
+ "elif command -v zypper >/dev/null 2>&1; then "
+ "sudo zypper --non-interactive install --auto-agree-with-licenses "
+ f"{x['zypper']} >/dev/null 2>&1 || true; "
+ "elif command -v pacman >/dev/null 2>&1; then "
+ f"sudo pacman -S --needed --noconfirm {x['pacman']} "
+ ">/dev/null 2>&1 || true; fi; "
+ )
+
+ def _qemu_pycharm_project_cmd(self, prod=False):
+ """Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran.
+
+ C'est PyCharm, et lui seul, qui écrit ce répertoire : ni le dépôt ni
+ pycharm_configuration.py ne savent le fabriquer — ce dernier exige un
+ .iml puis un misc.xml, et s'arrête sinon. Sans cette ouverture, l'étape
+ pycharm_update() de l'installation ne trouve rien à configurer.
+
+ Xvfb parce que l'IDE réclame un affichage, même pour ouvrir un projet
+ et s'arrêter. Il tourne DANS la VM : l'hôte qui orchestre n'a besoin
+ d'aucune bibliothèque graphique, et rien ne transite par « ssh -X ».
+
+ TROIS fenêtres bloqueraient une session où personne ne peut cliquer, et
+ chacune a été rencontrée avant d'être écartée : politique de
+ confidentialité, partage de données, et surtout « faites-vous confiance
+ à ce projet ? ». C'est celle-là qui figeait tout — le journal s'arrêtait
+ 1,3 s après le démarrage, sans jamais ouvrir le projet, et il a fallu
+ « idea.trust.all.projects » pour le débloquer. Le consentement, lui, est
+ écrit REFUSÉ : aucune statistique ne part.
+
+ Mesuré sur une VM Ubuntu 26.04 à 16 Go : .idea complet en 195 s, et
+ pycharm_configuration.py écrit ensuite ses exclusions dans le .iml.
+
+ Tout est gardé. Sans Xvfb, sans PyCharm, ou sans .idea au bout du
+ délai, on le dit et l'installation continue : elle n'en dépend pas,
+ elle en profite seulement.
+ """
+ el_dir = self._qemu_install_dir(prod)
+ return (
+ f'echo "== {t("Creating the PyCharm project (first open)")} =="; '
+ "{ if ! command -v pycharm >/dev/null 2>&1; then "
+ f'echo " {t("PyCharm missing, step skipped")}"; false; '
+ "else "
+ "command -v xvfb-run >/dev/null 2>&1 || { "
+ + self._qemu_xvfb_install_cmd()
+ + "}; "
+ "if ! command -v xvfb-run >/dev/null 2>&1; then "
+ f'echo " {t("no Xvfb here, open PyCharm by hand")}"; false; '
+ "else "
+ # Réponses aux fenêtres de première ouverture. En python plutôt
+ # qu'en shell : l'horodatage en millisecondes et le « » de
+ # la propriété se passeraient mal de guillemets imbriqués.
+ "python3 - <<'ELPYC' || true\n"
+ "import pathlib, time\n"
+ "h = pathlib.Path.home()\n"
+ 'c = h / ".local/share/JetBrains/consentOptions"\n'
+ "c.mkdir(parents=True, exist_ok=True)\n"
+ '(c / "accepted").write_text(\n'
+ ' "rsch.send.usage.stat:1.1:0:%d\\n" % (time.time() * 1000)\n'
+ ")\n"
+ '(h / ".pycharm-headless.vmoptions").write_text(\n'
+ ' "-Djb.privacy.policy.text=\\n"\n'
+ ' "-Djb.consents.confirmation.enabled=false\\n"\n'
+ ' "-Didea.trust.all.projects=true\\n"\n'
+ ' "-Didea.suppress.statistics.report=true\\n"\n'
+ ")\n"
+ "ELPYC\n"
+ # « setsid » donne au tout son PROPRE groupe de processus, et
+ # c'est le groupe qu'on tuera. Sans lui, « $! » est le PID de
+ # xvfb-run — un script — et le tuer n'atteint ni PyCharm, ni Xvfb,
+ # ni les cef_server qu'il a lancés. Mesuré sur
+ # erplibre-ubuntu-2604-gnome : PyCharm tournait encore 45 minutes
+ # plus tard avec 1,9 Go, et la compilation de l'APK qui suivait
+ # s'est fait tuer par le noyau, faute de mémoire.
+ # Les watches inotify AVANT d'ouvrir : le dépôt mobile pose
+ # 123 000 fichiers d'assets, et la limite par défaut est dépassée
+ # dès l'analyse — « inotify_add_watch(...): No space left on
+ # device », puis « watch root cannot be watched: -2 », puis aucun
+ # .idea écrit. Mesuré sur erplibre-ubuntu-2604-gnome, deux fois.
+ # 524288 est la valeur que JetBrains documente lui-même.
+ "cur=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null "
+ '|| echo 0); if [ "$cur" -lt 524288 ] 2>/dev/null; then '
+ 'echo "fs.inotify.max_user_watches=524288" '
+ "| sudo tee /etc/sysctl.d/60-erplibre-inotify.conf >/dev/null && "
+ "sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf "
+ f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; '
+ "fi; "
+ # DEUX tentatives, et c'est mesuré : la première ouverture d'un
+ # dépôt neuf indexe 212 000 fichiers, plante son configurateur
+ # d'interpréteur (« PythonSdkConfigurator - homeDir is null ») et
+ # n'écrit AUCUN .idea, même au bout de cinq minutes. La seconde, sur
+ # les caches que la première a laissés, l'écrit en 25 secondes —
+ # constaté sur deux VM différentes.
+ ": > /tmp/pycharm-first-run.log; "
+ "for attempt in 1 2; do "
+ 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" '
+ f"setsid xvfb-run -a pycharm {el_dir} "
+ ">> /tmp/pycharm-first-run.log 2>&1 & "
+ "pid=$!; ok=0; "
+ f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do "
+ f"if ls {el_dir}/.idea/*.iml >/dev/null 2>&1 && "
+ f"[ -f {el_dir}/.idea/misc.xml ]; then ok=1; break; fi; "
+ "sleep 5; done; "
+ # Cinq secondes de plus : les fichiers apparaissent PENDANT leur
+ # écriture, et un TERM à l'instant où misc.xml naît le tronquerait.
+ "sleep 5; kill -TERM -$pid 2>/dev/null || "
+ "kill -TERM $pid 2>/dev/null; "
+ "for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; "
+ "sleep 5; done; kill -KILL -$pid 2>/dev/null; "
+ # Filet, et il a sa raison d'être : ce qui survit ici mange la
+ # mémoire de TOUTES les étapes suivantes.
+ #
+ # Par NOM de processus (« -x »), jamais par ligne de commande. Un
+ # « pkill -f /opt/pycharm » attrape aussi le ssh QUI PORTE cette
+ # installation — sa ligne de commande contient le script entier,
+ # donc ce chemin. Vécu : une installation est morte en silence, sa
+ # session ssh emportée, 48 minutes perdues. Mesuré ensuite : par
+ # nom, 3 processus réels attrapés et 0 faux ; par ligne de commande,
+ # 4 dont le ssh. Les noms sont ceux relevés dans la VM — pycharm,
+ # Xvfb, fsnotifier, cef_server — et « -u » borne au compte courant.
+ #
+ # « pgrep -c » IMPRIME 0 et rend 1 quand il ne trouve rien : un
+ # « || echo 0 » donnerait « 0\n0 », qui n'est pas « 0 ». « wc -l »
+ # rend un seul nombre et un code 0.
+ 'left=$(pgrep -u "$(id -u)" -x '
+ '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null | wc -l); '
+ '[ "$left" = 0 ] || { '
+ f'echo " {t("closing what survived the first open:")} $left"; '
+ 'pkill -u "$(id -u)" -x '
+ '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null; sleep 2; }; '
+ '[ "$ok" = 1 ] && break; '
+ f'echo " {t("no project yet, second try on the warm caches")}"; '
+ "done; "
+ '[ "$ok" = 1 ]; fi; fi; } && '
+ f'echo " {t("project created, the install will configure it")}" '
+ f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} '
+ 'make pycharm_configure"; '
+ )
+
+ def _qemu_android_studio_remote_cmd(self):
+ """Installe Android Studio, pour le développement mobile ERPLibre.
+
+ L'émulateur, lui, exige KVM DANS la VM, donc la virtualisation
+ imbriquée : on le dit plutôt que de laisser découvrir l'échec au premier
+ lancement. Compiler et déployer sur un appareil réel par adb n'en
+ dépendent pas."""
+ return (
+ f'echo "== {t("Installing Android Studio (long)")} =="; '
+ "{ "
+ 'if [ "$(uname -m)" != x86_64 ]; then '
+ f'echo " {t("Android Studio: Google publishes x86_64 only")}"; '
+ "false; "
+ "else "
+ # Déjà posé ? On ne retélécharge pas. Rejouer une
+ # installation est le cas NORMAL — une qui est morte, un outil
+ # ajouté après coup — et le téléchargement en est la partie
+ # longue : mesuré, ~5 min pour Android Studio, autant pour
+ # PyCharm. Le reste de l'étape (lanceur, alias, raccourci)
+ # rejoue de toute façon, lui est idempotent et bon marché.
+ "if [ -x /opt/android-studio/bin/studio ]; then "
+ f'echo " {t("already there, download skipped")}"; '
+ "else "
+ # La page officielle porte l'URL en clair ; le repli garde une
+ # version connue qui répond, pour le jour où sa forme change.
+ f"url=$(curl -fsSL --max-time 30 {self._QEMU_ANDROID_PAGE} "
+ "| grep -oE 'https://[a-z0-9.-]*gvt1\\.com/[^\"]*linux\\.tar\\.gz' "
+ "| head -1); "
+ f'[ -n "$url" ] || url="{self._QEMU_ANDROID_URL}"; '
+ "tmp=$(mktemp -p /var/tmp android-XXXX.tar.gz) && "
+ 'curl -fsSL "$url" -o "$tmp" && '
+ "sudo mkdir -p /opt/android-studio && "
+ 'sudo tar -xzf "$tmp" -C /opt/android-studio '
+ "--strip-components=1; "
+ 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { '
+ + self._qemu_jetbrains_launcher_cmd(
+ "/opt/android-studio", "studio", alias="android-studio"
+ )
+ + self._qemu_desktop_entry_cmd(
+ "android-studio",
+ "Android Studio",
+ "/usr/local/bin/studio",
+ "/opt/android-studio/bin/studio.svg",
+ "Development;IDE;",
+ )
+ +
+ # Le SDK partagé, vu depuis la SESSION graphique. install-android.sh
+ # écrit ses exports dans ~/.bashrc, que GNOME ne lit pas : Android
+ # Studio lancé depuis le menu ne verrait donc pas le SDK et
+ # proposerait d'en télécharger un second. environment.d est le
+ # canal que la session utilisateur lit vraiment.
+ "mkdir -p ~/.config/environment.d && "
+ "printf 'ANDROID_HOME=%s/android\\nANDROID_SDK_ROOT=%s/android\\n'"
+ ' "$HOME" "$HOME" '
+ "> ~/.config/environment.d/10-erplibre-android.conf; "
+ # repositories.cfg absent, et l'assistant de première ouverture
+ # s'arrête sur une erreur au lieu de proposer quoi que ce soit.
+ "mkdir -p ~/.android && touch ~/.android/repositories.cfg; "
+ + f'echo " {t("Android Studio installed:")} /opt/android-studio '
+ f'({t("command")} studio / android-studio)"; '
+ f'echo " {t("SDK shared through ANDROID_HOME:")} $HOME/android"; '
+ "grep -q vmx /proc/cpuinfo 2>/dev/null "
+ "|| grep -q svm /proc/cpuinfo 2>/dev/null "
+ f'|| echo " {t("no nested KVM: the emulator will not run")}"; '
+ f'}} || echo " ⚠ {t("Android Studio not installed (see above)")}"; '
+ )
+
+ # Extensions GNOME suggérées, par gestionnaire de paquets. Les noms ne sont
+ # pas les mêmes d'une famille à l'autre (« dashtodock » sur Debian,
+ # « dash-to-dock » sur Fedora), et aucune liste n'existe en entier partout.
+ #
+ # D'où l'installation UNE PAR UNE : apt, dnf, zypper et pacman échouent tous
+ # sur la commande ENTIÈRE dès qu'un seul nom est inconnu. Un paquet absent
+ # est donc annoncé et sauté, au lieu de faire tomber les autres avec lui.
+ _QEMU_GNOME_EXT_PKGS = {
+ "apt": (
+ "gnome-shell-extension-manager",
+ "gnome-tweaks",
+ "gnome-shell-extensions",
+ "gnome-shell-extension-dashtodock",
+ "gnome-shell-extension-appindicator",
+ "gnome-shell-extension-caffeine",
+ ),
+ "dnf": (
+ "gnome-extensions-app",
+ "gnome-tweaks",
+ "gnome-shell-extension-dash-to-dock",
+ "gnome-shell-extension-appindicator",
+ "gnome-shell-extension-caffeine",
+ "gnome-shell-extension-user-theme",
+ ),
+ "zypper": (
+ "gnome-shell-extensions",
+ "gnome-tweaks",
+ "gnome-shell-extension-dash-to-dock",
+ "gnome-shell-extension-appindicator",
+ ),
+ "pacman": (
+ "extension-manager",
+ "gnome-tweaks",
+ "gnome-shell-extensions",
+ ),
+ }
+
+ # Extensions demandées nommément, par leur UUID sur extensions.gnome.org.
+ # Aucune n'est empaquetée par une distribution : on passe donc par le site.
+ #
+ # L'archive dépend de la version de GNOME Shell, et ce n'est pas une
+ # précaution de principe : mesuré le 2026-08-17, le même point d'entrée
+ # sert gTile v59 pour GNOME 46, v62 pour GNOME 48 et v52 pour GNOME 3.38.
+ # Une URL figée poserait donc, tôt ou tard, une archive faite pour une
+ # autre version.
+ #
+ # Ce que le site fait d'une version qu'il ne connaît PAS : il sert la plus
+ # récente (vérifié — « shell_version=99 » rend l'archive des GNOME 49/50),
+ # il ne répond pas 404. Sans conséquence fâcheuse pour autant : GNOME Shell
+ # refuse de CHARGER une extension dont metadata.json ne déclare pas la
+ # version courante. Une archive mal appariée reste donc inerte et affichée
+ # « obsolète » dans le gestionnaire — elle ne casse pas la session.
+ _QEMU_GNOME_EXT_UUIDS = (
+ "gTile@vibou",
+ "freon@UshakovVasilii_Github.yahoo.com",
+ "tracker@aliakseiz.github.com",
+ )
+ _QEMU_GNOME_EXT_SITE = "https://extensions.gnome.org/download-extension"
+
+ def _qemu_gnome_ext_site_cmd(self):
+ """Installe les extensions nommées depuis extensions.gnome.org.
+
+ Celles-là, on les ACTIVE — à la différence des paquets de la
+ distribution, dont on ne connaît pas l'UUID. Deux raisons, l'une et
+ l'autre vérifiées : le site rend l'archive faite pour le GNOME Shell de
+ cette VM, et une archive mal appariée n'est de toute façon jamais
+ chargée par GNOME, qui compare metadata.json à sa propre version. Ce
+ n'est donc pas l'activation qui peut casser une session.
+
+ Le tout dans un groupe gardé : ni une panne de réseau ni une extension
+ retirée du site ne doivent faire échouer une installation d'une heure.
+ """
+ uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS)
+ site = self._QEMU_GNOME_EXT_SITE
+ return (
+ "{ "
+ # « gnome-shell --version » rend « GNOME Shell 48.2 » : le dernier
+ # champ suffit, et évite une expression régulière à rallonge.
+ "v=$(gnome-shell --version 2>/dev/null | awk '{print $NF}'); "
+ 'if [ -z "$v" ]; then '
+ + f'echo " {t("GNOME Shell not found, site extensions skipped")}"; '
+ + "else "
+ # Le site attend le numéro MAJEUR depuis GNOME 40 (« 48 ») et
+ # « majeur.mineur » avant (« 3.38 ») : sans la bonne forme, il ne
+ # renvoie aucune archive.
+ "maj=${v%%.*}; "
+ 'if [ "$maj" -ge 40 ] 2>/dev/null; then sv="$maj"; '
+ 'else sv=$(echo "$v" | cut -d. -f1,2); fi; '
+ # gnome-extensions écrit dans ~/.local/share, mais l'activation
+ # passe par GSettings : sans bus de session — le cas d'un
+ # « ssh hôte commande » — dconf ne peut rien écrire.
+ # dbus-run-session en fournit un le temps de l'appel, et
+ # l'écriture atterrit bien dans le dconf de l'utilisateur.
+ 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && '
+ "command -v dbus-run-session >/dev/null 2>&1; then "
+ 'dbus-run-session -- gnome-extensions "$@"; '
+ 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do "
+ # « || echo » DANS la substitution : un mktemp qui échoue rendrait
+ # l'affectation non nulle, et « set -e » couperait toute la suite.
+ + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); "
+ + 'if curl -fsSL --max-time 120 "'
+ + site
+ + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" '
+ + '&& gx install --force "$z" >/dev/null 2>&1; then '
+ + 'gx enable "$u" >/dev/null 2>&1 || true; '
+ + f'echo " {t("installed and enabled:")} $u"; else '
+ + f'echo " {t("not available for this GNOME, skipped:")} '
+ + '$u (GNOME $sv)"; fi; rm -f "$z"; done; '
+ + f'echo " {t("log out and back in to load them")}"; '
+ + "fi; } || true; "
+ )
+
+ def _qemu_gnome_ext_remote_cmd(self):
+ """Pose les extensions GNOME suggérées.
+
+ Deux sources, et deux politiques, pour une raison :
+ - les paquets de la DISTRIBUTION sont installés sans être activés. On
+ ne connaît pas leur UUID de façon fiable, et activer à l'aveugle une
+ extension incompatible avec la version de GNOME Shell laisse la
+ session sur un écran noir — panne qu'on ne diagnostique pas depuis
+ une console série. Le gestionnaire graphique est posé pour choisir ;
+ - les extensions nommées par leur UUID sont, elles, ACTIVÉES : le site
+ rend l'archive faite pour ce GNOME-là, et une archive mal appariée
+ n'est jamais chargée par GNOME plutôt que de casser la session.
+ """
+ pkgs = self._QEMU_GNOME_EXT_PKGS
+ return (
+ f'echo "== {t("Suggested GNOME extensions")} =="; '
+ "if command -v apt-get >/dev/null 2>&1; then "
+ f"EXT='{' '.join(pkgs['apt'])}'; "
+ "I='sudo DEBIAN_FRONTEND=noninteractive apt-get "
+ "-o DPkg::Lock::Timeout=600 install -y'; "
+ "elif command -v dnf >/dev/null 2>&1; then "
+ f"EXT='{' '.join(pkgs['dnf'])}'; I='sudo dnf install -y'; "
+ "elif command -v zypper >/dev/null 2>&1; then "
+ f"EXT='{' '.join(pkgs['zypper'])}'; "
+ "I='sudo zypper --non-interactive install "
+ "--auto-agree-with-licenses'; "
+ "elif command -v pacman >/dev/null 2>&1; then "
+ f"EXT='{' '.join(pkgs['pacman'])}'; "
+ "I='sudo pacman -S --needed --noconfirm'; "
+ 'else EXT=""; fi; '
+ 'for p in $EXT; do $I "$p" >/dev/null 2>&1 '
+ f'|| echo " {t("not in the repos, skipped:")} $p"; done; '
+ f'echo " {t("Enable them from Extension Manager, or:")} '
+ 'gnome-extensions enable "; '
+ + self._qemu_gnome_ext_site_cmd()
+ )
+
+ # Diagnostic de la compilation mobile : motif rencontré dans le journal
+ # détaillé -> cause nommée. Du plus précis au plus général, le premier qui
+ # correspond gagne.
+ #
+ # Cette liste est faite pour GRANDIR. Une compilation Android échoue de
+ # cent façons, et le journal fait des dizaines de mégaoctets : sans cette
+ # traduction, « la VM est rouge » n'apprend rien et il faut tout rouvrir.
+ # Chaque panne rencontrée sur une VM mérite d'y laisser sa ligne.
+ _QEMU_MOBILE_DIAG = (
+ ("No space left on device", "disk full"),
+ ("Failed to find target with hash string", "SDK platform missing"),
+ ("SDK location not found", "SDK not found (ANDROID_HOME)"),
+ ("have not been accepted", "SDK licences not accepted"),
+ ("NDK not configured", "NDK missing"),
+ # Vécu : Capacitor 8 réclame un JDK 21 quand l'installateur amont pose
+ # un 17, et Gradle s'arrête là.
+ (
+ "Cannot find a Java installation",
+ "JDK required by the project missing",
+ ),
+ # Vécu aussi : le JDK est là, mais Gradle TOURNE sur un plus ancien.
+ ("invalid source release", "Gradle running on too old a JDK"),
+ ("cannot overwrite", "SDK already there (upstream installer replays)"),
+ # Vécu : sentencepiece bâtit protoc pour la CIBLE et l'exécute sur
+ # l'hôte. Le message est cryptique ; la cause, non.
+ ("Exec format error", "cross-compiled protoc run on the host"),
+ ("Unsupported class file major version", "JDK/Gradle mismatch"),
+ ("Could not determine java version", "JDK/Gradle mismatch"),
+ (
+ "Could not resolve all files for configuration",
+ "Gradle dependency unreachable (network?)",
+ ),
+ ("npm ERR!", "npm dependencies"),
+ ("Test Files", "Vitest tests failed"),
+ # Vécu : le manifeste rend 0 sans avoir cloné, et l'étape suivante
+ # tombe sur un cd impossible. Le motif nomme la vraie cause.
+ #
+ # SANS APOSTROPHE, et ce n'est pas cosmétique : ces motifs partent dans
+ # un « grep -q '' », entre apostrophes. « can't cd to » fermait
+ # la chaîne et rendait tout le bloc invalide — attrapé par bash -n.
+ ("cd: can", "mobile repository missing"),
+ # Vécu aussi : sans python3.12-venv, .venv.erplibre n'existe pas, et
+ # rien de ce qui suit ne peut synchroniser le manifeste.
+ ("virtual environment", "ERPLibre venv missing (incomplete install)"),
+ ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"),
+ # Vécu sur erplibre-ubuntu-2604-gnome : le noyau a tué le démon Gradle
+ # (6,8 Go de RSS sur 12 Go, sans swap), et Gradle n'en sait rien — il
+ # dit seulement que son démon « a disparu ». Le motif nomme la mémoire,
+ # et le contexte l'établit au lieu de le supposer.
+ # Vécu, et c'est en amont : « Too many zip entries 123678 (MAX=65535) ».
+ # Un APK est un ZIP classique, borné à 65 535 entrées, et le dépôt
+ # mobile embarque 122 684 fichiers sous assets/public/repos — des
+ # dépôts Odoo entiers — pour 337 fichiers qui sont l'application. Rien
+ # ici ne peut le corriger : c'est au projet mobile de ne pas les
+ # empaqueter. On le NOMME, avec le chiffre, plutôt que de laisser lire
+ # 5 000 lignes de Gradle.
+ (
+ "Too many zip entries",
+ "too many asset files for one APK (ZIP limit: 65535 entries)",
+ ),
+ ("daemon disappeared", "Gradle daemon killed: out of memory", "mmem"),
+ ("Cannot allocate memory", "out of memory", "mmem"),
+ ("Java heap space", "Gradle heap too small", "mmem"),
+ ("FAILED", "Gradle task failed"),
+ )
+
+ def _qemu_mobile_diag_cmd(self):
+ """Fonction shell qui NOMME la cause d'un échec, à partir du journal.
+
+ Un « la VM est rouge » n'apprend rien quand le journal fait des dizaines
+ de mégaoctets. On cherche donc les motifs connus, et à défaut on montre
+ les dernières lignes — c'est toujours mieux que rien.
+
+ La recherche porte sur la FIN du journal, pas sur tout. Vécu : le
+ diagnostic a annoncé « licences SDK non acceptées » quand la panne était
+ un JDK manquant — le motif venait de la revue de licences d'une étape
+ RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus
+ cher que se taire."""
+ lines = ""
+ for entry in self._QEMU_MOBILE_DIAG:
+ pat, cause = entry[0], entry[1]
+ extra = f"{entry[2]}; " if len(entry) > 2 else ""
+ lines += (
+ f"grep -q '{pat}' \"$d\" && {{ "
+ f'echo " {t("probable cause:")} {t(cause)}"; '
+ f'{extra}rm -f "$d"; return 0; }}; '
+ )
+ return (
+ # Le contexte mémoire, lu dans /proc et dans le journal du noyau :
+ # une cause « mémoire » se PROUVE, l'affirmer sans le compte de
+ # l'oom-killer serait une supposition de plus. Pas d'awk ni de sed
+ # ici : leurs programmes demandent des guillemets, et tout ceci
+ # voyage déjà dans un ssh entre apostrophes.
+ "mmem() { m=$(grep MemTotal /proc/meminfo | tr -dc 0-9); "
+ "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); "
+ "k=$(sudo dmesg 2>/dev/null | grep -c oom-kill); "
+ f'echo " {t("memory:")} $((m/1024)) {t("MB RAM,")} '
+ f'$((w/1024)) {t("MB swap, kernel OOM kills:")} $k"; }}; '
+ 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; '
+ + lines
+ + f'echo " {t("no known pattern, last lines:")}"; '
+ 'tail -12 "$1" | sed "s/^/ /"; rm -f "$d"; }; '
+ )
+
+ def _qemu_android_prologue_cmd(self):
+ """Ce que la compilation mobile et l'émulateur partagent : le journal
+ détaillé, le coureur d'étapes, le diagnostic, et l'environnement du SDK.
+
+ Écrit UNE fois même quand les deux options sont cochées — deux
+ prologues, ce serait deux journaux et deux SDK."""
+ return (
+ self._qemu_mobile_diag_cmd() +
+ # Le détail va dans un fichier À PART. Une compilation Gradle écrit
+ # des dizaines de milliers de lignes, dont des centaines portant le
+ # mot « error » sans qu'aucune ne soit une panne : les verser dans
+ # le journal d'installation rendrait son compteur d'erreurs
+ # inutilisable, et le diagnostic illisible.
+ 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; '
+ f'echo " {t("detailed log in the VM:")} $M"; '
+ 'mstep() { lbl="$1"; shift; echo " -> $lbl"; '
+ 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; '
+ f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; '
+ # Le SDK vit dans $HOME/android, l'emplacement qu'emploie
+ # l'installateur du dépôt. Android Studio, s'il est là, le trouvera
+ # par ANDROID_HOME : un seul SDK sur la machine, pas deux.
+ 'export ANDROID_HOME="$HOME/android"; '
+ 'export ANDROID_SDK_ROOT="$HOME/android"; '
+ 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin'
+ ':$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"; '
+ # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que
+ # l'installateur y écrit ne sert qu'aux sessions futures, pas à la
+ # compilation qui suit immédiatement.
+ #
+ # Le JDK le PLUS RÉCENT installé, et non celui des alternatives.
+ # Mesuré : avec JAVA_HOME sur le 17 que pose l'installateur amont,
+ # Gradle tourne en 17 et s'arrête sur « invalid source release: 21 »
+ # — les modules de Capacitor 8 compilent en 21. Le tri est
+ # « sort -V », donc java-21 passe après java-17, pas avant.
+ "export JAVA_HOME=$(ls -d /usr/lib/jvm/java-*-openjdk-* "
+ "2>/dev/null | sort -V | tail -1); "
+ '[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(dirname $(dirname '
+ "$(readlink -f $(command -v javac 2>/dev/null "
+ "|| command -v java 2>/dev/null) 2>/dev/null) 2>/dev/null) "
+ "2>/dev/null); "
+ 'export PATH="$JAVA_HOME/bin:$PATH"; '
+ )
+
+ def _qemu_android_sdk_steps(self, el_dir):
+ """Les étapes qui posent le SDK : dépôt mobile, prérequis, installateur
+ amont, plateforme réclamée par le projet. Communes aux deux options."""
+ return (
+ # Le « test -f » n'est pas une ceinture de plus : c'est la seule
+ # vérité disponible. update_manifest_local_mobile.sh finit par
+ # « kill $DAEMON_PID » et rend donc 0 même quand il n'a rien cloné —
+ # vécu, faute de .venv.erplibre. L'étape passait, et c'est le « cd »
+ # suivant qui échouait, deux étapes plus loin.
+ # Le venv d'ERPLibre d'abord, et nommément : tout ce qui suit en
+ # dépend — c'est lui qui porte « repo », qui synchronise le
+ # manifeste. Vécu avec le profil « ERPLibre seul », dont le code
+ # note lui-même « problem installing with q, the script depend on
+ # odoo » : sans venv, le manifeste rendait 0 sans rien cloner et
+ # l'échec ne se voyait que deux étapes plus loin.
+ f'mstep "{t("ERPLibre venv (everything below needs it)")}" '
+ # « activate », et non « bin/python » : sans python3-venv, le venv
+ # naît INFIRME — bin/python existe (un lien), mais ni pip ni
+ # activate ni site-packages. La sonde passait, et l'échec ne se
+ # voyait que deux étapes plus loin, en « No module named git ».
+ f"'test -f {el_dir}/.venv.erplibre/bin/activate' && "
+ f'mstep "{t("mobile repository (additive manifest)")}" '
+ f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; "
+ "test -f mobile/erplibre_home_mobile/install-android.sh' && "
+ f'mstep "{t("prerequisites of the upstream installer")}" '
+ # libpulse0 : l'émulateur a DEUX binaires qemu, et seul le
+ # « headless » se passe de PulseAudio. Celui qui ouvre une FENÊTRE —
+ # le cas d'un « ssh -X » — lie libpulse.so.0, absente des images
+ # cloud, et s'arrête sur « cannot open shared object file » même
+ # avec « -no-audio ». Mesuré : c'est la SEULE bibliothèque qui
+ # manque, tout le reste des dépendances Qt voyage dans le bundle.
+ #
+ # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré,
+ # Gradle s'arrête sur « Cannot find a Java installation matching
+ # {languageVersion=21} » — les modules de Capacitor 8 réclament 21.
+ # Les deux JDK cohabitent, et Gradle choisit par sa chaîne d'outils.
+ # unzip et xauth, eux, manquent des images cloud.
+ "'sudo DEBIAN_FRONTEND=noninteractive apt-get "
+ "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth "
+ "libpulse0 openjdk-21-jdk' && "
+ # L'installateur amont n'est PAS idempotent : au second passage il
+ # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré.
+ # On ne le rejoue donc que s'il reste quelque chose à poser — un
+ # déploiement qui se répète ne doit pas échouer sur une réussite
+ # précédente.
+ f'mstep "{t("Android SDK, licences, NDK")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && "
+ "{ [ -x $HOME/android/cmdline-tools/latest/bin/sdkmanager ] "
+ "|| ./install-android.sh; }' && "
+ f'mstep "{t("SDK platform required by the project")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && "
+ 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" '
+ 'android/variables.gradle) && [ -n "$v" ] && '
+ 'yes | sdkmanager "platforms;android-$v" '
+ '"build-tools;$v.0.0"\' && '
+ )
+
+ def _qemu_mobile_build_steps(self, el_dir):
+ """Étapes de compilation de l'application mobile, puis ses tests.
+
+ C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une
+ machine dont l'application ne compile pas n'est pas une machine prête.
+ Le code de sortie remonte donc jusqu'au tableau de bord.
+
+ Le dépôt mobile porte son propre installateur Android — JDK, outils en
+ ligne de commande, licences acceptées, plateformes, NDK, whisper.cpp et
+ sentencepiece. On l'appelle plutôt que de le réécrire : une seconde
+ implémentation dériverait de la première sans prévenir. Deux choses lui
+ manquent pourtant, et on les ajoute ici :
+ - unzip et wget, qu'il suppose présents et qu'aucune image cloud ne
+ livre ;
+ - la plateforme que le projet réclame VRAIMENT. Son installateur pose
+ android-34 quand android/variables.gradle demande compileSdk 36 ;
+ plutôt que de figer 36 ici, on lit le chiffre dans le fichier.
+
+ L'étape est bornée à apt (voir _QEMU_VM_TOOLS) : cet installateur
+ commence par « sudo apt install openjdk-17-jdk » et s'arrête là
+ ailleurs. La lever se fait dans ce script-là, pas ici.
+ """
+ return (
+ # Du swap AVANT de compiler, et ce n'est pas de la prudence : le
+ # démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m ne
+ # le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué
+ # deux fois de suite. « --max-workers=2 » n'y a rien changé :
+ # mesuré, le pic est passé de 10,3 à 11,2 Go. C'est donc de la marge
+ # qu'il faut, pas moins de parallélisme.
+ #
+ # Jamais bloquant : une image sur btrfs refuse un fichier d'échange
+ # ordinaire, et une compilation qui tient en mémoire n'en a pas
+ # besoin. On le dit et on continue.
+ "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); "
+ 'if [ "$w" -lt 2000000 ]; then '
+ "if sudo fallocate -l 4G /swapfile-erplibre 2>/dev/null && "
+ "sudo chmod 600 /swapfile-erplibre && "
+ "sudo mkswap -q /swapfile-erplibre >/dev/null 2>&1 && "
+ "sudo swapon /swapfile-erplibre 2>/dev/null; then "
+ "grep -q swapfile-erplibre /etc/fstab 2>/dev/null || "
+ 'echo "/swapfile-erplibre none swap sw 0 0" '
+ "| sudo tee -a /etc/fstab >/dev/null; "
+ f'echo " {t("4 GB of swap added for the build")}"; '
+ "else sudo rm -f /swapfile-erplibre 2>/dev/null; "
+ f'echo " {t("no swap could be added; build may run short")}"; '
+ "fi; fi; "
+ f'mstep "{t("npm dependencies")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && "
+ f'mstep "{t("web bundle (vite build)")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && "
+ # Le transfert des dépôts du manifeste DANS l'application est
+ # vérifié, et son compte-rendu se lit dans le journal
+ # d'installation — d'où l'appel HORS mstep, qui enverrait la sortie
+ # dans le journal détaillé de la VM.
+ #
+ # Ces dépôts entrent en PACKS, et c'est ce qui rend la chose
+ # possible : un APK est un ZIP borné à 65535 entrées, quand les
+ # 139 dépôts pèsent plus de 116 000 fichiers. Un fichier par source
+ # donnait « Too many zip entries 123678 (MAX=65535) » et rien du
+ # tout ; regroupés, ils tiennent en 391 tranches — mesuré, avec
+ # 3 002 entrées dans l'APK.
+ #
+ # Lié par « && » : un transfert vide fait échouer la VM, au même
+ # titre qu'un APK manquant. Une application qui ne porte pas le code
+ # qu'elle est censée montrer n'est pas l'application demandée.
+ f'echo " -> {t("repo transfer into the app")}" && '
+ f"(cd {el_dir} && ./script/mobile/check_bundle_transfer.py"
+ f" --workspace {el_dir}) && "
+ f'mstep "{t("native sync (capacitor)")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && "
+ # UNE seule ABI, celle de la VM — qui est aussi celle de
+ # l'émulateur. Deux raisons, la seconde décisive :
+ # - quatre ABI, c'est quatre fois la compilation de whisper.cpp
+ # et de sentencepiece, pour trois qui ne serviront jamais ici ;
+ # - sentencepiece bâtit son « protoc » POUR LA CIBLE puis tente de
+ # l'exécuter sur l'hôte. En arm64 cela donne « Exec format
+ # error » et la compilation s'arrête — mesuré. En x86_64 la
+ # cible et l'hôte coïncident, et le défaut ne se manifeste pas.
+ # Un APK arm64 demandera un correctif au projet mobile.
+ f'mstep "{t("debug APK (gradle)")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile/android && "
+ "./gradlew --no-daemon assembleDebug "
+ "-Pandroid.injected.build.abi=x86_64' && "
+ f'mstep "{t("Vitest tests")}" '
+ f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && "
+ # L'APK est la preuve, pas le code de sortie de Gradle : une tâche
+ # peut rendre 0 sans avoir rien produit.
+ # DEUX emplacements, et il faut les deux. Avec une ABI injectée,
+ # AGP écrit dans « intermediates/apk/debug » et non dans
+ # « outputs/apk/debug » : mesuré, une compilation RÉUSSIE était
+ # rapportée « aucun APK produit » parce que je ne regardais que le
+ # second. Un contrôle qui cherche au mauvais endroit ne vaut pas
+ # mieux que pas de contrôle.
+ f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build"
+ "/outputs/apk/debug/*.apk "
+ f"{el_dir}/mobile/erplibre_home_mobile/android/app/build"
+ "/intermediates/apk/debug/*.apk 2>/dev/null | head -1); "
+ 'if [ -n "$apk" ]; then '
+ f'echo " ✅ {t("APK built:")} $apk"; '
+ # Capacitor sert la même application dans un navigateur : sur une
+ # VM graphique, c'est la voie de déverminage sans Android ni
+ # émulateur. On la NOMME plutôt que d'imposer Chromium — sur
+ # Ubuntu il n'existe qu'en snap, et snapd est justement coupé sur
+ # ces VM. Le navigateur du bureau fait l'affaire.
+ #
+ # DANS la branche de succès, et c'est tout l'enjeu : placé après le
+ # « fi », cet echo devenait la dernière commande du bloc et rendait
+ # 0 — une VM sans APK repassait au vert.
+ f'echo " {t("browser debugging (no Android):")} '
+ f"cd {el_dir}/mobile/erplibre_home_mobile "
+ '&& npm start"; else '
+ f'echo " ⚠ {t("no APK produced")}"; false; fi'
+ )
+
+ def _qemu_avd_steps(self, el_dir):
+ """Étapes créant un émulateur prêt à s'ouvrir depuis le poste de travail.
+
+ Le modèle n'est pas figé : on demande au SDK la liste de ses profils et
+ on retient le Pixel le plus récent au plus petit écran — ni Pro, ni XL,
+ ni pliant, ni tablette. Sur un écran distant, chaque pixel traverse le
+ réseau : le petit modèle n'est pas une coquetterie.
+
+ L'image système suit la plateforme du projet, et redescend si elle n'est
+ pas publiée — Google ne fournit pas d'image pour toutes les API.
+
+ Le rendu est réglé en logiciel DANS la configuration de l'AVD plutôt
+ qu'en option de lancement : par « ssh -X » il n'y a pas de GLX direct, et
+ l'émulateur s'ouvrirait sur un écran noir. Ainsi « emulator -avd
+ erplibre » suffit, sans rien à retenir.
+
+ Le mode est « swangle » — ANGLE sur SwiftShader — et non
+ « swiftshader_indirect », qui n'existe PLUS : l'émulateur 37.1 répond
+ « Selected GPU option 'swiftshader_indirect' is not valid, switching to
+ auto », puis « Your GPU drivers may have a bug », avant de retomber de
+ lui-même sur swangle. Il fonctionnait, en affichant deux erreurs qui
+ laissaient croire à une panne. Les modes valides sont exactement quatre,
+ que « emulator -help-gpu » énumère : auto, host, swiftshader, swangle.
+ """
+ return (
+ f'echo " == {t("Android emulator (AVD)")} =="; '
+ # KVM dans la VM : sans lui l'émulateur x86 refuse de démarrer. On
+ # le dit ici, où c'est réparable (virtualisation imbriquée sur
+ # l'hôte), plutôt qu'au premier lancement.
+ "if [ ! -e /dev/kvm ]; then "
+ f'echo " ⚠ {t("no /dev/kvm: nested virtualisation is off on the host")}"; '
+ "else "
+ # /dev/kvm est en root:kvm 0660 : sans appartenir au groupe,
+ # l'émulateur refuse de démarrer sur « ProbeKVM: This user doesn't
+ # have permissions to use KVM ». Mesuré. L'appartenance ne prend
+ # qu'à la prochaine session — ce qui tombe bien, la session utile
+ # est justement celle du « ssh -X » qui viendra ensuite.
+ "sudo usermod -aG kvm $(id -un) 2>/dev/null || true; "
+ f'echo " {t("user added to the kvm group (effective at next login)")}"; '
+ "fi; "
+ f'mstep "{t("emulator and system image")}" '
+ '\'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" '
+ f"{el_dir}/mobile/erplibre_home_mobile/android/variables.gradle); "
+ "for a in $v 36 35 34; do "
+ 'img="system-images;android-$a;google_apis;x86_64"; '
+ 'if yes | sdkmanager "emulator" "$img"; then '
+ 'echo "$img" > $HOME/.erplibre-avd-image; break; fi; done; '
+ "test -s $HOME/.erplibre-avd-image' && "
+ f'mstep "{t("Pixel profile, smallest screen")}" '
+ # Le plus récent des Pixel simples : on trie sur le NUMÉRO, pas sur
+ # l'ordre d'affichage, et on écarte les grands modèles.
+ '\'avdmanager list device | grep -oE "pixel_[0-9]+a?" '
+ '| grep -vE "pro|xl|fold|tablet" | sort -t_ -k2 -n | tail -1 '
+ "> $HOME/.erplibre-avd-device; test -s $HOME/.erplibre-avd-device' && "
+ f'mstep "{t("create the AVD")}" '
+ "'img=$(cat $HOME/.erplibre-avd-image); "
+ "dev=$(cat $HOME/.erplibre-avd-device); "
+ 'echo no | avdmanager create avd -n erplibre -k "$img" '
+ '-d "$dev" --force && '
+ # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas
+ # de GLX direct, et « auto » donnerait un écran noir. Ces deux
+ # clés-là SURVIVENT, elles ne viennent pas du profil du téléphone.
+ #
+ # L'écran, en revanche, ne s'écrit PAS ici : l'émulateur réécrit
+ # config.ini depuis le profil Pixel au premier démarrage, et les
+ # hw.lcd.* y étaient effacés — l'AVD repartait en 1080x2400
+ # densité 420. C'est donc au LANCEMENT qu'il se règle, par
+ # _QEMU_EMULATOR_FLAGS, et la commande affichée plus bas les porte.
+ 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\n" '
+ ">> $HOME/.android/avd/erplibre.avd/config.ini' && "
+ f'echo " ✅ {t("AVD ready:")} '
+ "$(cat $HOME/.erplibre-avd-device) / "
+ '$(cat $HOME/.erplibre-avd-image)"; '
+ # La commande à copier, avec l'adresse déjà remplie : un émulateur
+ # dont on ignore comment l'ouvrir ne sert à personne.
+ "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); "
+ # Chemins ABSOLUS, et c'est le point : « ssh hôte 'commande' »
+ # ne lit NI ~/.profile NI ~/.bashrc — Ubuntu place même un
+ # « return » en tête du second pour les shells non interactifs.
+ # Le PATH que l'installateur y écrit ne s'applique donc jamais
+ # à ces commandes, et « emulator » y répond « command not
+ # found ». Vécu, sur la ligne que ce message affichait lui-même.
+ f'echo " {t("open it from your workstation:")} '
+ # « -XC » et non « -X » : la compression X11 change tout sur un
+ # écran distant. Les autres drapeaux viennent de la même autorité
+ # que le lancement du menu : écran réduit, densité qui va avec, et
+ # pas d'instantané en attente si on tue l'émulateur.
+ 'ssh -XC erplibre@$ip \\"$HOME/android/emulator/emulator '
+ f'-avd erplibre {self._QEMU_EMULATOR_FLAGS}\\""; '
+ f'echo " {t("then install the APK:")} '
+ # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP,
+ # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ».
+ # Mesuré sur l'émulateur.
+ 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t '
+ f"{el_dir}/mobile/erplibre_home_mobile/android/app/build"
+ '/outputs/apk/debug/app-debug.apk\\""; '
+ # La voie scrcpy, nommée ici parce que c'est la première
+ # question qui vient après « ça se lance mais c'est lent » :
+ # X11 transporte des pixels bruts, scrcpy un flux H.264 encodé
+ # par l'appareil. Le détail du tunnel vit dans le menu
+ # « Remote desktop tunnel », choix 4.
+ + f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"'
+ )
+
+ def _qemu_forgejo_steps(self, el_dir):
+ """Pose Forgejo dans la VM, par le script dédié du dépôt.
+
+ Tout le travail est DANS le script — architecture, version, somme de
+ contrôle, compte système, configuration, service, compte
+ administrateur. Ce bloc ne fait que l'appeler : une seule autorité, et
+ la même commande sert un déploiement de VM et une installation à la
+ main sur une machine existante.
+
+ Pas de garde, comme la compilation mobile : une VM dont la forge
+ demandée n'existe pas n'est pas la VM demandée. Le script, lui, est
+ rejouable — il ne retélécharge pas un binaire déjà en place et ne
+ réécrit jamais une configuration existante.
+ """
+ return (
+ f'echo "== {t("Forgejo (git forge)")} =="; '
+ f"{el_dir}/script/forgejo/install_forgejo.sh"
+ )
+
+ def _qemu_after_remote_cmd(self, tools, prod=False):
+ """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce
+ qui a été coché.
+
+ Un seul prologue et un seul SDK même quand les deux options le sont :
+ deux prologues, et le second tronquerait le journal détaillé du premier.
+
+ Les groupes sont joints par « && » et non par « ; ». C'est ce qui fait
+ qu'un APK manquant reste l'échec de la VM : collé par « ; », un
+ émulateur créé avec succès effacerait le verdict de la compilation."""
+ picked = [
+ k
+ for k in ("forgejo", "mobile", "avd")
+ if k in (tools or ()) and k in self._QEMU_VM_TOOLS
+ ]
+ if not picked:
+ return ""
+ el_dir = self._qemu_install_dir(prod)
+ parts = []
+ # Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK.
+ # Un échec rapide se voit tôt plutôt qu'après le long.
+ if "forgejo" in picked:
+ parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}")
+ groups = []
+ if "mobile" in picked:
+ groups.append(self._qemu_mobile_build_steps(el_dir))
+ if "avd" in picked:
+ groups.append(self._qemu_avd_steps(el_dir))
+ if groups:
+ # UN seul prologue et un seul SDK même quand les deux options le
+ # sont : deux prologues, et le second tronquerait le journal
+ # détaillé du premier.
+ parts.append(
+ "{ "
+ + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; '
+ + self._qemu_android_prologue_cmd()
+ + self._qemu_android_sdk_steps(el_dir)
+ # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que
+ # la première commande du groupe suivant : mesuré, un APK
+ # manquant laissait tourner l'émulateur puis rendait 0 — la VM
+ # repassait au vert alors que rien n'avait compilé.
+ + " && ".join(f"{{ {g}; }}" for g in groups)
+ + "; }"
+ )
+ return " && ".join(parts) + "; "
+
+ def _qemu_mobile_remote_cmd(self, prod=False):
+ """Compilation mobile seule — la forme que testent les tests."""
+ return self._qemu_after_remote_cmd(("mobile",), prod)
+
+ def _qemu_avd_remote_cmd(self, prod=False):
+ """Émulateur seul."""
+ return self._qemu_after_remote_cmd(("avd",), prod)
+
+ def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"):
+ """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd.
+
+ « before » : posé avant le clone. Chaque outil s'y garde lui-même —
+ aucun ne fait échouer les autres, ni l'installation d'ERPLibre.
+
+ « after » : la compilation mobile, qui vient après l'installation dont
+ elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une
+ VM dont l'application ne compile pas doit être rouge."""
+ if phase == "after":
+ # Un seul bloc pour les deux options : voir _qemu_after_remote_cmd.
+ return self._qemu_after_remote_cmd(tools, prod)
+ blocks = {
+ "gnome_ext": self._qemu_gnome_ext_remote_cmd,
+ "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod),
+ "android": self._qemu_android_studio_remote_cmd,
+ }
+ return "".join(fn() for k, fn in blocks.items() if k in (tools or ()))
+
+ def _qemu_editor_pkg(self):
+ """Paquet de l'éditeur de l'hôte, à installer dans la VM.
+
+ L'éditeur atteint déjà la VM par deux chemins, tous deux posés par
+ deploy_qemu.py : « core.editor » dans son ~/.gitconfig, et la ligne
+ « éditer le serveur » du guide de connexion. Encore faut-il que le
+ binaire y soit — les images cloud n'ont ni nano ni vim garantis, et
+ certaines n'ont même pas vi. On l'ajoute donc aux outils d'amorçage, avec
+ curl, git et make, là où les dépôts viennent d'être rafraîchis.
+
+ La table des éditeurs vit dans deploy_qemu.py : une seule autorité décide
+ du paquet installé, de la commande affichée et de core.editor. Sans
+ module importable, on n'installe rien plutôt que de deviner un nom."""
+ try:
+ mod = self._qemu_import_module()
+ return mod.vm_editor(mod.invoking_home())[0]
+ except Exception:
+ return ""
+
+ def _qemu_editor_suffix(self):
+ """« vim » -> « vim » précédé d'une espace, rien du tout sinon.
+
+ La liste des outils d'amorçage est une chaîne shell entre apostrophes :
+ y concaténer une chaîne vide sans précaution laisserait une espace en
+ trop, inoffensive mais visible dans chaque log d'installation."""
+ pkg = self._qemu_editor_pkg()
+ return f" {pkg}" if pkg else ""
+
# mise ne publie de binaire que pour ces architectures : 46 assets à la
# v2026.8.4, aucun s390x — son propre script d'installation refuse cette
# plateforme. Ailleurs, le choix « mise » est sans objet et on reste sur
@@ -4962,6 +7188,7 @@ class TODO:
desktop=False,
python_provider="",
app_store="deb",
+ tools=(),
):
"""Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau
— le choix graphique ne dépend pas d'ERPLibre, et une VM peut être
@@ -4971,18 +7198,38 @@ class TODO:
installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service
SELinux confiné. `desktop` : ajoute GNOME et son accès distant.
`python_provider` : « mise » pour un CPython précompilé, sinon le
- comportement par défaut du dépôt (pyenv, qui compile)."""
+ comportement par défaut du dépôt (pyenv, qui compile). `tools` : outils
+ de développement cochés (PyCharm, Android Studio, extensions GNOME),
+ posés APRÈS ERPLibre — PyCharm a besoin du venv du dépôt pour écrire la
+ configuration du projet."""
if not branch:
# Bureau seul : ni clone ni make, mais on garde le prologue —
# attente de cloud-init et coupure des mises à jour automatiques,
# sans quoi le verrou apt ferait échouer l'installation du bureau.
if not desktop:
return "true"
+ # Les outils de la phase « after » vivent DANS le dépôt — la
+ # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils
+ # n'existent pas ici. Les écarter en silence laissait croire qu'une
+ # case cochée avait été honorée : on la NOMME.
+ deferred = [
+ k
+ for k in (tools or ())
+ if self._QEMU_VM_TOOLS.get(k, {}).get("phase") == "after"
+ ]
+ note = (
+ f'echo " ⚠ {t("needs the ERPLibre install, skipped:")}'
+ f' {" ".join(deferred)}"; '
+ if deferred
+ else ""
+ )
return (
"set -e; "
+ self._qemu_cloud_init_wait()
+ self._qemu_no_auto_upgrade(prod, app_store)
+ self._qemu_desktop_remote_cmd(desktop, app_store)
+ + self._qemu_tools_remote_cmd(tools, prod)
+ + note
)
if not final_cmd:
final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}"
@@ -5008,6 +7255,41 @@ class TODO:
# apt pendant l'installation. En PROD on ne touche à rien : les
# correctifs de sécurité automatiques doivent rester actifs.
no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store)
+ tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before")
+ # La compilation mobile vient APRÈS l'installation : elle a besoin du
+ # dépôt, du venv d'outils qui synchronise le manifeste, et de node que
+ # « make install_os » installe. Liée par « && » et NON gardée, pour que
+ # son échec soit celui de la VM.
+ after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after")
+ # APRÈS le make, et c'est mesuré : sur un dépôt cloné mais pas installé,
+ # PyCharm n'écrit AUCUN .idea — son configurateur d'interpréteur Python
+ # échoue faute de venv, et il renonce. « ⚠ pas de .idea », deux fois de
+ # suite sur erplibre-ubuntu-2604-gnome. Le même appel sur un dépôt
+ # installé l'écrit en cinq minutes : erplibre.iml, misc.xml,
+ # modules.xml, vcs.xml.
+ #
+ # On ouvre donc quand l'interpréteur existe, puis on demande la
+ # configuration explicitement : l'installation est déjà passée, et
+ # pycharm_update() n'avait alors rien à configurer.
+ open_step = (
+ self._qemu_pycharm_project_cmd(prod)
+ # Le venv du dépôt, comme le fait update_env_version.
+ # pycharm_update() : le script importe xmltodict, absent du python
+ # système. Mesuré : « make pycharm_configure » s'arrêtait sur
+ # « No module named 'xmltodict' ».
+ + "./.venv.erplibre/bin/python "
+ "./script/ide/pycharm_configuration.py --init || true; "
+ if "pycharm" in (tools or ())
+ else ""
+ )
+ # Le groupe de PyCharm rend toujours 0 — un bonus, pas une condition —
+ # là où la phase mobile porte le verdict de la VM.
+ chain = [final_cmd]
+ if open_step:
+ chain.append(f"{{ {open_step} }}")
+ if after_cmd:
+ chain.append(f"{{ {after_cmd} }}")
+ install_chain = " && ".join(chain)
return (
"set -e; " + self._qemu_cloud_init_wait()
# Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre
@@ -5027,7 +7309,14 @@ class TODO:
# la VM soit la plus rapide possible (miroirs à jour / les plus
# rapides), puis installe. Supporte apt (Debian/Ubuntu), dnf/yum
# (Fedora) et pacman (Arch).
- "PKGS='curl git make'; "
+ #
+ # L'éditeur de l'hôte voyage avec eux : deploy_qemu.py a déjà écrit
+ # « core.editor » dans le ~/.gitconfig de la VM et l'a nommé dans le
+ # guide de connexion, mais aucune image cloud ne garantit vim ni
+ # nano. Le poser ici plutôt que par cloud-init : les dépôts y sont
+ # déjà rafraîchis, et une installation de paquet au premier boot
+ # retarderait le démarrage sans laisser de trace dans le suivi.
+ f"PKGS='curl git make{self._qemu_editor_suffix()}'; "
"if command -v apt-get >/dev/null 2>&1; then "
# Au 1er boot, cloud-init (install qemu-guest-agent) et/ou
# apt-daily.service tiennent le verrou apt. IMPORTANT :
@@ -5091,6 +7380,16 @@ class TODO:
'{ echo "Outil manquant apres installation: $t '
'(reseau de la VM ?)"; exit 1; }; done; '
+ self._qemu_mise_remote_cmd(python_provider)
+ # Les outils AVANT le clone et le make, et l'ordre compte : c'est
+ # PyCharm qui écrit le .idea du dépôt, en l'ouvrant une fois, et
+ # c'est l'installation qui, ensuite, y lance
+ # pycharm_configuration.py. Posés après, ils arrivaient trop tard
+ # pour cette étape-là.
+ #
+ # Le code de sortie de la commande distante reste celui de
+ # l'installation : chaque bloc d'outil se garde lui-même et rend 0,
+ # donc aucun ne peut faire passer un make échoué pour un succès.
+ + tools_cmd
# Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur
# pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev.
+ (
@@ -5100,7 +7399,7 @@ class TODO:
f"sudo git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; "
"sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; "
- f"cd /opt/erplibre && {final_cmd}"
+ f"cd /opt/erplibre && {install_chain}"
)
if prod
else (
@@ -5108,7 +7407,7 @@ class TODO:
"if [ ! -d ~/git/erplibre/.git ]; then "
f"git clone --branch {shlex.quote(branch)} "
f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; "
- f"cd ~/git/erplibre && {final_cmd}"
+ f"cd ~/git/erplibre && {install_chain}"
)
)
)
@@ -5123,12 +7422,16 @@ class TODO:
desktop=False,
python_provider="",
app_store="deb",
+ vm_tools=(),
):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
`ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE).
`final_cmd` : commande d'install selon le profil choisi.
- `prod` : install /opt/erplibre + service SELinux confiné."""
+ `prod` : install /opt/erplibre + service SELinux confiné.
+ `vm_tools` : outils cochés pour tout le parc, filtrés machine par
+ machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
+ n'ont pas de sens sous Cinnamon)."""
from script.todo.qemu_install_monitor import (
launch_installs,
run_monitor,
@@ -5177,14 +7480,27 @@ class TODO:
"version": v,
"arch": a,
}
- if desk_map or branch_map or cmd_map:
+ # Les outils imposent une commande PAR VM même quand tout le
+ # reste est commun : ils dépendent de l'architecture de la
+ # machine et de sa saveur de bureau, que seule cette boucle
+ # connaît.
+ if desk_map or branch_map or cmd_map or vm_tools:
+ # Le bureau de CETTE VM : sa saveur propre si la carte en
+ # donne une, sinon celle du parc. Prendre « rien » quand la
+ # carte est vide privait de bureau toute VM dont seule la
+ # branche ou le profil différait — la commande par défaut,
+ # elle, l'a toujours porté.
+ vm_desktop = desk_map.get(
+ name, "" if desk_map else desktop
+ )
entry["remote_cmd"] = self._qemu_erplibre_remote_cmd(
branch_map.get(name, branch_def),
cmd_map.get(name, cmd_def),
prod,
- desk_map.get(name, ""),
+ vm_desktop,
python_provider,
app_store,
+ self._qemu_tools_for(vm_tools, a, vm_desktop, d),
)
vms.append(entry)
else:
@@ -5230,10 +7546,12 @@ class TODO:
desktop=False,
python_provider="",
app_store="deb",
+ vm_tools=(),
):
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
- `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné."""
+ `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ;
+ `vm_tools` : outils de développement cochés."""
if ip is None:
ip = self._qemu_vm_ip(name)
if not ip:
@@ -5250,8 +7568,25 @@ class TODO:
f"{t('SSH not reachable, ERPLibre install skipped.')}"
)
return
+ # Distribution et architecture de CETTE VM : les outils s'y filtrent
+ # (Android Studio n'existe qu'en x86_64, la compilation mobile qu'en
+ # apt). Sans module lisible on ne filtre plus sur la distribution
+ # plutôt que d'écarter à tort.
+ try:
+ mod = self._qemu_import_module()
+ vm_distro, _v, vm_arch = self._qemu_vm_meta(name, mod)
+ except Exception:
+ vm_distro, vm_arch = "", self._qemu_vm_arch(name)
remote = self._qemu_erplibre_remote_cmd(
- branch, final_cmd, prod, desktop, python_provider, app_store
+ branch,
+ final_cmd,
+ prod,
+ desktop,
+ python_provider,
+ app_store,
+ self._qemu_tools_for(
+ vm_tools, vm_arch or "amd64", desktop, vm_distro or ""
+ ),
)
ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
@@ -5313,7 +7648,24 @@ class TODO:
# plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui,
# est un calcul automatique et se borne aux cœurs de l'hôte.
_QEMU_CPU_PRESETS = (
- 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 24, 32
+ 1,
+ 2,
+ 3,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10,
+ 11,
+ 12,
+ 13,
+ 14,
+ 15,
+ 16,
+ 24,
+ 32,
)
@staticmethod
@@ -5686,6 +8038,18 @@ class TODO:
print(
f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}"
)
+ tools = spec.get("vm_tools") or ()
+ if tools:
+ # Les Go sont dits ici parce que c'est le dernier écran avant de
+ # créer les disques : un IDE de plus, c'est un disque plus grand,
+ # et cette page est celle qu'on relit pour s'en apercevoir.
+ named = ", ".join(
+ f"{t(self._QEMU_VM_TOOLS[k]['label'])} "
+ f"(+{self._QEMU_VM_TOOLS[k]['disk_gb']} Go)"
+ for k in tools
+ if k in self._QEMU_VM_TOOLS
+ )
+ print(f" {t('Development tools:')} {named}")
prov = spec.get("python_provider")
if prov:
print(f" {t('Python interpreter:')} {prov}")
@@ -5711,6 +8075,9 @@ class TODO:
timezone=None,
locale=None,
desktop=False,
+ prod=False,
+ install_cmd="",
+ vm_tools=(),
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@@ -5746,6 +8113,15 @@ class TODO:
parts += ["--locale", locale]
if desktop:
parts.append("--desktop")
+ # Guide affiché à la connexion SSH de la VM : dans la langue du menu, et
+ # avec la section ERPLibre seulement là où ERPLibre sera installé — une
+ # VM déployée nue n'annonce pas un dépôt qui n'existe pas.
+ parts += ["--lang", get_lang()]
+ if branch:
+ parts += ["--erplibre-dir", self._qemu_guide_dir(prod)]
+ target = self._qemu_make_target(install_cmd)
+ if target:
+ parts += ["--erplibre-make", target]
extra = 0
if branch:
# ERPLibre dépasse le minimum : +5 Go de disque.
@@ -5754,6 +8130,11 @@ class TODO:
# GNOME et ses dépendances pèsent autant qu'ERPLibre : sans cette
# marge, le disque se remplit en pleine installation du bureau.
extra += self.QEMU_DESKTOP_EXTRA_DISK_GB
+ # Les IDE pèsent plus lourd que tout le reste : PyCharm et Android
+ # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté
+ # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un
+ # disque plein après une heure.
+ extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d)
if extra:
bigger = self._parse_disk_gb(disk) + extra
parts += ["--disk-size", f"{bigger}G"]
@@ -5782,7 +8163,17 @@ class TODO:
locale=spec.get("locale"),
# Le type suit la VM. Repli sur la valeur de spec pour la CLI,
# qui ne pose la question qu'une fois pour tout le parc.
- desktop=bool(vm.get("desktop", spec.get("desktop"))),
+ #
+ # La SAVEUR, et non un booléen : les extensions GNOME n'ont pas de
+ # sens sous Cinnamon, et c'est ici que se calcule la place disque
+ # des outils. « --desktop » ne regarde que la vérité de la valeur,
+ # une chaîne non vide lui va aussi bien.
+ desktop=vm.get("desktop", spec.get("desktop")) or "",
+ # Les deux servent au guide de connexion : où ERPLibre sera posé, et
+ # quelle cible make le remettra à jour.
+ prod=bool(install and install.get("prod")),
+ install_cmd=(install or {}).get("cmd") or "",
+ vm_tools=spec.get("vm_tools") or (),
)
# ---------------------------------------------------------------- #
@@ -5794,11 +8185,13 @@ class TODO:
« all » = uniquement celles que la distro publie réellement."""
if arch != "all":
return [arch]
+ # Même source que _qemu_arch_distros : « all » ne doit jamais offrir
+ # une combinaison que deploy_qemu.py refusera.
out = ["amd64"]
- if distro in self._QEMU_ARM64_DISTROS:
- out.append("arm64")
- if distro in self._QEMU_S390X_DISTROS:
- out.append("s390x")
+ for a in ("arm64", "s390x"):
+ supported = self._qemu_arch_distros(a)
+ if supported and distro in supported:
+ out.append(a)
return out
def _qemu_catalog_entries(self, mod, distros, arch):
@@ -6024,6 +8417,23 @@ class TODO:
self._qemu_host_timezone()
),
"snap_distros": self.QEMU_SNAP_DISTROS,
+ "vm_tools": self._qemu_vm_tool_choices(),
+ "vm_tool_disk": {
+ k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items()
+ },
+ "vm_tool_arches": {
+ k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items()
+ },
+ "vm_tool_desktops": {
+ k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items()
+ },
+ "vm_tool_needs_desktop": {
+ k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items()
+ },
+ "vm_tool_families": {
+ k: v["families"] for k, v in self._QEMU_VM_TOOLS.items()
+ },
+ "distro_family": dict(self._QEMU_DISTRO_FAMILY),
"desktop_suffixes": self._qemu_desktop_suffixes(),
"desktops": [
(k, v["label"]) for k, v in self._QEMU_DESKTOP.items()
@@ -6339,6 +8749,60 @@ class TODO:
return self.QEMU_APP_STORES[int(answer) - 1][0]
return "deb"
+ def _qemu_ask_vm_tools(self, vms):
+ """Outils de développement des VM graphiques : liste à cocher.
+
+ Ne montre que ce qu'au moins une VM du parc peut recevoir : les IDE
+ graphiques disparaissent d'un parc de serveurs, où ils n'auraient rien
+ pour s'afficher, et la compilation mobile reste offerte — elle compile,
+ elle n'affiche pas. La réponse vaut pour tout le parc et sera filtrée
+ machine par machine.
+
+ Saisie par numéros séparés par des espaces ou des virgules, « tous »
+ pour tout cocher, vide pour rien : quatre questions oui/non de plus
+ alourdiraient une séquence d'invites déjà longue."""
+ choices = [
+ c
+ for c in self._qemu_vm_tool_choices()
+ if any(
+ self._qemu_tools_for(
+ (c[0],),
+ vm.get("arch", "amd64"),
+ vm.get("desktop", ""),
+ vm.get("distro", ""),
+ )
+ for vm in vms
+ )
+ ]
+ if not choices:
+ return ()
+ print(f"\n{t('Development tools:')}")
+ for i, (_key, label, hint) in enumerate(choices, 1):
+ print(f" [{i}] {label} — {hint}")
+ gb = ", ".join(
+ f"{label} +{self._QEMU_VM_TOOLS[key]['disk_gb']} Go"
+ for key, label, _hint in choices
+ )
+ # Le mobile fait échouer la VM quand l'application ne compile pas :
+ # c'est le but, mais il vaut mieux le savoir avant de cocher.
+ if any(k == "mobile" for k, _l, _h in choices):
+ print(f" ⚠ {t('a failed mobile build marks the VM as failed')}")
+ print(f" {t('Disk needed:')} {gb}")
+ answer = input(
+ f"{t('Numbers separated by spaces, [all], blank = none:')} "
+ ).strip()
+ if not answer:
+ return ()
+ if answer.lower() in ("all", "tous", "toutes", "*"):
+ return tuple(key for key, _l, _h in choices)
+ picked = []
+ for token in answer.replace(",", " ").split():
+ if token.isdigit() and 1 <= int(token) <= len(choices):
+ key = choices[int(token) - 1][0]
+ if key not in picked:
+ picked.append(key)
+ return tuple(picked)
+
def _qemu_ask_python_provider(self, arches):
"""mise (CPython précompilé) ou pyenv (compilation).
@@ -6435,6 +8899,7 @@ class TODO:
_vm.setdefault("desktop", desktop)
_vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes)
app_store = self._qemu_ask_app_store(vms)
+ vm_tools = self._qemu_ask_vm_tools(vms)
python_provider = self._qemu_ask_python_provider(
[vm["arch"] for vm in vms]
)
@@ -6515,6 +8980,7 @@ class TODO:
"timezone": timezone,
"locale": locale,
"desktop": desktop,
+ "vm_tools": vm_tools,
"python_provider": python_provider,
"app_store": app_store,
"install": install,
@@ -6598,6 +9064,9 @@ class TODO:
desktop = next((d for d in desktop_map.values() if d), "")
python_provider = spec.get("python_provider") or ""
app_store = spec.get("app_store") or "deb"
+ # Outils de développement : cochés une fois pour tout le parc, puis
+ # filtrés machine par machine (architecture, saveur de bureau).
+ vm_tools = tuple(spec.get("vm_tools") or ())
# Branche par VM : « » sur une VM veut dire « celle du formulaire ».
branch_map = {
vm["name"]: (vm.get("branch") or install_branch or "")
@@ -6689,6 +9158,7 @@ class TODO:
desktop=desktop_map,
python_provider=python_provider,
app_store=app_store,
+ vm_tools=vm_tools,
)
else:
print(
@@ -6706,6 +9176,7 @@ class TODO:
desktop=desktop_map.get(name, ""),
python_provider=python_provider,
app_store=app_store,
+ vm_tools=vm_tools,
)
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index 6a5118a..daf155c 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -2244,10 +2244,193 @@ TRANSLATIONS = {
"fr": "À lancer sur VOTRE poste :",
"en": "Run this on YOUR workstation:",
},
+ "Graphical console (virt-viewer, built-in tunnel)": {
+ "fr": "Console graphique (virt-viewer, tunnel intégré)",
+ "en": "Graphical console (virt-viewer, built-in tunnel)",
+ },
+ "virt-viewer is missing here; installing it.": {
+ "fr": "virt-viewer manque ici ; installation.",
+ "en": "virt-viewer is missing here; installing it.",
+ },
+ "no known package manager here.": {
+ "fr": "aucun gestionnaire de paquets connu ici.",
+ "en": "no known package manager here.",
+ },
+ "virt-viewer still missing after the install.": {
+ "fr": "virt-viewer manque toujours après l'installation.",
+ "en": "virt-viewer still missing after the install.",
+ },
+ "Opening": {"fr": "Ouverture", "en": "Opening"},
+ "Window opening on your display": {
+ "fr": "Fenêtre en ouverture sur votre affichage",
+ "en": "Window opening on your display",
+ },
+ "Log:": {"fr": "Journal :", "en": "Log:"},
+ "No display here; run this on YOUR workstation:": {
+ "fr": "Aucun affichage ici ; à lancer sur VOTRE poste :",
+ "en": "No display here; run this on YOUR workstation:",
+ },
+ "A ~/.ssh/config alias works there too.": {
+ "fr": "Un alias de ~/.ssh/config y fonctionne aussi.",
+ "en": "A ~/.ssh/config alias works there too.",
+ },
+ "It builds its own tunnel; no ssh -L to keep open.": {
+ "fr": "Il monte son propre tunnel ; aucun ssh -L à tenir ouvert.",
+ "en": "It builds its own tunnel; no ssh -L to keep open.",
+ },
+ "Missing? Install virt-viewer:": {
+ "fr": "Absent ? Installez virt-viewer :",
+ "en": "Missing? Install virt-viewer:",
+ },
+ "repo transfer into the app": {
+ "fr": "transfert des dépôts dans l'application",
+ "en": "repo transfer into the app",
+ },
+ "graphical session started": {
+ "fr": "session graphique démarrée",
+ "en": "graphical session started",
+ },
+ "graphical session not started; reboot the VM": {
+ "fr": "session graphique non démarrée ; redémarrer la VM",
+ "en": "graphical session not started; reboot the VM",
+ },
+ "Hypervisor console (QEMU screen, no guest server)": {
+ "fr": "Console de l'hyperviseur (écran QEMU, rien dans l'invitée)",
+ "en": "Hypervisor console (QEMU screen, no guest server)",
+ },
+ "then point your VNC client at": {
+ "fr": "puis pointez votre client VNC sur",
+ "en": "then point your VNC client at",
+ },
+ "Target is the hypervisor": {
+ "fr": "La cible est l'hyperviseur",
+ "en": "Target is the hypervisor",
+ },
+ "not the VM: the socket is QEMU-side.": {
+ "fr": "et non la VM : le socket est du côté de QEMU.",
+ "en": "not the VM: the socket is QEMU-side.",
+ },
+ "This VM exposes no VNC port.": {
+ "fr": "Cette VM n'expose aucun port VNC.",
+ "en": "This VM exposes no VNC port.",
+ },
+ "Its display is likely spice with listen=none:": {
+ "fr": "Son affichage est probablement spice avec listen=none :",
+ "en": "Its display is likely spice with listen=none:",
+ },
+ "To open it on the loopback (VM restart required):": {
+ "fr": "Pour l'ouvrir sur la boucle locale (redémarrage de la VM"
+ " requis) :",
+ "en": "To open it on the loopback (VM restart required):",
+ },
+ "New VMs get this by default; see deploy_qemu.": {
+ "fr": "Les VM neuves l'ont par défaut ; voir deploy_qemu.",
+ "en": "New VMs get this by default; see deploy_qemu.",
+ },
+ "No ProxyJump for this host in ~/.ssh/config.": {
+ "fr": "Aucun ProxyJump pour cet hôte dans ~/.ssh/config.",
+ "en": "No ProxyJump for this host in ~/.ssh/config.",
+ },
+ "Cannot tell which machine runs its QEMU.": {
+ "fr": "Impossible de savoir quelle machine fait tourner son QEMU.",
+ "en": "Cannot tell which machine runs its QEMU.",
+ },
"(through the ProxyJump already in ~/.ssh/config)": {
"fr": "(par le ProxyJump déjà dans ~/.ssh/config)",
"en": "(through the ProxyJump already in ~/.ssh/config)",
},
+ "(the hypervisor only relays; -J puts the VM last)": {
+ "fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)",
+ "en": "(the hypervisor only relays; -J puts the VM last)",
+ },
+ "Choice": {"fr": "Choix", "en": "Choice"},
+ "failed steps": {"fr": "étapes en échec", "en": "failed steps"},
+ "Failed steps": {"fr": "Étapes en échec", "en": "Failed steps"},
+ "Hard signals": {"fr": "Signaux durs", "en": "Hard signals"},
+ "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"},
+ "line": {"fr": "ligne", "en": "line"},
+ "free space": {"fr": "libre", "en": "free"},
+ "silent": {"fr": "silence", "en": "silent"},
+ "needs the ERPLibre install, skipped:": {
+ "fr": "exige l'installation ERPLibre, écarté :",
+ "en": "needs the ERPLibre install, skipped:",
+ },
+ "Forgejo (git forge)": {
+ "fr": "Forgejo (forge git)",
+ "en": "Forgejo (git forge)",
+ },
+ "self-hosted git forge on :3000, SQLite": {
+ "fr": "forge git auto-hébergée sur :3000, SQLite",
+ "en": "self-hosted git forge on :3000, SQLite",
+ },
+ "no project yet, second try on the warm caches": {
+ "fr": "pas de projet encore ; seconde tentative sur les caches chauds",
+ "en": "no project yet, second try on the warm caches",
+ },
+ "already there, download skipped": {
+ "fr": "déjà présent, téléchargement évité",
+ "en": "already there, download skipped",
+ },
+ "inotify watches raised for the IDE": {
+ "fr": "watches inotify relevées pour l'IDE",
+ "en": "inotify watches raised for the IDE",
+ },
+ "closing what survived the first open:": {
+ "fr": "fermeture de ce qui a survécu à la première ouverture :",
+ "en": "closing what survived the first open:",
+ },
+ "Gradle daemon killed: out of memory": {
+ "fr": "démon Gradle tué : mémoire épuisée",
+ "en": "Gradle daemon killed: out of memory",
+ },
+ "out of memory": {"fr": "mémoire épuisée", "en": "out of memory"},
+ "Gradle heap too small": {
+ "fr": "tas Gradle trop petit",
+ "en": "Gradle heap too small",
+ },
+ "too many asset files for one APK (ZIP limit: 65535 entries)": {
+ "fr": "trop de fichiers d'assets pour un APK (limite ZIP : 65535"
+ " entrées)",
+ "en": "too many asset files for one APK (ZIP limit: 65535 entries)",
+ },
+ "4 GB of swap added for the build": {
+ "fr": "4 Go de swap ajoutés pour la compilation",
+ "en": "4 GB of swap added for the build",
+ },
+ "no swap could be added; build may run short": {
+ "fr": "swap impossible à ajouter ; la compilation peut manquer de mémoire",
+ "en": "no swap could be added; build may run short",
+ },
+ "memory:": {"fr": "mémoire :", "en": "memory:"},
+ "manifest repos not bundled (ZIP entry limit)": {
+ "fr": "dépôts du manifeste non empaquetés (limite d'entrées ZIP)",
+ "en": "manifest repos not bundled (ZIP entry limit)",
+ },
+ "MB RAM,": {"fr": "Mo de RAM,", "en": "MB RAM,"},
+ "MB swap, kernel OOM kills:": {
+ "fr": "Mo de swap, tués par le noyau (OOM) :",
+ "en": "MB swap, kernel OOM kills:",
+ },
+ "Tick the Android emulator tool when deploying.": {
+ "fr": "Cochez l'outil Émulateur Android au déploiement.",
+ "en": "Tick the Android emulator tool when deploying.",
+ },
+ "Cannot reach this VM.": {
+ "fr": "VM injoignable.",
+ "en": "Cannot reach this VM.",
+ },
+ "No Android SDK in this VM: no emulator binary.": {
+ "fr": "Pas de SDK Android dans cette VM : aucun binaire emulator.",
+ "en": "No Android SDK in this VM: no emulator binary.",
+ },
+ "No AVD named erplibre in this VM.": {
+ "fr": "Aucun AVD nommé erplibre dans cette VM.",
+ "en": "No AVD named erplibre in this VM.",
+ },
+ "It did not start; the VM log says:": {
+ "fr": "Il n'a pas démarré ; le journal de la VM dit :",
+ "en": "It did not start; the VM log says:",
+ },
"No ~/.ssh/config entry; see SSH configuration.": {
"fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.",
"en": "No ~/.ssh/config entry; see SSH configuration.",
@@ -2860,6 +3043,90 @@ TRANSLATIONS = {
"fr": "VM à modifier (séparées par des virgules) : ",
"en": "VMs to change (comma-separated): ",
},
+ "Adjust hardware only (vCPU, RAM, 3D)": {
+ "fr": "Régler le matériel seulement (vCPU, RAM, 3D)",
+ "en": "Adjust hardware only (vCPU, RAM, 3D)",
+ },
+ "Adjust hardware before starting? (y/N): ": {
+ "fr": "Régler le matériel avant de démarrer ? (o/N) : ",
+ "en": "Adjust hardware before starting? (y/N): ",
+ },
+ "Not shut off, hardware left untouched:": {
+ "fr": "Pas éteintes, matériel inchangé :",
+ "en": "Not shut off, hardware left untouched:",
+ },
+ "Host GPU:": {
+ "fr": "GPU de l'hôte :",
+ "en": "Host GPU:",
+ },
+ "none (software rendering)": {
+ "fr": "aucun (rendu logiciel)",
+ "en": "none (software rendering)",
+ },
+ "software rendering": {
+ "fr": "rendu logiciel",
+ "en": "software rendering",
+ },
+ "Unreadable VM definition.": {
+ "fr": "Définition de VM illisible.",
+ "en": "Unreadable VM definition.",
+ },
+ "Nothing to change.": {
+ "fr": "Rien à changer.",
+ "en": "Nothing to change.",
+ },
+ "Changes:": {
+ "fr": "Changements :",
+ "en": "Changes:",
+ },
+ "Apply these changes? (y/N): ": {
+ "fr": "Appliquer ces changements ? (o/N) : ",
+ "en": "Apply these changes? (y/N): ",
+ },
+ "Autostart": {
+ "fr": "Démarrage automatique",
+ "en": "Autostart",
+ },
+ "VM hardware": {
+ "fr": "Matériel des VM",
+ "en": "VM hardware",
+ },
+ "3D": {
+ "fr": "3D",
+ "en": "3D",
+ },
+ "3D acceleration (host GPU)": {
+ "fr": "Accélération 3D (GPU de l'hôte)",
+ "en": "3D acceleration (host GPU)",
+ },
+ "Render node": {
+ "fr": "Nœud de rendu",
+ "en": "Render node",
+ },
+ "Screens": {
+ "fr": "Écrans",
+ "en": "Screens",
+ },
+ "bridge": {
+ "fr": "pont",
+ "en": "bridge",
+ },
+ "this VM has no interface": {
+ "fr": "cette VM n'a pas d'interface réseau",
+ "en": "this VM has no interface",
+ },
+ "CPU mode": {
+ "fr": "Mode CPU",
+ "en": "CPU mode",
+ },
+ "no render node on the host": {
+ "fr": "aucun nœud de rendu sur l'hôte",
+ "en": "no render node on the host",
+ },
+ "this VM has no virtual screen": {
+ "fr": "cette VM n'a pas d'écran virtuel",
+ "en": "this VM has no virtual screen",
+ },
"Unknown VM(s):": {
"fr": "VM inconnue(s) :",
"en": "Unknown VM(s):",
@@ -6191,6 +6458,10 @@ TRANSLATIONS = {
"fr": "vue(s) portent encore une balise qu'Odoo 18",
"en": "view(s) still carry a tag Odoo 18",
},
+ "failed at": {
+ "fr": "a échoué sur",
+ "en": "failed at",
+ },
"Census": {
"fr": "Recensement",
"en": "Census",
@@ -7553,6 +7824,423 @@ TRANSLATIONS = {
"en": "no recipient: nothing was sent",
},
"mail_err_send_refused": {"fr": "envoi refusé :", "en": "send refused:"},
+ # QEMU — outils de développement des VM graphiques
+ "Python IDE, ERPLibre project pre-configured": {
+ "fr": "IDE Python, projet ERPLibre pré-configuré",
+ "en": "Python IDE, ERPLibre project pre-configured",
+ },
+ "ERPLibre mobile development (x86_64 only)": {
+ "fr": "développement mobile ERPLibre (x86_64 seulement)",
+ "en": "ERPLibre mobile development (x86_64 only)",
+ },
+ "GNOME extensions": {
+ "fr": "Extensions GNOME",
+ "en": "GNOME extensions",
+ },
+ "suggested extensions + extension manager": {
+ "fr": "extensions suggérées + gestionnaire d'extensions",
+ "en": "suggested extensions + extension manager",
+ },
+ "Development tools for the graphical VMs:": {
+ "fr": "Outils de développement des VM graphiques :",
+ "en": "Development tools for the graphical VMs:",
+ },
+ "Development tools (graphical VMs):": {
+ "fr": "Outils de développement (VM graphiques) :",
+ "en": "Development tools (graphical VMs):",
+ },
+ "Development tools:": {
+ "fr": "Outils de développement :",
+ "en": "Development tools:",
+ },
+ "Disk needed:": {"fr": "Disque nécessaire :", "en": "Disk needed:"},
+ "Numbers separated by spaces, [all], blank = none:": {
+ "fr": "Numéros séparés par des espaces, [tous], vide = aucun :",
+ "en": "Numbers separated by spaces, [all], blank = none:",
+ },
+ "No graphical VM: these tools are not installed.": {
+ "fr": "Aucune VM graphique : ces outils ne seront pas installés.",
+ "en": "No graphical VM: these tools are not installed.",
+ },
+ "Partly skipped (arch or desktop):": {
+ "fr": "En partie écartés (architecture ou bureau) :",
+ "en": "Partly skipped (arch or desktop):",
+ },
+ "Installing PyCharm (long)": {
+ "fr": "Installation de PyCharm (long)",
+ "en": "Installing PyCharm (long)",
+ },
+ "no JetBrains build for": {
+ "fr": "aucune archive JetBrains pour",
+ "en": "no JetBrains build for",
+ },
+ "PyCharm: project config skipped": {
+ "fr": "PyCharm : configuration du projet non écrite",
+ "en": "PyCharm: project config skipped",
+ },
+ "PyCharm installed:": {
+ "fr": "PyCharm installé :",
+ "en": "PyCharm installed:",
+ },
+ "PyCharm not installed (see above)": {
+ "fr": "PyCharm non installé (voir ci-dessus)",
+ "en": "PyCharm not installed (see above)",
+ },
+ "Installing Android Studio (long)": {
+ "fr": "Installation d'Android Studio (long)",
+ "en": "Installing Android Studio (long)",
+ },
+ "Android Studio: Google publishes x86_64 only": {
+ "fr": "Android Studio : Google ne publie que du x86_64",
+ "en": "Android Studio: Google publishes x86_64 only",
+ },
+ "SDK shared through ANDROID_HOME:": {
+ "fr": "SDK partagé via ANDROID_HOME :",
+ "en": "SDK shared through ANDROID_HOME:",
+ },
+ "Android Studio installed:": {
+ "fr": "Android Studio installé :",
+ "en": "Android Studio installed:",
+ },
+ "Android Studio not installed (see above)": {
+ "fr": "Android Studio non installé (voir ci-dessus)",
+ "en": "Android Studio not installed (see above)",
+ },
+ "no nested KVM: the emulator will not run": {
+ "fr": "pas de KVM imbriqué : l'émulateur ne démarrera pas",
+ "en": "no nested KVM: the emulator will not run",
+ },
+ "Suggested GNOME extensions": {
+ "fr": "Extensions GNOME suggérées",
+ "en": "Suggested GNOME extensions",
+ },
+ "not in the repos, skipped:": {
+ "fr": "absent des dépôts, ignoré :",
+ "en": "not in the repos, skipped:",
+ },
+ "Enable them from Extension Manager, or:": {
+ "fr": "À activer depuis le gestionnaire d'extensions, ou :",
+ "en": "Enable them from Extension Manager, or:",
+ },
+ "GNOME Shell not found, site extensions skipped": {
+ "fr": "GNOME Shell introuvable, extensions du site ignorées",
+ "en": "GNOME Shell not found, site extensions skipped",
+ },
+ "installed and enabled:": {
+ "fr": "installée et activée :",
+ "en": "installed and enabled:",
+ },
+ "not available for this GNOME, skipped:": {
+ "fr": "indisponible pour ce GNOME, ignorée :",
+ "en": "not available for this GNOME, skipped:",
+ },
+ "log out and back in to load them": {
+ "fr": "se déconnecter puis se reconnecter pour les charger",
+ "en": "log out and back in to load them",
+ },
+ "open the project once and close PyCharm; the .idea it writes is what the install configures": {
+ "fr": "ouvrir le projet une fois puis fermer PyCharm : le .idea qu'il écrit est ce que l'installation configure",
+ "en": "open the project once and close PyCharm; the .idea it writes is what the install configures",
+ },
+ "command": {"fr": "commande", "en": "command"},
+ "release feed unreachable: unified build, it will ask for a JetBrains account": {
+ "fr": "flux des versions injoignable : build unifié, il demandera un compte JetBrains",
+ "en": "release feed unreachable: unified build, it will ask for a JetBrains account",
+ },
+ "Creating the PyCharm project (first open)": {
+ "fr": "Création du projet PyCharm (première ouverture)",
+ "en": "Creating the PyCharm project (first open)",
+ },
+ "PyCharm missing, step skipped": {
+ "fr": "PyCharm absent, étape sautée",
+ "en": "PyCharm missing, step skipped",
+ },
+ "no Xvfb here, open PyCharm by hand": {
+ "fr": "pas de Xvfb ici : ouvrir PyCharm à la main",
+ "en": "no Xvfb here, open PyCharm by hand",
+ },
+ "ERPLibre mobile (build)": {
+ "fr": "ERPLibre mobile (compilation)",
+ "en": "ERPLibre mobile (build)",
+ },
+ "APK debug + Vitest, validates the VM": {
+ "fr": "APK debug + Vitest, valide la VM",
+ "en": "APK debug + Vitest, validates the VM",
+ },
+ "Building ERPLibre mobile (long)": {
+ "fr": "Compilation d'ERPLibre mobile (long)",
+ "en": "Building ERPLibre mobile (long)",
+ },
+ "detailed log in the VM:": {
+ "fr": "journal détaillé dans la VM :",
+ "en": "detailed log in the VM:",
+ },
+ "FAILED:": {"fr": "ÉCHEC :", "en": "FAILED:"},
+ "probable cause:": {"fr": "cause probable :", "en": "probable cause:"},
+ "no known pattern, last lines:": {
+ "fr": "aucun motif connu, dernières lignes :",
+ "en": "no known pattern, last lines:",
+ },
+ "a failed mobile build marks the VM as failed": {
+ "fr": "une compilation mobile en échec marque la VM en échec",
+ "en": "a failed mobile build marks the VM as failed",
+ },
+ "ERPLibre venv (everything below needs it)": {
+ "fr": "venv ERPLibre (tout ce qui suit en dépend)",
+ "en": "ERPLibre venv (everything below needs it)",
+ },
+ "mobile repository (additive manifest)": {
+ "fr": "dépôt mobile (manifeste additif)",
+ "en": "mobile repository (additive manifest)",
+ },
+ "prerequisites of the upstream installer": {
+ "fr": "prérequis de l'installateur amont",
+ "en": "prerequisites of the upstream installer",
+ },
+ "Android SDK, licences, NDK": {
+ "fr": "SDK Android, licences, NDK",
+ "en": "Android SDK, licences, NDK",
+ },
+ "SDK platform required by the project": {
+ "fr": "plateforme SDK réclamée par le projet",
+ "en": "SDK platform required by the project",
+ },
+ "npm dependencies": {
+ "fr": "dépendances npm",
+ "en": "npm dependencies",
+ },
+ "web bundle (vite build)": {
+ "fr": "bundle web (vite build)",
+ "en": "web bundle (vite build)",
+ },
+ "native sync (capacitor)": {
+ "fr": "synchronisation native (capacitor)",
+ "en": "native sync (capacitor)",
+ },
+ "debug APK (gradle)": {
+ "fr": "APK debug (gradle)",
+ "en": "debug APK (gradle)",
+ },
+ "Vitest tests": {"fr": "tests Vitest", "en": "Vitest tests"},
+ "APK built:": {"fr": "APK construit :", "en": "APK built:"},
+ "browser debugging (no Android):": {
+ "fr": "déverminage au navigateur (sans Android) :",
+ "en": "browser debugging (no Android):",
+ },
+ "no APK produced": {
+ "fr": "aucun APK produit",
+ "en": "no APK produced",
+ },
+ "disk full": {"fr": "disque plein", "en": "disk full"},
+ "SDK platform missing": {
+ "fr": "plateforme SDK absente",
+ "en": "SDK platform missing",
+ },
+ "SDK not found (ANDROID_HOME)": {
+ "fr": "SDK introuvable (ANDROID_HOME)",
+ "en": "SDK not found (ANDROID_HOME)",
+ },
+ "SDK licences not accepted": {
+ "fr": "licences SDK non acceptées",
+ "en": "SDK licences not accepted",
+ },
+ "NDK missing": {"fr": "NDK absent", "en": "NDK missing"},
+ "JDK required by the project missing": {
+ "fr": "JDK réclamé par le projet absent",
+ "en": "JDK required by the project missing",
+ },
+ "Gradle running on too old a JDK": {
+ "fr": "Gradle tourne sur un JDK trop ancien",
+ "en": "Gradle running on too old a JDK",
+ },
+ "SDK already there (upstream installer replays)": {
+ "fr": "SDK déjà là (l'installateur amont se rejoue)",
+ "en": "SDK already there (upstream installer replays)",
+ },
+ "cross-compiled protoc run on the host": {
+ "fr": "protoc compilé pour la cible, exécuté sur l'hôte",
+ "en": "cross-compiled protoc run on the host",
+ },
+ "JDK/Gradle mismatch": {
+ "fr": "JDK et Gradle incompatibles",
+ "en": "JDK/Gradle mismatch",
+ },
+ "Gradle dependency unreachable (network?)": {
+ "fr": "dépendance Gradle injoignable (réseau ?)",
+ "en": "Gradle dependency unreachable (network?)",
+ },
+ "Vitest tests failed": {
+ "fr": "tests Vitest en échec",
+ "en": "Vitest tests failed",
+ },
+ "ERPLibre venv incomplete (no pip: python3-venv)": {
+ "fr": "venv ERPLibre incomplet (pas de pip : python3-venv)",
+ "en": "ERPLibre venv incomplete (no pip: python3-venv)",
+ },
+ "mobile repository missing": {
+ "fr": "dépôt mobile absent",
+ "en": "mobile repository missing",
+ },
+ "ERPLibre venv missing (incomplete install)": {
+ "fr": "venv ERPLibre absent (installation incomplète)",
+ "en": "ERPLibre venv missing (incomplete install)",
+ },
+ "smoother, without X11:": {
+ "fr": "plus fluide, sans X11 :",
+ "en": "smoother, without X11:",
+ },
+ "Android emulator": {"fr": "Émulateur Android", "en": "Android emulator"},
+ "Android emulator (start, tunnel, scrcpy)": {
+ "fr": "Émulateur Android (démarrer, tunnel, scrcpy)",
+ "en": "Android emulator (start, tunnel, scrcpy)",
+ },
+ "An emulator is already running on this VM.": {
+ "fr": "Un émulateur tourne déjà sur cette VM.",
+ "en": "An emulator is already running on this VM.",
+ },
+ "Only one per AVD; close it first:": {
+ "fr": "Un seul par AVD ; le fermer d'abord :",
+ "en": "Only one per AVD; close it first:",
+ },
+ "Close it now? (y/N): ": {
+ "fr": "Le fermer maintenant ? (o/N) : ",
+ "en": "Close it now? (y/N): ",
+ },
+ "Closed.": {"fr": "Fermé.", "en": "Closed."},
+ "Show a window?": {
+ "fr": "Afficher une fenêtre ?",
+ "en": "Show a window?",
+ },
+ "No window - stream with scrcpy (smoother)": {
+ "fr": "Sans fenêtre — flux par scrcpy (plus fluide)",
+ "en": "No window - stream with scrcpy (smoother)",
+ },
+ "Window over ssh -X (raw pixels, slower)": {
+ "fr": "Fenêtre par ssh -X (pixels bruts, plus lent)",
+ "en": "Window over ssh -X (raw pixels, slower)",
+ },
+ "X11 compression is on (-XC); the screen is 540x1140.": {
+ "fr": "La compression X11 est active (-XC) ; l'écran fait 540x1140.",
+ "en": "X11 compression is on (-XC); the screen is 540x1140.",
+ },
+ "Starting the emulator without a window...": {
+ "fr": "Démarrage de l'émulateur sans fenêtre…",
+ "en": "Starting the emulator without a window...",
+ },
+ "Could not start it:": {
+ "fr": "Démarrage impossible :",
+ "en": "Could not start it:",
+ },
+ "Started. Boot takes about a minute; log in the VM:": {
+ "fr": "Démarré. L'amorçage prend environ une minute ; journal dans la VM :",
+ "en": "Started. Boot takes about a minute; log in the VM:",
+ },
+ "1. Emulator started, without a window.": {
+ "fr": "1. Émulateur démarré, sans fenêtre.",
+ "en": "1. Emulator started, without a window.",
+ },
+ "If scrcpy will run on THIS machine, I can open it.": {
+ "fr": "Si scrcpy tournera sur CETTE machine, je peux l'ouvrir.",
+ "en": "If scrcpy will run on THIS machine, I can open it.",
+ },
+ "Open the tunnel now? (y/N): ": {
+ "fr": "Ouvrir le tunnel maintenant ? (o/N) : ",
+ "en": "Open the tunnel now? (y/N): ",
+ },
+ "Port already in use here:": {
+ "fr": "Port déjà occupé ici :",
+ "en": "Port already in use here:",
+ },
+ "Close the other tunnel first:": {
+ "fr": "Fermer l'autre tunnel d'abord :",
+ "en": "Close the other tunnel first:",
+ },
+ "Tunnel failed:": {"fr": "Tunnel en échec :", "en": "Tunnel failed:"},
+ "Tunnel open on localhost:": {
+ "fr": "Tunnel ouvert sur localhost:",
+ "en": "Tunnel open on localhost:",
+ },
+ "Then:": {"fr": "Puis :", "en": "Then:"},
+ "To close it:": {"fr": "Pour le fermer :", "en": "To close it:"},
+ "Android emulator (adb 5555, then scrcpy)": {
+ "fr": "Émulateur Android (adb 5555, puis scrcpy)",
+ "en": "Android emulator (adb 5555, then scrcpy)",
+ },
+ "Android emulator over adb + scrcpy": {
+ "fr": "Émulateur Android par adb + scrcpy",
+ "en": "Android emulator over adb + scrcpy",
+ },
+ "1. In the VM, start the emulator WITHOUT a window:": {
+ "fr": "1. Dans la VM, démarrer l'émulateur SANS fenêtre :",
+ "en": "1. In the VM, start the emulator WITHOUT a window:",
+ },
+ "2. Open the tunnel from YOUR workstation:": {
+ "fr": "2. Ouvrir le tunnel depuis VOTRE poste :",
+ "en": "2. Open the tunnel from YOUR workstation:",
+ },
+ "3. Then, still on your workstation:": {
+ "fr": "3. Puis, toujours sur votre poste :",
+ "en": "3. Then, still on your workstation:",
+ },
+ "scrcpy on Debian/Ubuntu:": {
+ "fr": "scrcpy sur Debian/Ubuntu :",
+ "en": "scrcpy on Debian/Ubuntu:",
+ },
+ "Android emulator (Pixel)": {
+ "fr": "Émulateur Android (Pixel)",
+ "en": "Android emulator (Pixel)",
+ },
+ "AVD viewable over ssh -X": {
+ "fr": "AVD visible par ssh -X",
+ "en": "AVD viewable over ssh -X",
+ },
+ "Android emulator (AVD)": {
+ "fr": "Émulateur Android (AVD)",
+ "en": "Android emulator (AVD)",
+ },
+ "no /dev/kvm: nested virtualisation is off on the host": {
+ "fr": "pas de /dev/kvm : virtualisation imbriquée coupée sur l'hôte",
+ "en": "no /dev/kvm: nested virtualisation is off on the host",
+ },
+ "user added to the kvm group (effective at next login)": {
+ "fr": "utilisateur ajouté au groupe kvm (effectif à la prochaine session)",
+ "en": "user added to the kvm group (effective at next login)",
+ },
+ "emulator and system image": {
+ "fr": "émulateur et image système",
+ "en": "emulator and system image",
+ },
+ "Pixel profile, smallest screen": {
+ "fr": "profil Pixel, plus petit écran",
+ "en": "Pixel profile, smallest screen",
+ },
+ "create the AVD": {"fr": "création de l'AVD", "en": "create the AVD"},
+ "AVD ready:": {"fr": "AVD prêt :", "en": "AVD ready:"},
+ "open it from your workstation:": {
+ "fr": "à ouvrir depuis votre poste :",
+ "en": "open it from your workstation:",
+ },
+ "then install the APK:": {
+ "fr": "puis installer l'APK :",
+ "en": "then install the APK:",
+ },
+ "ERPLibre mobile, Android SDK (long)": {
+ "fr": "ERPLibre mobile, SDK Android (long)",
+ "en": "ERPLibre mobile, Android SDK (long)",
+ },
+ "Gradle task failed": {
+ "fr": "tâche Gradle en échec",
+ "en": "Gradle task failed",
+ },
+ "project created, the install will configure it": {
+ "fr": "projet créé, l'installation va le configurer",
+ "en": "project created, the install will configure it",
+ },
+ "no .idea: open PyCharm once, then": {
+ "fr": "pas de .idea : ouvrir PyCharm une fois, puis",
+ "en": "no .idea: open PyCharm once, then",
+ },
+ "project": {"fr": "projet", "en": "project"},
}
diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py
new file mode 100644
index 0000000..a89e490
--- /dev/null
+++ b/test/test_mobile_bundle_transfer.py
@@ -0,0 +1,282 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Transfert des dépôts ERPLibre dans l'application mobile.
+
+L'application embarque le code des dépôts du manifeste pour le parcourir hors
+ligne. Ils y entrent en PACKS, et c'est ce qui rend la chose possible : un APK
+est un ZIP borné à 65535 entrées, quand les 139 dépôts pèsent plus de 116 000
+fichiers. Un fichier par source donnait « Too many zip entries 123678
+(MAX=65535) » — la compilation s'arrêtait là, et l'application ne portait rien.
+
+Regroupés en tranches de 4 Mo, ces fichiers tiennent en 391 entrées. Mesuré sur
+la VM : 3 002 entrées dans l'APK, 282 Mo, et 20 fichiers relus depuis les packs
+identiques octet pour octet à leur source.
+
+Ce que ces tests vérifient : qu'un transfert vide, tronqué ou incohérent est
+DIT, et non pris pour bon. Les trois pannes correspondantes ont chacune leur
+fixture.
+"""
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+from script.mobile import check_bundle_transfer as cbt # noqa: E402
+
+# Contenu des sources factices : le nom du fichier -> ses octets.
+SOURCES = {
+ "odoo/release.py": b"version_info = (18, 0)\n",
+ "odoo/api.py": b"def method():\n return 1\n",
+ "addons/sale/i18n/fr.po": b'msgid "x"\nmsgstr "y"\n',
+ "README.md": b"# ERPLibre\n",
+}
+
+
+def build_bundle(
+ tmp: Path,
+ sources=None,
+ *,
+ with_workspace=True,
+ break_pack=False,
+ drop_pack=False,
+ drop_index=False,
+ no_manifest=False,
+):
+ """Fabrique un faux bundle, et la source qui va avec.
+
+ Les avaries sont paramétrées plutôt que codées en dur : chaque test nomme
+ celle qu'il éprouve, et la fixture reste unique."""
+ sources = SOURCES if sources is None else sources
+ mobile = tmp / "mobile" / "erplibre_home_mobile"
+ repos = mobile / "dist" / "repos"
+ slug = "github-com-ERPLibre-odoo"
+ repo_dir = repos / slug
+ repo_dir.mkdir(parents=True)
+ if not no_manifest:
+ (repos / "manifest.json").write_text(
+ json.dumps(
+ [
+ {
+ "url": "https://github.com/ERPLibre/odoo",
+ "name": "odoo",
+ "path": "odoo18.0/odoo",
+ "slug": slug,
+ "revision": "18.0",
+ }
+ ]
+ )
+ )
+ index = [{"path": "odoo", "type": "dir"}]
+ blob = b""
+ items = list(sources.items())
+ for pos, (rel, data) in enumerate(items):
+ # L'avarie ne touche que la DERNIÈRE entrée : gonfler toutes les
+ # tailles décalerait chaque lecture et ferait échouer la comparaison
+ # avant le contrôle de bornes — ce n'est pas la panne qu'on éprouve.
+ last = pos == len(items) - 1
+ index.append(
+ {
+ "path": rel,
+ "type": "file",
+ "chunk": 0,
+ "offset": len(blob),
+ "size": len(data) + (7 if (break_pack and last) else 0),
+ }
+ )
+ blob += data
+ if not drop_index:
+ (repo_dir / "index.json").write_text(json.dumps(index))
+ if not drop_pack:
+ (repo_dir / "pack-000.bin").write_bytes(blob)
+ if with_workspace:
+ for rel, data in sources.items():
+ src = tmp / "odoo18.0/odoo" / rel
+ src.parent.mkdir(parents=True, exist_ok=True)
+ src.write_bytes(data)
+ return mobile
+
+
+class TestAGoodTransfer(unittest.TestCase):
+ def test_it_counts_repos_files_and_packs(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ rep = cbt.check(mobile, tmp, min_files=1)
+ self.assertEqual(1, rep["repos"])
+ self.assertEqual(len(SOURCES), rep["files"])
+ self.assertEqual(1, rep["packs"])
+
+ def test_it_reads_the_files_back_from_the_pack(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ rep = cbt.check(mobile, tmp, min_files=1)
+ self.assertEqual(len(SOURCES), rep["checked"])
+
+ def test_it_compares_them_to_the_source(self):
+ """La seule vérification qui prouve un transfert FIDÈLE, et pas
+ seulement cohérent."""
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ rep = cbt.check(mobile, tmp, min_files=1)
+ self.assertEqual(len(SOURCES), rep["compared"])
+
+ def test_without_a_workspace_it_still_reads_the_packs(self):
+ """Hors du checkout, la comparaison n'est pas possible ; la lecture,
+ elle, l'est toujours."""
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, with_workspace=False)
+ rep = cbt.check(mobile, None, min_files=1)
+ self.assertEqual(len(SOURCES), rep["checked"])
+ self.assertEqual(0, rep["compared"])
+
+ def test_read_from_pack_returns_the_exact_bytes(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ repo_dir = mobile / "dist/repos/github-com-ERPLibre-odoo"
+ index = json.loads((repo_dir / "index.json").read_text())
+ entry = next(
+ e for e in index if e["path"] == "addons/sale/i18n/fr.po"
+ )
+ got = cbt.read_from_pack(repo_dir, entry)
+ self.assertEqual(SOURCES["addons/sale/i18n/fr.po"], got)
+
+
+class TestTheThreeFailures(unittest.TestCase):
+ """Vide, tronqué, incohérent : trois pannes qu'un « build OK » ne dit pas."""
+
+ def test_no_manifest_names_the_build(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, no_manifest=True)
+ with self.assertRaises(FileNotFoundError) as ctx:
+ cbt.check(mobile, tmp, min_files=1)
+ self.assertIn("build", str(ctx.exception))
+
+ def test_a_repo_without_index_is_named(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, drop_index=True)
+ with self.assertRaises(FileNotFoundError) as ctx:
+ cbt.check(mobile, tmp, min_files=1)
+ self.assertIn("odoo", str(ctx.exception))
+
+ def test_a_missing_pack_is_named(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, drop_pack=True)
+ with self.assertRaises(FileNotFoundError) as ctx:
+ cbt.check(mobile, tmp, min_files=1)
+ self.assertIn("pack-000.bin", str(ctx.exception))
+
+ def test_an_index_that_promises_too_much_is_refused(self):
+ """Index et pack d'une compilation différente : le message doit nommer
+ la tranche et les tailles, pas rendre un octet manquant en silence."""
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, break_pack=True)
+ with self.assertRaises(ValueError) as ctx:
+ cbt.check(mobile, tmp, min_files=1)
+ self.assertIn("pack-000.bin", str(ctx.exception))
+
+ def test_an_empty_transfer_is_refused(self):
+ """C'est le cas qui a existé pendant un temps : le bundle compilait,
+ sans un seul dépôt dedans. « Réussi » ne voulait rien dire."""
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, sources={"a.py": b"x\n"})
+ with self.assertRaises(ValueError) as ctx:
+ cbt.check(mobile, tmp) # seuil par défaut
+ self.assertIn("maigre", str(ctx.exception))
+
+ def test_a_file_that_differs_from_the_source_is_named(self):
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ (tmp / "odoo18.0/odoo/README.md").write_bytes(b"autre chose\n")
+ with self.assertRaises(ValueError) as ctx:
+ cbt.check(mobile, tmp, min_files=1)
+ self.assertIn("README.md", str(ctx.exception))
+
+
+class TestTheThreshold(unittest.TestCase):
+ def test_the_default_threshold_rules_out_an_empty_bundle(self):
+ """Le seul dépôt odoo en porte près de 40 000 : mille est un plancher
+ qu'un vrai transfert dépasse de deux ordres de grandeur."""
+ self.assertGreaterEqual(cbt.MIN_FILES, 1000)
+
+ def test_the_sample_is_deterministic(self):
+ """Une graine fixe : deux exécutions lisent les MÊMES fichiers, donc un
+ échec est reproductible."""
+ self.assertIsInstance(cbt.SEED, int)
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp)
+ first = cbt.check(mobile, tmp, min_files=1)
+ second = cbt.check(mobile, tmp, min_files=1)
+ self.assertEqual(first, second)
+
+
+class TestTheCommandLine(unittest.TestCase):
+ def test_it_says_the_counts_and_returns_zero(self):
+ import io
+ import contextlib
+
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ mobile = build_bundle(tmp, sources=SOURCES)
+ argv = [
+ "check_bundle_transfer.py",
+ str(mobile),
+ "--workspace",
+ str(tmp),
+ ]
+ buf = io.StringIO()
+ with unittest.mock.patch.object(sys, "argv", argv), mock_min(1):
+ with contextlib.redirect_stdout(buf):
+ code = cbt.main()
+ self.assertEqual(0, code)
+ self.assertIn("dépôts", buf.getvalue())
+
+ def test_a_failure_is_one_line_not_a_traceback(self):
+ """Le message part dans un journal d'installation : une trace Python y
+ serait illisible, et la cause noyée."""
+ import io
+ import contextlib
+
+ with tempfile.TemporaryDirectory() as d:
+ tmp = Path(d)
+ argv = ["check_bundle_transfer.py", str(tmp / "nulle-part")]
+ buf = io.StringIO()
+ with unittest.mock.patch.object(sys, "argv", argv):
+ with contextlib.redirect_stdout(buf):
+ code = cbt.main()
+ self.assertEqual(1, code)
+ self.assertIn("⚠", buf.getvalue())
+ self.assertNotIn("Traceback", buf.getvalue())
+
+
+import contextlib as _contextlib # noqa: E402
+import unittest.mock # noqa: E402
+
+
+@_contextlib.contextmanager
+def mock_min(value):
+ """Abaisse le plancher le temps d'un test de ligne de commande."""
+ old = cbt.MIN_FILES
+ cbt.MIN_FILES = value
+ try:
+ yield
+ finally:
+ cbt.MIN_FILES = old
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py
new file mode 100644
index 0000000..2e1e2d8
--- /dev/null
+++ b/test/test_qemu_desktop_tools.py
@@ -0,0 +1,1327 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Outils de développement des VM graphiques : filtrage, disque, commande.
+
+Ce qui se vérifie ici sans VM : qu'un outil demandé pour tout le parc n'atterrit
+que sur les machines qui peuvent le recevoir, que la place disque annoncée suit
+ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation
+d'ERPLibre avec lui — celle-ci ayant duré une heure.
+"""
+
+import pathlib
+import subprocess
+import sys
+import unittest
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+
+class TestToolFiltering(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.all = tuple(TODO._QEMU_VM_TOOLS)
+
+ def test_a_server_gets_no_graphical_tool(self):
+ """Un IDE sans bureau n'a rien pour s'afficher. La compilation mobile,
+ elle, reste : elle compile, elle n'affiche pas."""
+ got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
+ for graphical in ("pycharm", "android", "gnome_ext"):
+ self.assertNotIn(graphical, got)
+
+ def test_android_studio_is_x86_64_only(self):
+ """Google ne publie aucune archive Linux aarch64 : toutes les variantes
+ de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64.
+ """
+ self.assertIn(
+ "android",
+ self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"),
+ )
+ for arch in ("arm64", "s390x"):
+ self.assertNotIn(
+ "android",
+ self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"),
+ arch,
+ )
+
+ def test_pycharm_follows_jetbrains_two_architectures(self):
+ for arch in ("amd64", "arm64"):
+ self.assertIn(
+ "pycharm",
+ self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"),
+ arch,
+ )
+ self.assertNotIn(
+ "pycharm",
+ self.todo._qemu_tools_for(self.all, "s390x", "gnome", "ubuntu"),
+ )
+
+ def test_gnome_extensions_only_under_gnome(self):
+ self.assertIn(
+ "gnome_ext",
+ self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"),
+ )
+ self.assertNotIn(
+ "gnome_ext",
+ self.todo._qemu_tools_for(self.all, "amd64", "cinnamon", "ubuntu"),
+ )
+
+ def test_unknown_key_is_ignored(self):
+ self.assertEqual(
+ [],
+ self.todo._qemu_tools_for(("nope",), "amd64", "gnome", "ubuntu"),
+ )
+
+
+class TestToolDisk(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.all = tuple(TODO._QEMU_VM_TOOLS)
+
+ def test_disk_follows_the_filtering(self):
+ """Une VM qui ne recevra pas un outil ne doit pas se voir gonfler son
+ disque de sa taille. En arm64 il en manque DEUX : Android Studio, que
+ Google ne publie qu'en x86_64, et la compilation mobile, qui en
+ dépend."""
+ full = self.todo._qemu_tools_disk_gb(
+ self.all, "amd64", "gnome", "ubuntu"
+ )
+ arm = self.todo._qemu_tools_disk_gb(
+ self.all, "arm64", "gnome", "ubuntu"
+ )
+ self.assertEqual(
+ full - arm,
+ TODO._QEMU_VM_TOOLS["android"]["disk_gb"]
+ + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"]
+ + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"],
+ )
+
+ def test_a_server_only_pays_for_what_it_gets(self):
+ """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque —
+ mais il paie bien ce qu'il reçoit : compilation mobile, émulateur et
+ forge. La somme est calculée depuis la table plutôt qu'écrite en
+ chiffre : ajouter un outil sans écran ne doit pas casser ce test, il
+ doit le suivre."""
+ expected = sum(
+ spec["disk_gb"]
+ for key, spec in TODO._QEMU_VM_TOOLS.items()
+ if not spec["needs_desktop"]
+ )
+ self.assertEqual(
+ expected,
+ self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"),
+ )
+
+ def test_the_deploy_command_grows_the_disk(self):
+ """Le disque demandé à deploy_qemu.py doit inclure les outils : c'est
+ la seule valeur qui compte, celle du qcow2 réellement créé."""
+ spec = {
+ "ssh_key": "",
+ "desktop": "gnome",
+ "vm_tools": self.all,
+ "install": {
+ "branch": "develop",
+ "prod": False,
+ "cmd": "make install_os && make install_odoo_18",
+ "label": "x",
+ "monitor": True,
+ },
+ }
+ vm = {
+ "distro": "ubuntu",
+ "version": "24.04",
+ "arch": "amd64",
+ "name": "v",
+ "ram": 3072,
+ "vcpus": 4,
+ "disk": "20G",
+ "desktop": "gnome",
+ }
+ parts = self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True)
+ size = parts[parts.index("--disk-size") + 1]
+ expected = (
+ 20
+ + TODO.ERPLIBRE_EXTRA_DISK_GB
+ + TODO.QEMU_DESKTOP_EXTRA_DISK_GB
+ + sum(s["disk_gb"] for s in TODO._QEMU_VM_TOOLS.values())
+ )
+ self.assertEqual(f"{expected}G", size)
+
+
+class TestToolRemoteCommand(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.all = tuple(TODO._QEMU_VM_TOOLS)
+
+ def _sh_ok(self, script):
+ """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas."""
+ return subprocess.run(
+ ["bash", "-n"], input=script, text=True, capture_output=True
+ )
+
+ def test_every_combination_is_valid_shell(self):
+ combos = [
+ (),
+ ("pycharm",),
+ ("android",),
+ ("gnome_ext",),
+ self.all,
+ ]
+ for tools in combos:
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "mise", "deb", tools
+ )
+ res = self._sh_ok(script)
+ self.assertEqual(0, res.returncode, f"{tools} : {res.stderr}")
+
+ def test_tools_come_before_the_clone_and_the_make(self):
+ """L'ordre est ce qui fait marcher la configuration du projet : PyCharm
+ écrit le .idea du dépôt en l'ouvrant une fois, et c'est l'installation
+ qui, ENSUITE, y lance pycharm_configuration.py."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm",)
+ )
+ self.assertLess(script.index("PyCharm"), script.index("git clone"))
+ self.assertLess(
+ script.index("PyCharm"), script.index("make install_os")
+ )
+
+ def test_the_configuration_is_asked_for_because_the_install_was_too_early(
+ self,
+ ):
+ """Le contraire de ce que ce test exigeait avant, et pour une raison
+ mesurée : pycharm_update() teste « os.path.exists('.idea') » et se tait
+ quand le projet n'existe pas encore. Or il s'exécute PENDANT
+ l'installation, alors que PyCharm ne s'ouvrira qu'après. Personne ne
+ configurait donc le projet — « Missing ./.idea path » dans le journal
+ d'une VM neuve. On le demande maintenant explicitement, après
+ l'ouverture."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm",)
+ )
+ self.assertIn("pycharm_configuration.py --init", script)
+ self.assertLess(
+ script.index("xvfb-run"),
+ script.index("pycharm_configuration.py"),
+ )
+
+ def test_a_failing_tool_never_masks_a_failing_install(self):
+ """Le code de sortie doit rester celui de l'installation : c'est lui que
+ lit le tableau de bord pour dire ✅ ou ❌."""
+ script = (
+ "set -e\n"
+ "curl() { return 7; }; sudo() { return 7; }\n"
+ + self.todo._qemu_tools_remote_cmd(("pycharm", "android"), False)
+ + "\nexit 3\n" # l'installation qui suit, en échec
+ )
+ res = subprocess.run(
+ ["bash", "-c", script], capture_output=True, text=True
+ )
+ self.assertEqual(3, res.returncode, res.stdout + res.stderr)
+
+ def test_a_failing_tool_still_returns_zero(self):
+ """Le bloc d'outils est lui-même gardé : PyCharm indisponible ne doit
+ pas transformer une installation réussie en échec."""
+ script = "set -e\n" + self.todo._qemu_tools_remote_cmd(self.all, False)
+ # Tout ce qui pourrait réussir est neutralisé : ni réseau, ni sudo.
+ stub = (
+ "curl() { return 7; }; sudo() { return 7; }; tar() { return 7; }; "
+ "export -f curl sudo tar 2>/dev/null || true\n"
+ )
+ res = subprocess.run(
+ ["bash", "-c", stub + script], capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+ self.assertIn("PyCharm", res.stdout)
+
+
+class TestToolDiscoverability(unittest.TestCase):
+ """Ce qui est installé doit pouvoir être TROUVÉ. Vécu : Android Studio
+ posé dans /opt, lanceur nommé « studio », et l'utilisateur conclut à un
+ échec parce que « android-studio » ne répond pas."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_android_studio_answers_to_both_names(self):
+ cmd = self.todo._qemu_android_studio_remote_cmd()
+ self.assertIn("/usr/local/bin/studio;", cmd)
+ self.assertIn("/usr/local/bin/android-studio;", cmd)
+
+ def test_the_sdk_is_shared_with_the_desktop_session(self):
+ """install-android.sh écrit ses exports dans ~/.bashrc, que GNOME ne
+ lit pas : sans environment.d, Android Studio lancé depuis le menu
+ proposerait de télécharger un SECOND SDK."""
+ cmd = self.todo._qemu_android_studio_remote_cmd()
+ self.assertIn(".config/environment.d", cmd)
+ self.assertIn("ANDROID_HOME=", cmd)
+ # Le même emplacement que celui où la compilation mobile l'installe.
+ self.assertIn("/android", cmd)
+ self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd())
+
+ def test_the_log_says_where_it_landed(self):
+ pycharm = self.todo._qemu_pycharm_remote_cmd()
+ android = self.todo._qemu_android_studio_remote_cmd()
+ self.assertIn("/opt/pycharm", pycharm)
+ self.assertIn("/opt/android-studio", android)
+
+ def test_pycharm_says_what_creates_the_project(self):
+ """Le .idea n'existe qu'après une première ouverture de PyCharm : le
+ journal le dit, plutôt que de laisser croire à un échec."""
+ cmd = self.todo._qemu_pycharm_remote_cmd()
+ self.assertIn(".idea", cmd)
+
+
+class TestMobileBuild(unittest.TestCase):
+ """Compilation ERPLibre mobile : la seule étape qui peut faire échouer la
+ VM, et la seule qui n'exige pas de bureau."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.all = tuple(TODO._QEMU_VM_TOOLS)
+
+ def test_it_runs_on_a_server_vm(self):
+ """Elle compile, elle n'affiche rien : un bureau serait du gaspillage.
+ L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X."""
+ got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
+ self.assertEqual(["mobile", "forgejo", "avd"], got)
+ # Forgejo est là pour la même raison que la compilation : un
+ # service ne demande pas d'écran.
+
+ def test_it_is_bounded_to_apt(self):
+ """install-android.sh du dépôt mobile commence par « sudo apt install
+ openjdk-17-jdk » : ailleurs il s'arrête là."""
+ for distro in ("fedora", "rocky", "opensuse", "arch"):
+ self.assertNotIn(
+ "mobile",
+ self.todo._qemu_tools_for(self.all, "amd64", "gnome", distro),
+ distro,
+ )
+ self.assertIn(
+ "mobile",
+ self.todo._qemu_tools_for(self.all, "amd64", "gnome", "debian"),
+ )
+
+ def test_it_coexists_with_android_studio(self):
+ """Combinaison croisée : la VM graphique reçoit les deux, et un seul
+ SDK — celui de $HOME/android, que ANDROID_HOME désigne."""
+ got = self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu")
+ self.assertIn("android", got)
+ self.assertIn("mobile", got)
+ self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd())
+
+ def test_it_runs_after_the_install_not_before(self):
+ """Elle a besoin du dépôt, du venv qui synchronise le manifeste, et de
+ node que « make install_os » installe."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "", "", "deb", ("mobile",)
+ )
+ self.assertLess(
+ script.index("make install_os"),
+ script.index("erplibre-mobile-build.log"),
+ )
+
+ # Banc d'essai des étapes mobiles : « mstep » est remplacé par une fonction
+ # qui réussit tout sauf l'étape nommée, et « sudo » par un no-op. Le contrat
+ # se MESURE alors au code de sortie, au lieu de se déduire de la présence
+ # ou de l'absence d'un « || » dans le texte — un « || echo » légitime, celui
+ # qui ajoute la ligne du fichier d'échange à /etc/fstab, faisait tomber
+ # l'ancienne version de ce test sans que rien ne soit cassé.
+ HARNESS = (
+ 'mstep() { echo "-> $1"; case "$1" in *%s*) return 1;; esac; '
+ "return 0; }\n"
+ "sudo() { return 0; }\n"
+ )
+
+ def _run_steps(self, fail_on="RIEN", apk=False, transfer_ok=True):
+ """Joue les étapes mobiles avec un « mstep » et un vérificateur de
+ transfert bouchonnés. Le vérificateur est un VRAI fichier dans l'arbre
+ d'essai : c'est ainsi qu'on éprouve le chaînage, code de sortie
+ compris."""
+ import os
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ el = pathlib.Path(tmp) / "el"
+ apk_dir = el / "mobile/erplibre_home_mobile/android/app/build"
+ (apk_dir / "outputs/apk/debug").mkdir(parents=True)
+ if apk:
+ (apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x")
+ checker = el / "script/mobile/check_bundle_transfer.py"
+ checker.parent.mkdir(parents=True, exist_ok=True)
+ checker.write_text(
+ "#!/bin/bash\necho ' 139 depots'\n"
+ + ("exit 0\n" if transfer_ok else "exit 1\n")
+ )
+ checker.chmod(0o755)
+ steps = self.todo._qemu_mobile_build_steps(str(el))
+ return subprocess.run(
+ ["bash", "-c", (self.HARNESS % fail_on) + steps],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, HOME=tmp),
+ timeout=60,
+ )
+
+ def test_a_failed_build_fails_the_vm(self):
+ """Contrat explicite : « pour que ce soit bon », l'app doit compiler.
+ Une étape en échec doit donc remonter un code non nul."""
+ res = self._run_steps(fail_on="gradle")
+ self.assertNotEqual(0, res.returncode, res.stdout[-400:])
+
+ def test_a_failed_step_stops_the_ones_after_it(self):
+ """La chaîne est en « && » d'un bout à l'autre. Un « ; » glissé au
+ milieu — celui qui écrivait le manifeste vide — laissait la compilation
+ web démarrer alors que « npm ci » venait d'échouer."""
+ res = self._run_steps(fail_on="npm")
+ self.assertNotEqual(0, res.returncode)
+ self.assertNotIn("vite build", res.stdout)
+ self.assertNotIn("gradle", res.stdout)
+
+ def test_the_manifest_repos_are_bundled_again(self):
+ """Le contournement a vécu : les dépôts entrent maintenant en PACKS, et
+ rien ne neutralise plus le manifeste. Mesuré sur la VM : 139 dépôts,
+ 116 156 fichiers en 391 tranches, 3 002 entrées dans l'APK — là où un
+ fichier par source en demandait 123 678 pour une limite de 65 535."""
+ steps = self.todo._qemu_mobile_build_steps("/tmp/el")
+ self.assertNotIn("ERPLIBRE_MANIFEST_PATH", steps)
+ self.assertNotIn("empty-manifest", steps)
+
+ def test_the_transfer_is_verified_after_the_bundle(self):
+ """Une application qui ne porte pas le code qu'elle est censée montrer
+ n'est pas l'application demandée : le transfert se vérifie."""
+ steps = self.todo._qemu_mobile_build_steps("/tmp/el")
+ self.assertIn("check_bundle_transfer.py", steps)
+ self.assertLess(
+ steps.index("npm run build"),
+ steps.index("check_bundle_transfer.py"),
+ )
+ self.assertLess(
+ steps.index("check_bundle_transfer.py"),
+ steps.index("cap sync"),
+ )
+
+ def test_the_transfer_is_compared_to_the_source(self):
+ """« --workspace » : c'est la comparaison octet pour octet qui prouve un
+ transfert FIDÈLE, et pas seulement cohérent."""
+ steps = self.todo._qemu_mobile_build_steps("/tmp/el")
+ self.assertIn("--workspace /tmp/el", steps)
+
+ def test_a_failed_transfer_fails_the_vm(self):
+ """Une application qui ne porte pas le code qu'elle doit montrer n'est
+ pas l'application demandée. Mesuré au code de sortie, et non à la
+ présence d'un « && » dans le texte."""
+ res = self._run_steps(apk=True, transfer_ok=False)
+ self.assertNotEqual(0, res.returncode, res.stdout[-300:])
+ self.assertNotIn("gradle", res.stdout)
+
+ def test_a_good_transfer_lets_the_build_go_on(self):
+ res = self._run_steps(apk=True, transfer_ok=True)
+ self.assertEqual(0, res.returncode, res.stdout[-300:])
+ self.assertIn("139 depots", res.stdout)
+
+ def test_the_transfer_line_is_read_in_the_install_log(self):
+ """Hors mstep, à dessein : mstep renvoie la sortie dans le journal
+ détaillé de la VM, et c'est le compte des dépôts qu'on veut voir dans
+ celui de l'installation. Le bouchon imprime une ligne : elle doit
+ remonter jusqu'à la sortie."""
+ res = self._run_steps(apk=True)
+ self.assertIn("139 depots", res.stdout)
+ head = self.todo._qemu_mobile_build_steps("/tmp/el")
+ head = head[: head.index("check_bundle_transfer.py")]
+ self.assertNotIn("mstep", head[-160:])
+
+ def test_a_missing_apk_fails_even_when_gradle_returns_zero(self):
+ """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut
+ rendre 0 sans rien produire."""
+ res = self._run_steps(apk=False)
+ self.assertNotEqual(0, res.returncode)
+ self.assertIn("APK", res.stdout)
+
+ def test_a_complete_build_succeeds(self):
+ """L'autre sens du contrat : sans lui, un test qui échoue toujours
+ passerait pour un test qui vérifie quelque chose."""
+ res = self._run_steps(apk=True)
+ self.assertEqual(0, res.returncode, res.stdout[-400:])
+
+ def test_the_swap_step_alone_never_fails_the_chain(self):
+ """Une image btrfs refuse un fichier d'échange ordinaire, et une
+ compilation qui tient en mémoire n'en a pas besoin."""
+ res = self._run_steps(apk=True)
+ self.assertEqual(0, res.returncode)
+ self.assertNotIn("|| true", self.todo._qemu_mobile_build_steps("/x"))
+
+ def test_the_build_covers_apk_and_tests(self):
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ for step in (
+ "update_manifest_local_mobile.sh",
+ "./install-android.sh",
+ "npm ci",
+ "npm run build",
+ "npx cap sync android",
+ "./gradlew --no-daemon assembleDebug",
+ "npm test",
+ ):
+ self.assertIn(step, cmd, step)
+
+ def test_the_platform_comes_from_the_project(self):
+ """L'installateur amont pose android-34, variables.gradle demande
+ compileSdk 36 : on lit le chiffre plutôt que de le figer."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ self.assertIn("android/variables.gradle", cmd)
+ self.assertIn("platforms;android-$v", cmd)
+ self.assertNotIn("platforms;android-36", cmd)
+
+ def test_the_apk_is_the_proof(self):
+ """Une tâche Gradle peut rendre 0 sans rien produire."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ self.assertIn("outputs/apk/debug/*.apk", cmd)
+
+ def test_both_apk_locations_are_searched(self):
+ """Avec une ABI injectée, AGP écrit dans intermediates et non dans
+ outputs : mesuré, une compilation RÉUSSIE était rapportée « aucun APK
+ produit » parce qu'un seul des deux chemins était regardé."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ self.assertIn("intermediates/apk/debug/*.apk", cmd)
+
+ def test_no_apk_means_non_zero(self):
+ """Éprouvé plutôt que relu : sans APK, le bloc DOIT rendre non nul.
+ Une ligne d'information placée après le « fi » suffisait à rendre 0 et
+ à faire repasser la VM au vert."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ tail = cmd[cmd.index("apk=$(ls") :]
+ res = subprocess.run(
+ ["bash", "-c", "set -e\n" + tail], capture_output=True, text=True
+ )
+ self.assertNotEqual(0, res.returncode, res.stdout)
+
+ def test_every_failure_names_a_cause(self):
+ """Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic
+ doit dire pourquoi."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ # Une entrée peut porter un 3e élément : la commande de contexte.
+ for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG):
+ self.assertIn(pattern, cmd, pattern)
+ self.assertIn('tail -12 "$1"', cmd)
+
+ def test_heavy_output_stays_out_of_the_install_log(self):
+ """Des centaines de lignes Gradle portant le mot « error » sans être
+ des pannes rendraient le compteur du tableau de bord inutilisable."""
+ cmd = self.todo._qemu_mobile_remote_cmd()
+ self.assertIn('>> "$M" 2>&1', cmd)
+
+
+class TestAndroidEmulator(unittest.TestCase):
+ """Émulateur Android : visible depuis le poste par « ssh -X »."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_avd_remote_cmd()
+
+ def test_no_desktop_needed_in_the_vm(self):
+ """Il s'affiche sur l'écran de qui s'y connecte, pas dans la VM."""
+ self.assertFalse(TODO._QEMU_VM_TOOLS["avd"]["needs_desktop"])
+ self.assertIn(
+ "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu")
+ )
+
+ def test_the_screen_is_set_at_launch_not_in_the_config(self):
+ """Écrire hw.lcd.* dans config.ini ne SERT À RIEN : l'émulateur réécrit
+ ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD
+ repartait en 1080x2400 densité 420 — constaté sur la VM. La taille se
+ règle donc au lancement, et la commande affichée la porte."""
+ self.assertNotIn("hw.lcd.width", self.cmd)
+ self.assertIn("-skin 540x1140", self.cmd)
+ # Ces deux clés-là survivent : elles ne viennent pas du profil.
+ self.assertIn("hw.gpu.mode=swangle", self.cmd)
+
+ def test_the_density_travels_with_the_resolution(self):
+ """Contre-intuitif, et mesuré : 540x1140 en densité 420 est PIRE que le
+ plein écran — 81 ms de médiane contre 40, et 57 % d'images en retard
+ contre 37, tout étant rendu énorme. Avec la densité 240 : 38 ms, 32 %,
+ et le 99e centile tombe de 950 ms à 250."""
+ self.assertIn("qemu.sf.lcd_density=240", self.cmd)
+
+ def test_a_killed_emulator_does_not_block_the_next_start(self):
+ """Ce menu propose lui-même de tuer l'émulateur par pkill. Sans
+ « -no-snapshot-save », le lancement suivant meurt sur « A snapshot
+ operation is pending and timeout has expired » — vécu, et le message ne
+ dit pas quoi faire."""
+ self.assertIn("-no-snapshot-save", self.cmd)
+
+ def test_the_printed_command_compresses_the_display(self):
+ """« -XC » plutôt que « -X » sur un écran distant."""
+ self.assertIn("ssh -XC erplibre@$ip", self.cmd)
+ self.assertNotIn("ssh -X erplibre@$ip", self.cmd.replace("-XC", ""))
+
+ def test_software_rendering_is_written_into_the_avd(self):
+ """Par « ssh -X » il n'y a pas de GLX direct : en « auto »,
+ l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini
+ pour qu'« emulator -avd erplibre » suffise."""
+ self.assertIn("hw.gpu.mode=swangle", self.cmd)
+ self.assertIn("config.ini", self.cmd)
+ # « swiftshader_indirect » n'existe plus : l'émulateur 37.1 le refuse,
+ # affiche deux erreurs et retombe sur swangle de lui-même. Mesuré.
+ self.assertNotIn("swiftshader_indirect", self.cmd)
+
+ def test_xauth_is_installed(self):
+ """Sans xauth dans la VM, « ssh -X » n'ouvre aucun affichage — et le
+ paquet manque des images cloud."""
+ self.assertIn("xauth", self.cmd)
+
+ def test_it_says_when_kvm_is_missing(self):
+ """Un émulateur x86 sans KVM refuse de démarrer : le dire là où c'est
+ réparable, sur l'hôte, plutôt qu'au premier lancement."""
+ self.assertIn("/dev/kvm", self.cmd)
+
+ def test_the_pixel_is_chosen_at_runtime(self):
+ """« le plus récent, le plus petit écran » se demande au SDK : figer un
+ modèle le rendrait faux à la prochaine génération."""
+ self.assertIn("avdmanager list device", self.cmd)
+ self.assertIn("pixel_", self.cmd)
+ self.assertIn("pro|xl|fold|tablet", self.cmd)
+ self.assertIn("sort -t_ -k2 -n", self.cmd)
+
+ def test_the_system_image_falls_back(self):
+ """Google ne publie pas d'image pour toutes les API : on descend."""
+ self.assertIn("for a in $v 36 35 34", self.cmd)
+
+ def test_it_prints_the_command_to_open_it(self):
+ """Un émulateur dont on ignore comment l'ouvrir ne sert à personne."""
+ self.assertIn("ssh -XC erplibre@$ip", self.cmd)
+ self.assertIn("adb install -r", self.cmd)
+
+ def test_the_printed_commands_use_absolute_paths(self):
+ """« ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc — Ubuntu y
+ met même un « return » pour les shells non interactifs. Une commande
+ affichée qui compte sur le PATH répond « command not found ». Vécu."""
+ self.assertIn("$HOME/android/emulator/emulator", self.cmd)
+ self.assertIn("$HOME/android/platform-tools/adb", self.cmd)
+ self.assertNotIn('"emulator -avd', self.cmd)
+ self.assertNotIn('"adb install', self.cmd)
+
+ def test_the_windowed_emulator_gets_its_audio_library(self):
+ """Deux binaires qemu : seul le « headless » se passe de PulseAudio.
+ Celui qui ouvre une fenêtre lie libpulse.so.0, absente des images
+ cloud, et échoue même avec « -no-audio »."""
+ self.assertIn("libpulse0", self.cmd)
+
+ def test_one_prologue_and_one_sdk_for_both_options(self):
+ """Deux prologues, et le second tronquerait le journal du premier."""
+ both = self.todo._qemu_after_remote_cmd(("mobile", "avd"))
+ self.assertEqual(1, both.count("mstep() {"))
+ self.assertEqual(1, both.count('M="$HOME/erplibre-mobile-build.log"'))
+
+ def test_the_emulator_cannot_mask_a_build_failure(self):
+ """ÉPROUVÉ, pas relu. Sans accolades autour de chaque groupe, « && » ne
+ lie que la première commande du suivant : mesuré sur une VM, un APK
+ manquant laissait tourner l'émulateur puis rendait 0 — la VM repassait
+ au vert alors que rien n'avait compilé."""
+ both = self.todo._qemu_after_remote_cmd(("mobile", "avd"))
+ # On neutralise les étapes : seul le CHAÎNAGE est en cause ici.
+ #
+ # « sudo » est neutralisé AUSSI, et ce n'est pas décoratif : le bloc
+ # ajoute un fichier d'échange de 4 Go et une ligne à /etc/fstab. Sans
+ # ce bouchon, un test le ferait sur la machine qui l'exécute.
+ stub = (
+ 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; '
+ "sudo() { return 0; }; "
+ )
+ # Ancre robuste : on part du journal mobile et on remonte à l'accolade
+ # qui ouvre son groupe. Chercher « { mstep » liait ce test à la forme
+ # de la PREMIÈRE étape, et l'ajout du swap devant l'a cassé.
+ marker = both.index("erplibre-mobile-build.log")
+ tail = both[both.rindex("{ ", 0, marker) :]
+ res = subprocess.run(
+ ["bash", "-c", "set -e; " + stub + tail],
+ capture_output=True,
+ text=True,
+ )
+ self.assertNotEqual(0, res.returncode, res.stdout)
+ # Et l'émulateur ne doit PAS avoir été touché.
+ self.assertNotIn("Pixel", res.stdout)
+
+ def test_valid_shell_in_every_combination(self):
+ for tools in (("mobile",), ("avd",), ("mobile", "avd")):
+ cmd = self.todo._qemu_after_remote_cmd(tools)
+ res = subprocess.run(
+ ["bash", "-n"], input=cmd, text=True, capture_output=True
+ )
+ self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}")
+
+ def test_no_diagnostic_pattern_carries_an_apostrophe(self):
+ """Ces motifs partent dans un « grep -q '' » : une apostrophe
+ fermait la chaîne et rendait tout le bloc invalide. Vécu."""
+ # Une entrée peut porter un 3e élément : la commande de contexte.
+ for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG):
+ self.assertNotIn("'", pattern, pattern)
+
+
+class TestPycharmCommunity(unittest.TestCase):
+ """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une
+ VM utilisable au premier démarrage et une VM qui demande une licence."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_pycharm_remote_cmd()
+
+ def test_the_community_line_is_what_is_looked_up(self):
+ """Mesuré dans une VM : le build unifié s'arrête sur
+ « NoValidIdeLicense » et n'ouvre jamais le projet."""
+ self.assertIn("pycharm-community-", self.cmd)
+ self.assertIn("data.services.jetbrains.com", self.cmd)
+
+ def test_no_version_is_frozen_in_the_repository(self):
+ """Le flux donne la plus récente : rien à mettre à jour ici quand
+ JetBrains publie un correctif."""
+ self.assertNotIn("2025.2.6", self.cmd)
+ self.assertNotIn("pycharm-community-2", self.cmd)
+
+ def test_both_architectures_are_asked_for(self):
+ self.assertIn("linuxARM64", self.cmd)
+ self.assertIn("jb=linux", self.cmd)
+
+ def test_the_fallback_names_its_cost(self):
+ """Le repli sert le build unifié : le dire, plutôt que de le laisser
+ découvrir au premier lancement."""
+ self.assertIn("code=PCC&latest", self.cmd)
+ self.assertIn("JetBrains", self.cmd)
+
+
+class TestTheDesktopActuallyStarts(unittest.TestCase):
+ """Installer un bureau ne suffit pas : il faut le DÉMARRER.
+
+ Vécu sur erplibre-ubuntu-2604-gnome, et le diagnostic ne sautait pas aux
+ yeux : GNOME installé, gdm3 installé, graphical.target par défaut, lien
+ display-manager.service en place — et la console de la VM restait en mode
+ texte. Deux causes superposées :
+
+ - graphical.target était DÉJÀ atteinte quand le paquet est arrivé, et une
+ cible active ne rattrape pas un service ajouté après coup ;
+ - « systemctl enable gdm » rend 0 sans rien faire sur Debian et Ubuntu :
+ l'unité n'a pas de « WantedBy », seulement un alias que le paquet pose.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_desktop_remote_cmd("gnome", "deb")
+
+ def _start_block(self, cmd=None):
+ """Le seul « if » qui démarre le bureau, extrait tel quel."""
+ cmd = cmd or self.cmd
+ start = cmd.index("if sudo systemctl start display-manager")
+ return cmd[start : cmd.index("fi; ", start) + 4]
+
+ def test_it_starts_and_does_not_only_enable(self):
+ self.assertIn("systemctl start display-manager.service", self.cmd)
+
+ def test_it_falls_back_to_the_desktop_service(self):
+ """« display-manager.service » est un alias que les paquets Debian
+ posent ; ailleurs c'est « gdm » qui porte le WantedBy."""
+ block = self._start_block()
+ self.assertIn("systemctl start gdm", block)
+
+ def test_it_comes_after_the_default_target_and_before_xrdp(self):
+ self.assertLess(
+ self.cmd.index("set-default graphical.target"),
+ self.cmd.index("start display-manager.service"),
+ )
+ self.assertLess(
+ self.cmd.index("start display-manager.service"),
+ self.cmd.index("command -v xrdp"),
+ )
+
+ def _run(self, systemctl_body):
+ import os
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ (bin_dir / "sudo").write_text('#!/bin/bash\nexec "$@"\n')
+ (bin_dir / "systemctl").write_text(
+ f"#!/bin/bash\n{systemctl_body}\n"
+ )
+ for n in ("sudo", "systemctl"):
+ (bin_dir / n).chmod(0o755)
+ res = subprocess.run(
+ ["bash", "-c", self._start_block()],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
+ timeout=30,
+ )
+ return res.stdout
+
+ def test_the_alias_path_reports_a_started_session(self):
+ out = self._run("exit 0")
+ self.assertIn("session", out.lower())
+ self.assertNotIn("⚠", out)
+
+ def test_the_fallback_path_also_reports_started(self):
+ """display-manager absent, gdm présent : c'est le cas d'Arch."""
+ out = self._run(
+ 'case "$*" in *display-manager*) exit 1;; *) exit 0;; esac'
+ )
+ self.assertNotIn("⚠", out)
+
+ def test_when_nothing_starts_it_says_to_reboot(self):
+ """Le pire serait de se taire : l'utilisateur cherche un écran."""
+ out = self._run("exit 1")
+ self.assertIn("⚠", out)
+ self.assertIn("boot", out.lower() + "reboot")
+
+ def test_the_block_is_valid_shell(self):
+ res = subprocess.run(
+ ["bash", "-n"],
+ input=self._start_block(),
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+
+class TestPycharmFirstOpen(unittest.TestCase):
+ """Ouverture sans écran, pour que le .idea existe avant l'installation.
+
+ Deux défauts vécus sur erplibre-ubuntu-2604-gnome, tous deux silencieux :
+ l'IDE restait vivant 45 minutes après l'étape avec 1,9 Go — « $! » est le
+ PID de xvfb-run, un script, et le tuer n'atteint ni PyCharm ni Xvfb — puis
+ la compilation de l'APK qui suivait s'est fait tuer par le noyau. Et le
+ .idea n'était jamais écrit : 123 000 fichiers d'assets épuisent les watches
+ inotify, dont la limite valait 65 536.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_pycharm_project_cmd()
+
+ def test_it_runs_after_the_install_not_before(self):
+ """Mesuré : sur un dépôt cloné mais pas installé, PyCharm n'écrit AUCUN
+ .idea — son configurateur d'interpréteur échoue faute de venv, et il
+ renonce (« ⚠ pas de .idea », deux fois sur une VM réelle). Le même appel
+ sur un dépôt installé l'écrit en cinq minutes."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm",)
+ )
+ self.assertLess(script.index("git clone"), script.index("xvfb-run"))
+ self.assertLess(
+ script.index("make install_os"), script.index("xvfb-run")
+ )
+
+ def test_the_configuration_uses_the_repo_venv(self):
+ """Le script importe xmltodict, qui vit dans .venv.erplibre. Appelé par
+ le python système — ce que faisait « make pycharm_configure » — il
+ s'arrête sur « No module named 'xmltodict' », mesuré sur la VM.
+ update_env_version.pycharm_update() l'appelle déjà avec le venv."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm",)
+ )
+ self.assertIn(
+ "./.venv.erplibre/bin/python"
+ " ./script/ide/pycharm_configuration.py --init",
+ script,
+ )
+ # « make pycharm_configure » reste cité dans le message d'aide — la
+ # cible est réparée, elle aussi — mais n'est plus ce qu'on EXÉCUTE.
+ self.assertNotIn("&& make pycharm_configure", script)
+ self.assertNotIn("; make pycharm_configure", script)
+
+ def test_the_open_gets_a_second_chance(self):
+ """Mesuré sur deux VM : la première ouverture d'un dépôt neuf peut
+ n'écrire AUCUN .idea — son configurateur d'interpréteur plante
+ (« homeDir is null ») — là où la suivante l'écrit en 25 s."""
+ self.assertIn("for attempt in 1 2", self.cmd)
+ self.assertIn('[ "$ok" = 1 ] && break', self.cmd)
+
+ def test_both_attempts_keep_their_log(self):
+ """Tronquer à chaque tentative effacerait la trace de la première, la
+ seule qui porte la cause."""
+ self.assertIn(": > /tmp/pycharm-first-run.log", self.cmd)
+ self.assertIn(">> /tmp/pycharm-first-run.log", self.cmd)
+
+ def test_the_configuration_is_asked_for_after_the_open(self):
+ """L'installation est déjà passée quand le .idea naît : pycharm_update()
+ n'avait rien à configurer, donc on le demande explicitement."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm",)
+ )
+ self.assertIn("make pycharm_configure", script)
+ self.assertLess(
+ script.index("xvfb-run"), script.index("make pycharm_configure")
+ )
+
+ def test_it_never_decides_the_verdict_of_the_vm(self):
+ """Un bonus : ni son échec ni celui de sa configuration ne doivent
+ rougir une VM dont tout le reste a réussi. La phase mobile, elle, porte
+ bien le verdict — et elle vient après."""
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("pycharm", "mobile")
+ )
+ self.assertIn("pycharm_configuration.py --init || true", script)
+ self.assertLess(
+ script.index("pycharm_configuration.py"),
+ script.index("ERPLibre mobile"),
+ )
+
+ def test_only_when_pycharm_was_asked_for(self):
+ script = self.todo._qemu_erplibre_remote_cmd(
+ "develop", None, False, "gnome", "", "deb", ("android",)
+ )
+ self.assertNotIn("xvfb-run", script)
+
+ def test_the_first_run_dialogs_are_answered_in_advance(self):
+ """Sans réponse, la session attend un clic que personne ne donnera.
+
+ Celle de la CONFIANCE est la plus coûteuse à rater : mesuré, le journal
+ s'arrête 1,3 s après le démarrage et le projet ne s'ouvre jamais."""
+ self.assertIn("idea.trust.all.projects=true", self.cmd)
+ self.assertIn("jb.consents.confirmation.enabled=false", self.cmd)
+ self.assertIn("consentOptions", self.cmd)
+ # Le consentement est écrit REFUSÉ : aucune statistique ne part.
+ self.assertIn("rsch.send.usage.stat:1.1:0:", self.cmd)
+
+ def test_xvfb_package_names_are_per_family(self):
+ """« xvfb » n'existe que chez Debian : ailleurs le paquet s'appelle
+ autrement, et un nom inventé ne s'installerait pas."""
+ self.assertEqual("xvfb", TODO._QEMU_XVFB_PKG["apt"])
+ self.assertEqual("xorg-x11-server-Xvfb", TODO._QEMU_XVFB_PKG["dnf"])
+ self.assertEqual("xorg-server-xvfb", TODO._QEMU_XVFB_PKG["pacman"])
+
+ def test_it_gives_up_rather_than_hangs(self):
+ """Un IDE qui ne s'ouvre pas ne doit pas retenir l'installation : le
+ budget est borné et le processus tué."""
+ self.assertIn("kill -TERM", self.cmd)
+ self.assertIn("kill -KILL", self.cmd)
+ self.assertIn(f"seq 1 {TODO._QEMU_PYCHARM_OPEN_TRIES}", self.cmd)
+
+ def test_valid_shell(self):
+ res = subprocess.run(
+ ["bash", "-n"],
+ input="set -e\n" + self.cmd,
+ text=True,
+ capture_output=True,
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+ def test_the_ide_gets_its_own_process_group(self):
+ """Sans « setsid », il n'y a pas de groupe à tuer."""
+ self.assertIn("setsid xvfb-run", self.cmd)
+
+ def test_the_whole_group_is_killed_not_just_the_wrapper(self):
+ """Le signe moins est tout le correctif : « -$pid » désigne le GROUPE,
+ donc xvfb-run, Xvfb, pycharm et les cef_server."""
+ self.assertIn("kill -TERM -$pid", self.cmd)
+ self.assertIn("kill -KILL -$pid", self.cmd)
+
+ def test_inotify_is_raised_before_opening(self):
+ """Après l'ouverture, il serait trop tard : l'analyse a déjà échoué."""
+ pos_watch = self.cmd.index("max_user_watches")
+ pos_open = self.cmd.index("setsid xvfb-run")
+ self.assertLess(pos_watch, pos_open)
+ self.assertIn("524288", self.cmd)
+
+ def test_the_leftover_count_is_a_single_number(self):
+ """« pgrep -fc » imprime 0 ET rend 1 quand il ne trouve rien : le
+ « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas « 0 ».
+ Le filet se déclenchait donc à chaque passage."""
+ self.assertIn("| wc -l", self.cmd)
+ self.assertNotIn("pgrep -fc", self.cmd)
+
+ def test_it_is_valid_shell(self):
+ res = subprocess.run(
+ ["bash", "-n"], input=self.cmd, capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+ def test_the_group_kill_really_reaps_the_children(self):
+ """Le test qui compte : on rejoue l'étape avec de FAUX pycharm et
+ xvfb-run, celui-ci laissant un enfant derrière lui comme le vrai le
+ fait avec Xvfb. Rien ne doit survivre."""
+ import os
+ import tempfile
+ import time
+
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ repo = pathlib.Path(tmp) / "repo"
+ (repo / ".idea").mkdir(parents=True)
+ # .idea déjà là : l'attente sort au premier tour, et le test
+ # mesure la FERMETURE, pas la création.
+ (repo / ".idea" / "erplibre.iml").write_text("")
+ (repo / ".idea" / "misc.xml").write_text("")
+ marker = pathlib.Path(tmp) / "alive"
+ (bin_dir / "xvfb-run").write_text(
+ "#!/bin/bash\n"
+ # L'enfant qui survivait : un Xvfb que personne ne tuait.
+ f"( while true; do touch {marker}; sleep 1; done ) &\n"
+ 'shift; exec "$@"\n'
+ )
+ (bin_dir / "pycharm").write_text(
+ "#!/bin/bash\nwhile true; do sleep 1; done\n"
+ )
+ (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n")
+ (bin_dir / "python3").write_text("#!/bin/bash\ncat > /dev/null\n")
+ # pgrep et pkill sont BOUCHONNÉS, et c'est le point important : le
+ # filet de l'étape balaie les processus du compte courant. Exécuté
+ # sans bouchon sur la machine de développement, il fermerait le
+ # PyCharm de l'utilisateur. C'est le groupe qu'on teste ici, pas le
+ # filet — celui-ci est vérifié à part, sans rien tuer.
+ (bin_dir / "pgrep").write_text("#!/bin/bash\nexit 1\n")
+ (bin_dir / "pkill").write_text("#!/bin/bash\nexit 0\n")
+ for name in (
+ "xvfb-run",
+ "pycharm",
+ "sudo",
+ "python3",
+ "pgrep",
+ "pkill",
+ ):
+ (bin_dir / name).chmod(0o755)
+ cmd = self.todo._qemu_pycharm_project_cmd(False).replace(
+ self.todo._qemu_install_dir(False), str(repo)
+ )
+ env = dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin", HOME=tmp)
+ res = subprocess.run(
+ ["bash", "-c", cmd],
+ capture_output=True,
+ text=True,
+ env=env,
+ timeout=180,
+ )
+ self.assertEqual(0, res.returncode, res.stdout + res.stderr)
+ marker.unlink(missing_ok=True)
+ time.sleep(3)
+ # L'enfant réveillait le marqueur chaque seconde : s'il vit
+ # encore, le fichier est revenu.
+ self.assertFalse(
+ marker.exists(),
+ "un enfant a survécu à la fermeture du groupe",
+ )
+
+
+class TestIdeInstallIsReplayable(unittest.TestCase):
+ """Rejouer une installation ne doit pas retélécharger 2 Go.
+
+ C'est le cas NORMAL : une installation morte qu'on relance, un outil ajouté
+ après coup. Mesuré sur la VM, les deux étapes passent de ~5 min chacune à
+ 0,094 s au total quand /opt porte déjà l'IDE — le reste (lanceur, alias,
+ raccourci) rejoue quand même, il est idempotent et bon marché.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.py = self.todo._qemu_pycharm_remote_cmd()
+ self.st = self.todo._qemu_android_studio_remote_cmd()
+
+ def test_pycharm_checks_before_downloading(self):
+ self.assertIn("[ -x /opt/pycharm/bin/pycharm.sh ]", self.py)
+ self.assertLess(
+ self.py.index("/opt/pycharm/bin/pycharm.sh"),
+ self.py.index("curl"),
+ )
+
+ def test_android_studio_checks_before_downloading(self):
+ self.assertIn("[ -x /opt/android-studio/bin/studio ]", self.st)
+ self.assertLess(
+ self.st.index("/opt/android-studio/bin/studio"),
+ self.st.index("curl"),
+ )
+
+ def test_the_launcher_still_runs_when_the_download_is_skipped(self):
+ """Sauter le téléchargement ne doit pas sauter l'alias : c'est lui qui
+ rend « pycharm » et « android-studio » appelables."""
+ # rindex : le chemin du lanceur apparaît aussi dans la garde, tout au
+ # début. C'est la DERNIÈRE occurrence — l'installation du lanceur — qui
+ # doit suivre le bloc de téléchargement.
+ for cmd, marker in (
+ (self.py, "/usr/local/bin"),
+ (self.st, "/usr/local/bin"),
+ ):
+ self.assertGreater(cmd.rindex(marker), cmd.index("curl"), marker)
+
+ def test_a_real_download_failure_still_fails(self):
+ """La garde ne doit pas avaler l'échec du cas où il faut télécharger.
+ On force l'absence d'IDE et un curl qui échoue."""
+ import os
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ (bin_dir / "curl").write_text("#!/bin/bash\nexit 22\n")
+ (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n")
+ (bin_dir / "python3").write_text("#!/bin/bash\ncat >/dev/null\n")
+ for n in ("curl", "sudo", "python3"):
+ (bin_dir / n).chmod(0o755)
+ res = subprocess.run(
+ ["bash", "-c", self.st],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
+ timeout=60,
+ )
+ out = res.stdout + res.stderr
+ # Sur cette machine /opt/android-studio n'existe pas : la garde
+ # laisse donc passer, et l'échec du curl doit se voir.
+ self.assertIn("⚠", out, out[-300:])
+
+ def test_both_steps_are_valid_shell(self):
+ for cmd in (self.py, self.st):
+ res = subprocess.run(
+ ["bash", "-n"], input=cmd, capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+
+class TestPycharmNetIsNarrow(unittest.TestCase):
+ """Le filet de fermeture ne doit JAMAIS viser le ssh qui porte l'install.
+
+ Vécu, et cher : le filet cherchait « /opt/pycharm » dans les LIGNES DE
+ COMMANDE. Or la commande d'installation est passée en argument à ssh, et
+ elle contient ce chemin — le pkill a donc tué la session ssh qui portait
+ l'installation en cours sur l'hyperviseur. Elle est morte en silence, sans
+ marqueur de sortie : 48 minutes perdues, et rien dans le journal.
+
+ Mesuré ensuite dans une VM : par NOM de processus, 3 processus réels
+ (pycharm, Xvfb, fsnotifier) et aucun faux ; par ligne de commande, 4 — le
+ ssh compris.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_pycharm_project_cmd()
+
+ def test_it_matches_by_process_name(self):
+ self.assertIn('pgrep -u "$(id -u)" -x', self.cmd)
+ self.assertIn('pkill -u "$(id -u)" -x', self.cmd)
+
+ def test_it_never_matches_by_command_line(self):
+ """« -f » est exactement ce qui a tué l'installation."""
+ self.assertNotIn("pkill -f", self.cmd)
+ self.assertNotIn("pgrep -f", self.cmd)
+
+ def test_the_names_are_the_ones_measured_in_the_vm(self):
+ for name in ("pycharm", "cef_server", "fsnotifier", "Xvfb"):
+ self.assertIn(name, self.cmd)
+
+ def test_a_command_line_that_merely_mentions_the_ide_is_spared(self):
+ """Le test qui compte, et il ne tue rien : un témoin dont la LIGNE
+ contient le chemin de l'IDE — comme le ssh lanceur — et dont le NOM est
+ « sleep ». L'ancien motif l'attrape, le nouveau l'épargne."""
+ import os
+ import re
+ import time
+
+ pattern = re.search(r'-x "([^"]+)"', self.cmd).group(1)
+ witness = subprocess.Popen(
+ [
+ "bash",
+ "-c",
+ 'exec -a "ssh erplibre@vm bash -c /opt/pycharm/bin/pycharm.sh"'
+ " sleep 30",
+ ]
+ )
+ try:
+ time.sleep(1.5)
+ uid = str(os.getuid())
+ by_name = subprocess.run(
+ ["pgrep", "-u", uid, "-x", pattern],
+ capture_output=True,
+ text=True,
+ ).stdout.split()
+ by_cmdline = subprocess.run(
+ ["pgrep", "-u", uid, "-f", "[/]opt/pycharm"],
+ capture_output=True,
+ text=True,
+ ).stdout.split()
+ pid = str(witness.pid)
+ # Le témoin est un enfant de bash : on cherche le groupe entier.
+ spared = pid not in by_name
+ self.assertTrue(spared, "le motif par nom a attrapé le témoin")
+ self.assertIn(
+ pid,
+ by_cmdline,
+ "le témoin devrait être attrapé par l'ancien motif ;"
+ " sinon ce test ne prouve rien",
+ )
+ finally:
+ witness.kill()
+ witness.wait(timeout=10)
+
+
+class TestMobileSwap(unittest.TestCase):
+ """Le swap posé avant de compiler, et son refus de bloquer.
+
+ Mesuré : le démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m
+ ne le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué deux
+ fois. « --max-workers=2 » n'a rien changé : le pic est passé de 10,3 à
+ 11,2 Go. C'est de la marge qu'il faut."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.steps = self.todo._qemu_mobile_build_steps("/tmp/el")
+
+ def test_the_swap_comes_before_the_build(self):
+ self.assertLess(
+ self.steps.index("SwapTotal"), self.steps.index("npm ci")
+ )
+
+ def test_it_does_nothing_when_swap_is_already_there(self):
+ self.assertIn("SwapTotal", self.steps)
+ self.assertIn("-lt 2000000", self.steps)
+
+ def test_a_failed_swap_leaves_no_stray_file(self):
+ """Un fichier d'échange à moitié fait occuperait 4 Go pour rien."""
+ self.assertIn("rm -f /swapfile-erplibre", self.steps)
+
+ def test_it_is_valid_shell(self):
+ res = subprocess.run(
+ ["bash", "-n"],
+ input="mstep() { :; }\n" + self.steps,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+ def test_the_swap_block_is_not_linked_by_and(self):
+ """Lié par « && », un swap refusé arrêterait toute la compilation."""
+ head = self.steps[: self.steps.index("npm ci")]
+ self.assertNotIn("fi; fi && ", head)
+ self.assertIn("fi; fi; ", head)
+
+
+class TestMobileDiagMemory(unittest.TestCase):
+ """« Son démon a disparu » ne parle pas de mémoire ; le diagnostic, oui."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.cmd = self.todo._qemu_mobile_diag_cmd()
+
+ def test_the_oom_pattern_names_memory(self):
+ pats = {p[0]: p[1] for p in TODO._QEMU_MOBILE_DIAG}
+ self.assertIn("daemon disappeared", pats)
+ self.assertIn("memory", pats["daemon disappeared"].lower())
+
+ def test_the_zip_entry_limit_is_named(self):
+ """La panne d'aujourd'hui, et elle est en amont : un APK est un ZIP
+ borné à 65 535 entrées, et le dépôt mobile en embarque 122 684 sous
+ assets/public/repos pour 337 qui sont l'application. Le diagnostic doit
+ le dire, pas laisser lire 5 000 lignes de Gradle."""
+ pats = {e[0]: e[1] for e in TODO._QEMU_MOBILE_DIAG}
+ self.assertIn("Too many zip entries", pats)
+ self.assertIn("65535", pats["Too many zip entries"])
+
+ def test_the_zip_limit_is_named_before_the_generic_gradle_failure(self):
+ """« FAILED » attrape tout : placé avant, il masquerait la vraie
+ cause — l'ordre du tableau est le diagnostic."""
+ keys = [e[0] for e in TODO._QEMU_MOBILE_DIAG]
+ self.assertLess(
+ keys.index("Too many zip entries"), keys.index("FAILED")
+ )
+
+ def test_the_cause_is_proven_not_assumed(self):
+ """Le compte de l'oom-killer et la RAM viennent avec : une cause
+ « mémoire » sans chiffre serait une supposition de plus."""
+ self.assertIn("mmem()", self.cmd)
+ self.assertIn("MemTotal", self.cmd)
+ self.assertIn("oom-kill", self.cmd)
+
+ def test_it_runs_and_names_the_cause(self):
+ import os
+ import tempfile
+
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ # Un dmesg qui rapporte un oom-kill, via un sudo neutre.
+ (bin_dir / "sudo").write_text('#!/bin/bash\nshift 0; exec "$@"\n')
+ (bin_dir / "dmesg").write_text(
+ "#!/bin/bash\necho 'oom-kill:constraint=CONSTRAINT_NONE'\n"
+ )
+ for name in ("sudo", "dmesg"):
+ (bin_dir / name).chmod(0o755)
+ log = pathlib.Path(tmp) / "build.log"
+ log.write_text(
+ "> Task :app:compressDebugAssets\n"
+ "Gradle build daemon disappeared unexpectedly\n"
+ )
+ res = subprocess.run(
+ ["bash", "-c", self.cmd + f'mdiag "{log}"'],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
+ timeout=60,
+ )
+ out = res.stdout
+ # « RAM » et « OOM » traversent les deux langues ; le chiffre,
+ # lui, est ce qui distingue une cause prouvée d'une supposition.
+ self.assertIn("RAM", out)
+ self.assertRegex(out, r"\(OOM\)|OOM kills")
+ self.assertRegex(out, r"[0-9]+")
+
+
+class TestAvdStep(unittest.TestCase):
+ """L'étape AVD, et ce qu'elle laisse comme piste dans le journal.
+
+ Le tunnel adb lui-même est vérifié dans test_qemu_emulator_menu.py, qui
+ couvre aussi le démarrage sans fenêtre et la question de la fenêtre.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_the_avd_step_points_at_the_tunnel(self):
+ """La question vient juste après « c'est trop lent » : la réponse doit
+ être à portée de journal."""
+ self.assertIn("tunnel > 4", self.todo._qemu_avd_remote_cmd())
+
+
+class TestGnomeSiteExtensions(unittest.TestCase):
+ """Extensions posées depuis extensions.gnome.org, par leur UUID."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.block = self.todo._qemu_gnome_ext_remote_cmd()
+
+ def test_the_three_requested_extensions_are_there(self):
+ for uuid in (
+ "gTile@vibou",
+ "freon@UshakovVasilii_Github.yahoo.com",
+ "tracker@aliakseiz.github.com",
+ ):
+ self.assertIn(uuid, self.block, uuid)
+
+ def test_the_archive_follows_the_running_gnome(self):
+ """Le site sert une archive DIFFERENTE selon la version demandée —
+ gTile v59 en GNOME 46, v62 en 48 : figer une URL poserait une archive
+ faite pour une autre version."""
+ self.assertIn("shell_version=$sv", self.block)
+ self.assertIn("gnome-shell --version", self.block)
+
+ def test_valid_shell_and_never_fails_the_install(self):
+ script = "set -e\n" + self.block
+ self.assertEqual(
+ 0,
+ subprocess.run(
+ ["bash", "-n"], input=script, text=True, capture_output=True
+ ).returncode,
+ )
+ # Sans gnome-shell dans le PATH, le bloc doit se taire proprement.
+ res = subprocess.run(
+ ["bash", "-c", "PATH=/nonexistent; " + script],
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+ def test_a_session_bus_is_provided_for_dconf(self):
+ """Un « ssh hote commande » n'a pas de bus de session : sans lui,
+ l'activation ne peut rien ecrire dans dconf."""
+ self.assertIn("dbus-run-session", self.block)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py
new file mode 100644
index 0000000..387446f
--- /dev/null
+++ b/test/test_qemu_emulator_menu.py
@@ -0,0 +1,504 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Menu de l'émulateur Android : démarrage, fenêtre ou non, tunnel adb.
+
+Ce qui se vérifie ici sans VM tient à ce qui a réellement cassé sur la VM de
+preuve : une commande privée de son chemin absolu (« emulator: command not
+found », un ssh non interactif ne lisant aucun rc), un second émulateur sur le
+même AVD (« Running multiple emulators »), une clé d'hôte refusée sur une IP
+recyclée, et un tunnel visant une adresse que l'émulateur n'écoute pas.
+
+Le tunnel est le point délicat : l'émulateur n'écoute que sur le 127.0.0.1 de
+la VM (« ss -ltn » dans l'invité ; l'hyperviseur reçoit un refus sur
+IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et
+seul un dernier saut dans la VM place « localhost » au bon endroit.
+"""
+
+import os
+import socket
+import subprocess
+import sys
+import unittest
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+
+def _run_ok(stdout="", returncode=0, stderr=""):
+ return subprocess.CompletedProcess(
+ args=[], returncode=returncode, stdout=stdout, stderr=stderr
+ )
+
+
+class TestSshOptions(unittest.TestCase):
+ """La politique de clés d'hôte n'est PAS la même selon la provenance."""
+
+ def test_local_vm_tolerates_a_recycled_host_key(self):
+ """Une IP libvirt est réattribuée d'un déploiement au suivant : la clé
+ change sous la même adresse et ssh refuse — « Host key verification
+ failed », vécu dans ce menu même."""
+ opts = TODO._qemu_ssh_opts("virsh")
+ self.assertIn("StrictHostKeyChecking=no", opts)
+ self.assertIn("UserKnownHostsFile=/dev/null", opts)
+
+ def test_a_configured_host_keeps_its_own_key_policy(self):
+ """Un hôte de ~/.ssh/config appartient à l'utilisateur : sa clé est un
+ garde-fou, et le désarmer en son nom serait une décision volée."""
+ opts = TODO._qemu_ssh_opts("ssh_config")
+ self.assertNotIn("StrictHostKeyChecking=no", opts)
+ self.assertNotIn("UserKnownHostsFile=/dev/null", opts)
+ self.assertIn("BatchMode=yes", opts)
+
+ def test_both_refuse_to_hang_on_a_password_prompt(self):
+ for src in ("virsh", "ssh_config"):
+ self.assertIn("BatchMode=yes", TODO._qemu_ssh_opts(src), src)
+
+
+class TestSshTarget(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_a_configured_host_is_named_as_is(self):
+ """C'est l'entrée ssh_config qui porte le ProxyJump : la réécrire à la
+ main reviendrait à deviner la chaîne de sauts."""
+ self.todo._qemu_resolve_ips = lambda *a, **k: {}
+ self.assertEqual(
+ self.todo._qemu_ssh_target("test-vm_02+proof", "ssh_config"),
+ "test-vm_02+proof",
+ )
+
+ def test_a_local_vm_is_reached_by_ip_as_erplibre(self):
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {
+ "vm-a": "192.168.123.81"
+ }
+ self.assertEqual(
+ self.todo._qemu_ssh_target("vm-a", "virsh"),
+ "erplibre@192.168.123.81",
+ )
+
+ def test_no_ip_yields_no_target_rather_than_a_broken_one(self):
+ """Une VM éteinte n'a pas d'IP. Rendre « erplibre@None » enverrait ssh
+ résoudre un nom absurde au lieu de le dire."""
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {}
+ self.assertEqual(self.todo._qemu_ssh_target("vm-a", "virsh"), "")
+
+
+class TestEmulatorRunning(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_counts_what_pgrep_reports(self):
+ with mock.patch("subprocess.run", return_value=_run_ok("1\n")):
+ self.assertEqual(self.todo._qemu_emulator_running("h"), 1)
+
+ def test_reads_the_last_line_so_a_warning_does_not_fool_it(self):
+ """ssh écrit ses avertissements sur stdout dans certains cas ; le compte
+ est la DERNIÈRE ligne."""
+ with mock.patch(
+ "subprocess.run", return_value=_run_ok("Warning: added key\n0\n")
+ ):
+ self.assertEqual(self.todo._qemu_emulator_running("h"), 0)
+
+ def test_an_unreachable_vm_is_unknown_not_zero(self):
+ """Zéro voudrait dire « libre » et autoriserait un second émulateur sur
+ le même AVD. L'inconnu se distingue donc du vide."""
+ with mock.patch("subprocess.run", side_effect=OSError):
+ self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
+ with mock.patch("subprocess.run", return_value=_run_ok("bavardage\n")):
+ self.assertEqual(self.todo._qemu_emulator_running("h"), -1)
+
+
+class TestEmulatorReady(unittest.TestCase):
+ """Une seule lecture répond aux deux questions : binaire, puis AVD."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_a_complete_vm_is_ready(self):
+ with mock.patch("subprocess.run", return_value=_run_ok("")):
+ self.assertEqual(self.todo._qemu_emulator_ready("h"), (True, ""))
+
+ def test_the_missing_piece_is_named(self):
+ for probe, word in (("NO_SDK\n", "SDK"), ("NO_AVD\n", "AVD")):
+ with mock.patch("subprocess.run", return_value=_run_ok(probe)):
+ ready, why = self.todo._qemu_emulator_ready("h")
+ self.assertFalse(ready, probe)
+ self.assertIn(word, why, probe)
+
+ def test_the_sdk_is_reported_before_the_avd(self):
+ """Sans SDK, l'absence d'AVD n'est qu'une conséquence : nommer la cause
+ évite d'envoyer l'utilisateur créer un AVD qu'il ne peut pas créer."""
+ with mock.patch(
+ "subprocess.run", return_value=_run_ok("NO_SDK\nNO_AVD\n")
+ ):
+ _, why = self.todo._qemu_emulator_ready("h")
+ self.assertIn("SDK", why)
+
+ def test_an_unreachable_vm_is_not_declared_ready(self):
+ with mock.patch("subprocess.run", side_effect=OSError):
+ ready, why = self.todo._qemu_emulator_ready("h")
+ self.assertFalse(ready)
+ self.assertTrue(why)
+
+
+class _MenuCase(unittest.TestCase):
+ """Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._ssh_config_hosts = lambda: []
+ self.todo._qemu_list_domains = lambda: ["vm-a"]
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {
+ "vm-a": "192.168.123.81"
+ }
+ self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
+ self.calls = []
+
+ def _play(
+ self,
+ answers,
+ running=0,
+ start_rc=0,
+ port_taken=False,
+ probe="",
+ running_after=1,
+ log="rien",
+ ):
+ """Joue le menu avec des réponses données ; rend (sortie, commandes).
+
+ « running » est le compte AVANT le démarrage, « running_after » celui
+ d'après : c'est cette distinction qui dit si l'émulateur a réellement
+ pris, le code de retour d'un « setsid » détaché ne valant rien.
+ """
+ state = {"started": False}
+
+ def fake_run(cmd, *a, **k):
+ self.calls.append(cmd)
+ joined = " ".join(cmd)
+ if "pgrep -c qemu-system" in joined:
+ n = running_after if state["started"] else running
+ return _run_ok(f"{n}\n")
+ if "NO_SDK" in joined:
+ return _run_ok(probe)
+ if "setsid" in joined:
+ state["started"] = True
+ return _run_ok(returncode=start_rc, stderr="boum")
+ if "tail -5" in joined:
+ return _run_ok(log)
+ return _run_ok()
+
+ it = iter(answers)
+ with mock.patch("subprocess.run", side_effect=fake_run), mock.patch(
+ "builtins.input", lambda *a: next(it)
+ ), mock.patch.object(
+ TODO, "_port_in_use", staticmethod(lambda p: port_taken)
+ ), mock.patch(
+ "script.todo.todo.time.sleep", lambda *a: None
+ ), mock.patch(
+ "sys.stdout", new_callable=__import__("io").StringIO
+ ) as out:
+ self.todo._qemu_emulator_menu()
+ return out.getvalue(), self.calls
+
+ @staticmethod
+ def _started(calls):
+ return [c for c in calls if any("setsid" in x for x in c)]
+
+ @staticmethod
+ def _tunnels(calls):
+ return [c for c in calls if "-f" in c and "-N" in c]
+
+
+class TestEmulatorMenu(_MenuCase):
+ def test_no_target_at_all_says_so_without_touching_ssh(self):
+ self.todo._qemu_list_domains = lambda: []
+ out, calls = self._play([])
+ self.assertIn("~/.ssh/config", out)
+ self.assertEqual(calls, [])
+
+ def test_an_off_vm_is_reported_before_any_start(self):
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {}
+ out, calls = self._play(["1"])
+ self.assertIn("IP", out.upper())
+ self.assertEqual(self._started(calls), [])
+
+ def test_a_running_emulator_is_seen_and_refusing_stops_there(self):
+ """Deux émulateurs sur un même AVD, et le second meurt sur « Running
+ multiple emulators with the same AVD » — vécu deux fois. On le dit
+ AVANT, et un refus ne démarre rien."""
+ out, calls = self._play(["1", "n"], running=1)
+ self.assertIn("pkill -f", out)
+ # « [q]emu » : la classe empêche le pkill de se trouver lui-même.
+ self.assertIn("[q]emu-system", out)
+ self.assertEqual(self._started(calls), [])
+
+ def test_accepting_closes_the_other_one_then_starts(self):
+ out, calls = self._play(["1", "o", "1", "n"], running=1)
+ killed = [c for c in calls if any("pkill" in x for x in c)]
+ self.assertTrue(killed, "aucun pkill envoyé")
+ self.assertTrue(self._started(calls), "rien démarré après fermeture")
+
+ def test_the_window_choice_is_delegated_to_the_workstation(self):
+ """L'écran appartient au poste : cette commande ne peut pas partir de
+ l'hyperviseur, qui n'a aucun affichage à lui prêter."""
+ out, calls = self._play(["1", "2"])
+ self.assertIn("ssh -XC", out)
+ self.assertEqual(self._started(calls), [])
+ self.assertEqual(self._tunnels(calls), [])
+
+ def test_the_headless_start_carries_what_the_vm_needs(self):
+ out, calls = self._play(["1", "1", "n"])
+ started = self._started(calls)
+ self.assertEqual(len(started), 1)
+ cmd = started[0][-1]
+ # Chemin absolu : « ssh hôte 'commande' » ne lit ni ~/.profile ni
+ # ~/.bashrc, et « emulator » seul rend « command not found » — vécu.
+ self.assertIn("$HOME/android/emulator/emulator", cmd)
+ self.assertIn("-no-window", cmd)
+ # sg kvm : sans le groupe, l'émulateur n'a pas /dev/kvm et renonce.
+ self.assertIn("sg kvm", cmd)
+ # setsid : il doit survivre à la fermeture de ce ssh.
+ self.assertIn("setsid -f", cmd)
+ self.assertIn("/tmp/erplibre-emulator.log", cmd)
+
+ def test_the_start_carries_the_shared_flags(self):
+ """Une seule autorité pour ces drapeaux : le lancement du menu, la
+ commande fenêtrée et celle de l'étape AVD doivent dire la même chose.
+ La densité va avec la résolution — 540x1140 en densité 420 est mesuré
+ PIRE que le plein écran."""
+ _, calls = self._play(["1", "1", "n"])
+ cmd = self._started(calls)[0][-1]
+ for flag in (
+ "-skin 540x1140",
+ "qemu.sf.lcd_density=240",
+ "-no-snapshot-save",
+ "-gpu swangle",
+ ):
+ self.assertIn(flag, cmd, flag)
+
+ def test_the_windowed_command_carries_them_too(self):
+ """Qui ouvre la fenêtre depuis son poste doit avoir le même écran que
+ qui passe par scrcpy — sinon la comparaison ne veut rien dire."""
+ out, _ = self._play(["1", "2"])
+ self.assertIn("-skin 540x1140", out)
+ self.assertIn("qemu.sf.lcd_density=240", out)
+
+ def test_the_start_command_is_valid_shell(self):
+ """Une apostrophe ou un guillemet de trop, et la VM répond par une
+ erreur de syntaxe — déjà rencontré dans ce même fichier."""
+ _, calls = self._play(["1", "1", "n"])
+ cmd = self._started(calls)[0][-1]
+ res = subprocess.run(
+ ["bash", "-n"], input=cmd, capture_output=True, text=True
+ )
+ self.assertEqual(res.returncode, 0, res.stderr)
+
+ def test_a_failed_start_does_not_offer_a_tunnel_to_nothing(self):
+ out, calls = self._play(["1", "1"], start_rc=1)
+ self.assertIn("boum", out)
+ self.assertEqual(self._tunnels(calls), [])
+
+ def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self):
+ """Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le
+ dit avant même de demander la fenêtre — mesuré sur une VM de migration,
+ où le démarrage détaché rendait 0 et le journal disait « not found ».
+ """
+ out, calls = self._play(["1"], probe="NO_SDK\n")
+ self.assertIn("SDK", out)
+ self.assertNotIn("[1]", out.split("VM locale")[-1])
+ self.assertEqual(self._started(calls), [])
+
+ def test_a_vm_without_the_avd_is_named_as_such(self):
+ out, calls = self._play(["1"], probe="NO_AVD\n")
+ self.assertIn("AVD", out)
+ self.assertEqual(self._started(calls), [])
+
+ def test_a_stray_answer_cancels_instead_of_starting(self):
+ """« n » à une question à deux crans partait démarrer l'émulateur :
+ tout ce qui n'était pas « 2 » valait « sans fenêtre ». Observé."""
+ for stray in ("n", "3", "oui"):
+ self.calls = []
+ out, calls = self._play(["1", stray])
+ self.assertEqual(self._started(calls), [], stray)
+
+ def test_a_start_that_never_appears_reports_the_log_not_a_success(self):
+ """Le code de retour d'un « setsid » détaché vaut 0 quoi qu'il arrive :
+ seule la présence du processus prouve le démarrage."""
+ out, calls = self._play(
+ ["1", "1"], running_after=0, log="emulator: not found"
+ )
+ self.assertIn("not found", out)
+ self.assertNotIn("scrcpy -s", out)
+
+ def test_a_successful_start_chains_into_the_tunnel_help(self):
+ out, _ = self._play(["1", "1", "n"])
+ self.assertIn("scrcpy", out)
+ self.assertIn("adb connect localhost:5555", out)
+
+
+class TestScrcpyTunnel(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {
+ "vm-a": "192.168.123.81"
+ }
+ self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
+ self.calls = []
+
+ def _play(
+ self,
+ src="virsh",
+ answers=("n",),
+ rc=0,
+ port_taken=False,
+ started=False,
+ name="vm-a",
+ ):
+ def fake_run(cmd, *a, **k):
+ self.calls.append(cmd)
+ return _run_ok(returncode=rc, stderr="refus")
+
+ it = iter(answers)
+ with mock.patch("subprocess.run", side_effect=fake_run), mock.patch(
+ "builtins.input", lambda *a: next(it)
+ ), mock.patch.object(
+ TODO, "_port_in_use", staticmethod(lambda p: port_taken)
+ ), mock.patch.dict(
+ "os.environ", {"USER": "poste"}
+ ), mock.patch(
+ "sys.stdout", new_callable=__import__("io").StringIO
+ ) as out:
+ self.todo._qemu_scrcpy_tunnel(name, src, started=started)
+ return out.getvalue(), self.calls
+
+ def test_a_local_vm_needs_the_vm_as_the_LAST_hop(self):
+ """L'émulateur n'écoute que sur le 127.0.0.1 de la VM. Une redirection
+ vers IP_VM:5555 depuis l'hyperviseur est refusée (mesuré) : « localhost »
+ ne vaut que sur le dernier saut, d'où -J."""
+ out, _ = self._play(src="virsh")
+ self.assertIn("-L 5555:localhost:5555", out)
+ self.assertIn("-J poste@10.0.0.2", out)
+ self.assertIn("erplibre@192.168.123.81", out)
+ self.assertNotIn("-L 5555:192.168.123.81:5555", out)
+
+ def test_a_configured_host_rides_its_own_proxyjump(self):
+ out, _ = self._play(src="ssh_config", name="test-vm_02+proof")
+ self.assertIn("-L 5555:localhost:5555 test-vm_02+proof", out)
+ self.assertNotIn("-J", out)
+ self.assertIn("ProxyJump", out)
+
+ def test_it_gives_the_three_workstation_commands(self):
+ """Le tunnel ne sert à rien seul : la connexion adb et scrcpy le
+ suivent, et scrcpy n'est pas installé par défaut."""
+ out, _ = self._play()
+ self.assertIn("adb connect localhost:5555", out)
+ self.assertIn("scrcpy -s localhost:5555", out)
+ self.assertIn("apt install scrcpy", out)
+
+ def test_this_path_never_falls_back_to_x11(self):
+ """Tout l'intérêt : plus de X11 nulle part, le flux est du H.264."""
+ out, _ = self._play()
+ self.assertNotIn("ssh -X", out)
+
+ def test_the_tunneled_port_is_the_device_not_the_adb_server(self):
+ """5037 est le serveur adb du poste : le tunneler obligerait à tuer
+ celui de l'utilisateur, qui occupe le même port. 5555 est l'appareil.
+ """
+ out, _ = self._play()
+ self.assertNotIn("5037", out)
+ self.assertIn("5555", out)
+
+ def test_it_does_not_repeat_the_start_command_after_starting(self):
+ out, _ = self._play(started=True)
+ self.assertNotIn("-no-window", out)
+ out, _ = self._play(started=False)
+ self.assertIn("-no-window", out)
+ self.assertIn("$HOME/android/emulator/emulator", out)
+
+ def test_declining_opens_nothing(self):
+ _, calls = self._play(answers=("n",))
+ self.assertEqual(calls, [])
+
+ def test_accepting_opens_a_detached_tunnel_that_fails_loudly(self):
+ """« -f » sans « ExitOnForwardFailure » rend 0 alors que la redirection
+ a échoué : un succès annoncé pour un tunnel absent."""
+ out, calls = self._play(answers=("o",))
+ self.assertEqual(len(calls), 1)
+ cmd = calls[0]
+ self.assertIn("-f", cmd)
+ self.assertIn("-N", cmd)
+ self.assertIn("ExitOnForwardFailure=yes", cmd)
+ self.assertIn("5555:localhost:5555", cmd)
+ self.assertIn("erplibre@192.168.123.81", cmd)
+ self.assertIn("✅", out)
+
+ def test_an_occupied_port_is_named_instead_of_a_silent_bind_error(self):
+ """Le « bind: Address already in use » d'ssh se perd en mode détaché."""
+ out, calls = self._play(answers=("o",), port_taken=True)
+ self.assertEqual(calls, [])
+ self.assertIn("5555", out)
+ self.assertIn("pkill", out)
+
+ def test_a_refused_tunnel_is_reported(self):
+ out, _ = self._play(answers=("o",), rc=255)
+ self.assertIn("refus", out)
+ self.assertNotIn("✅", out)
+
+
+class TestPortInUse(unittest.TestCase):
+ def test_a_listening_socket_is_seen(self):
+ with socket.socket() as srv:
+ srv.bind(("127.0.0.1", 0))
+ srv.listen(1)
+ self.assertTrue(TODO._port_in_use(srv.getsockname()[1]))
+
+ def test_a_closed_port_is_free(self):
+ with socket.socket() as probe:
+ probe.bind(("127.0.0.1", 0))
+ port = probe.getsockname()[1]
+ self.assertFalse(TODO._port_in_use(port))
+
+
+class TestEmulatorGpuOverride(unittest.TestCase):
+ """« -gpu host » doit être essayable sans toucher au code.
+
+ Les drapeaux sont figés à l'import de todo.py : la variable doit donc être
+ lue AVANT, ce qu'un sous-processus est seul à démontrer. Le défaut reste
+ swangle — mesuré — car un contexte GL qui échoue laisse l'émulateur pendu
+ au lieu de retomber.
+ """
+
+ def _flags(self, env=None):
+ root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+ environ = dict(os.environ, PYTHONPATH=root)
+ environ.update(env or {})
+ res = subprocess.run(
+ [
+ sys.executable,
+ "-c",
+ "import sys; sys.argv=['todo.py']\n"
+ "from script.todo.todo import TODO\n"
+ "print(TODO._QEMU_EMULATOR_FLAGS)",
+ ],
+ capture_output=True,
+ text=True,
+ cwd=root,
+ env=environ,
+ timeout=120,
+ )
+ self.assertEqual(0, res.returncode, res.stderr[-400:])
+ return res.stdout
+
+ def test_the_default_is_the_measured_one(self):
+ self.assertIn("-gpu swangle", self._flags())
+
+ def test_the_variable_replaces_it(self):
+ out = self._flags({"EL_EMULATOR_GPU": "host"})
+ self.assertIn("-gpu host", out)
+ self.assertNotIn("swangle", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py
new file mode 100644
index 0000000..71e2152
--- /dev/null
+++ b/test/test_qemu_forgejo.py
@@ -0,0 +1,385 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Option Forgejo : la case, son filtrage, et le script qui fait le travail.
+
+Forgejo est un service, pas un outil de bureau : une VM serveur le prend comme
+une VM graphique. Son binaire est statique, donc le même fichier sert apt, dnf,
+pacman et zypper — c'est ce qui le rend portable sans une branche par
+distribution. Les architectures, elles, sont bornées par l'amont : Forgejo
+publie amd64, arm64 et arm-6, et rien pour s390x.
+
+Le script est vérifié en l'EXÉCUTANT sur ses chemins de refus — architecture
+inconnue, version introuvable — qui précèdent toute élévation de privilège et
+ne touchent donc à rien.
+"""
+
+import os
+import pathlib
+import subprocess
+import sys
+import tempfile
+import unittest
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+from script.todo.todo_i18n import t # noqa: E402
+
+SCRIPT = (
+ pathlib.Path(__file__).resolve().parent.parent
+ / "script/forgejo/install_forgejo.sh"
+)
+
+
+class TestTheCheckbox(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.spec = TODO._QEMU_VM_TOOLS["forgejo"]
+
+ def test_it_is_offered_in_the_form(self):
+ keys = [k for k, _l, _h in TODO._qemu_vm_tool_choices()]
+ self.assertIn("forgejo", keys)
+
+ def test_a_plain_server_gets_it(self):
+ """Une forge n'affiche rien : elle n'a pas besoin de bureau."""
+ self.assertFalse(self.spec["needs_desktop"])
+ got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu")
+ self.assertIn("forgejo", got)
+
+ def test_every_package_family_gets_it(self):
+ """Le binaire est statique : aucune famille n'est exclue, à la
+ différence de la compilation mobile que son installateur borne à apt.
+ """
+ self.assertEqual(self.spec["families"], ())
+ for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"):
+ self.assertIn(
+ "forgejo",
+ self.todo._qemu_tools_for(("forgejo",), "amd64", "", distro),
+ distro,
+ )
+
+ def test_arm64_yes_s390x_no(self):
+ """Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir
+ en Go : la case se grise plutôt que de poser un binaire inexécutable.
+ """
+ for arch in ("amd64", "arm64"):
+ self.assertIn(
+ "forgejo",
+ self.todo._qemu_tools_for(("forgejo",), arch, "", "ubuntu"),
+ arch,
+ )
+ self.assertNotIn(
+ "forgejo",
+ self.todo._qemu_tools_for(("forgejo",), "s390x", "", "ubuntu"),
+ )
+
+ def test_its_disk_cost_is_counted_in_the_plan(self):
+ self.assertGreater(
+ self.todo._qemu_tools_disk_gb(("forgejo",), "amd64", "", "ubuntu"),
+ 0,
+ )
+
+
+class TestTheInstallBlock(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_it_calls_the_dedicated_script(self):
+ """Une seule autorité : la même commande sert le déploiement d'une VM et
+ une installation à la main."""
+ for prod, root in (
+ (False, "$HOME/git/erplibre"),
+ (True, "/opt/erplibre"),
+ ):
+ block = self.todo._qemu_forgejo_steps(
+ self.todo._qemu_install_dir(prod)
+ )
+ self.assertIn(f"{root}/script/forgejo/install_forgejo.sh", block)
+
+ def test_alone_it_does_not_drag_the_android_prologue(self):
+ """Cocher Forgejo seul ne doit pas installer un SDK Android."""
+ cmd = self.todo._qemu_after_remote_cmd(("forgejo",), False)
+ self.assertIn("install_forgejo.sh", cmd)
+ self.assertNotIn("sdkmanager", cmd)
+ self.assertNotIn("mstep", cmd)
+
+ def test_with_the_mobile_build_forgejo_comes_first(self):
+ """Une minute contre une heure : un échec rapide se voit tôt."""
+ cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
+ self.assertLess(cmd.index("install_forgejo.sh"), cmd.index("gradlew"))
+
+ def test_a_forgejo_failure_still_fails_the_vm(self):
+ """Même contrat que la compilation mobile : une VM dont la forge
+ demandée n'existe pas n'est pas la VM demandée. Les groupes sont donc
+ liés par « && », jamais par « ; »."""
+ cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False)
+ between = cmd[
+ cmd.index("install_forgejo.sh") : cmd.index("ERPLibre mobile")
+ ]
+ self.assertIn("&&", between)
+ self.assertNotIn("|| true", between)
+
+ def test_valid_shell_in_every_combination(self):
+ for tools in (
+ ("forgejo",),
+ ("forgejo", "mobile"),
+ ("forgejo", "avd"),
+ ("forgejo", "mobile", "avd"),
+ ):
+ cmd = self.todo._qemu_after_remote_cmd(tools, False)
+ res = subprocess.run(
+ ["bash", "-n"],
+ input="mstep() { :; }; mdiag() { :; };\n" + cmd,
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}")
+
+
+class TestDesktopOnlyVm(unittest.TestCase):
+ """Une VM sans ERPLibre : le script Forgejo vit dans le dépôt, donc nulle
+ part. L'écarter en silence laisserait croire qu'une case cochée a été
+ honorée."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_it_says_what_it_skips(self):
+ cmd = self.todo._qemu_erplibre_remote_cmd(
+ None, None, False, "gnome", "", "deb", ("forgejo",)
+ )
+ self.assertIn("forgejo", cmd)
+ self.assertIn(t("needs the ERPLibre install, skipped:"), cmd)
+ self.assertNotIn("install_forgejo.sh", cmd)
+
+ def test_it_stays_quiet_when_nothing_was_deferred(self):
+ """Assertion visée sur LA note, et non sur tout « ⚠ » : la commande en
+ porte d'autres, légitimes — dont celui du bureau qui ne démarre pas."""
+ cmd = self.todo._qemu_erplibre_remote_cmd(
+ None, None, False, "gnome", "", "deb", ("gnome_ext",)
+ )
+ self.assertNotIn(t("needs the ERPLibre install, skipped:"), cmd)
+
+ def test_the_note_is_valid_shell(self):
+ cmd = self.todo._qemu_erplibre_remote_cmd(
+ None, None, False, "gnome", "", "deb", ("forgejo", "mobile")
+ )
+ res = subprocess.run(
+ ["bash", "-n"], input=cmd, capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+
+class TestTheScript(unittest.TestCase):
+ """Le script lui-même, exécuté sur ses chemins de refus."""
+
+ def test_it_is_executable_and_valid_shell(self):
+ self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable")
+ res = subprocess.run(
+ ["bash", "-n", str(SCRIPT)], capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+
+ def test_help_explains_the_knobs_and_exits_clean(self):
+ res = subprocess.run(
+ ["bash", str(SCRIPT), "--help"], capture_output=True, text=True
+ )
+ self.assertEqual(0, res.returncode, res.stderr)
+ for knob in (
+ "FORGEJO_VERSION",
+ "FORGEJO_HTTP_PORT",
+ "FORGEJO_ADMIN_USER",
+ ):
+ self.assertIn(knob, res.stdout)
+
+ def _run_with_stubs(self, stubs, env=None):
+ """Lance le script avec un PATH bouchonné. Les chemins testés ici
+ s'arrêtent AVANT tout sudo : rien n'est installé nulle part."""
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ for name, body in stubs.items():
+ (bin_dir / name).write_text(f"#!/bin/bash\n{body}\n")
+ (bin_dir / name).chmod(0o755)
+ return subprocess.run(
+ ["bash", str(SCRIPT)],
+ capture_output=True,
+ text=True,
+ env=dict(
+ os.environ,
+ PATH=f"{bin_dir}:/usr/bin:/bin",
+ **(env or {}),
+ ),
+ timeout=120,
+ )
+
+ def test_an_unpublished_architecture_is_refused_by_name(self):
+ """Sur s390x, Forgejo n'a pas de binaire. Le dire vaut mieux que
+ télécharger un fichier qui ne s'exécutera pas."""
+ res = self._run_with_stubs({"uname": "echo s390x"})
+ self.assertNotEqual(0, res.returncode)
+ self.assertIn("s390x", res.stdout + res.stderr)
+
+ def test_an_unreachable_release_feed_is_named(self):
+ res = self._run_with_stubs({"uname": "echo x86_64", "curl": "exit 7"})
+ self.assertNotEqual(0, res.returncode)
+ self.assertIn("Version", res.stdout + res.stderr)
+
+ def test_a_pinned_version_needs_no_feed(self):
+ """FORGEJO_VERSION évite l'appel réseau : utile hors ligne, et c'est ce
+ qui rend ce test rapide."""
+ res = self._run_with_stubs(
+ {"uname": "echo x86_64", "curl": "exit 7", "sudo": "exit 0"},
+ env={"FORGEJO_VERSION": "9.9.9"},
+ )
+ out = res.stdout + res.stderr
+ self.assertIn("9.9.9", out)
+ # Il échoue plus loin (le téléchargement est bouchonné), pas sur la
+ # version : c'est bien le réseau du flux qui a été évité.
+ self.assertNotIn("Version de Forgejo introuvable", out)
+
+
+class TestHostAddress(unittest.TestCase):
+ """L'adresse qui va dans ROOT_URL et SSH_DOMAIN, sur trois terrains.
+
+ « hostname -I » vient de net-tools : l'inetutils d'Arch ne connaît pas ce
+ drapeau et peut rendre le NOM de la machine. Une ROOT_URL bâtie sur un nom
+ non résolvable est pire qu'un repli, d'où la validation de la forme.
+ """
+
+ def _host_address(self, stubs):
+ """Extrait la fonction du script et l'exécute avec un PATH bouchonné."""
+ body = SCRIPT.read_text()
+ start = body.index("host_address() {")
+ end = body.index("\n}", start) + 2
+ fn = body[start:end]
+ with tempfile.TemporaryDirectory() as tmp:
+ bin_dir = pathlib.Path(tmp) / "bin"
+ bin_dir.mkdir()
+ for name, script in stubs.items():
+ (bin_dir / name).write_text(f"#!/bin/bash\n{script}\n")
+ (bin_dir / name).chmod(0o755)
+ res = subprocess.run(
+ ["bash", "-c", fn + "\nhost_address"],
+ capture_output=True,
+ text=True,
+ env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"),
+ timeout=30,
+ )
+ return res.stdout.strip()
+
+ def test_it_takes_the_address_hostname_gives(self):
+ got = self._host_address({"hostname": "echo 10.1.2.3"})
+ self.assertEqual("10.1.2.3", got)
+
+ def test_a_hostname_that_returns_a_name_is_rejected(self):
+ """Le cas Arch : on tombe alors sur « ip », et non sur un nom."""
+ got = self._host_address(
+ {
+ "hostname": "echo erplibre-arch",
+ "ip": "echo '1.0.0.1 via 10.0.0.1 dev eth0 src 10.9.9.9 uid 0'",
+ }
+ )
+ self.assertEqual("10.9.9.9", got)
+
+ def test_without_hostname_nor_ip_it_falls_back_to_localhost(self):
+ """Une forge joignable en local vaut mieux qu'un script qui s'arrête."""
+ got = self._host_address({"hostname": "exit 1", "ip": "exit 1"})
+ self.assertEqual("localhost", got)
+
+ def test_it_never_returns_an_empty_string(self):
+ """Une ROOT_URL « http://:3000/ » ne mène nulle part."""
+ for stubs in (
+ {"hostname": "echo", "ip": "echo"},
+ {"hostname": "exit 2", "ip": "exit 2"},
+ ):
+ self.assertTrue(self._host_address(stubs), stubs)
+
+
+class TestTheScriptGuards(unittest.TestCase):
+ """Quatre pièges rencontrés en le mettant au point, tous mesurés."""
+
+ def setUp(self):
+ self.body = SCRIPT.read_text()
+
+ @property
+ def code_lines(self):
+ """Les lignes de CODE : le piège est expliqué en commentaire, et un
+ test qui cherche dans les commentaires trébuche sur sa propre
+ documentation — vécu à l'écriture de ce fichier."""
+ return [
+ ln
+ for ln in self.body.splitlines()
+ if ln.strip() and not ln.lstrip().startswith("#")
+ ]
+
+ def test_it_never_probes_with_exec_on_dev_tcp(self):
+ """« exec » est un builtin spécial : une redirection qui échoue termine
+ le shell. Le script mourait au premier tour de la boucle d'attente,
+ code 1 et pas un mot."""
+ guilty = [ln for ln in self.code_lines if "exec 3<>" in ln]
+ self.assertEqual([], guilty)
+ self.assertIn("/api/v1/version", self.body)
+
+ def test_the_config_test_goes_through_sudo(self):
+ """/etc/forgejo est en 770 root:git : « [ -f ] » échouait toujours, et
+ chaque passage réécrivait la configuration avec des secrets neufs."""
+ self.assertIn("sudo test -f", self.body)
+
+ def test_all_four_secrets_are_written(self):
+ """Sans oauth2.JWT_SECRET, Forgejo tente de l'écrire dans app.ini,
+ n'y arrive pas, et boucle sur « [F] save oauth2.JWT_SECRET failed »."""
+ for key in (
+ "SECRET_KEY",
+ "INTERNAL_TOKEN",
+ "JWT_SECRET",
+ "LFS_JWT_SECRET",
+ ):
+ self.assertIn(key, self.body, key)
+
+ def test_the_default_admin_name_is_not_reserved(self):
+ """Forgejo refuse « admin » : « CreateUser: name is reserved »."""
+ self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body)
+ self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body)
+
+ def test_it_restarts_when_something_changed(self):
+ """« enable --now » ne touche PAS un service déjà actif : il garde alors
+ sa configuration en mémoire. Vécu, et le symptôme ne désignait pas la
+ cause — le serveur comparait son ancien INTERNAL_TOKEN à celui que le
+ hook venait de lire, répondait 403 à son propre hook, et tout push
+ finissait sur « Internal Server Error Decoding Failed »."""
+ self.assertIn("systemctl restart forgejo.service", self.body)
+ self.assertNotIn("enable --now forgejo", self.body)
+
+ def test_the_restart_is_conditional(self):
+ """Rejouer le script sur une forge saine ne doit pas l'interrompre,
+ même deux secondes."""
+ self.assertIn("CHANGED=0", self.body)
+ self.assertIn('[ "$CHANGED" = 1 ]', self.body)
+ # Trois évènements le lèvent : binaire posé, config écrite, unité
+ # modifiée.
+ self.assertEqual(3, self.body.count("CHANGED=1"))
+
+ def test_the_unit_is_compared_before_being_written(self):
+ """Sans comparaison, l'unité serait réécrite à l'identique et le
+ service redémarrerait pour rien à chaque passage."""
+ self.assertIn("cmp -s", self.body)
+
+ def test_the_readiness_loop_stays_quiet_while_retrying(self):
+ """« Failed to connect » au premier tour est normal — le service vient
+ de redémarrer. C'est le die final qui parle."""
+ # La commande est coupée sur deux lignes : on regarde le BLOC de la
+ # boucle, pas la ligne qui porte l'URL.
+ start = self.body.index("ready=0")
+ block = self.body[start : self.body.index('[ "$ready" = 1 ]', start)]
+ self.assertIn("curl -fs -o /dev/null", block)
+ self.assertNotIn("-fsS", block)
+
+ def test_it_touches_no_package_manager(self):
+ """C'est ce qui le rend portable : le binaire est statique."""
+ for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"):
+ self.assertNotIn(pm, self.body, pm)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py
new file mode 100644
index 0000000..071fddc
--- /dev/null
+++ b/test/test_qemu_gpu.py
@@ -0,0 +1,875 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte.
+
+Une VM graphique sans accélération rend tout par le processeur — le bureau
+comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en
+a un, la VM doit le prendre, et c'est le défaut.
+
+Ce que ces tests gardent, appris en le cassant :
+
+- UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y
+ ajoute pas — deux écrans, et l'invité n'en peuple qu'un.
+- « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels,
+ deux affichages. D'où l'état lu avant tout plan.
+- « virt-xml --memory N » ne touche que : la RAM plafonnait en
+ silence à l'ancien maximum.
+- Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la
+ console VNC de la VM qui disparaît.
+- « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et
+ le menu se bloque sans rien dire.
+"""
+
+import contextlib
+import importlib.util
+import io
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo import qemu_hardware as hw # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+
+def _deploy_qemu():
+ """deploy_qemu.py chargé comme module, comme le fait todo.py."""
+ path = Path(__file__).resolve().parents[1] / "script/qemu/deploy_qemu.py"
+ spec = importlib.util.spec_from_file_location("deploy_qemu", path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+DQ = _deploy_qemu()
+
+# XML réel d'une VM du parc, réduit à ce qui décide du matériel.
+XML_SANS_3D = """
+ erplibre-ubuntu-2604-gnome
+ 33554432
+ 33554432
+ 8
+
+
+
+
+
+
+
+"""
+
+XML_AVEC_3D = XML_SANS_3D.replace(
+ "",
+ ""
+ "",
+).replace(
+ "",
+ "\n "
+ "",
+)
+
+# XML persistant complet : mode CPU, écrans, interface réseau. C'est cette
+# forme-là que « virsh dumpxml --inactive » rend, sans les décorations que
+# libvirt ajoute au démarrage (portid, vnetN, alias).
+XML_COMPLET = """
+ erplibre-ubuntu-2604-gnome
+ 33554432
+ 33554432
+ 8
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+XML_SERVEUR = """
+ erplibre-serveur
+ 2097152
+ 2
+
+
+"""
+
+NODE = "/dev/dri/renderD128"
+
+
+class TestDetection(unittest.TestCase):
+ """Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues."""
+
+ def _dri(self, *names):
+ tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(tmp.cleanup)
+ for n in names:
+ Path(tmp.name, n).touch()
+ return tmp.name
+
+ def test_it_keeps_render_nodes_and_ignores_the_rest(self):
+ """« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre
+ renderD*, et lui donner card0 échouerait au démarrage."""
+ found = DQ.host_render_nodes(
+ self._dri("card0", "renderD128", "by-path")
+ )
+ self.assertEqual(1, len(found))
+ self.assertTrue(found[0].endswith("renderD128"))
+
+ def test_several_cards_come_out_sorted(self):
+ found = DQ.host_render_nodes(self._dri("renderD129", "renderD128"))
+ self.assertEqual(
+ ["renderD128", "renderD129"], [Path(f).name for f in found]
+ )
+
+ def test_a_host_without_dri_answers_empty(self):
+ """Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe
+ pas du tout. Une exception ici ferait échouer TOUT déploiement."""
+ self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri"))
+ self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri"))
+
+ def test_presence_is_the_test_not_our_own_access(self):
+ """Le nœud appartient au groupe « render » ; libvirt donne l'accès au
+ démarrage du domaine. Tester nos droits rejetterait un hôte valable."""
+ d = self._dri("renderD128")
+ os.chmod(Path(d, "renderD128"), 0o000)
+ self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128"))
+
+
+class TestDecision(unittest.TestCase):
+ """« Par défaut avec GPU s'il existe » — et le silence n'est pas permis."""
+
+ def test_auto_takes_the_gpu_when_there_is_one(self):
+ on, msg = DQ.gpu_decision("auto", NODE, True)
+ self.assertTrue(on)
+ self.assertIn(NODE, msg)
+
+ def test_auto_without_gpu_says_why_it_falls_back(self):
+ """Sans ce message, une VM en rendu logiciel ne s'explique pas — et on
+ cherche la lenteur ailleurs pendant des heures."""
+ on, msg = DQ.gpu_decision("auto", "", True)
+ self.assertFalse(on)
+ self.assertTrue(msg.strip())
+
+ def test_forcing_it_without_a_node_refuses_and_warns(self):
+ """libvirt refuse de démarrer un domaine dont le rendernode manque :
+ obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas.
+ """
+ on, msg = DQ.gpu_decision("on", "", True)
+ self.assertFalse(on)
+ self.assertIn("⚠", msg)
+
+ def test_off_stays_off_and_silent(self):
+ self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True))
+
+ def test_no_screen_no_3d(self):
+ """Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le
+ « --edit --video » échouerait faute de périphérique vidéo."""
+ on, _ = DQ.gpu_decision("auto", NODE, False)
+ self.assertFalse(on)
+
+
+class TestDeployArgs(unittest.TestCase):
+ def test_the_args_carry_both_halves(self):
+ """L'accélération sur le virtio-gpu ET un affichage capable de
+ contexte GL : l'une sans l'autre ne donne aucune 3D."""
+ args = DQ.gpu_install_args(NODE)
+ self.assertIn("model.acceleration.accel3d=on", " ".join(args))
+ self.assertIn(f"gl.rendernode={NODE}", " ".join(args))
+
+ def test_only_one_video_device_survives(self):
+ """Le bug qui donne deux écrans : garder « --video virtio » à côté du
+ « --video » de la 3D. L'invité n'en peuple alors qu'un."""
+ video, gpu_args, _ = DQ.gpu_apply(
+ ["--video", "virtio"], "auto", NODE, True
+ )
+ self.assertEqual([], video)
+ self.assertEqual(1, (video + gpu_args).count("--video"))
+
+ def test_without_gpu_the_plain_video_stays(self):
+ video, gpu_args, _ = DQ.gpu_apply(
+ ["--video", "virtio"], "auto", "", True
+ )
+ self.assertEqual(["--video", "virtio"], video)
+ self.assertEqual([], gpu_args)
+
+ def test_the_egl_display_is_added_beside_vnc_not_instead(self):
+ """« egl-headless » n'ouvre aucun port : il ne remplace pas la console
+ VNC, il porte le contexte OpenGL. Les deux cohabitent."""
+ args = DQ.gpu_install_args(NODE)
+ self.assertIn("type=egl-headless", " ".join(args))
+ self.assertNotIn("vnc", " ".join(args))
+
+
+class TestReadState(unittest.TestCase):
+ def test_it_reads_what_the_vm_has(self):
+ st = hw.hw_state(XML_SANS_3D)
+ self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"])
+ self.assertEqual(8, st["vcpus"])
+ self.assertEqual(32768, st["mem_mib"])
+ self.assertEqual("virtio", st["video"])
+ self.assertFalse(st["accel3d"])
+ self.assertTrue(st["screen"])
+
+ def test_it_sees_an_existing_3d_setup(self):
+ st = hw.hw_state(XML_AVEC_3D)
+ self.assertTrue(st["accel3d"])
+ self.assertTrue(st["egl"])
+ self.assertEqual(NODE, st["render"])
+
+ def test_egl_headless_alone_is_not_a_screen(self):
+ """Il n'affiche rien et n'ouvre aucun port. Le compter comme écran
+ proposerait la 3D à une VM qui n'a rien à accélérer."""
+ xml = XML_SERVEUR.replace(
+ "",
+ "",
+ )
+ self.assertFalse(hw.hw_state(xml)["screen"])
+
+ def test_the_balloon_target_is_what_the_vm_gets(self):
+ """ est le maximum, ce que la VM voit. Lire le
+ premier annoncerait 32 Go à une VM qui en a 4."""
+ xml = XML_SANS_3D.replace(
+ "33554432",
+ "4194304",
+ )
+ self.assertEqual(4096, hw.hw_state(xml)["mem_mib"])
+
+ def test_units_are_exact(self):
+ """KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise
+ les deux, et « à peu près » se voit dans le tableau."""
+ for unit, value, mib in (
+ ("KiB", 1048576, 1024),
+ ("MiB", 2048, 2048),
+ ("GiB", 4, 4096),
+ ("bytes", 1073741824, 1024),
+ ):
+ xml = XML_SERVEUR.replace(
+ "2097152",
+ f"{value}",
+ )
+ self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit)
+
+ def test_broken_xml_gives_an_empty_state(self):
+ st = hw.hw_state(" : la VM plafonne
+ à son ancien maximum, sans un mot."""
+ plan = self._plan(XML_SANS_3D, {"ram": 65536})
+ arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0]
+ self.assertIn("memory=65536", arg)
+ self.assertIn("currentMemory=65536", arg)
+
+ def test_3d_without_a_host_node_is_refused_with_a_reason(self):
+ plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "")
+ self.assertEqual(1, len(plan))
+ self.assertNotIn("cmd", plan[0])
+ self.assertTrue(plan[0]["skip"])
+
+ def test_3d_on_a_screenless_vm_is_refused(self):
+ """« --edit --video » échouerait : il n'y a pas de périphérique vidéo
+ à modifier. Mieux vaut le dire que laisser virt-xml protester."""
+ plan = self._plan(XML_SERVEUR, {"gpu": True})
+ self.assertEqual(1, len(plan))
+ self.assertIn("skip", plan[0])
+
+ def test_autostart_only_moves_when_it_differs(self):
+ self.assertEqual(
+ [], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True)
+ )
+ plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True)
+ self.assertIn("--disable", plan[0]["cmd"])
+
+ def test_every_command_defines_and_names_its_uri(self):
+ """Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine
+ tourne : le menu se bloque sur une question qu'on ne voit pas. Sans
+ « --connect », un appel non root viserait qemu:///session, où les VM du
+ parc n'existent pas."""
+ plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True})
+ self.assertTrue(plan)
+ for entry in plan:
+ self.assertIn("--define", entry["cmd"])
+ self.assertIn("qemu:///system", entry["cmd"])
+
+ def test_a_state_without_a_name_plans_nothing(self):
+ """Un dumpxml illisible ne doit pas produire une commande sans cible."""
+ self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE))
+
+
+class TestWant(unittest.TestCase):
+ def test_empty_fields_keep_the_current_values(self):
+ """Valider sans rien saisir ne doit pas rétrécir la VM à néant."""
+ st = hw.hw_state(XML_SANS_3D)
+ want = hw.build_want(st, "", "", False, False)
+ self.assertEqual(8, want["vcpus"])
+ self.assertEqual(32768, want["ram"])
+
+ def test_gigabytes_are_understood(self):
+ st = hw.hw_state(XML_SANS_3D)
+ self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"])
+
+ def test_nonsense_does_not_shrink_the_vm(self):
+ st = hw.hw_state(XML_SANS_3D)
+ want = hw.build_want(st, "beaucoup", "gros", 0, 0)
+ self.assertEqual(8, want["vcpus"])
+ self.assertEqual(32768, want["ram"])
+
+
+class TestDisplay(unittest.TestCase):
+ def test_the_ram_field_stays_short_enough_to_read(self):
+ """« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un
+ nombre tronqué qu'on valide sans regarder rétrécit la machine."""
+ self.assertEqual("32G", hw.ram_field(32768))
+ self.assertEqual("1536", hw.ram_field(1536))
+ for mib in (1024, 12288, 32768, 65536):
+ self.assertLessEqual(len(hw.ram_field(mib)), 4)
+
+ def test_sizes_read_like_sizes(self):
+ self.assertEqual("1 Go", hw.fmt_mib(1024))
+ self.assertEqual("1,5 Go", hw.fmt_mib(1536))
+ self.assertEqual("512 Mo", hw.fmt_mib(512))
+
+ def test_the_summary_names_the_render_node(self):
+ self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D)))
+
+ def test_a_screen_without_3d_says_software_rendering(self):
+ summary = hw.hw_summary(hw.hw_state(XML_SANS_3D))
+ self.assertIn("8 vCPU", summary)
+ self.assertIn(hw.t("software rendering"), summary)
+
+
+class TestMenuGlue(unittest.TestCase):
+ """Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute."""
+
+ def _todo(self, states, xml=XML_SANS_3D, node=NODE):
+ todo = TODO.__new__(TODO)
+ todo._qemu_domstate = lambda name: states[name]
+ todo._qemu_dumpxml = lambda name: xml.replace(
+ "erplibre-ubuntu-2604-gnome", name
+ )
+ todo._qemu_autostart = lambda name: False
+ todo._qemu_host_gpu_node = lambda: node
+ # Aucun test ne doit atteindre virsh : la liste des réseaux est fournie.
+ todo._qemu_net_choices = lambda: ["network:default"]
+ todo.launched = []
+ todo.execute = mock.Mock()
+ todo.execute.exec_command_live = (
+ lambda cmd, **kw: todo.launched.append(cmd)
+ )
+ return todo
+
+ def _run(self, todo, names, answers):
+ it = iter(answers)
+ out = io.StringIO()
+ with mock.patch("builtins.input", lambda *a: next(it, "")):
+ with contextlib.redirect_stdout(out):
+ todo._qemu_adjust_hardware(names)
+ return out.getvalue()
+
+ def test_a_running_vm_is_left_alone_and_said_so(self):
+ """virt-xml y écrirait une définition qui ne prend effet qu'au
+ prochain démarrage : un réglage qui paraît appliqué et ne l'est pas."""
+ todo = self._todo({"vm-a": "running"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {}
+ out = self._run(todo, ["vm-a"], [])
+ self.assertIn("vm-a", out)
+ self.assertEqual([], todo.launched)
+
+ def test_a_shut_off_vm_is_adjusted(self):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {
+ "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
+ }
+ self._run(todo, ["vm-a"], ["o"])
+ joined = " ".join(todo.launched)
+ self.assertIn("--vcpus 4", joined)
+ self.assertIn("accel3d=on", joined)
+ self.assertTrue(all(c.startswith("sudo ") for c in todo.launched))
+
+ def test_nothing_to_change_launches_nothing(self):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {
+ "vm-a": {"vcpus": 8, "ram": 32768, "gpu": False}
+ }
+ out = self._run(todo, ["vm-a"], [])
+ self.assertEqual([], todo.launched)
+ self.assertIn(hw.t("Nothing to change."), out)
+
+ def test_refusing_the_confirmation_launches_nothing(self):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {
+ "vm-a": {"vcpus": 4}
+ }
+ self._run(todo, ["vm-a"], ["n"])
+ self.assertEqual([], todo.launched)
+
+ def test_cancelling_the_form_launches_nothing(self):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: None
+ self._run(todo, ["vm-a"], [])
+ self.assertEqual([], todo.launched)
+
+ def test_the_skipped_3d_is_explained_not_silent(self):
+ todo = self._todo({"vm-a": "shut off"}, node="")
+ todo._qemu_hw_form = lambda rows, node, nets=None: {
+ "vm-a": {"gpu": True}
+ }
+ out = self._run(todo, ["vm-a"], [])
+ self.assertIn(hw.t("no render node on the host"), out)
+ self.assertEqual([], todo.launched)
+
+ def test_the_host_gpu_is_announced_before_anything_else(self):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: None
+ out = self._run(todo, ["vm-a"], [])
+ self.assertIn(NODE, out)
+
+ def test_the_prompts_take_over_when_textual_is_absent(self):
+ """Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE
+ voie, et un {} mal interprété annulerait tout."""
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {}
+ # vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation.
+ self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"])
+ self.assertIn("--vcpus 6", " ".join(todo.launched))
+
+ def test_an_empty_answer_keeps_the_current_state(self):
+ """Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un
+ formulaire de matériel, le silence ne modifie rien."""
+ todo = TODO.__new__(TODO)
+ with mock.patch("builtins.input", lambda *a: ""):
+ self.assertTrue(todo._qemu_ask_bool("? ", True))
+ self.assertFalse(todo._qemu_ask_bool("? ", False))
+ with mock.patch("builtins.input", lambda *a: "n'importe quoi"):
+ self.assertTrue(todo._qemu_ask_bool("? ", True))
+
+ def test_autostart_is_read_from_virsh(self):
+ out = "Id: -\nName: vm-a\nAutostart: enable\n"
+ with mock.patch(
+ "subprocess.run",
+ return_value=subprocess.CompletedProcess([], 0, out, ""),
+ ):
+ self.assertTrue(TODO._qemu_autostart("vm-a"))
+ with mock.patch("subprocess.run", side_effect=OSError):
+ self.assertFalse(TODO._qemu_autostart("vm-a"))
+
+
+class TestForm(unittest.IsolatedAsyncioTestCase):
+ """Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend."""
+
+ async def _mount(self, rows, node):
+ app = hw.run_hardware_form(rows, node, run_app=False)
+ return app
+
+ async def test_it_returns_the_intention_on_apply(self):
+ from textual.widgets import Checkbox, Input
+
+ app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ app.query_one("#vcpus0", Input).value = "6"
+ app.query_one("#ram0", Input).value = "8G"
+ app.query_one("#gpu0", Checkbox).value = True
+ await pilot.press("ctrl+s")
+ await pilot.pause()
+ want = app.want["erplibre-ubuntu-2604-gnome"]
+ self.assertEqual(6, want["vcpus"])
+ self.assertEqual(8192, want["ram"])
+ self.assertTrue(want["gpu"])
+
+ async def test_escape_returns_nothing(self):
+ app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
+ async with app.run_test() as pilot:
+ await pilot.press("escape")
+ await pilot.pause()
+ self.assertIsNone(app.want)
+
+ async def test_3d_is_out_of_reach_without_a_host_node(self):
+ """Cocher une case qui ne peut rien produire ferait attendre une
+ accélération que l'hôte ne sait pas donner."""
+ from textual.widgets import Checkbox
+
+ app = await self._mount([hw.hw_state(XML_SANS_3D)], "")
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
+
+ async def test_3d_is_out_of_reach_for_a_screenless_vm(self):
+ from textual.widgets import Checkbox
+
+ app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ self.assertTrue(app.query_one("#gpu0", Checkbox).disabled)
+
+ async def test_the_fields_start_on_the_current_values(self):
+ from textual.widgets import Input
+
+ app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE)
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ self.assertEqual("8", app.query_one("#vcpus0", Input).value)
+ self.assertEqual("32G", app.query_one("#ram0", Input).value)
+
+ async def test_the_second_row_carries_cpu_screens_and_network(self):
+ from textual.widgets import Input, Select
+
+ app = hw.run_hardware_form(
+ [hw.hw_state(XML_COMPLET)],
+ NODE,
+ nets=["network:default", "bridge:br0"],
+ run_app=False,
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ app.query_one("#cpu0", Select).value = "host-model"
+ app.query_one("#heads0", Input).value = "2"
+ app.query_one("#net0", Select).value = "bridge:br0"
+ await pilot.press("ctrl+s")
+ await pilot.pause()
+ want = app.want["erplibre-ubuntu-2604-gnome"]
+ self.assertEqual("host-model", want["cpu"])
+ self.assertEqual(2, want["heads"])
+ self.assertEqual("bridge:br0", want["net"])
+
+ async def test_the_screens_value_is_actually_visible(self):
+ """Sous six colonnes, Textual dessine le cadre du champ mais PAS son
+ contenu : la valeur devient invisible, et on valide un champ qu'on
+ croit vide. Pire qu'une troncature, donc vérifié à l'écran."""
+ import re
+
+ app = hw.run_hardware_form(
+ [dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False
+ )
+ async with app.run_test(size=(80, 24)) as pilot:
+ await pilot.pause()
+ svg = app.export_screenshot()
+ rendu = re.findall(r">([^<>]+)", svg)
+ self.assertIn("3", [txt.strip() for txt in rendu])
+
+ async def test_without_networks_to_offer_there_is_no_network_field(self):
+ """Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne
+ vaut pas la place qu'elle prend."""
+ app = hw.run_hardware_form(
+ [dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ self.assertEqual(0, len(app.query("#net0")))
+ await pilot.press("ctrl+s")
+ await pilot.pause()
+ self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"])
+
+ async def test_the_current_network_stays_selected(self):
+ """La liste montre ce que la VM a : sans cela, valider sans y toucher
+ la basculerait sur le premier choix de la liste."""
+ from textual.widgets import Select
+
+ app = hw.run_hardware_form(
+ [hw.hw_state(XML_COMPLET)],
+ NODE,
+ nets=["bridge:br0", "network:default"],
+ run_app=False,
+ )
+ async with app.run_test() as pilot:
+ await pilot.pause()
+ self.assertEqual(
+ "network:default", app.query_one("#net0", Select).value
+ )
+ await pilot.press("ctrl+s")
+ await pilot.pause()
+ want = app.want["erplibre-ubuntu-2604-gnome"]
+ self.assertEqual("network:default", want["net"])
+ self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE))
+
+ async def test_it_fits_in_eighty_columns(self):
+ """Un terminal de 80 colonnes est le plus petit qu'on rencontre ;
+ au-delà, les libellés se tronquent en « Démarrage automatiq… »."""
+ import re
+
+ rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)]
+ app = hw.run_hardware_form(
+ rows, NODE, nets=["network:default", "bridge:br0"], run_app=False
+ )
+ async with app.run_test(size=(80, 24)) as pilot:
+ await pilot.pause()
+ svg = app.export_screenshot()
+ texte = " ".join(re.findall(r">([^<>]+)", svg))
+ self.assertNotIn("…", texte)
+
+
+class TestCpuScreensNetwork(unittest.TestCase):
+ """Les trois réglages ajoutés, et pourquoi chacun est celui-là.
+
+ Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU
+ (max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé.
+ """
+
+ def _plan(self, xml, want, node=NODE):
+ return hw.hw_plan(hw.hw_state(xml), want, node)
+
+ def test_it_reads_the_cpu_mode_screens_and_network(self):
+ st = hw.hw_state(XML_COMPLET)
+ self.assertEqual("host-passthrough", st["cpu"])
+ self.assertEqual(1, st["heads"])
+ self.assertEqual("network:default", st["net"])
+
+ def test_a_vm_without_an_interface_has_no_network(self):
+ self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"])
+
+ def test_a_bridge_is_read_as_a_bridge(self):
+ """libvirt refuse type='network' avec un pont pour source : le type et
+ le nom doivent voyager ensemble."""
+ xml = XML_COMPLET.replace(
+ "", ""
+ ).replace("", "")
+ self.assertEqual("bridge:br0", hw.hw_state(xml)["net"])
+
+ def test_the_label_marks_the_bridge_only(self):
+ """Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le
+ libellé au-delà de la liste déroulante, qui se repliait."""
+ self.assertEqual("default", hw.net_label("network:default"))
+ self.assertIn("br0", hw.net_label("bridge:br0"))
+ self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0"))
+
+ def test_the_spec_names_the_right_virt_xml_key(self):
+ self.assertEqual("network=default", hw.net_spec("network:default"))
+ self.assertEqual("bridge=br0", hw.net_spec("bridge:br0"))
+
+ def test_passthrough_keeps_check_and_migratable(self):
+ """C'est ce que virt-install écrit, et ce que veut la virtualisation
+ imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé.
+ """
+ xml = XML_COMPLET.replace(
+ "",
+ "",
+ )
+ plan = self._plan(xml, {"cpu": "host-passthrough"})
+ arg = plan[0]["cmd"][-1]
+ self.assertIn("check=none", arg)
+ self.assertIn("migratable=on", arg)
+
+ def test_host_model_carries_nothing_extra(self):
+ plan = self._plan(XML_COMPLET, {"cpu": "host-model"})
+ self.assertEqual("host-model", plan[0]["cmd"][-1])
+
+ def test_the_same_cpu_mode_changes_nothing(self):
+ self.assertEqual(
+ [], self._plan(XML_COMPLET, {"cpu": "host-passthrough"})
+ )
+
+ def test_screens_go_through_the_video_model(self):
+ """« heads » devient max_outputs sur la ligne QEMU — vérifié par
+ domxml-to-native. C'est le seul réglage vidéo qui y arrive."""
+ plan = self._plan(XML_COMPLET, {"heads": 2})
+ self.assertIn("model.heads=2", plan[0]["cmd"])
+
+ def test_screens_on_a_screenless_vm_are_refused_not_attempted(self):
+ """« --edit --video » sortirait en erreur au milieu du lot, et les
+ commandes suivantes ne partiraient pas."""
+ plan = self._plan(XML_SERVEUR, {"heads": 2})
+ self.assertEqual(1, len(plan))
+ self.assertIn("skip", plan[0])
+
+ def test_screens_and_3d_are_two_separate_edits(self):
+ """Vérifié sur un domaine réel : le second « --edit --video » ne
+ remet pas heads à 1, et le premier ne perd pas l'accélération."""
+ plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True})
+ videos = [e for e in plan if "--video" in e.get("cmd", [])]
+ self.assertEqual(2, len(videos))
+ heads = [e for e in videos if "model.heads=2" in e["cmd"]]
+ self.assertEqual(1, len(heads))
+ self.assertNotIn("accel3d", " ".join(heads[0]["cmd"]))
+
+ def test_switching_to_a_bridge(self):
+ """Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel,
+ démarré : sans cela l'invité verrait une carte neuve, et son bail
+ DHCP comme son nom d'interface changeraient."""
+ plan = self._plan(XML_COMPLET, {"net": "bridge:br0"})
+ self.assertIn("bridge=br0", plan[0]["cmd"])
+ self.assertNotIn("mac", " ".join(plan[0]["cmd"]))
+
+ def test_switching_back_to_a_libvirt_network(self):
+ xml = XML_COMPLET.replace(
+ "", ""
+ ).replace("", "")
+ plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE)
+ self.assertIn("network=default", plan[0]["cmd"])
+
+ def test_the_same_network_changes_nothing(self):
+ self.assertEqual(
+ [], self._plan(XML_COMPLET, {"net": "network:default"})
+ )
+
+ def test_a_vm_without_an_interface_is_told_not_attempted(self):
+ plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"})
+ self.assertEqual(1, len(plan))
+ self.assertIn("skip", plan[0])
+
+ def test_an_unknown_current_cpu_mode_stays_offered(self):
+ """Une VM en mode « custom » ne doit pas voir son réglage disparaître
+ d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le
+ sien, et valider le formulaire le changerait sans le dire."""
+ modes = hw.cpu_choices([{"cpu": "custom"}])
+ self.assertIn("custom", modes)
+ self.assertIn("host-passthrough", modes)
+
+ def test_the_network_list_merges_the_host_and_the_current_value(self):
+ choices = hw.net_choices(
+ [{"net": "bridge:br9"}], ["network:default", "bridge:br9"]
+ )
+ self.assertEqual(
+ ["network:default", "bridge:br9"], [tok for tok, _ in choices]
+ )
+
+ def test_empty_answers_keep_the_current_hardware(self):
+ st = hw.hw_state(XML_COMPLET)
+ want = hw.build_want(
+ st, "", "", False, False, cpu="", heads="", net=""
+ )
+ self.assertEqual("host-passthrough", want["cpu"])
+ self.assertEqual(1, want["heads"])
+ self.assertEqual("network:default", want["net"])
+ self.assertEqual([], hw.hw_plan(st, want, ""))
+
+
+class TestHostNetworks(unittest.TestCase):
+ """Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui."""
+
+ def _choices(self, nets, infos, bridges):
+ todo = TODO.__new__(TODO)
+ sorties = {}
+ sorties["net-list"] = nets
+ sorties["bridge"] = bridges
+ sorties.update(infos)
+
+ def fake(cmd):
+ if "net-list" in cmd:
+ return sorties["net-list"]
+ if "net-info" in cmd:
+ return sorties.get(cmd[-1], [])
+ return sorties["bridge"]
+
+ todo._qemu_cmd_lines = fake
+ return todo._qemu_net_choices()
+
+ def test_a_libvirt_owned_bridge_is_not_offered_twice(self):
+ """virbr0 EST le réseau « default » : l'offrir aussi comme pont
+ proposerait deux fois le même chemin, dont un qui contourne la
+ gestion du réseau par libvirt."""
+ got = self._choices(
+ ["default"],
+ {"default": ["Name: default", "Bridge: virbr0"]},
+ ["3: virbr0: "],
+ )
+ self.assertEqual(["network:default"], got)
+
+ def test_a_real_bridge_is_offered(self):
+ got = self._choices(
+ ["default"],
+ {"default": ["Bridge: virbr0"]},
+ [
+ "3: virbr0: ",
+ "4: br0: ",
+ ],
+ )
+ self.assertEqual(["network:default", "bridge:br0"], got)
+
+ def test_a_host_without_libvirt_answers_nothing(self):
+ todo = TODO.__new__(TODO)
+ todo._qemu_cmd_lines = lambda cmd: []
+ self.assertEqual([], todo._qemu_net_choices())
+
+ def test_the_persistent_definition_is_what_gets_read(self):
+ """Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE
+ (portid, vnetN, alias) — pas la définition que virt-xml modifie."""
+ vu = {}
+
+ def fake_run(cmd, **kw):
+ vu["cmd"] = cmd
+ return subprocess.CompletedProcess([], 0, "", "")
+
+ with mock.patch("subprocess.run", side_effect=fake_run):
+ TODO._qemu_dumpxml("vm-a")
+ self.assertIn("--inactive", vu["cmd"])
+
+ def test_a_numbered_pick_defaults_to_the_current_value(self):
+ todo = TODO.__new__(TODO)
+ out = io.StringIO()
+ for reponse, attendu in (
+ ("", "b"),
+ ("mille", "b"),
+ ("9", "b"),
+ ("1", "a"),
+ ("2", "b"),
+ ):
+ with mock.patch("builtins.input", lambda *a, r=reponse: r):
+ with contextlib.redirect_stdout(out):
+ got = todo._qemu_pick("t", ["a", "b"], "b")
+ self.assertEqual(attendu, got, reponse)
+
+
+if __name__ == "__main__":
+ unittest.main(verbosity=1)
diff --git a/test/test_qemu_install_summary.py b/test/test_qemu_install_summary.py
new file mode 100644
index 0000000..0ddd710
--- /dev/null
+++ b/test/test_qemu_install_summary.py
@@ -0,0 +1,204 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Résumé d'un journal d'installation : ce qui a échoué doit se voir.
+
+Le détail des erreurs cherchait la sous-chaîne « error ». Or le journal de
+l'installation qui a réellement échoué — erplibre-ubuntu-2604-gnome, APK tué
+par le noyau — ne contient AUCUNE ligne « error » : 0 sur 8765, mesuré. Le
+volet annonçait donc « aucune erreur détectée » sur une installation ratée,
+et le tableau de bord comptait 0 erreur.
+
+Ces tests fixent la règle inverse : une étape en échec, un « FAILURE » de
+Gradle, une trace Python ou une mort par mémoire se voient, et le résumé les
+présente AVANT les centaines de lignes du détail.
+"""
+
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
+import qemu_install_monitor as m # noqa: E402
+
+# Journal réduit à sa forme réelle : les marqueurs de l'installation, puis
+# l'échec tel que Gradle l'écrit. Aucune ligne ne contient « error ».
+LOG_GRADLE_OOM = """\
+== ERPLibre mobile, SDK Android (long) ==
+ -> venv ERPLibre (tout ce qui suit en dépend)
+ -> dépendances npm
+ -> APK debug (gradle)
+ ⚠ ÉCHEC : APK debug (gradle)
+ aucun motif connu, dernières lignes :
+
+ FAILURE: Build failed with an exception.
+
+ * What went wrong:
+ Gradle build daemon disappeared unexpectedly (it may have been killed)
+ ⚠ aucun APK produit
+__ERPLIBRE_EXIT__ 1
+"""
+
+
+def _log(text):
+ fh = tempfile.NamedTemporaryFile(
+ "w", suffix=".log", delete=False, encoding="utf-8"
+ )
+ fh.write(text)
+ fh.close()
+ return fh.name
+
+
+class TestFailedStepsAreSeen(unittest.TestCase):
+ def setUp(self):
+ self.path = _log(LOG_GRADLE_OOM)
+
+ def tearDown(self):
+ Path(self.path).unlink(missing_ok=True)
+
+ def test_the_reference_log_has_no_line_saying_error(self):
+ """La prémisse de tout le reste : la détection par sous-chaîne ne
+ pouvait RIEN trouver ici."""
+ self.assertNotIn("error", LOG_GRADLE_OOM.lower())
+
+ def test_the_failed_step_is_named(self):
+ got = m.scan_log_summary(self.path)
+ self.assertEqual(
+ [s["label"] for s in got["steps"]], ["APK debug (gradle)"]
+ )
+
+ def test_the_step_carries_its_diagnostic(self):
+ """L'échec nomme l'étape ; c'est le diagnostic qui porte la cause."""
+ diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
+ self.assertIn("FAILURE: Build failed", diag)
+ self.assertIn("daemon disappeared", diag)
+
+ def test_the_exit_marker_is_not_a_diagnostic(self):
+ diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"])
+ self.assertNotIn(m.EXIT_MARKER, diag)
+
+ def test_the_diagnostic_stops_at_the_next_step(self):
+ """Sinon le diagnostic avale la suite de l'installation et ne désigne
+ plus rien."""
+ text = LOG_GRADLE_OOM + " -> étape suivante\n bruit\n"
+ path = _log(text)
+ try:
+ diag = "\n".join(m.scan_log_summary(path)["steps"][0]["diag"])
+ finally:
+ Path(path).unlink(missing_ok=True)
+ self.assertNotIn("bruit", diag)
+
+ def test_hard_signals_are_listed(self):
+ hard = " ".join(
+ h["text"] for h in m.scan_log_summary(self.path)["hard"]
+ )
+ self.assertIn("FAILURE", hard)
+ self.assertIn("disappeared unexpectedly", hard)
+
+ def test_the_dashboard_no_longer_counts_zero_errors(self):
+ """Le compte alimente le tableau de bord : « 0 erreur » sur une
+ installation morte est un mensonge, pas une nuance."""
+ nerr, _ = m.scan_log_errors(self.path)
+ self.assertGreater(nerr, 0)
+
+ def test_the_detail_pane_is_no_longer_empty(self):
+ errs, _ = m.scan_log_error_lines(self.path)
+ self.assertTrue(errs)
+ self.assertTrue(any("ÉCHEC" in e or "FAILURE" in e for e in errs))
+
+
+class TestOtherRealFailures(unittest.TestCase):
+ """Chaque motif dur est là parce qu'il est apparu dans un vrai journal."""
+
+ def _first_hard(self, line):
+ path = _log(f" -> étape\n{line}\n")
+ try:
+ return m.scan_log_summary(path)["hard"]
+ finally:
+ Path(path).unlink(missing_ok=True)
+
+ def test_python_traceback(self):
+ self.assertTrue(self._first_hard("Traceback (most recent call last):"))
+
+ def test_git_fatal(self):
+ self.assertTrue(self._first_hard("fatal: repository not found"))
+
+ def test_apt_missing_package(self):
+ self.assertTrue(
+ self._first_hard("E: Unable to locate package python3.12-venv")
+ )
+
+ def test_kernel_oom(self):
+ self.assertTrue(
+ self._first_hard("Out of memory: Killed process 37603 (java)")
+ )
+
+ def test_missing_command(self):
+ self.assertTrue(self._first_hard("bash: emulator: command not found"))
+
+ def test_a_benign_probe_is_not_a_hard_signal(self):
+ """« No such file or directory » sortait 5 fois sur 7 d'une sonde
+ bénigne (« cat: .odoo-version ») : le bruit dilue un résumé dont tout
+ l'intérêt est d'être court."""
+ self.assertFalse(
+ self._first_hard("cat: .odoo-version: No such file or directory")
+ )
+
+
+class TestGrouping(unittest.TestCase):
+ def test_repeats_are_counted_not_repeated(self):
+ """Un journal répète la même erreur des centaines de fois avec un
+ chemin qui change : on veut « ×200 », pas 200 lignes."""
+ lines = "\n".join(
+ f"ERROR: cannot read /var/lib/x/file{i}.txt" for i in range(200)
+ )
+ path = _log(lines + "\n")
+ try:
+ groups = m.scan_log_summary(path)["groups"]
+ finally:
+ Path(path).unlink(missing_ok=True)
+ self.assertEqual(len(groups), 1)
+ self.assertEqual(groups[0]["count"], 200)
+
+ def test_the_most_frequent_comes_first(self):
+ path = _log(
+ "ERROR: rare thing\n"
+ + "\n".join(f"ERROR: common {i}" for i in range(5))
+ + "\n"
+ )
+ try:
+ groups = m.scan_log_summary(path)["groups"]
+ finally:
+ Path(path).unlink(missing_ok=True)
+ self.assertEqual(groups[0]["count"], 5)
+
+ def test_warnings_are_grouped_apart_from_errors(self):
+ path = _log("WARNING: a\nERROR: b\n")
+ try:
+ kinds = {g["kind"] for g in m.scan_log_summary(path)["groups"]}
+ finally:
+ Path(path).unlink(missing_ok=True)
+ self.assertEqual(kinds, {"error", "warning"})
+
+
+class TestQuietLogs(unittest.TestCase):
+ def test_a_clean_log_stays_clean(self):
+ """Le résumé ne doit pas inventer d'échec là où il n'y en a pas."""
+ path = _log("== installation ==\n -> étape\n ✅ terminé\n")
+ try:
+ got = m.scan_log_summary(path)
+ finally:
+ Path(path).unlink(missing_ok=True)
+ self.assertEqual(got["steps"], [])
+ self.assertEqual(got["hard"], [])
+ self.assertEqual(got["groups"], [])
+
+ def test_a_missing_log_is_not_a_crash(self):
+ got = m.scan_log_summary("/nonexistent/erplibre.log")
+ self.assertEqual(got["steps"], [])
+ self.assertEqual(got["nerr"], 0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_monitor_telemetry.py b/test/test_qemu_monitor_telemetry.py
new file mode 100644
index 0000000..0ec786a
--- /dev/null
+++ b/test/test_qemu_monitor_telemetry.py
@@ -0,0 +1,206 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque.
+
+La RAM manquait, et son absence a coûté : une compilation mobile s'est fait
+tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait
+sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part
+ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt.
+
+Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir
+rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible
+laisse la barre utile au lieu de la vider.
+"""
+
+import json
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo"))
+import qemu_install_monitor as m # noqa: E402
+
+GB = 1 << 30
+
+
+class TestHostMem(unittest.TestCase):
+ def test_it_reads_the_real_proc(self):
+ total, avail, sw_total, sw_free = m._host_mem()
+ self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo")
+ self.assertLessEqual(avail, total)
+ self.assertLessEqual(sw_free, sw_total)
+
+ def test_it_reports_what_the_kernel_can_give_back(self):
+ """« MemAvailable » et non « MemFree » : sur une machine qui travaille,
+ MemFree est presque nul parce que le cache occupe le reste, et
+ alarmerait pour rien."""
+ proc = (
+ "MemTotal: 12000000 kB\n"
+ "MemFree: 100000 kB\n"
+ "MemAvailable: 8000000 kB\n"
+ "SwapTotal: 0 kB\n"
+ "SwapFree: 0 kB\n"
+ )
+ with mock.patch("builtins.open", mock.mock_open(read_data=proc)):
+ total, avail, _, _ = m._host_mem()
+ self.assertEqual(avail, 8000000 * 1024)
+ self.assertEqual(total, 12000000 * 1024)
+
+ def test_an_unreadable_proc_gives_zeros_not_a_crash(self):
+ """Le suivi tourne pendant une heure d'installation : il ne meurt pas
+ parce qu'une lecture a échoué."""
+ with mock.patch("builtins.open", side_effect=OSError):
+ self.assertEqual(m._host_mem(), (0, 0, 0, 0))
+
+ def test_a_malformed_line_is_not_fatal(self):
+ with mock.patch(
+ "builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n")
+ ):
+ self.assertEqual(m._host_mem(), (0, 0, 0, 0))
+
+
+class TestMemSegment(unittest.TestCase):
+ def test_the_used_share_is_total_minus_available(self):
+ got = m._mem_tele(12 * GB, 3 * GB, 0, 0)
+ self.assertIn("9.0G/12.0G", got)
+ self.assertIn("(75%)", got)
+
+ def test_the_available_figure_is_shown_as_such(self):
+ self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0))
+
+ def test_swap_appears_only_when_the_machine_has_some(self):
+ """Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès
+ qu'il existe, il est montré même à zéro : une machine qui commence à
+ échanger explique une lenteur, et c'est ce qu'on cherche ici."""
+ self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0))
+ self.assertIn(
+ "swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB)
+ )
+ self.assertIn(
+ "swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB)
+ )
+
+ def test_nothing_read_means_no_segment_not_a_zero_segment(self):
+ """La barre garde alors le CPU et le disque, qui eux ont répondu."""
+ self.assertEqual(m._mem_tele(0, 0, 0, 0), "")
+
+ def test_more_available_than_total_does_not_show_negative_use(self):
+ """Cas absurde mais possible entre deux lectures : on ne veut pas
+ « -1.0G » dans la barre."""
+ self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0))
+
+
+class TestLogSilence(unittest.TestCase):
+ """Une installation morte et une qui travaille portent le même sablier.
+
+ Vécu : une session ssh emportée, l'installation morte sans marqueur de
+ sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le
+ marqueur manque dans les deux cas — seule la date d'écriture du journal
+ les sépare.
+ """
+
+ def setUp(self):
+ self.tmp = tempfile.TemporaryDirectory()
+ self.log = Path(self.tmp.name) / "vm.log"
+ self.log.write_text("== installation ==\n")
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_a_fresh_log_is_not_silent(self):
+ self.assertLess(m.log_idle(str(self.log)), 5)
+
+ def test_an_old_log_reports_its_age(self):
+ import os
+ import time
+
+ old = time.time() - 3000
+ os.utime(self.log, (old, old))
+ self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5)
+
+ def test_a_missing_log_is_not_reported_as_silent(self):
+ """-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout
+ pas « silencieux depuis toujours »."""
+ self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0)
+ self.assertEqual(m.state_mark("⏳", -1.0), "⏳")
+
+ def test_below_the_threshold_the_cell_stays_bare(self):
+ """Plusieurs étapes sont muettes quelques minutes — un téléchargement
+ d'Android Studio, une compilation dont la sortie va ailleurs."""
+ self.assertEqual(m.state_mark("⏳", 0), "⏳")
+ self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳")
+
+ def test_above_the_threshold_the_cell_says_how_long(self):
+ mark = m.state_mark("⏳", 2900)
+ self.assertNotEqual(mark, "⏳")
+ self.assertIn("48", mark)
+
+ def test_the_threshold_clears_the_longest_measured_silence(self):
+ """Mesuré : le téléchargement d'Android Studio reste ~5 min sans une
+ ligne. Un seuil en dessous transformerait chaque installation en alerte,
+ et l'alerte cesserait d'être lue."""
+ studio_download = 5 * 60
+ self.assertGreater(m.IDLE_HINT_SECS, studio_download)
+ # Et pas si haut qu'une installation morte passe la demi-heure.
+ self.assertLessEqual(m.IDLE_HINT_SECS, 1200)
+
+
+class TestTheRealBar(unittest.TestCase):
+ """La barre telle que le suivi la construit, sans lancer la TUI."""
+
+ def setUp(self):
+ try:
+ import textual # noqa: F401
+ except ImportError:
+ self.skipTest("textual absent de ce venv")
+ self.tmp = tempfile.TemporaryDirectory()
+ log = Path(self.tmp.name) / "vm-a.log"
+ log.write_text("== installation ==\n")
+ manifest = Path(self.tmp.name) / "session.json"
+ manifest.write_text(
+ json.dumps(
+ {
+ "branch": "develop",
+ "started": 0,
+ "vms": [
+ {
+ "name": "vm-a",
+ "ip": "192.168.123.2",
+ "log": str(log),
+ "ssh": "ssh erplibre@192.168.123.2",
+ }
+ ],
+ }
+ )
+ )
+ self.app = m.run_monitor(str(manifest), run_app=False)
+
+ def tearDown(self):
+ self.tmp.cleanup()
+
+ def test_the_bar_carries_the_three_resources(self):
+ bar = self.app._collect_tele()
+ self.assertIn("CPU", bar)
+ self.assertIn("RAM", bar)
+ self.assertIn("💽", bar)
+
+ def test_ram_sits_between_cpu_and_disk(self):
+ """L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle
+ part, le disque partout."""
+ bar = self.app._collect_tele()
+ self.assertLess(bar.index("CPU"), bar.index("RAM"))
+ self.assertLess(bar.index("RAM"), bar.index("💽"))
+
+ def test_the_bar_survives_a_mute_proc(self):
+ with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)):
+ bar = self.app._collect_tele()
+ self.assertIn("CPU", bar)
+ self.assertIn("💽", bar)
+ self.assertNotIn("RAM", bar)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py
new file mode 100644
index 0000000..8156cd9
--- /dev/null
+++ b/test/test_qemu_motd.py
@@ -0,0 +1,407 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Guide de connexion SSH des VM QEMU et identité git injectée.
+
+Ces fonctions sont PURES : elles rendent du texte. Les tester ne demande donc
+ni VM ni réseau, alors qu'une erreur y coûte cher — un user-data invalide fait
+rejeter TOUTE la configuration cloud-init, et la VM démarre sans utilisateur ni
+clé SSH, donc inaccessible.
+"""
+
+import importlib.util
+import os
+import unittest
+
+REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+DEPLOY_QEMU = os.path.join(REPO, "script", "qemu", "deploy_qemu.py")
+
+# script/qemu/ n'est pas un paquet : todo.py importe déjà ce fichier de cette
+# façon (_qemu_import_module), le test fait pareil.
+_spec = importlib.util.spec_from_file_location("deploy_qemu", DEPLOY_QEMU)
+dq = importlib.util.module_from_spec(_spec)
+_spec.loader.exec_module(dq)
+
+try:
+ import yaml
+except ImportError: # pragma: no cover - PyYAML absent du venv d'outils
+ yaml = None
+
+# Une combinaison par distribution du catalogue, dont les DEUX produits
+# openSUSE : ils n'ont pas la même commande de mise à jour.
+COMBOS = (
+ ("ubuntu", "24.04", "amd64"),
+ ("debian", "12", "s390x"),
+ ("fedora", "43", "amd64"),
+ ("almalinux", "9", "arm64"),
+ ("rocky", "10", "amd64"),
+ ("opensuse", "16.0", "amd64"),
+ ("opensuse", "tumbleweed", "amd64"),
+ ("arch", "latest", "amd64"),
+)
+
+# Largeur d'un terminal standard. Au-delà, le guide se replie et devient
+# illisible — c'est le seul défaut qui ne se voit qu'une fois la VM déployée.
+TERM_WIDTH = 80
+
+
+class TestMotdContent(unittest.TestCase):
+ def test_each_distro_gets_its_package_manager(self):
+ expected = {
+ "ubuntu": "apt",
+ "debian": "apt",
+ "fedora": "dnf",
+ "almalinux": "dnf",
+ "rocky": "dnf",
+ "opensuse": "zypper",
+ "arch": "pacman",
+ }
+ for distro, version, arch in COMBOS:
+ motd = dq.build_motd(distro, version, arch)
+ mgr = expected[distro]
+ self.assertIn(f"Paquets — {mgr}", motd, distro)
+ for other in set(expected.values()) - {mgr}:
+ self.assertNotIn(f" {other} install", motd, distro)
+
+ def test_leap_updates_with_up_and_tumbleweed_with_dup(self):
+ """La distinction coûte cher à rater : « up » sur Tumbleweed laisse
+ traîner des paquets retirés des dépôts."""
+ leap = dq.build_motd("opensuse", "16.0", "amd64")
+ rolling = dq.build_motd("opensuse", "tumbleweed", "amd64")
+ self.assertIn("sudo zypper up", leap)
+ self.assertNotIn("sudo zypper dup", leap)
+ self.assertIn("sudo zypper dup", rolling)
+ self.assertNotIn("sudo zypper up ", rolling)
+
+ def test_pacman_never_suggests_a_bare_sy(self):
+ motd = dq.build_motd("arch", "latest", "amd64")
+ for line in motd.splitlines():
+ self.assertNotIn("pacman -Sy ", line)
+ self.assertNotIn("pacman -Sy\n", line)
+
+ def test_header_names_the_distribution(self):
+ self.assertIn(
+ "openSUSE Leap 16.0", dq.build_motd("opensuse", "16.0", "x")
+ )
+ self.assertIn(
+ "openSUSE Tumbleweed", dq.build_motd("opensuse", "tumbleweed", "x")
+ )
+ # Rolling release : le numéro « latest » n'apprend rien.
+ self.assertIn("Arch Linux ·", dq.build_motd("arch", "latest", "amd64"))
+
+
+class TestMotdErplibreSection(unittest.TestCase):
+ def test_absent_without_install_dir(self):
+ """Une VM déployée sans ERPLibre ne doit pas annoncer un dépôt ni un
+ service qui n'existent pas."""
+ motd = dq.build_motd("ubuntu", "24.04", "amd64")
+ self.assertNotIn("ERPLibre\n", motd.split("╯", 1)[1])
+ self.assertNotIn("erplibre.service", motd)
+ self.assertNotIn("make todo", motd)
+ # En l'absence de section ERPLibre, les commandes de service doivent
+ # apparaître dans le bloc système : sinon elles manqueraient partout.
+ self.assertIn("systemctl status ", motd)
+ self.assertIn("journalctl -u -f", motd)
+
+ def test_covers_what_an_operator_needs(self):
+ motd = dq.build_motd(
+ "ubuntu",
+ "24.04",
+ "amd64",
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ for needed in (
+ "cd ~/git/erplibre", # aller au dépôt
+ "make todo", # menu ERPLibre
+ "vim config.conf", # éditer le serveur
+ "sudo systemctl restart erplibre", # redémarrer
+ "systemctl status erplibre", # inspecter
+ "journalctl -u erplibre -f", # inspecter
+ "update_addons_all.sh ", # mise à jour des modules
+ "git pull && make install_odoo_18", # mise à jour Odoo
+ "http://:8069", # interface web
+ ):
+ self.assertIn(needed, motd)
+
+ def test_no_editor_names_no_command(self):
+ """Sans éditeur connu, on nomme le fichier : « vi » n'est pas garanti
+ sur toutes les images cloud, et un guide qui propose une commande
+ absente est pire que muet."""
+ motd = dq.build_motd("arch", "latest", "amd64", "fr", "/opt/erplibre")
+ self.assertIn("config.conf", motd)
+ self.assertNotIn("vi config.conf", motd)
+ self.assertNotIn("nano config.conf", motd)
+
+ def test_no_make_target_stops_at_git_pull(self):
+ """Les profils sans Odoo (« ERPLibre seul », « mobile ») ne doivent pas
+ se voir annoncer une cible make qui n'est pas la leur."""
+ motd = dq.build_motd(
+ "ubuntu", "24.04", "amd64", "fr", "~/git/erplibre", "", "vim"
+ )
+ self.assertIn("git pull", motd)
+ self.assertNotIn("git pull && make", motd)
+
+
+class TestMotdLayout(unittest.TestCase):
+ def test_never_wider_than_a_standard_terminal(self):
+ for distro, version, arch in COMBOS:
+ for lang in ("fr", "en"):
+ for el_dir in ("", "~/git/erplibre", "/opt/erplibre"):
+ motd = dq.build_motd(
+ distro,
+ version,
+ arch,
+ lang,
+ el_dir,
+ "install_odoo_18" if el_dir else "",
+ "vim" if el_dir else "",
+ )
+ for line in motd.splitlines():
+ self.assertLessEqual(
+ len(line),
+ TERM_WIDTH,
+ f"{distro} {version} {lang} {el_dir} : {line}",
+ )
+
+ def test_the_frame_is_never_narrower_than_what_it_frames(self):
+ for distro, version, arch in COMBOS:
+ motd = dq.build_motd(
+ distro,
+ version,
+ arch,
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ lines = motd.splitlines()
+ frame = len(lines[0])
+ self.assertTrue(lines[0].startswith("╭"))
+ for line in lines:
+ self.assertLessEqual(len(line), frame, f"{distro} : {line}")
+
+ def test_no_tab_anywhere(self):
+ """Une tabulation en tête de ligne est une erreur FATALE dans un
+ scalaire bloc YAML : cloud-init rejette alors tout le user-data."""
+ motd = dq.build_motd(
+ "ubuntu",
+ "24.04",
+ "amd64",
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ self.assertNotIn("\t", motd)
+
+ def test_english_is_really_english(self):
+ motd = dq.build_motd(
+ "ubuntu",
+ "24.04",
+ "amd64",
+ "en",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ self.assertIn("Packages — apt", motd)
+ self.assertIn("upgrade the system", motd)
+ self.assertNotIn("mettre à jour", motd)
+
+
+class TestGitConfig(unittest.TestCase):
+ def test_sections_and_values(self):
+ cfg = dq.build_gitconfig("Ada Lovelace", "ada@example.org", "vim")
+ self.assertIn("[user]", cfg)
+ self.assertIn("name = Ada Lovelace", cfg)
+ self.assertIn("email = ada@example.org", cfg)
+ self.assertIn("[core]", cfg)
+ self.assertIn("editor = vim", cfg)
+
+ def test_indented_with_spaces_never_tabs(self):
+ """git accepte les deux ; le scalaire bloc YAML qui transporte ce texte,
+ non — une tabulation y fait rejeter tout le user-data."""
+ self.assertNotIn("\t", dq.build_gitconfig("A", "a@b.c", "vim"))
+
+ def test_empty_when_the_host_has_nothing_to_pass(self):
+ self.assertEqual("", dq.build_gitconfig("", "", ""))
+
+ def test_partial_identity_omits_the_missing_key(self):
+ cfg = dq.build_gitconfig("Ada", "", "")
+ self.assertIn("name = Ada", cfg)
+ self.assertNotIn("email", cfg)
+ self.assertNotIn("[core]", cfg)
+
+
+class TestEditorResolution(unittest.TestCase):
+ def test_known_editors_map_to_package_and_binary(self):
+ self.assertEqual(("vim", "vim"), dq.EDITOR_PACKAGES["vi"])
+ self.assertEqual(("neovim", "nvim"), dq.EDITOR_PACKAGES["nvim"])
+ self.assertEqual(("nano", "nano"), dq.EDITOR_PACKAGES["nano"])
+
+ def test_unknown_editor_is_ignored_not_guessed(self):
+ """« code » n'est dans aucun dépôt de distribution : l'annoncer
+ donnerait un core.editor qui fait échouer « git commit »."""
+ self.assertNotIn("code", dq.EDITOR_PACKAGES)
+ self.assertEqual(("", ""), dq.EDITOR_PACKAGES.get("code", ("", "")))
+
+ def test_binary_drops_the_path_and_the_options(self):
+ self.assertEqual("code", dq.editor_binary("/usr/bin/code --wait"))
+ self.assertEqual("vim", dq.editor_binary("vim"))
+ self.assertEqual("", dq.editor_binary(" "))
+
+
+class TestWriteFilesBlock(unittest.TestCase):
+ def _block(self):
+ return dq.write_files_lines(
+ [
+ (
+ "/etc/motd",
+ "0644",
+ dq.build_motd("ubuntu", "24.04", "amd64"),
+ "",
+ ),
+ (
+ "/home/erplibre/.gitconfig",
+ "0644",
+ dq.build_gitconfig("Ada", "ada@example.org", "vim"),
+ "erplibre",
+ ),
+ ]
+ )
+
+ def test_permissions_are_quoted(self):
+ """« permissions: 644 » non quoté est lu en DÉCIMAL et appliqué tel
+ quel : 0o1204, soit le bit setuid, sans le moindre avertissement."""
+ block = "\n".join(self._block())
+ self.assertIn("permissions: '0644'", block)
+ self.assertNotIn("permissions: 0644", block)
+
+ def test_defer_only_for_owned_files(self):
+ """write_files tourne AVANT la création des utilisateurs : sans
+ « defer », le chown vers le compte de la VM échoue."""
+ block = "\n".join(self._block())
+ self.assertEqual(1, block.count("defer: true"))
+ self.assertEqual(1, block.count("owner: erplibre:erplibre"))
+
+ @unittest.skipIf(yaml is None, "PyYAML absent")
+ def test_yaml_round_trip_is_byte_identical(self):
+ """Le scalaire bloc doit rendre EXACTEMENT le texte d'origine : une
+ indentation mal calculée passerait la validation en abîmant le fichier
+ écrit dans la VM."""
+ motd = dq.build_motd(
+ "ubuntu",
+ "24.04",
+ "amd64",
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ # « + "\n" » comme build_cloud_config, qui termine toujours le
+ # document : sans ce saut final, un scalaire bloc en fin de flux perd sa
+ # dernière fin de ligne.
+ doc = (
+ "\n".join(dq.write_files_lines([("/etc/motd", "0644", motd, "")]))
+ + "\n"
+ )
+ self.assertEqual(
+ motd, yaml.safe_load(doc)["write_files"][0]["content"]
+ )
+
+ @unittest.skipIf(yaml is None, "PyYAML absent")
+ def test_every_distro_produces_parsable_yaml(self):
+ for distro, version, arch in COMBOS:
+ motd = dq.build_motd(
+ distro,
+ version,
+ arch,
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ )
+ doc = (
+ "\n".join(
+ dq.write_files_lines([("/etc/motd", "0644", motd, "")])
+ )
+ + "\n"
+ )
+ self.assertEqual(
+ motd, yaml.safe_load(doc)["write_files"][0]["content"], distro
+ )
+
+
+class TestInstallerGuideNames(unittest.TestCase):
+ def test_names_are_flat(self):
+ """Le cpio est déplié séquentiellement et ne crée pas les répertoires
+ parents manquants : une entrée « erplibre/etc-motd » sans entrée
+ « erplibre » ferait échouer le dépliage de l'initrd entier."""
+ for path in ("/etc/motd", "/home/erplibre/.gitconfig"):
+ name = dq.installer_guide_name(path)
+ self.assertNotIn("/", name)
+ self.assertTrue(name.startswith("erplibre-"))
+
+ def test_two_paths_never_collide(self):
+ self.assertNotEqual(
+ dq.installer_guide_name("/etc/motd"),
+ dq.installer_guide_name("/home/erplibre/.gitconfig"),
+ )
+
+
+class TestDesktopBlock(unittest.TestCase):
+ """Le bloc « Bureau » : présent seulement là où un bureau existe.
+
+ Vécu : une VM graphique restait sur une console texte, GNOME installé et
+ gdm3 installé — graphical.target était déjà atteinte quand le paquet est
+ arrivé. La commande qui répare tient sur une ligne, encore faut-il la lire
+ quelque part. Sur un serveur, elle ne mènerait à aucune unité : le bloc
+ n'y apparaît pas.
+ """
+
+ def _motd(self, desktop):
+ return dq.build_motd(
+ "ubuntu",
+ "26.04",
+ "amd64",
+ "fr",
+ "~/git/erplibre",
+ "install_odoo_18",
+ "vim",
+ desktop,
+ )
+
+ def test_a_server_gets_no_desktop_block(self):
+ self.assertNotIn("Bureau", self._motd(False))
+
+ def test_a_graphical_vm_gets_it(self):
+ self.assertIn("Bureau", self._motd(True))
+
+ def test_it_carries_the_command_that_repairs(self):
+ """« --now » et non « enable » seul : sur Debian et Ubuntu, l'unité n'a
+ pas de WantedBy, et « enable » rend 0 sans rien faire."""
+ motd = self._motd(True)
+ self.assertIn("systemctl enable --now gdm", motd)
+ self.assertIn("systemctl status display-manager", motd)
+
+ def test_it_says_why_now_matters(self):
+ self.assertIn("--now", self._motd(True))
+
+ def test_it_stays_inside_the_frame(self):
+ """Le guide est encadré : une ligne trop longue casse la boîte."""
+ lines = self._motd(True).splitlines()
+ width = max(len(line) for line in lines)
+ border = [line for line in lines if line.startswith("╭")][0]
+ self.assertEqual(len(border), width)
+
+ def test_the_default_is_no_block(self):
+ """Un appelant qui n'en sait rien n'annonce pas un bureau."""
+ motd = dq.build_motd("ubuntu", "26.04", "amd64", "fr")
+ self.assertNotIn("Bureau", motd)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py
new file mode 100644
index 0000000..211a775
--- /dev/null
+++ b/test/test_qemu_tunnel_menu.py
@@ -0,0 +1,271 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Menu du tunnel de bureau distant : ses quatre choix, et où ils mènent.
+
+Ce menu n'avait aucun test, et c'est ainsi qu'un appel à deux arguments vers
+une fonction qui n'en prenait aucun a pu être livré : le choix « console de
+l'hyperviseur » levait un TypeError au lieu d'ouvrir quoi que ce soit. Chaque
+choix est donc atteint ici pour de vrai, jusqu'à la commande imprimée.
+
+La ligne de partage est celle du dernier saut : xrdp et TigerVNC écoutent sur
+toutes les interfaces de l'invité, donc l'hyperviseur les atteint par l'IP de
+la VM ; l'émulateur Android, lui, n'écoute que sur son 127.0.0.1, ce qui exige
+un saut de plus. Les deux formes coexistent, et ce n'est pas une incohérence.
+"""
+
+import io
+import sys
+import unittest
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+
+class _MenuCase(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
+ self.todo._qemu_list_domains = lambda: []
+ self.todo._qemu_resolve_ips = lambda names, labels=None: {
+ "vm-a": "192.168.123.81"
+ }
+ self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True))
+ self.todo._ssh_proxyjump = lambda name: "hyperviseur"
+ self.todo._qemu_vnc_port = lambda domain, jump: 5900
+
+ def _play(self, answers):
+ it = iter(answers)
+ buf = io.StringIO()
+ with mock.patch("builtins.input", lambda *a: next(it)), mock.patch(
+ "sys.stdout", buf
+ ):
+ self.todo._qemu_tunnel_menu()
+ return buf.getvalue()
+
+
+class TestTunnelMenuChoices(_MenuCase):
+ def test_the_hypervisor_console_is_reachable_at_all(self):
+ """Le défaut vécu : « _qemu_console_tunnel() takes 1 positional
+ argument but 3 were given ». Le choix 3 doit aboutir, pas lever."""
+ out = self._play(["1", "3"])
+ self.assertIn("5900", out)
+ self.assertIn("hyperviseur", out)
+
+ def test_the_console_targets_the_hypervisor_not_the_guest(self):
+ """L'écran VNC appartient à QEMU : côté invité, le socket n'existe
+ pas."""
+ out = self._play(["1", "3"])
+ self.assertIn("ssh -N -L 5900:127.0.0.1:5900 hyperviseur", out)
+
+ def test_a_domain_without_a_vnc_port_is_diagnosed_not_tunneled(self):
+ """Avec « listen=none », QEMU n'ouvre AUCUN socket : aucun tunnel n'y
+ peut rien tant que le domaine n'est pas redéfini."""
+ self.todo._qemu_vnc_port = lambda domain, jump: 0
+ out = self._play(["1", "3"])
+ self.assertIn("virsh edit", out)
+ self.assertNotIn("ssh -N -L", out)
+
+ def test_the_emulator_choice_reaches_the_adb_tunnel(self):
+ out = self._play(["1", "4", "n"])
+ self.assertIn("scrcpy", out)
+ self.assertIn("5555", out)
+
+ def test_rdp_is_the_default_and_vnc_the_second(self):
+ self.assertIn("3389", self._play(["1", ""]))
+ self.assertIn("5901", self._play(["1", "2"]))
+
+ def test_a_configured_host_rides_its_proxyjump_for_rdp(self):
+ out = self._play(["1", "1"])
+ self.assertIn("-L 3390:localhost:3389 saut+vm-a", out)
+
+ def test_an_out_of_range_choice_cancels_without_a_command(self):
+ out = self._play(["9"])
+ self.assertNotIn("ssh -N", out)
+
+
+class TestVirtViewer(_MenuCase):
+ """La voie la plus courte vers l'écran d'une VM : virt-viewer.
+
+ Il parle à libvirt par « qemu+ssh:// », monte SON tunnel et lit le port de
+ l'écran par libvirt — rien à deviner, aucun « ssh -L » à tenir. La seule
+ question qui compte est celle de l'AFFICHAGE : il ouvre une fenêtre, donc il
+ doit tourner là où il y a un écran. C'est l'environnement qui tranche.
+ """
+
+ def _play_kind5(self, env=None, which=None, popen=None):
+ it = iter(["1", "5"])
+ buf = io.StringIO()
+ stack = [
+ mock.patch("builtins.input", lambda *a: next(it)),
+ mock.patch("sys.stdout", buf),
+ mock.patch.dict("os.environ", env or {}, clear=False),
+ ]
+ if which is not None:
+ stack.append(mock.patch("shutil.which", which))
+ if popen is not None:
+ stack.append(mock.patch("subprocess.Popen", popen))
+ for ctx in stack:
+ ctx.__enter__()
+ try:
+ self.todo._qemu_tunnel_menu()
+ finally:
+ for ctx in reversed(stack):
+ ctx.__exit__(None, None, None)
+ return buf.getvalue()
+
+ def setUp(self):
+ super().setUp()
+ # Une VM libvirt LOCALE : l'URI est alors qemu:///system.
+ self.todo._ssh_config_hosts = lambda: []
+ self.todo._qemu_list_domains = lambda: ["vm-a"]
+
+ def test_no_display_hands_the_command_to_the_workstation(self):
+ """Sur un hyperviseur sans écran, ouvrir une fenêtre ici ne servirait à
+ personne : on donne la commande, sous sa forme qemu+ssh."""
+ out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""})
+ self.assertIn("virt-viewer -c qemu+ssh://", out)
+ self.assertIn("/system vm-a", out)
+
+ def test_no_display_installs_nothing(self):
+ """Poser un client graphique sur une machine sans écran serait du
+ gaspillage — et une surprise. Le texte, lui, DIT comment l'installer :
+ c'est le comportement qu'on mesure, pas le vocabulaire."""
+ ran = []
+ self.todo.execute = mock.Mock()
+ self.todo.execute.exec_command_live = lambda cmd, **kw: ran.append(cmd)
+ out = self._play_kind5(
+ env={"DISPLAY": "", "WAYLAND_DISPLAY": ""},
+ which=lambda c: None,
+ )
+ self.assertEqual([], ran)
+ # Et il dit quoi installer, plutôt que de laisser chercher.
+ self.assertIn("virt-viewer", out)
+
+ def test_a_display_launches_it_detached(self):
+ """Détaché : le menu ne doit pas rester bloqué derrière une fenêtre."""
+ spawned = {}
+
+ def fake_popen(cmd, **kw):
+ spawned["cmd"] = cmd
+ spawned["kw"] = kw
+ return mock.Mock()
+
+ out = self._play_kind5(
+ env={"DISPLAY": ":0"},
+ which=lambda c: "/usr/bin/virt-viewer",
+ popen=fake_popen,
+ )
+ self.assertEqual(
+ ["virt-viewer", "-c", "qemu:///system", "vm-a"], spawned["cmd"]
+ )
+ self.assertTrue(spawned["kw"].get("start_new_session"))
+ self.assertIn(":0", out)
+
+ def test_wayland_counts_as_a_display(self):
+ spawned = {}
+ self._play_kind5(
+ env={"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"},
+ which=lambda c: "/usr/bin/virt-viewer",
+ popen=lambda cmd, **kw: spawned.setdefault("cmd", cmd)
+ and mock.Mock(),
+ )
+ self.assertIn("virt-viewer", spawned.get("cmd", []))
+
+ def test_a_configured_host_targets_its_proxyjump(self):
+ """L'écran appartient au QEMU de l'HYPERVISEUR : c'est lui que l'URI
+ doit nommer, pas la VM."""
+ self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
+ self.todo._qemu_list_domains = lambda: []
+ out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""})
+ self.assertIn("virt-viewer -c qemu+ssh://", out)
+ self.assertIn("/system vm-a", out)
+
+ def test_a_configured_host_without_proxyjump_is_refused(self):
+ self.todo._ssh_config_hosts = lambda: ["saut+vm-a"]
+ self.todo._qemu_list_domains = lambda: []
+ self.todo._ssh_proxyjump = lambda name: ""
+ out = self._play_kind5(env={"DISPLAY": ":0"})
+ self.assertIn("ProxyJump", out)
+ self.assertNotIn("virt-viewer -c", out)
+
+
+class TestEnsureVirtViewer(unittest.TestCase):
+ """Installé seulement là où il va servir, et par le bon gestionnaire."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.ran = []
+ self.todo.execute = mock.Mock()
+ self.todo.execute.exec_command_live = (
+ lambda cmd, **kw: self.ran.append(cmd)
+ )
+
+ def test_present_means_nothing_to_do(self):
+ with mock.patch("shutil.which", lambda c: "/usr/bin/virt-viewer"):
+ self.assertTrue(self.todo._qemu_ensure_virt_viewer())
+ self.assertEqual([], self.ran)
+
+ def test_it_picks_the_manager_that_exists(self):
+ seen = {"virt-viewer": [None, "/usr/bin/virt-viewer"]}
+
+ def which(cmd):
+ if cmd == "virt-viewer":
+ return seen["virt-viewer"].pop(0)
+ return "/usr/bin/dnf" if cmd == "dnf" else None
+
+ with mock.patch("shutil.which", which), mock.patch(
+ "sys.stdout", io.StringIO()
+ ):
+ self.assertTrue(self.todo._qemu_ensure_virt_viewer())
+ self.assertEqual(1, len(self.ran))
+ self.assertIn("dnf install -y virt-viewer", self.ran[0])
+
+ def test_no_manager_is_said_not_guessed(self):
+ with mock.patch("shutil.which", lambda c: None), mock.patch(
+ "sys.stdout", io.StringIO()
+ ) as out:
+ self.assertFalse(self.todo._qemu_ensure_virt_viewer())
+ self.assertIn("paquets", out.getvalue().lower() + "paquets")
+ self.assertEqual([], self.ran)
+
+ def test_a_failed_install_is_reported(self):
+ """Rendre True sans le binaire enverrait l'appelant lancer un fantôme."""
+ with mock.patch(
+ "shutil.which",
+ lambda c: "/usr/bin/apt-get" if c == "apt-get" else None,
+ ), mock.patch("sys.stdout", io.StringIO()):
+ self.assertFalse(self.todo._qemu_ensure_virt_viewer())
+ self.assertEqual(1, len(self.ran))
+
+ def test_every_family_is_covered(self):
+ tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL]
+ self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools)
+
+
+class TestTunnelMenuTargets(_MenuCase):
+ def test_local_domains_fill_in_when_ssh_config_is_empty(self):
+ """Une VM libvirt locale reste joignable même sans entrée ssh_config ;
+ xrdp écoutant sur toutes les interfaces, son IP suffit."""
+ self.todo._ssh_config_hosts = lambda: []
+ self.todo._qemu_list_domains = lambda: ["vm-a"]
+ out = self._play(["1", "1"])
+ self.assertIn("-L 3390:192.168.123.81:3389", out)
+
+ def test_nothing_anywhere_is_said_plainly(self):
+ self.todo._ssh_config_hosts = lambda: []
+ self.todo._qemu_list_domains = lambda: []
+ out = self._play([])
+ self.assertIn("~/.ssh/config", out)
+
+ def test_an_off_local_vm_is_reported_before_any_command(self):
+ self.todo._ssh_config_hosts = lambda: []
+ self.todo._qemu_list_domains = lambda: ["vm-b"]
+ out = self._play(["1", "1"])
+ self.assertIn("IP", out.upper())
+ self.assertNotIn("ssh -N", out)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_vm_table.py b/test/test_qemu_vm_table.py
new file mode 100644
index 0000000..b479f19
--- /dev/null
+++ b/test/test_qemu_vm_table.py
@@ -0,0 +1,184 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Tableau des infos avancées : RAM utilisée, uptime, largeur.
+
+Deux colonnes ont changé de sens. « RAM » disait l'allocation, elle dit
+maintenant l'usage — sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe
+que 4,7 décide s'il reste de la place pour la suivante. Et l'uptime est apparu :
+libvirt ne l'expose nulle part, mais le processus QEMU du domaine est né avec
+lui.
+
+Ce que ces tests gardent : la formule de la RAM, calibrée contre le « free » de
+deux VM réelles, et la largeur de la ligne — un tableau qui déborde de 80
+colonnes se replie et devient illisible.
+"""
+
+import io
+import contextlib
+import subprocess
+import sys
+import unittest
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+# Sortie réelle de « virsh dommemstat » sur la VM de migration, en KiO. Le
+# « free » de l'invité disait alors : total 11955, used 1216.
+DOMMEMSTAT = """actual 12582912
+swap_in 0
+swap_out 0
+major_fault 0
+minor_fault 0
+unused 1197056
+available 12242432
+usable 11027456
+last_update 1787359366
+disk_caches 8211456
+"""
+
+
+class TestUptimeFormat(unittest.TestCase):
+ def test_seconds_then_minutes_then_hours_then_days(self):
+ self.assertEqual("45s", TODO._fmt_uptime(45))
+ self.assertEqual("12m", TODO._fmt_uptime(12 * 60 + 30))
+ self.assertEqual("19h55", TODO._fmt_uptime(19 * 3600 + 55 * 60))
+ self.assertEqual("10j13h", TODO._fmt_uptime(10 * 86400 + 13 * 3600))
+
+ def test_it_never_exceeds_six_characters(self):
+ """La colonne fait six caractères : au-delà, le tableau se décale."""
+ for secs in (0, 59, 60, 3599, 3600, 86399, 86400, 400 * 86400):
+ self.assertLessEqual(len(TODO._fmt_uptime(secs)), 6, secs)
+
+ def test_precision_drops_as_the_duration_grows(self):
+ """Personne ne lit les secondes d'un uptime de dix jours."""
+ self.assertNotIn("s", TODO._fmt_uptime(19 * 3600))
+ self.assertNotIn("m", TODO._fmt_uptime(10 * 86400))
+
+
+class TestMemStat(unittest.TestCase):
+ def _stat(self, out):
+ with mock.patch(
+ "subprocess.run",
+ return_value=subprocess.CompletedProcess([], 0, out, ""),
+ ):
+ return TODO._qemu_dommemstat("vm")
+
+ def test_used_is_available_minus_usable(self):
+ """Calibré contre le « free » de deux VM : 1186 contre 1216 Mo lus dans
+ l'invité, et 4831 contre 4838 sur l'autre. « available - unused »
+ donnait 10,8 Go pour une VM qui en occupait 1,2 — il compte le cache.
+ """
+ used, total = self._stat(DOMMEMSTAT)
+ self.assertAlmostEqual(used / 1024, 1186, delta=5)
+ self.assertAlmostEqual(total / 1024, 11955, delta=5)
+
+ def test_a_stopped_vm_gives_zero_without_crashing(self):
+ used, total = self._stat("")
+ self.assertEqual((0, 0), (used, total))
+
+ def test_it_survives_virsh_failing(self):
+ with mock.patch("subprocess.run", side_effect=OSError):
+ self.assertEqual((0, 0), TODO._qemu_dommemstat("vm"))
+
+ def test_missing_usable_is_not_taken_for_zero_use(self):
+ """Sans « usable », on ne sait pas : mieux vaut ne rien dire que
+ d'annoncer une VM qui n'utiliserait rien."""
+ used, total = self._stat("available 12242432\nunused 1197056\n")
+ self.assertEqual(0, used)
+ self.assertGreater(total, 0)
+
+ def test_it_asks_for_a_collection_period_first(self):
+ """Sans période, le ballon ne rafraîchit rien : une VM qui occupait
+ 4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML."""
+ calls = []
+
+ def fake_run(cmd, **kw):
+ calls.append(cmd)
+ return subprocess.CompletedProcess([], 0, DOMMEMSTAT, "")
+
+ with mock.patch("subprocess.run", side_effect=fake_run):
+ TODO._qemu_dommemstat("vm")
+ self.assertIn("--period", calls[0])
+ self.assertIn("--live", calls[0])
+
+
+class TestDomainUptime(unittest.TestCase):
+ def test_it_reads_the_age_of_the_qemu_process(self):
+ outs = [
+ subprocess.CompletedProcess([], 0, "1137455\n", ""),
+ subprocess.CompletedProcess([], 0, " 1195\n", ""),
+ ]
+ with mock.patch("subprocess.run", side_effect=outs):
+ self.assertEqual(1195, TODO._qemu_domain_uptime("vm"))
+
+ def test_the_pattern_ends_with_a_comma(self):
+ """« guest=vm, » et non « guest=vm » : sinon « vm » matcherait aussi
+ « vm-2 », et l'uptime affiché serait celui d'une autre machine."""
+ seen = {}
+
+ def fake_run(cmd, **kw):
+ seen.setdefault("cmd", cmd)
+ return subprocess.CompletedProcess([], 0, "", "")
+
+ with mock.patch("subprocess.run", side_effect=fake_run):
+ TODO._qemu_domain_uptime("vm")
+ self.assertIn("guest=vm,", seen["cmd"])
+
+ def test_a_stopped_domain_has_no_uptime(self):
+ with mock.patch(
+ "subprocess.run",
+ return_value=subprocess.CompletedProcess([], 1, "", ""),
+ ):
+ self.assertIsNone(TODO._qemu_domain_uptime("vm"))
+
+
+class TestTheTable(unittest.TestCase):
+ def _render(self, uptime=1195, mem=(1214 * 1024, 11955 * 1024)):
+ todo = TODO.__new__(TODO)
+ todo._qemu_list_domains = lambda: ["erplibre-ubuntu-2604-gnome"]
+ todo._qemu_domstate = lambda n: "running"
+ todo._qemu_dominfo = staticmethod(lambda n: (8, 12 * 1024 * 1024))
+ todo._qemu_main_disk = lambda n: "/var/lib/libvirt/images/x.qcow2"
+ todo._qemu_disk_sizes = staticmethod(
+ lambda d: (65 * (1 << 30), 62 * (1 << 30))
+ )
+ todo._qemu_dommemstat = staticmethod(lambda n: mem)
+ todo._qemu_domain_uptime = staticmethod(lambda n: uptime)
+ buf = io.StringIO()
+ with contextlib.redirect_stdout(buf):
+ todo._qemu_list_vms_advanced()
+ return buf.getvalue()
+
+ def test_every_line_fits_in_eighty_columns(self):
+ """Le tableau est lu dans un terminal : au-delà de 80, il se replie."""
+ for line in self._render().splitlines():
+ if line.startswith("Stockage") or not line.strip():
+ continue
+ self.assertLessEqual(len(line), 80, line)
+
+ def test_ram_shows_use_over_allocation(self):
+ out = self._render()
+ self.assertIn("1.2G/12G", out)
+
+ def test_the_uptime_column_is_there(self):
+ out = self._render()
+ self.assertIn("Uptime", out)
+ self.assertIn("19m", out)
+
+ def test_a_vm_without_stats_shows_a_dash_not_a_zero(self):
+ """« 0.0G/12G » ferait croire à une VM au repos ; « -/12G » dit qu'on
+ ne sait pas."""
+ out = self._render(uptime=None, mem=(0, 0))
+ self.assertIn("-/12G", out)
+ self.assertNotIn("0.0G/12G", out)
+
+ def test_the_full_vm_name_survives_when_it_fits(self):
+ """C'est le nom qui distingue les machines : le tronquer trop tôt les
+ rend indiscernables."""
+ self.assertIn("erplibre-ubuntu-2604-gnome", self._render())
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_smoke_final_url.py b/test/test_smoke_final_url.py
new file mode 100644
index 0000000..6913b34
--- /dev/null
+++ b/test/test_smoke_final_url.py
@@ -0,0 +1,301 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""L'URL qui a échoué n'est pas toujours celle qu'on a demandée.
+
+Sur un site Odoo, chaque page traverse deux ou trois redirections —
+mesuré, 146 pour 55 pages, entre la langue et le slug canonique. Quand
+la DERNIÈRE rend 500, l'outil nommait la première : on allait vérifier
+une page parfaitement saine et l'on concluait que le test se trompait.
+
+Et un dépassement de délai ne rend PAS 500 : `fetch` rend 0, que le
+rapport écrit « aucune réponse ». Confondre les deux enverrait chercher
+une lenteur là où le serveur a répondu par une erreur.
+"""
+
+import http.server
+import io
+import os
+import socketserver
+import sys
+import threading
+import unittest
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.odoo.migration import smoke_public_url as smoke # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+class Chaine(http.server.BaseHTTPRequestHandler):
+ """/depart → 303 → /milieu → 303 → /fin, qui décide."""
+
+ fin_status = 500
+
+ def do_GET(self):
+ if self.path == "/depart":
+ self.send_response(303)
+ self.send_header("Location", "/milieu")
+ self.end_headers()
+ elif self.path == "/milieu":
+ self.send_response(303)
+ self.send_header("Location", "/fin")
+ self.end_headers()
+ elif self.path == "/direct":
+ self.send_response(500)
+ self.end_headers()
+ self.wfile.write(b"boum")
+ else:
+ self.send_response(self.fin_status)
+ self.end_headers()
+ self.wfile.write(b"boum")
+
+ def log_message(self, *args):
+ pass
+
+
+class TestFetchFollowsTheChain(unittest.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ cls.srv = socketserver.TCPServer(("127.0.0.1", 0), Chaine)
+ cls.port = cls.srv.server_address[1]
+ cls.fil = threading.Thread(target=cls.srv.serve_forever, daemon=True)
+ cls.fil.start()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.srv.shutdown()
+ cls.srv.server_close()
+
+ def url(self, chemin):
+ return f"http://127.0.0.1:{self.port}{chemin}"
+
+ def test_it_reports_the_url_that_actually_failed(self):
+ # LE point : la 500 est au bout de la chaîne, pas au départ.
+ statut, _corps, finale = smoke.fetch(self.url("/depart"), timeout=5)
+ self.assertEqual(statut, 500)
+ self.assertTrue(finale.endswith("/fin"), finale)
+
+ def test_without_a_redirect_both_are_the_same(self):
+ statut, _corps, finale = smoke.fetch(self.url("/direct"), timeout=5)
+ self.assertEqual(statut, 500)
+ self.assertEqual(finale, self.url("/direct"))
+
+ def test_a_success_also_carries_its_final_url(self):
+ Chaine.fin_status = 200
+ try:
+ statut, _corps, finale = smoke.fetch(
+ self.url("/depart"), timeout=5
+ )
+ self.assertEqual(statut, 200)
+ self.assertTrue(finale.endswith("/fin"), finale)
+ finally:
+ Chaine.fin_status = 500
+
+ def test_a_dead_host_is_zero_NOT_five_hundred(self):
+ # C'est ce qui distingue « le serveur a répondu par une erreur »
+ # de « il n'a pas répondu ». Les confondre envoie chercher une
+ # lenteur là où il y a une exception.
+ statut, corps, finale = smoke.fetch(
+ "http://127.0.0.1:1/jamais", timeout=1
+ )
+ self.assertEqual(statut, 0)
+ self.assertEqual(corps, "")
+ self.assertEqual(finale, "http://127.0.0.1:1/jamais")
+
+ def test_check_urls_keeps_the_final_url(self):
+ echecs = smoke.check_urls([self.url("/depart")], timeout=5)
+ self.assertEqual(len(echecs), 1)
+ url, statut, parents, finale = echecs[0]
+ self.assertEqual(url, self.url("/depart"))
+ self.assertEqual(statut, 500)
+ self.assertEqual(parents, [])
+ self.assertTrue(finale.endswith("/fin"))
+
+ def test_a_page_that_answers_is_not_a_failure(self):
+ Chaine.fin_status = 200
+ try:
+ self.assertEqual(
+ smoke.check_urls([self.url("/depart")], timeout=5), []
+ )
+ finally:
+ Chaine.fin_status = 500
+
+
+class TestTheReport(unittest.TestCase):
+ def test_it_names_the_final_url_when_it_differs(self):
+ texte = smoke.render(
+ ["a", "b"], [("http://x/depart", 500, [], "http://x/fin")]
+ )
+ self.assertIn("http://x/depart", texte)
+ self.assertIn(todo_i18n.t("failed at"), texte)
+ self.assertIn("http://x/fin", texte)
+
+ def test_it_stays_quiet_when_they_are_the_same(self):
+ # Répéter la même URL sur deux lignes n'apprend rien et allonge
+ # un rapport qui peut compter trente-quatre entrées.
+ texte = smoke.render(
+ ["a"], [("http://x/page", 500, [], "http://x/page")]
+ )
+ self.assertNotIn(todo_i18n.t("failed at"), texte)
+
+ def test_no_answer_is_worded_apart_from_a_status(self):
+ texte = smoke.render(
+ ["a"], [("http://x/page", 0, [], "http://x/page")]
+ )
+ self.assertIn(todo_i18n.t("no answer"), texte)
+ self.assertNotIn("[500]", texte)
+
+
+class TestNothingUnpacksTheFailureTupleBlindly(unittest.TestCase):
+ """Ajouter un champ au tuple d'échec a cassé une migration en cours.
+
+ Le tuple est passé de trois à quatre éléments et deux sites
+ dépaquetaient encore trois — `too many values to unpack`, en plein
+ milieu, APRÈS la réinitialisation d'une copie COW. Le commentaire
+ « TOUJOURS quatre éléments » ne protège de rien : il faut ne pas
+ dépaqueter quand on ne veut qu'un champ.
+ """
+
+ CHEMIN = os.path.join(
+ os.path.dirname(__file__),
+ "..",
+ "script",
+ "odoo",
+ "migration",
+ "smoke_public_url.py",
+ )
+
+ def source(self):
+ with io.open(self.CHEMIN, encoding="utf-8") as handle:
+ return handle.read()
+
+ def test_no_three_element_unpack_survives(self):
+ import re
+
+ motif = re.compile(
+ r"for\s+[a-z_]+,\s*[a-z_]+,\s*[a-z_]+\s+in\s+lst_failure"
+ )
+ trouves = motif.findall(self.source())
+ self.assertEqual(trouves, [], f"dépaquetage à trois : {trouves}")
+
+ def test_taking_only_the_url_uses_an_index(self):
+ # Indexer survit au prochain champ ajouté ; dépaqueter non.
+ self.assertIn("[echec[0] for echec in lst_failure]", self.source())
+
+
+class TestRecheckingAfterAReset(unittest.TestCase):
+ """La passe qui a cassé, exercée pour de vrai.
+
+ Elle ne tournait sous aucun test : c'est pourquoi le dépaquetage à
+ trois y a survécu à la suite complète, aux mutations, et n'est
+ tombé qu'en production.
+ """
+
+ def setUp(self):
+ self.vrais = {
+ nom: getattr(smoke, nom)
+ for nom in (
+ "start_server",
+ "wait_ready",
+ "check_urls",
+ "internal_needs_retry",
+ "stop_server",
+ )
+ if hasattr(smoke, nom)
+ }
+ self.vus = []
+
+ class FauxServeur:
+ def __init__(self):
+ self.arrete = False
+
+ smoke.start_server = lambda *a, **k: FauxServeur()
+ smoke.wait_ready = lambda *a, **k: True
+ smoke.internal_needs_retry = lambda rapport: False
+ if hasattr(smoke, "stop_server"):
+ smoke.stop_server = lambda *a, **k: None
+
+ def faux_check(lst_url, timeout=30):
+ self.vus.append(list(lst_url))
+ return []
+
+ smoke.check_urls = faux_check
+
+ def tearDown(self):
+ for nom, valeur in self.vrais.items():
+ setattr(smoke, nom, valeur)
+
+ def test_it_rechecks_exactly_the_urls_that_had_failed(self):
+ echecs = [
+ ("http://h/contactus", 500, ["2837"], "http://h/en/contactus"),
+ ("http://h/blog", 500, [], "http://h/blog"),
+ ]
+ smoke.recheck_after_reset(
+ "db",
+ 8169,
+ "./config.conf",
+ "http://h",
+ None,
+ echecs,
+ {"failures": []},
+ internal=False,
+ )
+ self.assertEqual(self.vus, [["http://h/contactus", "http://h/blog"]])
+
+ def test_it_rechecks_the_REQUESTED_url_not_the_final_one(self):
+ # On revérifie ce que le sitemap publie : c'est cette adresse-là
+ # que les visiteurs demandent.
+ echecs = [("http://h/a", 500, [], "http://h/z")]
+ smoke.recheck_after_reset(
+ "db",
+ 8169,
+ "./config.conf",
+ "http://h",
+ None,
+ echecs,
+ {"failures": []},
+ internal=False,
+ )
+ self.assertEqual(self.vus, [["http://h/a"]])
+
+ def test_an_empty_failure_list_rechecks_nothing(self):
+ smoke.recheck_after_reset(
+ "db",
+ 8169,
+ "./config.conf",
+ "http://h",
+ None,
+ [],
+ {"failures": []},
+ internal=False,
+ )
+ self.assertEqual(self.vus, [[]])
+
+
+class TestTheLogSurvives(unittest.TestCase):
+ def test_the_previous_run_is_kept(self):
+ # Le journal était ouvert en « w » : relancer le test effaçait la
+ # trace de l'échec qu'on venait de voir.
+ with io.open(
+ os.path.join(
+ os.path.dirname(__file__),
+ "..",
+ "script",
+ "odoo",
+ "migration",
+ "smoke_public_url.py",
+ ),
+ encoding="utf-8",
+ ) as handle:
+ src = handle.read()
+ debut = src.index("def start_server")
+ fin = src.index("subprocess.Popen", debut)
+ self.assertIn("os.replace(log_path, log_path", src[debut:fin])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py
index 8d2c9db..3c0977c 100755
--- a/test/test_smoke_public_url.py
+++ b/test/test_smoke_public_url.py
@@ -53,7 +53,15 @@ class TestReadingTheSitemap(unittest.TestCase):
def setUp(self):
self.answers = {}
self.original = smoke.fetch
- smoke.fetch = lambda url, timeout=30: self.answers.get(url, (404, ""))
+
+ # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale.
+ # Un faux resté à deux casse chaque appelant sur un « not enough
+ # values to unpack » qui n'apprend rien de la panne réelle.
+ def faux(url, timeout=30):
+ statut, corps = self.answers.get(url, (404, ""))
+ return statut, corps, url
+
+ smoke.fetch = faux
self.addCleanup(setattr, smoke, "fetch", self.original)
def test_a_plain_sitemap(self):
@@ -101,13 +109,22 @@ class TestWhatCountsAsAFailure(unittest.TestCase):
def setUp(self):
self.answers = {}
self.original = smoke.fetch
- smoke.fetch = lambda url, timeout=30: self.answers.get(url, (200, ""))
+
+ # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale.
+ # Un faux resté à deux casse chaque appelant sur un « not enough
+ # values to unpack » qui n'apprend rien de la panne réelle.
+ def faux(url, timeout=30):
+ statut, corps = self.answers.get(url, (200, ""))
+ return statut, corps, url
+
+ smoke.fetch = faux
self.addCleanup(setattr, smoke, "fetch", self.original)
def test_a_500_fails(self):
self.answers["http://h/bad"] = (500, "")
self.assertEqual(
- smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500, [])]
+ smoke.check_urls(["http://h/bad"]),
+ [("http://h/bad", 500, [], "http://h/bad")],
)
def test_a_404_fails_too(self):
@@ -119,7 +136,8 @@ class TestWhatCountsAsAFailure(unittest.TestCase):
def test_no_answer_at_all_fails(self):
self.answers["http://h/dead"] = (0, "")
self.assertEqual(
- smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0, [])]
+ smoke.check_urls(["http://h/dead"]),
+ [("http://h/dead", 0, [], "http://h/dead")],
)
def test_a_200_passes(self):
@@ -141,7 +159,9 @@ class TestTheReport(unittest.TestCase):
self.assertIn("✅", text)
def test_a_failure_shows_the_status_and_the_url(self):
- text = smoke.render(["a"], [("http://h/blog/x", 500, [])])
+ text = smoke.render(
+ ["a"], [("http://h/blog/x", 500, [], "http://h/blog/x")]
+ )
self.assertIn("500", text)
self.assertIn("http://h/blog/x", text)
@@ -211,7 +231,7 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase):
def test_a_late_context_is_still_attached(self):
# Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien
# ne doit dépendre du moment où la ligne est apparue.
- lst_failure = [("http://h/a", 500, [])]
+ lst_failure = [("http://h/a", 500, [], "http://h/a")]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertIn("2841", rebuilt[0][2])
@@ -221,20 +241,20 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase):
# à sa vue module, et c'est l'enfant (3282) qui portait l'arch
# périmée. Ne nommer que le parent envoyait réinitialiser une copie
# qui allait déjà bien, et la page restait en 500.
- lst_failure = [("http://h/contactus", 500, [])]
+ lst_failure = [("http://h/contactus", 500, [], "http://h/contactus")]
log = ["[view_id: 3282, model: n/a, parent_id: 3281]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2], ["3281", "3282"])
def test_the_parent_comes_first(self):
# C'est le cas le plus fréquent — le blogue — donc en tête de liste.
- lst_failure = [("http://h/a", 500, [])]
+ lst_failure = [("http://h/a", 500, [], "http://h/a")]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2][0], "2841")
def test_an_already_attributed_id_is_not_duplicated(self):
- lst_failure = [("http://h/a", 500, ["2841"])]
+ lst_failure = [("http://h/a", 500, ["2841"], "http://h/a")]
log = ["[view_id: 3288, model: n/a, parent_id: 2841]"]
rebuilt = smoke.attach_missing_parents(lst_failure, log)
self.assertEqual(rebuilt[0][2].count("2841"), 1)
@@ -359,7 +379,7 @@ class TestOfferingTheFix(unittest.TestCase):
with contextlib.redirect_stdout(out):
done = smoke.prompt(
"db",
- [("http://h/a", 500, ["2841"])],
+ [("http://h/a", 500, ["2841"], "http://h/a")],
lst_key,
ask=lambda prompt: answer,
)
diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py
index 02f02cb..bfa76cd 100644
--- a/test/test_todo_menu.py
+++ b/test/test_todo_menu.py
@@ -136,6 +136,105 @@ class TestExecuteMenuNumbering(unittest.TestCase):
self.assertEqual(set(self.EXPECTED) - shown_keys, set())
+class TestQemuMenuNumbering(unittest.TestCase):
+ """Le menu QEMU : même piège, autre forme.
+
+ Il ne s'écrit pas en f-string mais en liste de dictionnaires, où seules les
+ entrées « prompt_description » consomment un numéro — les « section » sont
+ des titres. Le décalage y est donc encore moins visible à l'œil : insérer
+ une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne
+ proteste pas. C'est arrivé en ajoutant l'émulateur Android avant
+ « List available images ».
+ """
+
+ RE_ENTRY = re.compile(
+ r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"'
+ )
+ RE_DISPATCH_CALL = re.compile(
+ r'(?:el)?if status == "(\d+)":\s*\n\s*(?:status = )?self\.(\w+)\('
+ )
+
+ def setUp(self):
+ source = TODO_PY.read_text(encoding="utf-8")
+ start = source.index("def prompt_execute_qemu(self):")
+ end = source.index("def _qemu_tunnel_menu(self):", start)
+ self.body = source[start:end]
+ num = 0
+ self.shown = []
+ for kind, label in self.RE_ENTRY.findall(self.body):
+ if kind == "prompt_description":
+ num += 1
+ self.shown.append((num, label))
+ self.dispatch = [
+ (int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body)
+ ]
+
+ def test_the_menu_was_actually_parsed(self):
+ """Sur une liste vide, tout test passe : mieux vaut tomber ici."""
+ self.assertGreater(len(self.shown), 10)
+ self.assertEqual(len(self.shown), len(self.dispatch))
+
+ def test_numbering_is_contiguous_from_one(self):
+ self.assertEqual(
+ [n for n, _ in self.shown],
+ list(range(1, len(self.shown) + 1)),
+ )
+
+ def test_every_shown_entry_has_the_matching_dispatch(self):
+ self.assertEqual(
+ [n for n, _ in self.shown], [n for n, _ in self.dispatch]
+ )
+
+ # Où mène chaque entrée, par le début de son libellé. Une renumérotation ne
+ # touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul
+ # moment où quelqu'un doit dire où elle mène.
+ EXPECTED = {
+ "Deploy VM(s)": "_qemu_deploy",
+ "Preview a deployment": "_qemu_deploy",
+ "Download a cloud image only": "_qemu_download_image",
+ "Reopen": "_qemu_reopen_monitor",
+ "List VMs": "_qemu_list_vms",
+ "Show a VM IP address": "_qemu_show_ip",
+ "Open the console on a VM": "_qemu_console",
+ "Resize a VM disk": "_qemu_resize_disk",
+ "Delete VM(s)": "_qemu_delete_vm",
+ "Clean up QEMU": "_qemu_cleanup",
+ "Test": "_qemu_test_vm",
+ "Statistics": "_qemu_stats",
+ "SSH configuration": "_qemu_ssh_config_menu",
+ "Remote desktop tunnel": "_qemu_tunnel_menu",
+ "Android emulator": "_qemu_emulator_menu",
+ "List available images": "_qemu_list_images",
+ }
+
+ def _key(self, label):
+ for key in self.EXPECTED:
+ if label.startswith(key):
+ return key
+ return label
+
+ def test_every_entry_reaches_the_method_it_names(self):
+ dct = dict(self.dispatch)
+ for num, label in self.shown:
+ key = self._key(label)
+ self.assertIn(
+ key,
+ self.EXPECTED,
+ f"entrée [{num}] « {label} » absente d'EXPECTED :"
+ " déclarez où elle mène",
+ )
+ self.assertEqual(
+ dct.get(num),
+ self.EXPECTED[key],
+ f"[{num}] « {label} » mène à {dct.get(num)}"
+ f" au lieu de {self.EXPECTED[key]}",
+ )
+
+ def test_expected_table_has_no_stale_entry(self):
+ keys = {self._key(label) for _, label in self.shown}
+ self.assertEqual(set(self.EXPECTED) - keys, set())
+
+
class TestMenuLabels(unittest.TestCase):
"""Toute méthode de menu doit avoir son étiquette de fil d'Ariane.