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:
commit
5f951941a9
29 changed files with 12086 additions and 130 deletions
7
Makefile
7
Makefile
|
|
@ -384,4 +384,9 @@ pycharm_open:
|
|||
|
||||
.PHONY: pycharm_configure
|
||||
pycharm_configure:
|
||||
./script/ide/pycharm_configuration.py --init
|
||||
# Le venv du dépôt, et non le python système : le script importe xmltodict, qui
|
||||
# vit dans .venv.erplibre. Appelé directement, il s'arrête sur
|
||||
# « ModuleNotFoundError: No module named 'xmltodict' » — et c'est cette cible
|
||||
# que le message d'échec de l'installation recommande. update_env_version.
|
||||
# pycharm_update() l'appelle déjà ainsi : une seule autorité.
|
||||
./.venv.erplibre/bin/python ./script/ide/pycharm_configuration.py --init
|
||||
|
|
|
|||
|
|
@ -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
354
script/forgejo/install_forgejo.sh
Executable 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"
|
||||
|
|
@ -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=$?
|
||||
|
|
|
|||
0
script/mobile/__init__.py
Normal file
0
script/mobile/__init__.py
Normal file
167
script/mobile/check_bundle_transfer.py
Executable file
167
script/mobile/check_bundle_transfer.py
Executable 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())
|
||||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
|
|
|
|||
690
script/todo/qemu_hardware.py
Normal file
690
script/todo/qemu_hardware.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
2603
script/todo/todo.py
2603
script/todo/todo.py
File diff suppressed because it is too large
Load diff
|
|
@ -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"},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
282
test/test_mobile_bundle_transfer.py
Normal file
282
test/test_mobile_bundle_transfer.py
Normal 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()
|
||||
1327
test/test_qemu_desktop_tools.py
Normal file
1327
test/test_qemu_desktop_tools.py
Normal file
File diff suppressed because it is too large
Load diff
504
test/test_qemu_emulator_menu.py
Normal file
504
test/test_qemu_emulator_menu.py
Normal 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
385
test/test_qemu_forgejo.py
Normal 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
875
test/test_qemu_gpu.py
Normal 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)
|
||||
204
test/test_qemu_install_summary.py
Normal file
204
test/test_qemu_install_summary.py
Normal 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()
|
||||
206
test/test_qemu_monitor_telemetry.py
Normal file
206
test/test_qemu_monitor_telemetry.py
Normal 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
407
test/test_qemu_motd.py
Normal 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()
|
||||
271
test/test_qemu_tunnel_menu.py
Normal file
271
test/test_qemu_tunnel_menu.py
Normal 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
184
test/test_qemu_vm_table.py
Normal 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()
|
||||
301
test/test_smoke_final_url.py
Normal file
301
test/test_smoke_final_url.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue