Merge commit 'dev_qemu_graphique_mobile_and_s390x'

[ADD] release: a VM you can develop in, from the TODO menu

A deployed VM was a server. It can now be a workstation: PyCharm,
Android Studio, GNOME extensions, an Android emulator and an adb tunnel
for scrcpy that needs no X11. The mobile app is built and tested inside
it, and the VM fails if the build does.

Debian reaches s390x, where no cloud image is published, through
debian-installer -- a fixed address per VM because the initrd cannot do
DHCP there, network-console disabled by the lever d-i provides, and the
machine powered back on once the installer is done.

Hardware is set per machine rather than per fleet: the host GPU, the CPU
mode, the screens, the network. virt-viewer opens a screen from the menu,
and a VM greets its SSH login with the commands of its own distribution.

Forgejo comes along as a tickable option: a git forge on the host, for
sharing code between machines without leaving the network.

--- FR ---

Une VM déployée était un serveur. Elle peut désormais être un poste de
travail : PyCharm, Android Studio, extensions GNOME, un émulateur Android
et un tunnel adb pour scrcpy qui ne demande aucun X11. L'application
mobile y est compilée et testée, et la VM échoue si la compilation échoue.

Debian atteint s390x, où aucune image cloud n'est publiée, par
debian-installer — une adresse fixe par VM car l'initrd n'y sait pas faire
de DHCP, network-console désactivé par le levier que d-i prévoit, et la
machine rallumée une fois l'installateur terminé.

Le matériel se règle par machine et non par parc : le GPU de l'hôte, le
mode CPU, les écrans, le réseau. virt-viewer ouvre un écran depuis le
menu, et une VM accueille sa connexion SSH avec les commandes propres à sa
distribution.

Forgejo vient avec : une forge git sur l'hôte, en option cochable, pour
partager du code entre machines sans quitter le réseau.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-23 02:08:50 -04:00
commit 5f951941a9
29 changed files with 12086 additions and 130 deletions

View file

@ -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

View file

@ -12,12 +12,20 @@
groups="mobile"
/>
<!-- sentencepiece : tokeniseur SentencePiece pour MarianMT (NDK JNI) -->
<!-- sentencepiece : tokeniseur SentencePiece pour MarianMT (NDK JNI)
Version PUBLIÉE, et non « master ». Deux raisons, la seconde
bloquante : « master » fait de ce dépôt une cible mouvante, et deux
VM construites le même jour peuvent ne pas contenir le même code ;
surtout, le master d'août 2026 récupère protobuf par FetchContent et
bâtit son « protoc » POUR LA CIBLE Android avant de l'exécuter sur
l'hôte — « Exec format error », la compilation s'arrête là. Mesuré sur
une VM Ubuntu 24.04. La v0.2.1 livre au contraire ses .pb.cc
pré-générés (src/builtin_pb) et n'a besoin d'aucun protoc. -->
<project
name="sentencepiece"
path="mobile/erplibre_home_mobile/android/app/src/main/cpp/sentencepiece"
remote="google"
revision="master"
revision="refs/tags/v0.2.1"
clone-depth="1"
groups="mobile"
/>

354
script/forgejo/install_forgejo.sh Executable file
View file

@ -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 <<CONFEOF
APP_NAME = ERPLibre Forgejo
RUN_USER = $RUN_USER
RUN_MODE = prod
WORK_PATH = $DATA
[server]
PROTOCOL = http
DOMAIN = $host
HTTP_PORT = $HTTP_PORT
ROOT_URL = http://$host:$HTTP_PORT/
APP_DATA_PATH = $DATA/data
DISABLE_SSH = false
START_SSH_SERVER = true
SSH_DOMAIN = $host
SSH_PORT = $SSH_PORT
SSH_LISTEN_PORT = $SSH_PORT
LFS_START_SERVER = true
LFS_JWT_SECRET = $lfs_jwt
[database]
DB_TYPE = sqlite3
PATH = $DATA/data/forgejo.db
[repository]
ROOT = $DATA/data/forgejo-repositories
[security]
INSTALL_LOCK = true
PASSWORD_COMPLEXITY = off
SECRET_KEY = $secret
INTERNAL_TOKEN = $token
[oauth2]
JWT_SECRET = $jwt
[service]
DISABLE_REGISTRATION = false
REQUIRE_SIGNIN_VIEW = false
[lfs]
PATH = $DATA/data/lfs
[log]
ROOT_PATH = $DATA/log
LEVEL = info
CONFEOF
sudo chown root:"$RUN_USER" "$CONF"
sudo chmod 640 "$CONF"
CHANGED=1
say "${Green}configuration écrite : $CONF${Color_Off}"
fi
# --- 6. Service ------------------------------------------------------------
unit_tmp=$(mktemp)
cat > "$unit_tmp" <<UNITEOF
[Unit]
Description=Forgejo (Beyond coding. We forge.)
After=network.target network-online.target
[Service]
Type=simple
User=$RUN_USER
Group=$RUN_USER
WorkingDirectory=$DATA
ExecStart=$BIN web --config $CONF
Restart=always
RestartSec=5
Environment=USER=$RUN_USER HOME=/home/$RUN_USER GITEA_WORK_DIR=$DATA
[Install]
WantedBy=multi-user.target
UNITEOF
if ! sudo cmp -s "$unit_tmp" "$UNIT" 2>/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"

View file

@ -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=$?

View file

View file

@ -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())

View file

@ -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 "<sitemapindex" in body.lower():
lst_page = []
for loc in lst_loc:
_status, sub = fetch(local_url(base_url, loc))
_status, sub, _fin = fetch(local_url(base_url, loc))
lst_page.extend(RE_LOC.findall(sub))
lst_loc = lst_page
seen, lst_url = set(), []
@ -252,7 +274,7 @@ def attach_missing_parents(lst_failure, lst_log):
ce qui n'avait été rattaché à rien — mieux vaut un coupable mal attribué
qu'un coupable perdu.
"""
known = {pid for _u, _s, lst in lst_failure for pid in lst}
known = {pid for _u, _s, lst, _f in lst_failure for pid in lst}
extra = []
for line in lst_log:
# LES DEUX : le parent ET l'enfant. Mesuré sur /contactus — le
@ -267,19 +289,22 @@ def attach_missing_parents(lst_failure, lst_log):
return lst_failure
rebuilt = []
placed = False
for url, status, lst_parent in lst_failure:
for url, status, lst_parent, finale in lst_failure:
if not lst_parent and not placed:
lst_parent = list(extra)
placed = True
rebuilt.append((url, status, lst_parent))
rebuilt.append((url, status, lst_parent, finale))
if not placed and rebuilt:
url, status, lst_parent = rebuilt[0]
rebuilt[0] = (url, status, lst_parent + extra)
url, status, lst_parent, finale = rebuilt[0]
rebuilt[0] = (url, status, lst_parent + extra, finale)
return rebuilt
def check_urls(lst_url, timeout=30):
"""[(url, statut, [])] pour celles qui ont échoué.
"""[(url, statut, [vues], url finale)] pour celles qui ont échoué.
TOUJOURS quatre éléments, le dernier étant l'URL réellement atteinte.
Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
Les vues en cause sont rattachées après coup, en relisant le journal du
serveur : elles y arrivent quand Odoo vide son tampon, pas quand la
@ -287,9 +312,9 @@ def check_urls(lst_url, timeout=30):
"""
lst_failure = []
for url in lst_url:
status, _body = fetch(url, timeout=timeout)
status, _body, finale = fetch(url, timeout=timeout)
if status == 0 or status >= 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

View file

@ -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@<ip> '$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@<vm-name>`. 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.
<!-- [fr] -->
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@<ip> '$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@<nom-de-vm>`. 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.
<!-- [en] -->
## 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 <vm-name> | 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 <nom-vm> | 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

View file

@ -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@<ip> '$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@<nom-de-vm>`. 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 <nom-vm> | 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

View file

@ -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@<ip> '$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@<vm-name>`. 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 <vm-name> | 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

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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 <currentMemory>, 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

View file

@ -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 : <libellé> ». 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.

File diff suppressed because it is too large Load diff

View file

@ -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 <tree> qu'Odoo 18",
"en": "view(s) still carry a <tree> 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"},
}

View file

@ -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()

File diff suppressed because it is too large Load diff

View file

@ -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()

385
test/test_qemu_forgejo.py Normal file
View file

@ -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()

875
test/test_qemu_gpu.py Normal file
View file

@ -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 <currentMemory> : 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 = """<domain type='kvm'>
<name>erplibre-ubuntu-2604-gnome</name>
<memory unit='KiB'>33554432</memory>
<currentMemory unit='KiB'>33554432</currentMemory>
<vcpu placement='static'>8</vcpu>
<devices>
<graphics type='vnc' port='5900' autoport='yes' listen='127.0.0.1'>
<listen type='address' address='127.0.0.1'/>
</graphics>
<video>
<model type='virtio' heads='1' primary='yes'/>
</video>
</devices>
</domain>
"""
XML_AVEC_3D = XML_SANS_3D.replace(
"<model type='virtio' heads='1' primary='yes'/>",
"<model type='virtio' heads='1' primary='yes'>"
"<acceleration accel3d='yes'/></model>",
).replace(
"</video>",
"</video>\n <graphics type='egl-headless'>"
"<gl rendernode='/dev/dri/renderD128'/></graphics>",
)
# 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 = """<domain type='kvm'>
<name>erplibre-ubuntu-2604-gnome</name>
<memory unit='KiB'>33554432</memory>
<currentMemory unit='KiB'>33554432</currentMemory>
<vcpu placement='static'>8</vcpu>
<cpu mode='host-passthrough' check='none' migratable='on'/>
<devices>
<interface type='network'>
<mac address='52:54:00:fc:a1:34'/>
<source network='default'/>
<model type='virtio'/>
</interface>
<graphics type='vnc' port='-1' autoport='yes' listen='127.0.0.1'/>
<video>
<model type='virtio' heads='1' primary='yes'/>
</video>
</devices>
</domain>
"""
XML_SERVEUR = """<domain type='kvm'>
<name>erplibre-serveur</name>
<memory unit='KiB'>2097152</memory>
<vcpu placement='static'>2</vcpu>
<devices/>
</domain>
"""
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(
"<devices/>",
"<devices><graphics type='egl-headless'/></devices>",
)
self.assertFalse(hw.hw_state(xml)["screen"])
def test_the_balloon_target_is_what_the_vm_gets(self):
"""<memory> est le maximum, <currentMemory> ce que la VM voit. Lire le
premier annoncerait 32 Go à une VM qui en a 4."""
xml = XML_SANS_3D.replace(
"<currentMemory unit='KiB'>33554432</currentMemory>",
"<currentMemory unit='KiB'>4194304</currentMemory>",
)
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(
"<memory unit='KiB'>2097152</memory>",
f"<memory unit='{unit}'>{value}</memory>",
)
self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit)
def test_broken_xml_gives_an_empty_state(self):
st = hw.hw_state("<domain")
self.assertEqual("", st["name"])
self.assertEqual(0, st["vcpus"])
def test_autostart_comes_from_outside_the_xml(self):
"""Il vit dans un lien symbolique de libvirt, pas dans la définition."""
self.assertTrue(hw.hw_state(XML_SANS_3D, autostart=True)["autostart"])
class TestPlan(unittest.TestCase):
def _plan(self, xml, want, node=NODE, autostart=False):
return hw.hw_plan(hw.hw_state(xml, autostart), want, node)
def test_nothing_wanted_nothing_planned(self):
self.assertEqual([], self._plan(XML_SANS_3D, {}))
def test_the_same_values_change_nothing(self):
"""Revalider le formulaire sans rien toucher ne doit RIEN lancer."""
plan = self._plan(
XML_SANS_3D, {"vcpus": 8, "ram": 32768, "gpu": False}
)
self.assertEqual([], plan)
def test_3d_already_on_the_same_node_is_not_added_twice(self):
"""« --add-device --graphics egl-headless » deux fois pose DEUX
affichages : c'est l'état lu qui l'empêche, pas virt-xml."""
self.assertEqual([], self._plan(XML_AVEC_3D, {"gpu": True}))
def test_another_node_is_edited_in_place(self):
plan = self._plan(
XML_AVEC_3D, {"gpu": True}, node="/dev/dri/renderD129"
)
joined = [" ".join(e["cmd"]) for e in plan]
self.assertEqual(1, len(plan))
self.assertIn("--edit type=egl-headless", joined[0])
self.assertNotIn("--add-device", joined[0])
def test_turning_3d_on_carries_both_halves(self):
plan = self._plan(XML_SANS_3D, {"gpu": True})
joined = " ".join(" ".join(e["cmd"]) for e in plan)
self.assertIn("model.acceleration.accel3d=on", joined)
self.assertIn(f"gl.rendernode={NODE}", joined)
def test_turning_3d_off_targets_the_type_so_vnc_survives(self):
"""Un « --remove-device --graphics » sans cible emporterait la console
VNC : plus aucun accès à l'écran de la VM."""
plan = self._plan(XML_AVEC_3D, {"gpu": False})
removals = [e for e in plan if "--remove-device" in e["cmd"]]
self.assertEqual(1, len(removals))
self.assertIn("type=egl-headless", removals[0]["cmd"])
def test_ram_sets_the_maximum_too(self):
"""« --memory N » seul ne touche que <currentMemory> : 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">([^<>]+)</text>", 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">([^<>]+)</text>", 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(
"<interface type='network'>", "<interface type='bridge'>"
).replace("<source network='default'/>", "<source bridge='br0'/>")
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(
"<cpu mode='host-passthrough' check='none' migratable='on'/>",
"<cpu mode='host-model'/>",
)
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(
"<interface type='network'>", "<interface type='bridge'>"
).replace("<source network='default'/>", "<source bridge='br0'/>")
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: <BROADCAST,MULTICAST,UP,LOWER_UP>"],
)
self.assertEqual(["network:default"], got)
def test_a_real_bridge_is_offered(self):
got = self._choices(
["default"],
{"default": ["Bridge: virbr0"]},
[
"3: virbr0: <BROADCAST>",
"4: br0: <BROADCAST,MULTICAST,UP,LOWER_UP>",
],
)
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, "<domain/>", "")
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)

View file

@ -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()

View file

@ -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()

407
test/test_qemu_motd.py Normal file
View file

@ -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 <service>", motd)
self.assertIn("journalctl -u <service> -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 <base>", # mise à jour des modules
"git pull && make install_odoo_18", # mise à jour Odoo
"http://<ip>: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()

View file

@ -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()

184
test/test_qemu_vm_table.py Normal file
View file

@ -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()

View file

@ -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()

View file

@ -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,
)

View file

@ -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.