diff --git a/Makefile b/Makefile index 6b7e893..6ac6eff 100644 --- a/Makefile +++ b/Makefile @@ -384,4 +384,9 @@ pycharm_open: .PHONY: pycharm_configure pycharm_configure: - ./script/ide/pycharm_configuration.py --init +# Le venv du dépôt, et non le python système : le script importe xmltodict, qui +# vit dans .venv.erplibre. Appelé directement, il s'arrête sur +# « ModuleNotFoundError: No module named 'xmltodict' » — et c'est cette cible +# que le message d'échec de l'installation recommande. update_env_version. +# pycharm_update() l'appelle déjà ainsi : une seule autorité. + ./.venv.erplibre/bin/python ./script/ide/pycharm_configuration.py --init diff --git a/manifest/git_manifest_mobile_home.xml b/manifest/git_manifest_mobile_home.xml index 54d42ec..e91fffa 100644 --- a/manifest/git_manifest_mobile_home.xml +++ b/manifest/git_manifest_mobile_home.xml @@ -12,12 +12,20 @@ groups="mobile" /> - + diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh new file mode 100755 index 0000000..d2f873b --- /dev/null +++ b/script/forgejo/install_forgejo.sh @@ -0,0 +1,354 @@ +#!/usr/bin/env bash +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +# +# Installe Forgejo — https://forgejo.org — depuis le binaire statique officiel +# publié sur https://codeberg.org/forgejo/forgejo. +# +# Le binaire est statique et sans dépendance : le même fichier sert Debian, +# Ubuntu, AlmaLinux, Rocky, openSUSE et Arch. Ce script ne touche donc à AUCUN +# gestionnaire de paquets — c'est ce qui le rend portable sur les plateformes +# ERPLibre sans une branche par distribution. +# +# Il n'appelle PAS env_var.sh, à la différence des scripts d'installation +# ERPLibre : Forgejo ne dépend ni du dépôt ni de son venv, et le script doit +# rester utilisable hors d'un checkout. +# +# Réglages, tous par variables d'environnement : +# FORGEJO_VERSION version à poser (défaut : la dernière publiée) +# FORGEJO_HTTP_PORT port web (défaut : 3000) +# FORGEJO_SSH_PORT port SSH interne de Forgejo (défaut : 2222) +# FORGEJO_ADMIN_USER compte administrateur créé (défaut : erplibre) +# FORGEJO_ADMIN_PASSWORD son mot de passe (défaut : erplibre) +# FORGEJO_ADMIN_EMAIL son courriel (défaut : admin@erplibre.local) +# FORGEJO_USER compte système propriétaire (défaut : git) +# FORGEJO_SKIP_ADMIN à 1, ne crée aucun compte (installateur web) +set -euo pipefail + +Red='\033[0;31m' +Green='\033[0;32m' +Yellow='\033[0;33m' +Color_Off='\033[0m' + +VERSION="${FORGEJO_VERSION:-}" +HTTP_PORT="${FORGEJO_HTTP_PORT:-3000}" +SSH_PORT="${FORGEJO_SSH_PORT:-2222}" +# « admin » est REFUSÉ par Forgejo — « CreateUser: name is reserved », mesuré. +# La liste des noms réservés couvre aussi api, assets, avatars, explore, user… +ADMIN_USER="${FORGEJO_ADMIN_USER:-erplibre}" +ADMIN_PASSWORD="${FORGEJO_ADMIN_PASSWORD:-erplibre}" +ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}" +RUN_USER="${FORGEJO_USER:-git}" +SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}" + +# Ce qui a changé sur le disque pendant ce passage. Le service ne redémarre que +# si quelque chose a bougé : rejouer le script sur une forge saine ne doit pas +# l'interrompre, même deux secondes. +CHANGED=0 + +BIN=/usr/local/bin/forgejo +CONF_DIR=/etc/forgejo +CONF="$CONF_DIR/app.ini" +DATA=/var/lib/forgejo +UNIT=/etc/systemd/system/forgejo.service +API=https://codeberg.org/api/v1/repos/forgejo/forgejo/releases +DL=https://codeberg.org/forgejo/forgejo/releases/download + +usage() { + sed -n '5,26p' "$0" | sed 's/^# \?//' + exit 0 +} +case "${1:-}" in + -h|--help) usage ;; +esac + +say() { echo -e " $*"; } +die() { echo -e " ${Red}✗ $*${Color_Off}" >&2; exit 1; } + +# Adresse par laquelle la machine est joignable, pour ROOT_URL et SSH_DOMAIN. +# +# « hostname -I » d'abord, mais PAS seulement : ce drapeau vient de net-tools et +# l'inetutils d'Arch ne le connaît pas. « ip route get » le remplace partout où +# iproute2 est là, c'est-à-dire partout. « localhost » ferme la marche : une +# forge joignable en local vaut mieux qu'un script qui s'arrête. +host_address() { + local h="" + # Chaque candidat est VALIDÉ comme adresse IPv4 avant d'être retenu : un + # « hostname » qui ne connaît pas -I peut rendre le nom de la machine, et + # une ROOT_URL bâtie sur un nom non résolvable est pire qu'un repli. + for h in \ + "$(hostname -I 2>/dev/null | awk '{print $1}')" \ + "$(ip -4 route get 1 2>/dev/null | awk '{print $7; exit}')" \ + "$(ip -4 -o addr show scope global 2>/dev/null \ + | awk '{split($4, a, "/"); print a[1]; exit}')" + do + case "$h" in + [0-9]*.[0-9]*.[0-9]*.[0-9]*) echo "$h"; return 0 ;; + esac + done + echo localhost +} + +# --- 1. Architecture ------------------------------------------------------- +# Forgejo publie amd64, arm64 et arm-6. PAS de s390x : sur cette architecture +# il faudrait le bâtir depuis les sources en Go, ce que ce script ne fait pas — +# il le dit plutôt que de télécharger un binaire qui ne s'exécutera pas. +case "$(uname -m)" in + x86_64) ARCH=amd64 ;; + aarch64|arm64) ARCH=arm64 ;; + armv6l|armv7l) ARCH=arm-6 ;; + *) die "Forgejo ne publie pas de binaire pour $(uname -m)" \ + "(amd64, arm64 et arm-6 seulement)." ;; +esac + +# --- 2. Version ------------------------------------------------------------ +if [ -z "$VERSION" ]; then + # La liste des versions est en JSON : on la lit avec python3, présent dans + # toutes les images cloud visées. Sans lui, on retombe sur grep — mieux + # qu'un abandon, et le motif est celui d'un champ JSON, pas d'une page web. + if command -v python3 >/dev/null 2>&1; then + VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \ + | python3 -c 'import json,sys +try: + print(json.load(sys.stdin)[0]["tag_name"].lstrip("v")) +except Exception: + pass' || true) + else + VERSION=$(curl -fsSL --max-time 30 "$API?limit=1" 2>/dev/null \ + | grep -o '"tag_name":"v[^"]*"' | head -1 \ + | sed 's/.*"v//;s/"//' || true) + fi +fi +[ -n "$VERSION" ] || die "Version de Forgejo introuvable (réseau ? $API)" +say "Forgejo $VERSION pour $ARCH" + +# --- 3. Déjà posé ? -------------------------------------------------------- +# Rejouer une installation est le cas normal. Comparer la version évite de +# retélécharger 34 Mo pour rien, et de redémarrer un service qui va bien. +if [ -x "$BIN" ] && "$BIN" --version 2>/dev/null | grep -q "version $VERSION"; then + say "${Green}binaire déjà en version $VERSION, téléchargement évité${Color_Off}" +else + # L'archive .xz pèse 34 Mo contre 114 Mo pour le binaire nu. On la prend + # quand xz est là, sans jamais l'installer : le binaire nu est le repli. + tmp=$(mktemp -d) + trap 'rm -rf "$tmp"' EXIT + if command -v xz >/dev/null 2>&1; then + asset="forgejo-$VERSION-linux-$ARCH.xz" + else + asset="forgejo-$VERSION-linux-$ARCH" + fi + say "téléchargement de $asset" + curl -fsSL --retry 3 --max-time 900 "$DL/v$VERSION/$asset" \ + -o "$tmp/$asset" || die "téléchargement impossible : $DL/v$VERSION/$asset" + # Somme de contrôle publiée à côté du fichier : une archive tronquée par une + # coupure réseau donne un binaire qui ne s'exécute pas, et l'erreur arrive + # alors dix étapes plus loin. + if curl -fsSL --max-time 60 "$DL/v$VERSION/$asset.sha256" \ + -o "$tmp/$asset.sha256" 2>/dev/null; then + (cd "$tmp" && sha256sum -c "$asset.sha256" >/dev/null) \ + || die "somme de contrôle invalide pour $asset" + say "somme de contrôle vérifiée" + else + say "${Yellow}⚠ somme de contrôle indisponible, non vérifiée${Color_Off}" + fi + case "$asset" in + *.xz) xz -d "$tmp/$asset"; src="$tmp/${asset%.xz}" ;; + *) src="$tmp/$asset" ;; + esac + chmod +x "$src" + sudo install -m 0755 "$src" "$BIN" + CHANGED=1 + say "${Green}binaire posé : $BIN${Color_Off}" + rm -rf "$tmp" + trap - EXIT +fi + +# --- 4. Compte système et répertoires -------------------------------------- +if ! id "$RUN_USER" >/dev/null 2>&1; then + sudo useradd --system --create-home --home-dir "/home/$RUN_USER" \ + --shell /bin/bash --comment "Forgejo" "$RUN_USER" + say "compte système créé : $RUN_USER" +fi +sudo mkdir -p "$DATA"/{custom,data,log} "$CONF_DIR" +sudo chown -R "$RUN_USER:$RUN_USER" "$DATA" +sudo chmod 750 "$DATA" +# Le fichier de configuration appartient à root et se LIT par le groupe : le +# service en a besoin, et Forgejo y écrit ses secrets au premier démarrage si +# on ne les pose pas soi-même — ce que fait l'étape suivante. +sudo chown root:"$RUN_USER" "$CONF_DIR" +sudo chmod 770 "$CONF_DIR" + +# --- 5. Configuration ------------------------------------------------------ +# JAMAIS réécrite si elle existe : elle porte les secrets, et un utilisateur a +# pu l'ajuster. C'est aussi ce qui rend ce script rejouable. +# « sudo test », et non « [ -f ] » : /etc/forgejo appartient à root:git en 770, +# donc l'utilisateur qui lance le script ne peut même pas y statuer un fichier. +# Le test échouait toujours, et CHAQUE passage réécrivait la configuration avec +# des secrets neufs — ce qui invalide les sessions et les jetons existants. +if sudo test -f "$CONF"; then + say "configuration conservée : $CONF" +else + host=$(host_address) + # Les QUATRE secrets, et pas seulement les deux évidents. Vécu : sans + # « oauth2.JWT_SECRET », Forgejo tente de l'écrire dans app.ini au + # démarrage, n'y arrive pas — le fichier appartient à root — et s'arrête + # sur « [F] save oauth2.JWT_SECRET failed ». Le service redémarrait en + # boucle, 25 fois, sans jamais écouter le port. + # + # Les poser ici garde app.ini NON inscriptible par le service : c'est la + # bonne posture, et ça évite un fichier de configuration qui se réécrit + # tout seul. + secret=$("$BIN" generate secret SECRET_KEY) + token=$("$BIN" generate secret INTERNAL_TOKEN) + jwt=$("$BIN" generate secret JWT_SECRET) + lfs_jwt=$("$BIN" generate secret JWT_SECRET) + # « INSTALL_LOCK = true » verrouille l'installateur web : la machine est + # utilisable sans passer par un formulaire, ce qui est tout l'intérêt d'une + # option cochée au déploiement. SQLite, pour ne pas disputer PostgreSQL à + # Odoo, qui vit sur la même VM. + sudo tee "$CONF" >/dev/null < "$unit_tmp" </dev/null; then + sudo install -m 0644 "$unit_tmp" "$UNIT" + sudo systemctl daemon-reload + CHANGED=1 + say "service défini : $UNIT" +fi +rm -f "$unit_tmp" + +sudo systemctl enable forgejo.service >/dev/null 2>&1 || true +# « restart » et NON « enable --now » quand quelque chose a changé : « --now » +# ne touche pas à un service déjà actif, qui garde alors sa configuration en +# MÉMOIRE. Vécu, et le symptôme ne désigne pas la cause : le serveur comparait +# son ancien INTERNAL_TOKEN à celui que le hook venait de lire sur le disque, et +# répondait 403 à son propre hook. Tout push finissait sur « Forgejo: Internal +# Server Error Decoding Failed », le hook ne sachant pas décoder un 403. +if [ "$CHANGED" = 1 ]; then + sudo systemctl restart forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" + say "service redémarré (configuration ou binaire modifié)" +elif ! systemctl is-active --quiet forgejo.service; then + sudo systemctl start forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" +fi + +# --- 7. Attendre qu'il RÉPONDE -------------------------------------------- +# Une requête HTTP, pas un « systemctl is-active » : le service est « active » +# bien avant d'écouter, et « activating » en boucle de redémarrage ressemble à +# un démarrage en cours. /api/v1/version prouve que l'application SERT — la +# création du compte administrateur qui suit a besoin de la base migrée. +# +# Et surtout pas « exec 3<>/dev/tcp/... » : « exec » est un builtin spécial, et +# une redirection qui échoue termine le shell. Le script mourait donc en +# silence, au premier tour de la boucle, code 1 sans un mot — vécu. +ready=0 +for i in $(seq 1 60); do + # « -fs » sans « -S » : dans une boucle de réessai, le message de curl est + # du bruit — « Failed to connect » au premier tour est normal, le service + # vient de redémarrer. C'est le die final qui parle si rien ne répond. + if curl -fs -o /dev/null --max-time 3 \ + "http://127.0.0.1:$HTTP_PORT/api/v1/version"; then + ready=1 + break + fi + sleep 2 +done +[ "$ready" = 1 ] || die "aucune réponse sur le port $HTTP_PORT après 120 s" \ + "(sudo journalctl -u forgejo -n 40)" + +# --- 8. Compte administrateur --------------------------------------------- +# Créé seulement s'il n'y a AUCUN compte : rejouer le script ne doit pas +# échouer sur « user already exists », ni écraser un mot de passe choisi. +if [ "$SKIP_ADMIN" = 1 ]; then + say "aucun compte créé (FORGEJO_SKIP_ADMIN=1)" +elif sudo -u "$RUN_USER" "$BIN" admin user list --config "$CONF" 2>/dev/null \ + | tail -n +2 | grep -q .; then + say "comptes déjà présents, administrateur non recréé" +else + sudo -u "$RUN_USER" "$BIN" admin user create --admin \ + --username "$ADMIN_USER" --password "$ADMIN_PASSWORD" \ + --email "$ADMIN_EMAIL" --must-change-password=false \ + --config "$CONF" >/dev/null \ + || die "création de l'administrateur impossible" + say "${Green}administrateur créé : $ADMIN_USER / $ADMIN_PASSWORD${Color_Off}" +fi + +# --- 9. Résumé ------------------------------------------------------------- +host=$(host_address) +version=$("$BIN" --version 2>/dev/null | head -1) +say "${Green}Forgejo prêt${Color_Off} : http://$host:$HTTP_PORT/" +say " $version" +say " git par SSH : port $SSH_PORT (serveur interne de Forgejo)" +say " service : sudo systemctl status forgejo" +say " journal : sudo journalctl -u forgejo -f" diff --git a/script/install/install_debian_dependency.sh b/script/install/install_debian_dependency.sh index 61e759b..52e1b4e 100755 --- a/script/install/install_debian_dependency.sh +++ b/script/install/install_debian_dependency.sh @@ -18,9 +18,30 @@ APT_GET="sudo apt-get -o DPkg::Lock::Timeout=600" ## in order to have correct version of wkhtmltopdf installed, for a danger note refer to ## https://github.com/odoo/odoo/wiki/Wkhtmltopdf ): # Ubuntu 20.04 -UBUNTU_VERSION=$(lsb_release -rs) -DEBIAN_VERSION=$(lsb_release -cs) -OS=$(lsb_release -si) +# /etc/os-release D'ABORD, lsb_release seulement en repli. +# +# « lsb_release » vient du paquet lsb-release, qui arrive avec la tâche +# « standard ». Les images cloud l'ont ; une Debian posée par +# debian-installer, non. Les trois variables devenaient alors VIDES, et le +# script concluait « Your version of Ubuntu is not supported » sur une Debian +# — vécu sur s390x, la seule architecture qui passe par l'installateur. +# +# /etc/os-release, lui, appartient à systemd et est toujours là. Il donne +# ID=debian, VERSION_ID=13 et VERSION_CODENAME=trixie sans rien installer. +if [[ -r /etc/os-release ]]; then + # Sous-shell : « source » importerait NAME, PRETTY_NAME et le reste dans + # un script qui n'en veut pas. + UBUNTU_VERSION=$(. /etc/os-release && echo "${VERSION_ID}") + DEBIAN_VERSION=$(. /etc/os-release && echo "${VERSION_CODENAME}") + OS=$(. /etc/os-release && echo "${ID}") + # lsb_release rend « Ubuntu » et « Debian » ; os-release rend « ubuntu » et + # « debian ». Les comparaisons plus bas attendent la première forme. + OS="${OS^}" +else + UBUNTU_VERSION=$(lsb_release -rs) + DEBIAN_VERSION=$(lsb_release -cs) + OS=$(lsb_release -si) +fi # Ubuntu 18.04, 20.04 et 22.04 ne sont plus supportées, sur AUCUNE # architecture. Le mur le plus net est pikepdf, qui réclame qpdf >= 12.2, @@ -268,6 +289,18 @@ if [[ $retVal -ne 0 ]]; then echo "apt-get pyenv dependencies installation error." exit 1 fi +# python3-venv : le venv d'OUTILS (.venv.erplibre) est bâti avec le python du +# SYSTÈME, et sur Debian et Ubuntu « python3 -m venv » n'embarque pas ensurepip +# sans ce paquet. Sans lui le venv naît infirme — bin/python existe, ni pip ni +# activate — et tout ce qui en dépend tombe : « repo », la fusion du manifeste +# (ModuleNotFoundError: No module named 'git'), la configuration PyCharm, la +# compilation mobile. Mesuré sur une VM Ubuntu 24.04 fraîche. +${APT_GET} install python3-venv -y +retVal=$? +if [[ $retVal -ne 0 ]]; then + echo "apt-get python3-venv installation error." + exit 1 +fi # Dependencies for selenium ${APT_GET} install libcairo2-dev python3-dev pkg-config libxt-dev libgirepository1.0-dev -y retVal=$? diff --git a/script/mobile/__init__.py b/script/mobile/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/script/mobile/check_bundle_transfer.py b/script/mobile/check_bundle_transfer.py new file mode 100755 index 0000000..44e8c96 --- /dev/null +++ b/script/mobile/check_bundle_transfer.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le transfert des dépôts ERPLibre dans l'application mobile a-t-il eu lieu ? + +L'application embarque le code des dépôts du manifeste pour les parcourir hors +ligne. Ils y entrent sous forme de PACKS : un APK est un ZIP borné à 65535 +entrées, et ces dépôts pèsent plus de 120 000 fichiers — un fichier par source +faisait échouer la compilation sur « Too many zip entries ». Chaque dépôt a donc +un `index.json` qui dit, pour chaque fichier, dans quelle tranche il se trouve, +à quel offset et sur quelle longueur. + +Ce script VÉRIFIE ce transfert, et il est fait pour être exécuté par +l'installation comme à la main : + + ./script/mobile/check_bundle_transfer.py [racine_du_dépôt_mobile] + +Il échoue quand le transfert est vide, quand une tranche manque, ou quand un +index promet des octets que sa tranche n'a pas — trois pannes qu'un simple +« la compilation a réussi » ne dit pas. +""" + +import argparse +import json +import random +import sys +from pathlib import Path + +# En dessous, ce n'est plus un transfert : c'est un bundle vide qu'on aurait pris +# pour bon. Le seul dépôt odoo en porte près de 40 000 à lui seul. +MIN_FILES = 1000 +# Échantillon relu octet pour octet. Tout relire prendrait des minutes pour ne +# rien apprendre de plus : une tranche fausse l'est dès le premier extrait. +SAMPLE = 20 +SEED = 7 + + +def _read_json(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def pack_path(repo_dir: Path, chunk: int) -> Path: + """Nom de tranche, tel que l'écrit le plugin de compilation.""" + return repo_dir / ("pack-%03d.bin" % chunk) + + +def read_from_pack(repo_dir: Path, entry: dict) -> bytes: + """Relit un fichier depuis sa tranche. Lève si l'index et le pack mentent.""" + chunk = entry["chunk"] + path = pack_path(repo_dir, chunk) + size = entry.get("size", 0) + offset = entry.get("offset", 0) + if not path.is_file(): + raise FileNotFoundError(f"tranche absente : {path.name}") + if offset + size > path.stat().st_size: + raise ValueError( + f"{path.name} fait {path.stat().st_size} o, l'index y demande" + f" {size} o à {offset}" + ) + with open(path, "rb") as fh: + fh.seek(offset) + data = fh.read(size) + if len(data) != size: + raise ValueError(f"{path.name} : {len(data)} o lus au lieu de {size}") + return data + + +def check( + mobile_root: Path, workspace: Path = None, min_files: int = None +) -> dict: + """Vérifie le transfert et rend un compte-rendu. + + `workspace` : racine du checkout ERPLibre. Fournie, un échantillon est + comparé OCTET POUR OCTET à la source — c'est la seule vérification qui + prouve un transfert fidèle, et non seulement cohérent. + """ + # Résolu à l'APPEL, et non dans la signature : un défaut lié à la + # définition ne suit pas la constante si un appelant la change. + min_files = MIN_FILES if min_files is None else min_files + base = mobile_root / "dist" / "repos" + manifest = base / "manifest.json" + if not manifest.is_file(): + raise FileNotFoundError( + f"aucun transfert : {manifest} est absent (npm run build ?)" + ) + repos = _read_json(manifest) + report = { + "repos": len(repos), + "files": 0, + "packs": 0, + "checked": 0, + "compared": 0, + } + sample_pool = [] + for proj in repos: + repo_dir = base / proj["slug"] + index = repo_dir / "index.json" + if not index.is_file(): + raise FileNotFoundError(f"{proj['slug']} : index.json absent") + entries = _read_json(index) + files = [ + e + for e in entries + if e.get("type") == "file" and e.get("chunk") is not None + ] + report["files"] += len(files) + report["packs"] += len(list(repo_dir.glob("pack-*.bin"))) + sample_pool += [(proj, repo_dir, e) for e in files if e.get("size")] + + if report["files"] < min_files: + raise ValueError( + f"transfert trop maigre : {report['files']} fichiers pour" + f" {report['repos']} dépôts (au moins {min_files} attendus)" + ) + + random.seed(SEED) + for proj, repo_dir, entry in random.sample( + sample_pool, min(SAMPLE, len(sample_pool)) + ): + data = read_from_pack(repo_dir, entry) + report["checked"] += 1 + if workspace is None: + continue + src = workspace / proj["path"] / entry["path"] + if not src.is_file(): + continue + if data != src.read_bytes(): + raise ValueError( + f"{proj['slug']} : {entry['path']} diffère de la source" + ) + report["compared"] += 1 + return report + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "mobile_root", + nargs="?", + default="mobile/erplibre_home_mobile", + help="racine du dépôt mobile (défaut : mobile/erplibre_home_mobile)", + ) + parser.add_argument( + "--workspace", + default="", + help="racine ERPLibre, pour comparer un échantillon à la source", + ) + args = parser.parse_args() + root = Path(args.mobile_root) + ws = Path(args.workspace) if args.workspace else None + try: + rep = check(root, ws) + except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc: + print(f" ⚠ transfert des dépôts : {exc}") + return 1 + extra = ( + f", {rep['compared']} comparés à la source" if rep["compared"] else "" + ) + print( + f" {rep['repos']} dépôts, {rep['files']} fichiers en" + f" {rep['packs']} tranches ({rep['checked']} relus{extra})" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/odoo/migration/smoke_public_url.py b/script/odoo/migration/smoke_public_url.py index 501fe76..dd80601 100755 --- a/script/odoo/migration/smoke_public_url.py +++ b/script/odoo/migration/smoke_public_url.py @@ -138,6 +138,15 @@ def start_server(database, port, config_path="./config.conf", log_path=None): nomme la vue fautive faisait partie des absentes. Un fichier se relit entièrement, quand on veut. """ + # Garder l'exécution PRÉCÉDENTE. Le journal était ouvert en « w » : + # relancer le test effaçait la trace de l'échec qu'on venait de voir, + # et il ne restait plus rien à examiner. Une seule génération suffit — + # c'est celle d'avant qu'on vient chercher. + if log_path and os.path.isfile(log_path): + try: + os.replace(log_path, log_path + ".1") + except OSError: + pass handle = open(log_path, "w", encoding="utf-8") if log_path else None server = subprocess.Popen( [ @@ -179,23 +188,36 @@ def read_log(log_path): def fetch(url, timeout=30): - """(statut, corps). Statut 0 quand la connexion elle-même échoue.""" + """(statut, corps, url finale). Statut 0 si la connexion échoue. + + L'URL FINALE, pas seulement celle qu'on a demandée. Sur ce site + chaque page traverse deux ou trois redirections — mesuré, 146 pour + 55 pages — et quand la dernière rend 500, l'outil nommait la + première. On allait vérifier une page saine et l'on concluait que le + test se trompait. + """ try: with urllib.request.urlopen(url, timeout=timeout) as answer: - return answer.getcode(), answer.read().decode( - "utf-8", errors="replace" + return ( + answer.getcode(), + answer.read().decode("utf-8", errors="replace"), + answer.geturl(), ) except urllib.error.HTTPError as exc: - return exc.code, exc.read().decode("utf-8", errors="replace") + return ( + exc.code, + exc.read().decode("utf-8", errors="replace"), + exc.url or url, + ) except Exception: - return 0, "" + return 0, "", url def wait_ready(base_url, timeout=180, sleep=2): """Attendre que le serveur réponde. False s'il n'est jamais venu.""" deadline = time.time() + timeout while time.time() < deadline: - status, _body = fetch(base_url + "/web/login", timeout=5) + status, _body, _fin = fetch(base_url + "/web/login", timeout=5) if status: return True time.sleep(sleep) @@ -209,7 +231,7 @@ def sitemap_urls(base_url): servie en local. Garder le domaine ferait interroger la production — c'est le genre d'erreur qui ne se voit qu'après. """ - status, body = fetch(base_url + "/sitemap.xml") + status, body, _fin = fetch(base_url + "/sitemap.xml") if not status or status >= 400: return [], status lst_loc = RE_LOC.findall(body) @@ -217,7 +239,7 @@ def sitemap_urls(base_url): if "= 400: - lst_failure.append((url, status, [])) + lst_failure.append((url, status, [], finale)) return lst_failure @@ -324,7 +349,7 @@ def culprit_keys(database, lst_failure): exactement la recopie où l'on se trompe. """ lst_id = [] - for _url, _status, lst_parent in lst_failure: + for _url, _status, lst_parent, _finale in lst_failure: for parent_id in lst_parent: if parent_id not in lst_id: lst_id.append(parent_id) @@ -355,9 +380,14 @@ def render(lst_url, lst_failure, lst_key=None): f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}" f" {t('public URL(s) failed')} :" ] - for url, status, lst_parent in lst_failure: + for url, status, lst_parent, finale in lst_failure: label = status or t("no answer") lines.append(f" [{label}] {url}") + # L'URL du sitemap n'est pas celle qui a échoué quand une + # redirection s'est interposée. Ne montrer que la première + # envoyait vérifier une page saine. + if finale and finale != url: + lines.append(f" → {t('failed at')} {finale}") if lst_parent: lines.append( f" {t('parent view(s) in cause')} :" @@ -747,7 +777,7 @@ def recheck_after_reset( f"{t('The server never answered on')} {base_url}" ) lst_again = check_urls( - [url for url, _s, _p in lst_failure], timeout=timeout + [echec[0] for echec in lst_failure], timeout=timeout ) if internal_needs_retry(internal_report): reprise = internal_phase( @@ -906,7 +936,7 @@ def main(argv=None): f"\n↻ {t('Re-checked the')} {len(lst_failure)}" f" {t('failing URL(s) after the reset')} :" ) - print(render([url for url, _s, _p in lst_failure], lst_again, None)) + print(render([echec[0] for echec in lst_failure], lst_again, None)) return 1 if (lst_again or internal_failed) else 0 diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index b031ad7..cf6668a 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -262,6 +262,117 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm Community (installed from the official +JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre +checkout — the Community line, because the unified 2025.3 build stops on a +licence screen and never opens a project), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under +Xvfb — a virtual framebuffer inside the guest, so the orchestrating host +needs no graphics at all — with the trust, privacy and data-sharing dialogs +answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is +written in 195 s, and the install then adds its exclusions to the `.iml`. +When Xvfb is unavailable or the IDE does not get there in five minutes, the +log says so and the install carries on. + +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause instead of leaving a +40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle +mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the +machine's RAM, swap and oom-kill count, because a memory cause is proven and +not assumed), or too many asset files for one APK. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Images are packed too, and a packed file has no URL of its own: the reader turns +its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped — +41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that +Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them +back, `BUNDLE_SKIP_IMG=1` drops the images. + +Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB +with **2 844 entries**, and 20 files read back from the packs identical byte for +byte to their source. The APK does not follow the payload — text compresses, +PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The +install verifies the transfer with `script/mobile/check_bundle_transfer.py`, +which also runs on its own, and a failed transfer fails the VM — an app that +does not carry the code it is meant to show is not the app that was asked for. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the +QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator +without a window and hands you the adb tunnel and the scrcpy command. Prefer +that to a window over X11 — scrcpy receives H.264 encoded by the device, where +`ssh -X` ships every frame as raw pixels in software rendering. If you do want +the window, the path must be absolute, because `ssh host 'command'` reads +neither `~/.profile` nor `~/.bashrc`: +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +It needs no desktop in the VM, but it does need KVM inside the guest, so +nested virtualisation on the host; the log says so when `/dev/kvm` is missing. +The device is not frozen: the SDK is asked for its profiles and the newest +plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet). +Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a +black screen, and `swiftshader_indirect` no longer exists, the emulator +answering `Selected GPU option ... is not valid`. + +A sixth, **Forgejo**, installs a self-hosted git forge — the software behind +Codeberg — from the project's official static binary, and leaves it serving on +port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop, +and unlike it no package family is excluded: the binary is static, so the same +file serves apt, dnf, pacman and zypper. That is what makes it portable across +the ERPLibre platforms without a branch per distribution. Architectures follow +upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on +s390x rather than dropping a binary that cannot run. + +The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for +an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the +published checksum, writes all four secrets itself so the service never needs +to rewrite its own configuration, and stores its data in SQLite so it does not +dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe — +1.5 s measured with everything in place: it skips a binary already at the right +version, never overwrites an existing `app.ini`, and does not recreate the +administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and +a few others tune it; `--help` lists them. + +Each tool is filtered per VM — by architecture, desktop flavour and package +family — and its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -279,9 +390,41 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the + host has a render node), `on` (force), `off` (software rendering). +- `--gpu-node` — which render node to use, on a multi-GPU host. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + L'utilisateur par défaut est `erplibre` (modifiable avec `--user`). @@ -294,6 +437,127 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm Community (posé depuis l'archive +officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt +ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un +écran de licence et n'ouvre jamais de projet), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous +Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte +qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres +de confiance, de confidentialité et de partage de données répondues d'avance. +Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et +l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou +si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation +continue. + +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : +disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non +acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de +l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), +ou trop de fichiers d'assets pour un APK. Le détail va dans +`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation +reste lisible. + +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre : +le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont +écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un +contenu que Weblate maintient et que personne ne lit sur un téléphone. +`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images. + +Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de +354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'APK ne suit pas la charge — le texte se +compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo +d'APK. L'installation vérifie le transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis +le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre +l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy. +Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR +l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en +rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car +`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` : +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc +la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette). +Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un +écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant +`Selected GPU option ... is not valid`. + +Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel +derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse +en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation +mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de +paquets n'est exclue : le binaire est statique, donc le même fichier sert apt, +dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes +ERPLibre sans une branche par distribution. Les architectures suivent l'amont, +qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de +poser un binaire qui ne s'exécutera pas. + +Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une +machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme +de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait +jamais à réécrire sa propre configuration, et garde ses données en SQLite pour +ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et +bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la +bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas +l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` +et quelques autres le règlent ; `--help` les énumère. + +Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille +de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -314,9 +578,40 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si + l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel). +- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Managing VMs @@ -392,6 +687,51 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for personal access. Prefer a bridge (below) if the VM must be a full server exposed on the LAN. +## 3D acceleration (host GPU) + +A graphical VM without acceleration renders everything on the CPU — the +desktop, and the Android emulator running inside it. The deployment therefore +takes the host GPU **by default** (`--gpu auto`): when the host exposes a +render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless` +display that carries the OpenGL context **beside** the VNC console — it opens +no port and replaces nothing. No render node, no 3D, and the deployment says +why instead of quietly falling back. + +```bash +ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +An existing VM is adjusted from the TODO menu **while it is shut off**: +libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › +[2] Change the state`, then either accept *Adjust hardware before starting*, +or take `[3] Adjust hardware only`. In a form when Textual is available, in +prompts otherwise, it sets: + +- **vCPU, RAM, autostart** — the plain sizing knobs. +- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU + instructions over as they are: that is what makes nested virtualization + possible *inside* the VM. `host-model` describes an equivalent model, + migratable to another machine. +- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the + QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU + libvirt writes it into the XML and QEMU never receives it (check with + `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram. +- **Network** — the libvirt networks and the host bridges, the latter to put + the VM on the LAN (see the bridge section below). Switching keeps the MAC + address and the PCI slot, so the guest finds *its* card again — same + interface name, same DHCP lease. + +Two things worth knowing: + +- A host that is **itself a VM** has no render node unless a GPU was handed + down to it. Nested without passthrough, 3D is out of reach: the Android + emulator then runs on SwiftShader, and no option changes that. +- Once the VM does have 3D, the emulator can be tried with `-gpu host` + instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`. + It stays a manual test — an emulator whose GL context fails hangs instead + of falling back, so `swangle` remains the default. + ## QEMU inside QEMU (nested) & exposing the VM via a bridge If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only @@ -404,6 +744,53 @@ middle VM uses CPU mode `host-passthrough`. Check from inside the KVM host accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur à part entière exposé sur le LAN. +## Accélération 3D (GPU de l'hôte) + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU +de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu, +la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui +porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port +et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit +pourquoi au lieu de retomber en silence. + +```bash +ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est +éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › +Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de +démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si +Textual est présent, en invites sinon, il règle : + +- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire. +- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du + processeur hôte telles quelles : c'est lui qui rend la virtualisation + imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent, + migrable vers une autre machine. +- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la + ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu, + libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec + `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la + consomme. +- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour + poser la VM sur le LAN (voir la section du pont plus bas). Le basculement + garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte, + donc son nom d'interface et son bail DHCP. + +Deux choses à savoir : + +- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU + lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte : + l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y + change rien. +- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt + qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça + reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu + au lieu de retomber, d'où `swangle` par défaut. + ## QEMU dans QEMU (imbriqué) & exposer la VM via un pont Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index af7b826..fcff7ef 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -146,6 +146,127 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm Community (posé depuis l'archive +officielle JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt +ERPLibre — la ligne Community, car le build unifié 2025.3 s'arrête sur un +écran de licence et n'ouvre jamais de projet), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Cette première ouverture est automatisée : PyCharm est lancé une fois sous +Xvfb — un serveur d'affichage virtuel DANS la VM invitée, si bien que l'hôte +qui orchestre n'a besoin d'aucune bibliothèque graphique — avec les fenêtres +de confiance, de confidentialité et de partage de données répondues d'avance. +Mesuré sur une VM Ubuntu 26.04 à 16 Go : le `.idea/` est écrit en 195 s, et +l'installation y ajoute ensuite ses exclusions dans le `.iml`. Sans Xvfb, ou +si l'IDE n'y arrive pas en cinq minutes, le journal le dit et l'installation +continue. + +Un quatrième ne demande aucun bureau : **ERPLibre mobile (compilation)**. Il +ajoute le dépôt mobile au manifeste — additif, donc il cohabite avec une +installation Odoo 18 —, lance l'`install-android.sh` du dépôt lui-même (JDK 17, +outils en ligne de commande, licences SDK acceptées, NDK, whisper.cpp et +sentencepiece), puis compile : `npm ci`, `vite build`, `cap sync`, +`gradlew assembleDebug`, et enfin `npm test`. **Une compilation en échec fait +échouer la VM** : le code de sortie remonte au tableau de bord, et le journal +NOMME la cause probable au lieu de laisser 40 Mo de journal Gradle à relire : +disque plein, plateforme SDK absente, JDK et Gradle incompatibles, licences non +acceptées, démon Gradle tué par le noyau (avec la RAM, le swap et le compte de +l'oom-killer, parce qu'une cause « mémoire » se prouve au lieu de s'affirmer), +ou trop de fichiers d'assets pour un APK. Le détail va dans +`~/erplibre-mobile-build.log`, dans la VM, pour que le journal d'installation +reste lisible. + +Cette dernière cause est corrigée, et non contournée. L'application embarque les +dépôts du manifeste pour en parcourir le code hors ligne, et un APK est un ZIP +borné à 65535 entrées — un fichier par source en réclamait 123 678, et la +compilation s'arrêtait là. Ces fichiers y entrent désormais en **packs** : +des tranches de 4 Mo, plus un `index.json` par dépôt qui dit dans quelle tranche +se trouve un fichier, à quel offset et sur quelle longueur. La lecture demande +un intervalle d'octets, et retombe sur la tranche entière quand le serveur du +WebView ignore `Range` — 4 Mo au pire, et c'est pour cela que les tranches sont +bornées. Les images matricielles restent dehors : des captures d'écran +d'addons, dans un navigateur qui montre du texte. + +Les images sont empaquetées aussi, et un fichier empaqueté n'a pas d'URL propre : +le lecteur fait un blob de ses octets. Les catalogues gettext, en revanche, sont +écartés — 41 594 fichiers `.po`/`.pot` pour 857 Mo, soit 72 % du poids, d'un +contenu que Weblate maintient et que personne ne lit sur un téléphone. +`BUNDLE_KEEP_PO=1` les ramène, `BUNDLE_SKIP_IMG=1` retire les images. + +Mesuré sur une VM : 139 dépôts, 80 841 fichiers en 233 tranches, un APK de +354 Mo à **2 844 entrées**, et 20 fichiers relus depuis les packs identiques +octet pour octet à leur source. L'APK ne suit pas la charge — le texte se +compresse, le PNG non : le code seul fait 331 Mo d'assets pour environ 130 Mo +d'APK. L'installation vérifie le transfert avec +`script/mobile/check_bundle_transfer.py`, qui s'exécute aussi seul, et un +transfert manqué fait échouer la VM — une application qui ne porte pas le code +qu'elle est censée montrer n'est pas l'application demandée. + +Il est borné aux distributions apt, parce que cet installateur amont commence +par `sudo apt install openjdk-17-jdk`. Il n'exige PAS Android Studio — une +simple VM serveur produit l'APK — et quand Android Studio est aussi coché, les +deux partagent un seul SDK via `ANDROID_HOME`. Sans Android, la même +application tourne dans un navigateur : `npm start`. + +Un cinquième, **Émulateur Android (Pixel)**, crée un AVD. Conduisez-le depuis +le menu QEMU, *Émulateur Android (démarrage, tunnel, scrcpy)* : il démarre +l'émulateur sans fenêtre, puis donne le tunnel adb et la commande scrcpy. +Préférez cette voie à une fenêtre par X11 — scrcpy reçoit du H.264 encodé PAR +l'appareil, là où `ssh -X` fait traverser chaque image en pixels bruts, en +rendu logiciel. Si vous voulez la fenêtre, le chemin doit être ABSOLU, car +`ssh hôte 'commande'` ne lit ni `~/.profile` ni `~/.bashrc` : +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +Il ne demande aucun bureau dans la VM, mais il exige KVM dans l'invitée, donc +la virtualisation imbriquée sur l'hôte ; le journal le dit quand `/dev/kvm` +manque. Le modèle n'est pas figé : on demande au SDK ses profils et le Pixel le +plus récent au plus petit écran gagne (ni Pro, ni XL, ni pliant, ni tablette). +Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirait un +écran noir, et « swiftshader_indirect » n'existe plus, l'émulateur répondant +`Selected GPU option ... is not valid`. + +Un sixième, **Forgejo**, installe une forge git auto-hébergée — le logiciel +derrière Codeberg — depuis le binaire statique officiel du projet, et la laisse +en service sur le port 3000, avec git par SSH sur 2222. Comme la compilation +mobile, elle n'a besoin d'aucun bureau ; contrairement à elle, aucune famille de +paquets n'est exclue : le binaire est statique, donc le même fichier sert apt, +dnf, pacman et zypper. C'est ce qui la rend portable sur les plateformes +ERPLibre sans une branche par distribution. Les architectures suivent l'amont, +qui publie amd64, arm64 et arm-6 — la case se grise sur s390x plutôt que de +poser un binaire qui ne s'exécutera pas. + +Le travail vit dans `script/forgejo/install_forgejo.sh`, appelable seul sur une +machine existante : `./script/forgejo/install_forgejo.sh`. Il vérifie la somme +de contrôle publiée, écrit lui-même les quatre secrets pour que le service n'ait +jamais à réécrire sa propre configuration, et garde ses données en SQLite pour +ne pas disputer PostgreSQL à Odoo sur la même VM. Le rejouer est sans risque et +bon marché — 1,5 s mesuré, tout étant en place : il saute un binaire déjà à la +bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas +l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` +et quelques autres le règlent ; `--help` les énumère. + +Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille +de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -166,9 +287,40 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--gpu` — accélération 3D par le GPU de l'hôte : `auto` (défaut, activée si + l'hôte a un nœud de rendu), `on` (forcer), `off` (rendu logiciel). +- `--gpu-node` — quel nœud de rendu utiliser, sur un hôte à plusieurs cartes. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Gestion des VM Lister, arrêter et supprimer les VM (le disque qcow2 sous @@ -215,6 +367,53 @@ Host myvm accès personnel. Préférez un pont (ci-dessous) si la VM doit être un serveur à part entière exposé sur le LAN. +## Accélération 3D (GPU de l'hôte) + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. Le déploiement prend donc le GPU +de l'hôte **par défaut** (`--gpu auto`) : si l'hôte expose un nœud de rendu, +la VM reçoit un virtio-GPU avec `accel3d` et un affichage `egl-headless` qui +porte le contexte OpenGL **à côté** de la console VNC — il n'ouvre aucun port +et ne remplace rien. Pas de nœud de rendu, pas de 3D, et le déploiement dit +pourquoi au lieu de retomber en silence. + +```bash +ls /dev/dri/renderD* # le GPU utilisable par QEMU — vide : pas de 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +Une VM déjà installée se règle depuis le menu TODO **pendant qu'elle est +éteinte** : libvirt ne lit ces réglages qu'au démarrage de QEMU. `QEMU/KVM › +Liste des VM › [2] Changer l'état`, puis acceptez *Régler le matériel avant de +démarrer*, ou prenez `[3] Régler le matériel seulement`. En formulaire si +Textual est présent, en invites sinon, il règle : + +- **vCPU, RAM, démarrage automatique** — le dimensionnement ordinaire. +- **Mode CPU** — `host-passthrough` (celui du parc) donne les instructions du + processeur hôte telles quelles : c'est lui qui rend la virtualisation + imbriquée possible *dans* la VM. `host-model` décrit un modèle équivalent, + migrable vers une autre machine. +- **Écrans** — le `heads` du virtio-gpu, qui devient `max_outputs` sur la + ligne QEMU. La `vram` n'est délibérément *pas* proposée : sur un virtio-gpu, + libvirt l'écrit dans le XML et QEMU ne la reçoit jamais (à vérifier avec + `virsh domxml-to-native` : seul `max_outputs` y apparaît). Seul qxl la + consomme. +- **Réseau** — les réseaux libvirt et les ponts de l'hôte, ces derniers pour + poser la VM sur le LAN (voir la section du pont plus bas). Le basculement + garde l'adresse MAC et l'emplacement PCI : l'invité retrouve *sa* carte, + donc son nom d'interface et son bail DHCP. + +Deux choses à savoir : + +- Un hôte qui est **lui-même une VM** n'a aucun nœud de rendu, sauf si un GPU + lui a été transmis. Imbriqué sans passthrough, la 3D est hors d'atteinte : + l'émulateur Android tourne alors sur SwiftShader, et aucune option n'y + change rien. +- Quand la VM a la 3D, l'émulateur peut être essayé en `-gpu host` plutôt + qu'en `-gpu swangle`, son défaut : `EL_EMULATOR_GPU=host ./todo.sh`. Ça + reste un essai manuel — un émulateur dont le contexte GL échoue reste pendu + au lieu de retomber, d'où `swangle` par défaut. + ## QEMU dans QEMU (imbriqué) & exposer la VM via un pont Si l'hôte KVM est **lui-même une VM** (QEMU dans QEMU), le déploiement ne diff --git a/script/qemu/README.md b/script/qemu/README.md index 473a847..38cf1e2 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -136,6 +136,117 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm Community (installed from the official +JetBrains archive into `/opt/pycharm`, its launcher opening the ERPLibre +checkout — the Community line, because the unified 2025.3 build stops on a +licence screen and never opens a project), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). That first open is automated: PyCharm runs once under +Xvfb — a virtual framebuffer inside the guest, so the orchestrating host +needs no graphics at all — with the trust, privacy and data-sharing dialogs +answered in advance. Measured on an Ubuntu 26.04 VM with 16 GB: `.idea/` is +written in 195 s, and the install then adds its exclusions to the `.iml`. +When Xvfb is unavailable or the IDE does not get there in five minutes, the +log says so and the install carries on. + +A fourth one needs no desktop at all: **ERPLibre mobile (build)**. It adds +the mobile repository to the manifest (which is additive, so it coexists with +an Odoo 18 install), runs the repository's own `install-android.sh` — JDK 17, +command-line tools, SDK licences accepted, NDK, whisper.cpp and sentencepiece +— then builds: `npm ci`, `vite build`, `cap sync`, `gradlew assembleDebug`, +and finally `npm test`. **A failed build fails the VM**: the exit code reaches +the dashboard, and the log names the probable cause instead of leaving a +40 MB Gradle log to read: disk full, missing SDK platform, JDK/Gradle +mismatch, unaccepted licences, a Gradle daemon killed by the kernel (with the +machine's RAM, swap and oom-kill count, because a memory cause is proven and +not assumed), or too many asset files for one APK. The heavy output goes to +`~/erplibre-mobile-build.log` inside the VM so the install log stays readable. + +That last cause is fixed rather than avoided. The app carries the manifest +repositories so their code can be browsed offline, and an APK is a ZIP capped at +65535 entries — one file per source asked for 123 678 and the build stopped +there. Those files now enter as **packs**: 4 MB slices, plus an `index.json` per +repository saying which slice holds a file, at which offset and length. The +reader asks for a byte range, and falls back to the whole slice when the WebView +server ignores `Range` — 4 MB at worst, which is why the slices are bounded. +Raster images are left out: addon screenshots, in a browser that shows text. + +Images are packed too, and a packed file has no URL of its own: the reader turns +its bytes into a blob URL. Gettext catalogues, on the other hand, are dropped — +41 594 `.po`/`.pot` files weighing 857 MB, 72 % of the payload for content that +Weblate maintains and nobody reads on a phone. `BUNDLE_KEEP_PO=1` brings them +back, `BUNDLE_SKIP_IMG=1` drops the images. + +Measured on a VM: 139 repositories, 80 841 files in 233 slices, an APK of 354 MB +with **2 844 entries**, and 20 files read back from the packs identical byte for +byte to their source. The APK does not follow the payload — text compresses, +PNG does not: the code alone is 331 MB of assets for about 130 MB of APK. The +install verifies the transfer with `script/mobile/check_bundle_transfer.py`, +which also runs on its own, and a failed transfer fails the VM — an app that +does not carry the code it is meant to show is not the app that was asked for. + +It is bounded to apt-based distributions, because that upstream installer +starts with `sudo apt install openjdk-17-jdk`. It requires no Android Studio +— a plain server VM builds the APK — and when Android Studio is also ticked +they share one SDK through `ANDROID_HOME`. Without Android, the same app runs +in a browser: `npm start`. + +A fifth, **Android emulator (Pixel)**, creates an AVD. Drive it from the +QEMU menu, *Android emulator (start, tunnel, scrcpy)*: it starts the emulator +without a window and hands you the adb tunnel and the scrcpy command. Prefer +that to a window over X11 — scrcpy receives H.264 encoded by the device, where +`ssh -X` ships every frame as raw pixels in software rendering. If you do want +the window, the path must be absolute, because `ssh host 'command'` reads +neither `~/.profile` nor `~/.bashrc`: +`ssh -XC erplibre@ '$HOME/android/emulator/emulator -avd erplibre -no-audio'`. + +It needs no desktop in the VM, but it does need KVM inside the guest, so +nested virtualisation on the host; the log says so when `/dev/kvm` is missing. +The device is not frozen: the SDK is asked for its profiles and the newest +plain Pixel with the smallest screen wins (no Pro, XL, Fold or tablet). +Rendering is `swangle` in the AVD's own `config.ini` — `auto` would open a +black screen, and `swiftshader_indirect` no longer exists, the emulator +answering `Selected GPU option ... is not valid`. + +A sixth, **Forgejo**, installs a self-hosted git forge — the software behind +Codeberg — from the project's official static binary, and leaves it serving on +port 3000 with git-over-SSH on 2222. Like the mobile build it needs no desktop, +and unlike it no package family is excluded: the binary is static, so the same +file serves apt, dnf, pacman and zypper. That is what makes it portable across +the ERPLibre platforms without a branch per distribution. Architectures follow +upstream, which publishes amd64, arm64 and arm-6 — the checkbox greys out on +s390x rather than dropping a binary that cannot run. + +The work lives in `script/forgejo/install_forgejo.sh`, callable on its own for +an existing machine: `./script/forgejo/install_forgejo.sh`. It verifies the +published checksum, writes all four secrets itself so the service never needs +to rewrite its own configuration, and stores its data in SQLite so it does not +dispute PostgreSQL with Odoo on the same VM. Replaying it is cheap and safe — +1.5 s measured with everything in place: it skips a binary already at the right +version, never overwrites an existing `app.ini`, and does not recreate the +administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and +a few others tune it; `--help` lists them. + +Each tool is filtered per VM — by architecture, desktop flavour and package +family — and its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -153,9 +264,41 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--gpu` — 3D acceleration by the host GPU: `auto` (default, on when the + host has a render node), `on` (force), `off` (software rendering). +- `--gpu-node` — which render node to use, on a multi-GPU host. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + ## Managing VMs List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images` @@ -202,6 +345,51 @@ This works over Wi-Fi and needs no VM shutdown — the simplest option for personal access. Prefer a bridge (below) if the VM must be a full server exposed on the LAN. +## 3D acceleration (host GPU) + +A graphical VM without acceleration renders everything on the CPU — the +desktop, and the Android emulator running inside it. The deployment therefore +takes the host GPU **by default** (`--gpu auto`): when the host exposes a +render node, the VM gets a virtio-GPU with `accel3d` plus an `egl-headless` +display that carries the OpenGL context **beside** the VNC console — it opens +no port and replaces nothing. No render node, no 3D, and the deployment says +why instead of quietly falling back. + +```bash +ls /dev/dri/renderD* # the GPU QEMU can use — empty means no 3D +sudo virsh dumpxml | grep -A2 -E "accel3d|egl-headless" +``` + +An existing VM is adjusted from the TODO menu **while it is shut off**: +libvirt only reads these settings when QEMU starts. `QEMU/KVM › List VMs › +[2] Change the state`, then either accept *Adjust hardware before starting*, +or take `[3] Adjust hardware only`. In a form when Textual is available, in +prompts otherwise, it sets: + +- **vCPU, RAM, autostart** — the plain sizing knobs. +- **CPU mode** — `host-passthrough` (what the fleet uses) hands the host CPU + instructions over as they are: that is what makes nested virtualization + possible *inside* the VM. `host-model` describes an equivalent model, + migratable to another machine. +- **Screens** — the virtio-GPU `heads`, which becomes `max_outputs` on the + QEMU command line. `vram` is deliberately *not* offered: on a virtio-GPU + libvirt writes it into the XML and QEMU never receives it (check with + `virsh domxml-to-native` — only `max_outputs` shows up). Only qxl uses vram. +- **Network** — the libvirt networks and the host bridges, the latter to put + the VM on the LAN (see the bridge section below). Switching keeps the MAC + address and the PCI slot, so the guest finds *its* card again — same + interface name, same DHCP lease. + +Two things worth knowing: + +- A host that is **itself a VM** has no render node unless a GPU was handed + down to it. Nested without passthrough, 3D is out of reach: the Android + emulator then runs on SwiftShader, and no option changes that. +- Once the VM does have 3D, the emulator can be tried with `-gpu host` + instead of its default `-gpu swangle`: `EL_EMULATOR_GPU=host ./todo.sh`. + It stays a manual test — an emulator whose GL context fails hangs instead + of falling back, so `swangle` remains the default. + ## QEMU inside QEMU (nested) & exposing the VM via a bridge If the KVM host is **itself a VM** (QEMU-in-QEMU), the deployment works only diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 4f483de..c253bf0 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -46,9 +46,12 @@ Exemples from __future__ import annotations import argparse +import base64 import getpass import grp +import gzip import hashlib +import zlib import os import re import shutil @@ -173,6 +176,13 @@ S390X_DISTROS: tuple[str, ...] = ( "rocky", "fedora", "opensuse", + # Debian n'a PAS d'image cloud s390x, et n'en aura pas par cette voie : + # vérifié sur cloud.debian.org, les arborescences bookworm et trixie ne + # publient que amd64, arm64, ppc64el et riscv64. Le port s390x existe + # pourtant — « binary-s390x » répond 200 — et debian-installer livre + # kernel + initrd pour les deux versions. On y passe donc par + # l'INSTALLATEUR au lieu d'un qcow2 tout fait : voir uses_installer(). + "debian", ) # Une distro peut ne publier qu'une PARTIE de ses versions sur une @@ -184,9 +194,33 @@ S390X_DISTROS: tuple[str, ...] = ( # (404 sur le miroir maître), la 44 n'est pour l'instant que sur certains # miroirs tiers. Seule la 43 est servie par dl.fedoraproject.org — vérifié. ARCH_ONLY_VERSIONS: dict[str, dict[str, tuple[str, ...]]] = { - "s390x": {"fedora": ("43",)}, + # Debian sur s390x passe par debian-installer, dont les images sont + # publiées pour bookworm et trixie — vérifié. bullseye est écartée : elle + # est en fin de vie et son installateur n'a pas été éprouvé ici. + "s390x": {"fedora": ("43",), "debian": ("12", "13")}, } +# Distros installées par debian-installer plutôt que depuis une image cloud. +# La différence n'est pas cosmétique : pas de qcow2 à convertir, pas de seed +# cloud-init, un disque VIERGE et un amorçage kernel+initrd. +INSTALLER_COMBOS: tuple[tuple[str, str], ...] = (("debian", "s390x"),) + +# Plancher mémoire de l'installateur : il déplie un système de fichiers entier +# en RAM, là où une image cloud arrive déjà installée. +INSTALLER_MIN_RAM = 2048 + +# kernel.debian / initrd.debian du port s390x. « current » suit les mises à +# jour de l'installateur sans figer un numéro qui périmerait. +INSTALLER_URL = ( + "https://deb.debian.org/debian/dists/{code}/main/installer-s390x" + "/current/images/generic/{fichier}" +) + + +def uses_installer(distro: str, arch: str) -> bool: + """Vrai si cette combinaison s'installe par debian-installer.""" + return (distro, arch) in INSTALLER_COMBOS + def arch_versions(distro: str, arch: str, versions) -> list[str]: """Versions de `distro` réellement publiées pour `arch`.""" @@ -226,6 +260,103 @@ def host_arch() -> str: }.get(machine, "amd64") +# Nœud de rendu DRM : le fichier que le processus QEMU ouvre pour créer un +# contexte OpenGL (virgl) et donner la 3D à la VM. Un hôte sans GPU — ou +# lui-même virtualisé sans GPU transmis — n'expose AUCUN « renderD* », et +# aucune option de ligne de commande ne peut y suppléer : la VM retombe alors +# sur le rendu logiciel. On teste donc la présence du nœud, pas nos droits +# dessus : l'accès est accordé par libvirt au démarrage du domaine (cgroup + +# étiquette), et un test de lecture sous notre propre compte rejetterait à +# tort un hôte où seul le groupe « render » entre. +HOST_DRI_DIR = Path("/dev/dri") + + +def host_render_nodes(directory=HOST_DRI_DIR) -> list[str]: + """Nœuds de rendu de l'hôte, triés (ex. ['/dev/dri/renderD128']).""" + directory = Path(directory) + try: + names = sorted(p.name for p in directory.iterdir()) + except OSError: + return [] + return [str(directory / n) for n in names if n.startswith("renderD")] + + +def host_gpu_node(directory=HOST_DRI_DIR) -> str: + """Nœud de rendu à confier à QEMU, ou '' si l'hôte n'a pas de GPU. + + Le premier de la liste : sur une machine à plusieurs cartes, renderD128 + est le nœud du GPU primaire. --gpu-node force un autre choix. + """ + nodes = host_render_nodes(directory) + return nodes[0] if nodes else "" + + +def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]: + """(3D activée, message à dire) pour un mode --gpu et un nœud donnés. + + Séparée du reste pour être vérifiable sans hôte : c'est ici que se décide + « par défaut avec GPU s'il existe », et le silence n'est pas une option — + une VM en rendu logiciel doit dire pourquoi. + """ + mode = (mode or "auto").lower() + if mode == "off": + return False, "" + if not screen: + # Sans écran virtuel, la 3D n'a rien à accélérer. Le dire seulement + # quand elle a été demandée explicitement. + if mode == "on": + return ( + False, + " GPU : pas d'écran virtuel sur cette VM, 3D ignorée.", + ) + return False, "" + if not node: + if mode == "on": + return ( + False, + " ⚠ GPU demandé mais l'hôte n'a aucun nœud de rendu" + " (/dev/dri/renderD*) : la VM démarrerait sans écran." + " Rendu logiciel.", + ) + return ( + False, + " GPU : aucun sur l'hôte, rendu logiciel (virgl absent).", + ) + return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)." + + +def gpu_apply( + video: list, mode: str, node: str, screen: bool +) -> tuple[list, list, str]: + """(video, arguments 3D, message) — pour virt-install. + + Renvoie le `--video` à garder : celui de la 3D REMPLACE le simple + « --video virtio », il ne s'y ajoute pas. Deux --video donneraient deux + écrans à la VM, et l'invité n'afficherait le bureau que sur un seul. + """ + use_gpu, message = gpu_decision(mode, node, screen) + if not use_gpu: + return video, [], message + return [], gpu_install_args(node), message + + +def gpu_install_args(node: str) -> list[str]: + """Arguments virt-install qui donnent la 3D à la VM. + + Deux pièces indissociables : l'accélération sur le virtio-gpu, et un + affichage capable de contexte GL. « egl-headless » joue ce second rôle + SANS remplacer la console VNC — il n'ouvre aucun port, il n'existe que + pour porter le contexte OpenGL. C'est la recette documentée pour associer + 3D et VNC, là où ne vaut que pour SPICE. + """ + return [ + "--video", + "model.type=virtio,model.acceleration.accel3d=on", + "--graphics", + f"type=egl-headless,gl.rendernode={node}", + ] + + ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest" CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com" @@ -1449,6 +1580,589 @@ def user_groups(distro: str) -> str: return "users, wheel" +# --------------------------------------------------------------------------- # +# Guide de connexion (/etc/motd) et identité git de la VM +# --------------------------------------------------------------------------- # +# Le catalogue couvre quatre gestionnaires de paquets, et l'opérateur change de +# distribution d'un déploiement à l'autre. Le guide met SOUS LES YEUX, à la +# connexion, les commandes de la machine où l'on vient d'entrer : apt là où +# c'est apt, zypper là où c'est zypper. +# +# Le mécanisme est /etc/motd, et il est le même partout. Vérifié dans les images +# cloud elles-mêmes, montées en lecture seule : sshd y est en « PrintMotd no » +# et c'est pam_motd qui affiche le fichier. Trois conséquences tenues pour +# acquises ici : +# - il suffit d'ÉCRIRE /etc/motd. Ajouter « PrintMotd yes » afficherait le +# guide DEUX FOIS — sshd lit /etc/motd en dur, PAM le lit aussi ; +# - « ssh hôte 'commande' » ne l'affiche PAS (openssh coupe les deux chemins +# dès qu'une commande est passée), donc le suivi d'installation reste net. +# Un « ssh hôte < script » l'afficherait, lui : aucun n'est utilisé ici ; +# - openSUSE ajoute son « Have a lot of fun... » APRÈS le guide : il vient de +# /usr/lib/motd.d/welcome, que pam_motd lit après le fichier. On le laisse. +# +# Ubuntu n'a PAS de /etc/motd (son postinst base-files ne le crée pas, à la +# différence de Debian) : le fichier est donc créé, pas remplacé. Sur Debian il +# écrase les cinq lignes de base-files, ce qui ne fâche pas dpkg — /etc/motd n'y +# est ni un conffile ni même un fichier du paquet. + +# Étiquette lisible d'une distribution. openSUSE livre DEUX produits sous un +# seul nom de distro (Leap, numéroté ; Tumbleweed, rolling) : la version tranche. +DISTRO_LABELS: dict[str, str] = { + "ubuntu": "Ubuntu", + "debian": "Debian", + "fedora": "Fedora", + "almalinux": "AlmaLinux", + "rocky": "Rocky Linux", + "opensuse": "openSUSE", + "arch": "Arch Linux", +} + +# Gestionnaire de paquets de chaque distribution du catalogue. +DISTRO_PKG: dict[str, str] = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", +} + + +def distro_label(distro: str, version: str) -> str: + """« Ubuntu 24.04 », « openSUSE Leap 16.0 », « Arch Linux »…""" + name = DISTRO_LABELS.get(distro, distro) + if distro == "opensuse": + if version == "tumbleweed": + return f"{name} Tumbleweed" + return f"{name} Leap {version}" + if distro == "arch": + # Rolling release : « latest » n'apprend rien à personne. + return name + return f"{name} {version}" + + +# Aide-mémoire par gestionnaire de paquets : (commande, glose fr, glose en). +# Chaque ligne vient du manuel amont de l'outil, pas de mémoire, et doit +# fonctionner TELLE QUELLE — c'est un guide, pas une piste à vérifier. +# +# dnf : les formes écrites ici valent pour dnf4 (AlmaLinux/Rocky 9 ET 10, tous +# deux en dnf 4.x) comme pour dnf5 (Fedora 41+). Les raccourcis de dnf4 ont +# disparu de dnf5 : « dnf history » seul, « grouplist », « whatprovides », +# « list installed » sans tirets y échouent tous. Les formes longues passent +# partout, et ne coûtent rien. +PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = { + "apt": ( + ("sudo apt update", "rafraîchir l'index", "refresh the index"), + ( + "sudo apt upgrade", + "mettre à jour le système", + "upgrade the system", + ), + ("sudo apt install ", "installer", "install"), + ("sudo apt remove ", "retirer", "remove"), + ("apt search ", "chercher", "search"), + ("apt show ", "détails d'un paquet", "package details"), + ("apt list --installed", "lister l'installé", "list installed"), + ("sudo apt autoremove", "purger les orphelins", "purge orphans"), + ), + "dnf": ( + ( + "sudo dnf upgrade", + "mettre à jour le système", + "upgrade the system", + ), + ("sudo dnf install ", "installer", "install"), + ("sudo dnf remove ", "retirer", "remove"), + ("dnf check-update", "mises à jour disponibles", "available updates"), + ("dnf search ", "chercher", "search"), + ("dnf info ", "détails d'un paquet", "package details"), + ("dnf list --installed", "lister l'installé", "list installed"), + ("dnf history list", "journal des opérations", "transaction log"), + ), + "pacman": ( + ( + "sudo pacman -Syu", + "mettre à jour le système", + "upgrade the system", + ), + # Jamais « -Sy » seul : la base de paquets serait à jour et le système + # non, donc une installation tirerait des binaires liés à des + # bibliothèques absentes. Arch ne supporte que la mise à jour complète, + # d'où la forme « -Syu » pour installer. + ( + "sudo pacman -Syu ", + "installer (jamais « -Sy » seul)", + "install (never a bare « -Sy »)", + ), + ("sudo pacman -Rns ", "retirer", "remove"), + ("pacman -Ss ", "chercher", "search"), + ("pacman -Si ", "détails d'un paquet", "package details"), + ("pacman -Q", "lister l'installé", "list installed"), + ("pacman -Qdtq", "orphelins", "orphans"), + ("sudo pacman -Sc", "nettoyer le cache", "clean the cache"), + ), +} + + +def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]: + """Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap. + + La ligne de mise à jour n'est PAS la même, et ce n'est pas une préférence de + style : « up » sur Leap, dont la version est figée, et « dup » sur + Tumbleweed, où chaque mise à jour est un instantané complet de la + distribution. La doc amont est catégorique — « on Tumbleweed you will never + have to use zypper-up » — et « up » y laisse traîner des paquets retirés des + dépôts, donc des dépendances bancales. Le déploiement sait laquelle des deux + il installe : autant que le guide le sache aussi. + """ + upgrade = ( + ("sudo zypper dup", "mettre à jour (rolling)", "upgrade (rolling)") + if rolling + else ( + "sudo zypper up", + "mettre à jour le système", + "upgrade the system", + ) + ) + return ( + ("sudo zypper ref", "rafraîchir les dépôts", "refresh the repos"), + upgrade, + ("sudo zypper in ", "installer", "install"), + ("sudo zypper rm ", "retirer", "remove"), + ("zypper se ", "chercher", "search"), + ("zypper info ", "détails d'un paquet", "package details"), + ("zypper se -i", "lister l'installé", "list installed"), + ("zypper lu", "mises à jour disponibles", "available updates"), + ( + "sudo zypper ps -s", + "à redémarrer après MAJ", + "restart after upgrade", + ), + ) + + +# Lignes valables partout, quelle que soit la distribution. +SYSTEM_GUIDE: tuple[tuple[str, str, str], ...] = ( + ("hostname -I", "adresse IP de la VM", "the VM's IP address"), + ("df -h /", "espace disque", "disk space"), + ("free -h", "mémoire", "memory"), +) +# Ajoutées SEULEMENT quand il n'y a pas de section ERPLibre : celle-ci montre +# déjà les deux commandes, sur un service qui existe vraiment. Sur une VM +# déployée sans ERPLibre, elles manqueraient. +SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = ( + ("systemctl status ", "état d'un service", "a service's state"), + ("journalctl -u -f", "suivre son journal", "follow its log"), +) + + +# N'apparaît que sur une VM déployée AVEC un bureau. Vécu : GNOME installé, +# gdm3 installé, cible graphique par défaut… et la console restait en mode texte. +# graphical.target était déjà atteinte quand le paquet est arrivé, et une cible +# active ne rattrape pas un service ajouté après coup. « enable » seul n'y change +# rien sur Debian et Ubuntu — l'unité n'a pas de WantedBy, seulement un alias — +# d'où le « --now », qui démarre. +DESKTOP_GUIDE: tuple[tuple[str, str, str], ...] = ( + ( + "systemctl status display-manager", + "état du bureau graphique", + "graphical desktop state", + ), + ( + "sudo systemctl enable --now gdm", + "le démarrer (« --now » : enable seul ne suffit pas)", + 'start it ("--now": enable alone does nothing)', + ), +) + + +def erplibre_guide( + el_dir: str, el_make: str = "", editor: str = "" +) -> tuple[tuple[str, str, str], ...]: + """Commandes ERPLibre de la VM. + + `el_dir` : racine de l'installation (~/git/erplibre en développement, + /opt/erplibre en production). Toutes les autres lignes sont relatives à ce + répertoire, d'où le « cd » en tête. + + `el_make` : cible make qui a installé la VM, réutilisée pour la mettre à + jour. Vide, le guide s'arrête à « git pull » plutôt que d'annoncer une cible + qui n'est pas celle du profil retenu. + + `editor` : éditeur de l'hôte, quand il a pu être déterminé. Sans lui on + nomme le fichier de configuration sans nommer d'éditeur — « vi » n'est pas + garanti sur toutes les images cloud, et un guide qui propose une commande + absente est pire que muet. + """ + rows = [ + (f"cd {el_dir}", "aller au dépôt", "go to the checkout"), + ("make todo", "menu ERPLibre (TODO)", "ERPLibre menu (TODO)"), + ] + if editor: + rows.append( + ( + f"{editor} config.conf", + "éditer le serveur", + "edit the server", + ) + ) + else: + rows.append( + ( + "config.conf", + "configuration du serveur", + "the server's config", + ) + ) + rows += [ + ( + "sudo systemctl restart erplibre", + "redémarrer le serveur", + "restart the server", + ), + ("systemctl status erplibre", "état du serveur", "server state"), + ("journalctl -u erplibre -f", "suivre son journal", "follow its log"), + ("./run.sh -d ", "lancer à la main", "run it by hand"), + ( + "./script/addons/update_addons_all.sh ", + "mise à jour des modules", + "update the modules", + ), + ( + f"git pull && make {el_make}" if el_make else "git pull", + ( + "mise à jour ERPLibre/Odoo" + if el_make + else "mettre à jour le dépôt" + ), + "update ERPLibre/Odoo" if el_make else "update the checkout", + ), + ("http://:8069", "interface web", "web interface"), + ] + return tuple(rows) + + +# Plancher de largeur de l'encadré. Au-dessus, il SUIT le contenu : un cadre +# plus étroit que ce qu'il encadre serait pire qu'un cadre large. C'est au +# contenu de rester sous 80 colonnes — un guide qui se replie sur un terminal +# standard est illisible, et un test le vérifie pour les sept distributions. +MOTD_MIN_WIDTH = 62 + + +def _pick(pair: tuple[str, str], lang: str) -> str: + """Membre fr ou en d'un couple de libellés.""" + return pair[1] if lang == "en" else pair[0] + + +def gloss_col(*blocks: tuple[tuple[str, str, str], ...]) -> int: + """Colonne où commencent les gloses de ces blocs : la commande la plus + longue, plus deux espaces.""" + return max(len(cmd) for rows in blocks for cmd, _fr, _en in rows) + 2 + + +def motd_block( + title: str, rows: tuple[tuple[str, str, str], ...], lang: str, col: int +) -> list[str]: + """Un bloc du guide : un titre, puis « commande glose » alignées. + + `col` est donné plutôt que déduit du bloc : les blocs de commandes courtes + partagent une colonne commune, sans quoi le bloc « système » se tasserait à + treize caractères là où celui des paquets en occupe trente. Le bloc + ERPLibre, lui, garde la sienne — sa commande la plus longue fait + 43 caractères, et l'imposer au guide entier ferait déborder les lignes de + 80 colonnes. + """ + out = [f" {title}"] + for cmd, gloss_fr, gloss_en in rows: + out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}") + return out + + +def build_motd( + distro: str, + version: str, + arch: str, + lang: str = "fr", + el_dir: str = "", + el_make: str = "", + editor: str = "", + desktop: bool = False, +) -> str: + """Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable. + + La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans + installation ne doit pas annoncer un dépôt et un service qui n'existent pas. + Le bloc « Bureau » suit la même règle avec `desktop` : sur un serveur, ces + deux commandes ne mèneraient à aucune unité. + """ + body: list[str] = [] + mgr = DISTRO_PKG.get(distro, "") + if mgr == "zypper": + pkg_rows = zypper_guide(version == "tumbleweed") + else: + pkg_rows = PKG_GUIDE.get(mgr, ()) + sys_rows = SYSTEM_GUIDE if el_dir else SYSTEM_GUIDE + SERVICE_GUIDE + narrow = gloss_col(pkg_rows or sys_rows, sys_rows) + if pkg_rows: + body += motd_block( + f"{_pick(('Paquets', 'Packages'), lang)} — {mgr}", + pkg_rows, + lang, + narrow, + ) + if el_dir: + body.append("") + el_rows = erplibre_guide(el_dir, el_make, editor) + body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows)) + if desktop: + body.append("") + body += motd_block( + _pick(("Bureau", "Desktop"), lang), + DESKTOP_GUIDE, + lang, + gloss_col(DESKTOP_GUIDE), + ) + body.append("") + body += motd_block( + _pick(("Système", "System"), lang), sys_rows, lang, narrow + ) + title = f"ERPLibre · {distro_label(distro, version)} · {arch}" + width = max( + MOTD_MIN_WIDTH, max([len(line) for line in body] + [len(title)]) + 4 + ) + head = [ + "╭" + "─" * (width - 2) + "╮", + "│ " + title.ljust(width - 4) + " │", + "╰" + "─" * (width - 2) + "╯", + ] + foot = [ + "", + " " + + _pick( + ( + "Guide écrit au déploiement par", + "Guide written at deploy time by", + ), + lang, + ) + + " script/qemu/deploy_qemu.py", + ] + return "\n".join(head + [""] + body + foot) + "\n" + + +def invoking_home() -> Path: + """Foyer de l'utilisateur qui a lancé le script, sudo compris. + + Le script tourne sous sudo : `Path.home()` y renvoie /root, où il n'y a + aucune configuration git à reprendre. + """ + try: + return Path(os.path.expanduser(f"~{invoking_user()}")) + except (KeyError, RuntimeError): + return Path.home() + + +def _git_global(key: str, home: Path) -> str: + """Valeur d'une clé de la configuration git GLOBALE de `home`. + + HOME est forcé plutôt que de lire ~/.gitconfig à la main : git accepte DEUX + emplacements pour sa configuration globale (~/.gitconfig et + ~/.config/git/config), et lui poser la question évite de trancher à sa place. + """ + try: + res = subprocess.run( + ["git", "config", "--global", "--get", key], + capture_output=True, + text=True, + timeout=5, + env=dict(os.environ, HOME=str(home)), + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout.strip() if res.returncode == 0 else "" + + +def host_editor(home: Path) -> str: + """Éditeur que l'hôte utilise, dans l'ordre où git le résout lui-même. + + core.editor, puis $VISUAL/$EDITOR, puis /usr/bin/editor — le lien des + alternatives Debian, qui est LA réponse à « quel éditeur ce système + utilise-t-il » quand rien n'est configuré. Ailleurs ce lien n'existe pas et + on ne devine pas : mieux vaut ne rien écrire que d'imposer un éditeur. + + GIT_EDITOR est volontairement IGNORÉ. Les outils qui appellent git sans + interaction le posent à « true » pour empêcher toute ouverture d'éditeur ; + le recopier dans la VM y désactiverait silencieusement l'éditeur de git. + """ + editor = _git_global("core.editor", home) + if not editor: + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "" + if not editor: + try: + editor = Path("/usr/bin/editor").resolve(strict=True).name + except OSError: + editor = "" + return editor.strip() + + +def editor_binary(editor: str) -> str: + """Binaire seul d'une commande d'éditeur (« code --wait » -> « code »). + + Le guide affiche le binaire, pas la commande complète : les options de git + (attente de fermeture, fichier temporaire) n'ont pas de sens pour ouvrir un + fichier de configuration à la main. + """ + if not editor.strip(): + return "" + return editor.split()[0].rsplit("/", 1)[-1] + + +# Éditeurs que la VM sait se donner : binaire de l'hôte -> (paquet, binaire dans +# la VM). Le nom du paquet est le MÊME sur apt, dnf, zypper et pacman pour ces +# trois-là — vérifié pour chacun ; « vi » est fourni par vim, et le paquet +# neovim installe « nvim ». +# +# Cette table est la SEULE autorité, et elle décide de trois choses à la fois : +# le paquet que l'installation ajoute, la commande que le guide affiche, et la +# valeur de core.editor dans la VM. Les tenir liées est le point : un +# « core.editor = code » pointant un binaire absent fait échouer « git commit » +# (« cannot run code »), et un guide qui nomme une commande absente est pire que +# muet. Un éditeur hors de cette table est donc ignoré — pas deviné. +EDITOR_PACKAGES: dict[str, tuple[str, str]] = { + "vim": ("vim", "vim"), + "vi": ("vim", "vim"), + "nvim": ("neovim", "nvim"), + "neovim": ("neovim", "nvim"), + "nano": ("nano", "nano"), +} + + +def vm_editor(home: Path) -> tuple[str, str]: + """(paquet, binaire) de l'éditeur à donner à la VM, ou deux chaînes vides.""" + return EDITOR_PACKAGES.get(editor_binary(host_editor(home)), ("", "")) + + +def build_gitconfig(name: str, email: str, editor: str) -> str: + """~/.gitconfig de la VM. Chaîne vide si l'hôte n'a rien à transmettre. + + Une VM de développement sert à produire des commits, et un commit sans + identité est refusé par git (« Please tell me who you are ») : reprendre + celle de l'hôte évite de la retaper sur chaque machine, et surtout évite les + commits signés d'un « erplibre@ » que personne ne reconnaît. + + INDENTATION EN ESPACES, jamais en tabulation. git accepte les deux, mais ce + texte part dans un scalaire bloc YAML où une tabulation en tête de ligne est + une erreur FATALE : cloud-init rejette alors le user-data en entier et la VM + démarre sans utilisateur ni clé SSH, donc inaccessible. + """ + lines: list[str] = [] + if name or email: + lines.append("[user]") + if name: + lines.append(f" name = {name}") + if email: + lines.append(f" email = {email}") + if editor: + lines += ["[core]", f" editor = {editor}"] + return "\n".join(lines) + "\n" if lines else "" + + +def write_files_lines( + entries: list[tuple[str, str, str, str]], +) -> list[str]: + """Bloc « write_files » de cloud-init pour des fichiers TEXTE. + + entries : (chemin, mode, contenu, propriétaire) ; propriétaire vide = root. + + Deux règles YAML dont le non-respect coûte TOUTE la configuration — une + erreur de syntaxe fait rejeter le user-data en ENTIER, sans message sur la + console : la VM démarre nue, sans utilisateur ni clé SSH, inaccessible. + - Le mode est une CHAÎNE, entre guillemets. « permissions: 644 » non quoté + est lu comme 644 DÉCIMAL et appliqué tel quel, soit 0o1204 soit le bit + setuid allumé et des droits absurdes, sans le moindre avertissement. + - Le contenu est un scalaire bloc « | » indenté de six espaces, dont la + PREMIÈRE ligne non vide fixe l'indentation de référence : les suivantes + doivent être au moins aussi indentées. Les caractères d'encadrement + UTF-8 passent sans échappement. + + Un propriétaire impose « defer: true » : write_files tourne à l'étape init, + AVANT la création des utilisateurs, donc le chown vers le compte de la VM + échouerait. Reporté à l'étape finale, il passe — et le suivi d'installation + attend de toute façon la fin de cloud-init avant de se connecter. + """ + out = ["write_files:"] + for path, mode, content, owner in entries: + out.append(f" - path: {path}") + out.append(f" permissions: '{mode}'") + if owner: + out.append(f" owner: {owner}:{owner}") + out.append(" defer: true") + out.append(" content: |") + # textwrap.indent laisse les lignes vides VIDES : six espaces résiduels + # survivraient au scalaire bloc et se retrouveraient dans le fichier, + # invisibles en revue et bien présents à l'écran. + out += textwrap.indent(content.rstrip("\n"), " ").split("\n") + return out + + +# Préfixe des fichiers d'accueil embarqués dans l'initrd de l'installateur. +# Un préfixe, et non un répertoire : le cpio est déplié séquentiellement et les +# répertoires parents manquants ne sont pas créés — une entrée +# « erplibre/etc-motd » sans entrée « erplibre » ferait échouer le dépliage de +# l'initrd ENTIER, donc l'installation. Les fichiers restent à la racine. +INSTALLER_GUIDE_PREFIX = "erplibre-" + + +def installer_guide_name(path: str) -> str: + """Nom dans l'initrd du fichier destiné au chemin `path` de la VM. + + « /etc/motd » -> « erplibre-etc-motd ». Un nom PLAT, dérivé du chemin : les + deux fonctions qui s'en servent (le preseed qui copie, l'initrd qui range) + le calculent de la même façon, donc elles ne peuvent pas diverger. + """ + return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-") + + +def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: + """Fichiers d'accueil de la VM : le guide de connexion, l'identité git. + + Une seule source pour les deux voies de déploiement — cloud-init l'écrit + par write_files, l'installateur Debian par son late_command. + """ + home = invoking_home() + editor = "" if args.no_git_identity else vm_editor(home)[1] + files = [ + ( + "/etc/motd", + "0644", + build_motd( + args.distro, + args.version, + args.arch, + args.lang, + args.erplibre_dir, + args.erplibre_make, + editor, + bool(args.desktop), + ), + "", + ) + ] + if args.no_git_identity: + return files + gitconfig = build_gitconfig( + _git_global("user.name", home), + _git_global("user.email", home), + editor, + ) + if gitconfig: + files.append( + (f"/home/{args.user}/.gitconfig", "0644", gitconfig, args.user) + ) + return files + + def build_cloud_config( args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str] ) -> str: @@ -1490,6 +2204,11 @@ def build_cloud_config( f" layout: {args.keyboard_layout}", f" variant: {args.keyboard_variant}", ] + # Guide de connexion et identité git : posés par cloud-init, donc présents + # dès le PREMIER boot. C'est le point : ils sont là avant l'installation + # d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte + # justement à la main. + lines += write_files_lines(guide_files(args)) # apt update/upgrade désactivés par défaut : sur un réseau lent/instable # ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH # est déjà présent dans les images cloud ; on l'active via runcmd sans apt. @@ -1679,6 +2398,376 @@ def prepare_disk( runner.run(["qemu-img", "resize", str(disk), size], privileged=True) +def _ip_taken(ip: str) -> bool: + """Adresse déjà occupée, même par une machine qui ne parle pas SSH. + + Un simple essai sur le port 22 ne suffit pas : il laisse passer toute + machine éteinte au moment du choix, ou dont sshd est filtré. Vécu — une + adresse attribuée à une VM Debian neuve appartenait déjà à une machine du + parc, et l'installation ERPLibre s'est déroulée SUR CETTE DERNIÈRE. Le + journal ne le disait qu'à demi-mot : « git is already the newest + version », impossible sur un système que d-i vient de poser. + + On interroge donc trois choses : le voisinage ARP de l'hôte, qui connaît + ce qui a parlé récemment ; ICMP, qui répond même sans service ; puis SSH. + """ + try: + neigh = subprocess.run( + ["ip", "neigh", "show", ip], + capture_output=True, + text=True, + timeout=5, + ).stdout + # « FAILED » signifie justement que personne n'a répondu. + if ip in neigh and "FAILED" not in neigh: + return True + except (OSError, subprocess.SubprocessError): + pass + try: + if subprocess.run( + ["ping", "-c", "1", "-W", "1", ip], + capture_output=True, + timeout=5, + ).returncode == 0: + return True + except (OSError, subprocess.SubprocessError): + pass + return _ip_reachable(ip, port=22, timeout=1.5) + + +def static_net_plan(net: str | None, use_sudo: bool, name: str) -> dict[str, str] | None: + """Adresse fixe libre pour une VM installée par debian-installer. + + L'initrd s390x ne contient QUE « netcfg-static » : le journal de d-i + montre « Menu item 'netcfg-static' selected », jamais netcfg-dhcp, puis + « Taking down interface enc1 ». Aucun DHCP n'est tenté — c'est la + convention IBM Z, où la configuration réseau se donne au parmfile. Il + faut donc fournir une adresse, et elle doit être libre. + + On la prend en HAUT de la plage : dnsmasq attribue depuis le bas, donc + les collisions avec un bail futur sont les plus improbables là. + """ + if not net: + return None + cmd = ["virsh", "-c", LIBVIRT_URI, "net-dumpxml", net] + if use_sudo: + cmd.insert(0, "sudo") + try: + xml = subprocess.run( + cmd, capture_output=True, text=True, timeout=20 + ).stdout + except (OSError, subprocess.SubprocessError): + return None + m = re.search(r" str: + """Preseed debian-installer équivalent au cloud-config des autres distros. + + Il doit couvrir EXACTEMENT ce que cloud-init fait ailleurs : nom d'hôte, + utilisateur, clés SSH, sudo sans mot de passe, fuseau, paquets de base. + Tout ce qui manque ici devient une question posée à l'écran, et + l'installation s'arrête sur une console que personne ne regarde. + + « priority=critical » suffit à ne pas poser les questions restantes ; il + ne dispense PAS de répondre à celles qui n'ont pas de défaut, d'où le + partitionnement et le miroir écrits explicitement. + """ + user = args.user + # Sans mot de passe utilisable, d-i s'arrête sur la création du compte : + # « ! » est un hachage volontairement invalide — la connexion se fera par + # clé, comme le cloud-config le prévoit lui aussi. + crypted = pw_hash or "!" + lines = [ + "d-i debian-installer/locale string en_US.UTF-8", + "d-i keyboard-configuration/xkb-keymap select us", + # Question propre à s390x, posée par le udeb « s390-netdevice » et + # inexistante ailleurs : le matériel Z offre ctc, qeth, iucv ou + # virtio, et d-i ne devine pas. Elle n'a AUCUNE valeur par défaut, + # donc « priority=critical » ne la saute pas — l'installation se + # figeait dessus, sur le premier choix de la liste (ctc), en + # n'affichant rien d'autre qu'un écran bleu. Mesuré. + "d-i s390-netdevice/choose_networktype select virtio", + # « auto » évite la question du choix d'interface : sous virtio-ccw + # elle s'appelle enc1 et non eth0, et le nom n'est pas devinable. + f"d-i netcfg/get_hostname string {args.hostname}", + # Adresse fixe : sans elle, netcfg-static pose la question a l'ecran + # et l'installation s'arrete la, indefiniment. + *( + [ + "d-i netcfg/disable_autoconfig boolean true", + "d-i netcfg/disable_dhcp boolean true", + f"d-i netcfg/get_ipaddress string {static['ip']}", + f"d-i netcfg/get_netmask string {static['netmask']}", + f"d-i netcfg/get_gateway string {static['gateway']}", + f"d-i netcfg/get_nameservers string {static['dns']}", + "d-i netcfg/confirm_static boolean true", + ] + if static + else [] + ), + "d-i netcfg/get_domain string localdomain", + "d-i netcfg/hostname string " + args.hostname, + "d-i mirror/country string manual", + "d-i mirror/http/hostname string deb.debian.org", + "d-i mirror/http/directory string /debian", + "d-i mirror/http/proxy string", + "d-i passwd/root-login boolean false", + "d-i passwd/user-fullname string ERPLibre", + f"d-i passwd/username string {user}", + f"d-i passwd/user-password-crypted password {crypted}", + # network-console : sur IBM Z, d-i propose systématiquement de + # poursuivre par SSH — la console y est historiquement limitée. Il + # refuse un mot de passe vide et bloque l'installation non assistée. + # Ce secret ne vit QUE le temps de l'installateur, sur le réseau + # libvirt, et disparaît avec lui : il ne donne accès à rien ensuite. + "d-i network-console/password password erplibre", + "d-i network-console/password-again password erplibre", + "d-i clock-setup/utc boolean true", + f"d-i time/zone string {args.timezone}", + "d-i clock-setup/ntp boolean true", + # Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais + # d-i pose quand même la question quand rien ne le désigne. + # Ce que partman voit reellement, ecrit sur la console : « No root + # file system is defined » ne distingue pas « disque absent » de + # « recette non appliquee », et les deux se corrigent differemment. + # Toute commande preseedee DOIT rendre 0 : d-i bloque sur « Failed to + # run preseeded command » sinon, et le diagnostic devient le blocage. + # Vecu — un « ls /dev/dasd* » sans correspondance suffisait. + "d-i partman/early_command string cat /proc/partitions > /dev/console" + " ; ls /lib/partman/automatically_partition/ > /dev/console 2>&1" + " ; true", + # partman-auto RECLAME explicitement : il n'est pas tire d'office sur + # s390x, ou la voie attendue est le partitionnement DASD manuel. + # Mesure dans l'installateur : « /lib/partman/automatically_partition/ + # No such file or directory », et la liste des udebs recuperes montre + # partman-base, -utils, -partitioning, -target… mais jamais -auto. + # Sans lui, aucune recette ne s'applique et partman s'arrete sur + # « No root file system is defined ». + "d-i anna/choose_modules string partman-auto", + "d-i partman-auto/disk string /dev/vda", + "d-i partman-auto/method string regular", + "d-i partman-auto/choose_recipe select atomic", + "d-i partman/default_filesystem string ext4", + "d-i partman-partitioning/confirm_write_new_label boolean true", + "d-i partman/choose_partition select finish", + "d-i partman/confirm boolean true", + "d-i partman/confirm_nooverwrite boolean true", + "tasksel tasksel/first multiselect ssh-server", + "d-i pkgsel/include string openssh-server sudo python3" + " qemu-guest-agent ca-certificates", + "d-i pkgsel/upgrade select none", + "popularity-contest popularity-contest/participate boolean false", + "d-i finish-install/reboot_in_progress note", + ] + # late_command : tout ce que le preseed ne sait pas exprimer. « in-target » + # exécute DANS le système installé ; les redirections, elles, restent dans + # l'installateur et doivent donc viser /target. + post = [ + f"in-target usermod -aG sudo {user}", + f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}", + f"chmod 440 /target/etc/sudoers.d/{user}", + ] + if ssh_keys: + post.append(f"mkdir -p /target/home/{user}/.ssh") + for key in ssh_keys: + post.append( + f"echo '{key}' >> /target/home/{user}/.ssh/authorized_keys" + ) + post += [ + f"in-target chown -R {user}:{user} /home/{user}/.ssh", + f"chmod 700 /target/home/{user}/.ssh", + f"chmod 600 /target/home/{user}/.ssh/authorized_keys", + ] + # Guide de connexion et identité git : les mêmes fichiers que sur les autres + # distributions, mais ici il n'y a pas de cloud-init pour les écrire. Ils + # voyagent DANS l'initrd, à côté du preseed, et le late_command ne fait que + # les copier. + # + # Pourquoi pas leur contenu dans le preseed : la valeur d'une question tient + # sur UNE ligne, et celle-ci fait déjà 1165 caractères avec une clé RSA-4096. + # Y ajouter 1,2 Kio de guide — encodé ou en trente echo, la longueur est la + # même — doublerait une ligne dont aucune limite n'est documentée pour + # cdebconf. Et une troncature ne coûterait pas le guide : elle couperait le + # late_command au milieu, donc ni sudoers ni clé SSH, donc une VM + # inaccessible. + for path, mode, _content, owner in guide_files(args): + src = "/" + installer_guide_name(path) + post.append(f"cp {src} /target{path} || true") + post.append(f"chmod {mode} /target{path} || true") + if owner: + post.append(f"in-target chown {owner}:{owner} {path} || true") + # Le code de sortie du late_command est celui de sa DERNIÈRE commande, et + # d-i s'arrête sur « Failed to run preseeded command » dès qu'il n'est pas + # nul. Sans ce « true », un chmod qui échoue bloque l'installation sur un + # écran que personne ne regarde — c'est déjà la garde de + # partman/early_command, quelques lignes plus haut. + post.append("true") + # Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide. + # netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage + # statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on + # ne peut lire qu'en ouvrant un shell à la main. Ces quelques lignes + # atterrissent, elles, dans le journal de console — donc dans un fichier + # qu'il suffit de lire après coup. La sonde DHCP est celle de busybox, + # bornée à trois essais, et ne configure rien de durable. + early = [ + # LE correctif, pas un diagnostic : on ALLUME la carte. + # + # Mesuré dans l'installateur : « enc1: … + # qdisc noop » — ni UP ni LOWER_UP — alors qu'un udhcpc manuel + # obtenait un bail en deux secondes. Le réseau n'a jamais été en + # cause ; netcfg teste l'état du lien AVANT d'essayer, ne le voit + # pas, saute le DHCP et demande une adresse statique. + # + # Sur s390x c'est le udeb s390-netdevice qui active le périphérique. + # En preseedant sa question pour qu'il ne s'affiche plus, on + # court-circuite aussi cette activation. On la refait donc ici, avant + # que netcfg ne décide. + "ip link set enc1 up > /dev/console 2>&1", + "echo '=== EL: enc1 activee avant netcfg ===' > /dev/console", + "ip -o link show enc1 > /dev/console 2>&1", + # Même garde que partman/early_command : « ip -o link show » rend 1 + # quand l'interface n'existe pas, et le code de sortie du early_command + # est celui de sa dernière commande. Une ligne de DIAGNOSTIC bloquait + # donc l'installation qu'elle devait servir à comprendre. + "true", + ] + lines.append("d-i preseed/early_command string " + " ; ".join(early)) + lines.append("d-i preseed/late_command string " + " ; ".join(post)) + return "\n".join(lines) + "\n" + + +def build_installer_initrd( + preseed: str, + initrd_src: Path, + out: Path, + runner: Runner, + guide: list[tuple[str, str, str, str]] | None = None, +) -> None: + """Glisse le preseed DANS l'initrd de l'installateur. + + Servir le preseed en HTTP est l'autre voie documentée, mais elle ajoute un + serveur à faire vivre pendant toute l'installation et une dépendance à + l'ordre d'obtention de l'adresse. Embarquer le fichier ne dépend de rien : + d-i lit « /preseed.cfg » à la racine de l'initrd avant même le réseau. + + La méthode est celle de la documentation Debian — décompresser, ajouter le + fichier au cpio, recompresser — et non une concaténation d'archives, que + le noyau accepte mais que d-i ne parcourt pas de la même façon. + + `guide` : les fichiers d'accueil de la VM (guide de connexion, identité + git), rangés à côté du preseed. L'initrd EST le système de fichiers de + l'installateur : le late_command n'a plus qu'à les copier vers /target, + sans avoir à transporter leur contenu dans une valeur de preseed. + """ + if runner.dry_run: + print(f"[dry-run] preseed -> {out}") + for path, _mode, _content, _owner in guide or []: + print(f"[dry-run] + {installer_guide_name(path)} -> {path}") + return + if not shutil.which("cpio"): + sys.exit( + "cpio est requis pour embarquer le preseed dans l'initrd.\n" + " Debian/Ubuntu : sudo apt-get install cpio" + ) + with tempfile.TemporaryDirectory() as tmp: + work = Path(tmp) + (work / "preseed.cfg").write_text(preseed, encoding="utf-8") + members = ["preseed.cfg"] + for path, _mode, content, _owner in guide or []: + name = installer_guide_name(path) + (work / name).write_text(content, encoding="utf-8") + members.append(name) + # network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela. + # + # Sur IBM Z, d-i propose de poursuivre par SSH — la console y est + # historiquement limitée. Ce n'est pas une question à laquelle + # répondre : le composant démarre sshd puis ATTEND une connexion de + # l'utilisateur « installer », indéfiniment. Preseeder son mot de + # passe le fait avancer d'un écran, pas davantage — mesuré. + # + # Un composant dont « .isinstallable » sort en erreur est retiré du + # menu. L'original le fait déjà quand sshd tourne ; on le remplace par + # un refus inconditionnel. Le fichier ajouté APRÈS l'original prend sa + # place : le noyau déplie le cpio séquentiellement et le dernier + # écrit gagne. + gate = work / "var/lib/dpkg/info" + gate.mkdir(parents=True, exist_ok=True) + target = gate / "network-console.isinstallable" + target.write_text("#!/bin/sh\nexit 1\n", encoding="utf-8") + target.chmod(0o755) + plain = work / "initrd" + with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst: + shutil.copyfileobj(src, dst) + members.append("var/lib/dpkg/info/network-console.isinstallable") + subprocess.run( + ["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)], + input="\n".join(members) + "\n", + text=True, + cwd=work, + check=True, + capture_output=True, + ) + out.parent.mkdir(parents=True, exist_ok=True) + with open(plain, "rb") as src, gzip.open(out, "wb") as dst: + shutil.copyfileobj(src, dst) + print(f" preseed embarqué dans {out} ({out.stat().st_size} octets)") + + +def create_blank_disk( + disk: Path, size: str, runner: Runner, force: bool +) -> None: + """Disque VIERGE : l'installateur écrit tout, il n'y a rien à convertir.""" + if disk.exists() and not force: + sys.exit( + f"Le disque {disk} existe déjà. Utilisez --force pour l'écraser." + ) + runner.run( + ["qemu-img", "create", "-f", "qcow2", str(disk), size], + privileged=True, + ) + + def network_name(network_arg: str) -> str | None: """Extrait NAME de « network=NAME,... » ; None si c'est un bridge, etc.""" for part in network_arg.split(","): @@ -1749,6 +2838,7 @@ def virt_install( seed: Path, osinfo: str, runner: Runner, + installer: tuple[Path, Path] | None = None, ) -> None: # Émulée (TCG, pas de KVM) si l'arch demandée diffère de celle de l'hôte. # Deux causes d'émulation, à ne pas confondre : une architecture étrangère @@ -1773,6 +2863,7 @@ def virt_install( # s390x n'a pas de port série ISA : la console est SCLP (ttysclp0), et non # ttyS0. Ailleurs (x86/arm64), console série classique. console_target = "sclp" if args.arch == "s390x" else "serial" + console_log = f"/var/log/libvirt/qemu/{args.name}-console.log" # Écran virtuel pour une VM graphique. s390x en est écarté : QEMU y expose # bien « virtio-gpu-ccw », mais rien ne garantit que le noyau s390x de la # distribution embarque le pilote DRM virtio-gpu — la VM démarrerait alors @@ -1804,6 +2895,16 @@ def virt_install( # d'avant : --graphics spice,listen=none graphics = "vnc,listen=127.0.0.1" video = ["--video", "virtio"] + # 3D : allumée d'office quand l'hôte a un GPU (--gpu auto). Une VM + # graphique sans accélération rend tout par le processeur — le bureau + # comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus + # coûteux qu'on puisse laisser en place sans le dire. + gpu_node = args.gpu_node or host_gpu_node() + video, gpu_args, gpu_msg = gpu_apply( + video, args.gpu, gpu_node, graphics != "none" + ) + if gpu_msg: + print(gpu_msg) cmd = [ "virt-install", # Sans --connect, un utilisateur non root vise qemu:///session : le @@ -1817,17 +2918,45 @@ def virt_install( str(args.memory), "--vcpus", str(args.vcpus), - "--import", + ] + if installer: + kernel, initrd = installer + # « --install » et non « --boot » : virt-install écrit alors DEUX + # configurations — celle de l'installation, transitoire, et celle du + # système installé. Avec « --boot kernel=… » la VM repartirait sur + # l'installateur à chaque démarrage, indéfiniment. + # + # console=ttysclp0 : s390x n'a pas de port série ISA. Sans cet + # argument l'installateur tourne sur une console invisible, et un + # échec ne laisse aucune trace lisible. + cmd += [ + "--install", + f"kernel={kernel},initrd={initrd}," + # PAS de « auto=true » : il vise le preseed par URL et réordonne + # l'installation pour monter le réseau AVANT tout le reste. Le + # nôtre est local, il n'y a rien à aller chercher — et c'est + # précisément là que netcfg dérapait. + "kernel_args=priority=critical " + "preseed/file=/preseed.cfg console=ttysclp0", + ] + else: + cmd.append("--import") + cmd += [ "--disk", f"path={disk},format=qcow2,bus=virtio", + ] + # Le seed n'existe QUE sur la voie image cloud. Sous debian-installer, le + # preseed voyage dans l'initrd et un second disque ne ferait qu'ajouter un + # /dev/vdb dont partman-auto devrait être protégé. + if not installer: # Seed cloud-init attaché comme DISQUE virtio en lecture seule (et non # en CD-ROM) : le pilote virtio-blk est dans l'initramfs, donc le # volume « cidata » est visible dès init-local et cloud-init le lit. # En CD-ROM, l'initramfs Debian ne charge pas sr_mod à temps -> le # seed n'est pas vu et rien ne s'applique (Ubuntu, lui, tolère le CD). # Sur s390x, bus=virtio est mappé en virtio-ccw par libvirt. - "--disk", - f"path={seed},readonly=on,bus=virtio", + cmd += ["--disk", f"path={seed},readonly=on,bus=virtio"] + cmd += [ "--osinfo", osinfo, "--network", @@ -1835,14 +2964,21 @@ def virt_install( "--graphics", graphics, "--console", - f"pty,target_type={console_target}", + # Journal de console pour la voie installateur. Une console « pty » + # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une + # VM que personne ne regarde, et il ne reste RIEN à lire ensuite — + # exactement « l'installation a échoué, pas de sortie pertinente ». + # Le fichier, lui, survit à l'arrêt du domaine. + f"pty,target_type={console_target},log.file={console_log}" + if installer + else f"pty,target_type={console_target}", # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après # un redimensionnement de disque). Inoffensif si l'agent est absent. "--channel", "unix,target.type=virtio,target.name=org.qemu.guest_agent.0", ] - cmd += video + cmd += video + gpu_args if args.arch == "s390x": # s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le # disque (ni BIOS ni UEFI/OVMF -> aucun --boot). @@ -1881,6 +3017,13 @@ def virt_install( cmd += ["--virt-type", "qemu"] if not args.attach_console: cmd.append("--noautoconsole") + if installer: + # Sans « --wait 0 », virt-install RESTE au premier plan jusqu'à la + # fin de l'installation. Sous émulation s390x elle se compte en + # heures, et le déploiement parallèle attendrait chaque VM l'une + # après l'autre. La configuration finale est déjà écrite : rendre + # la main n'abandonne rien. + cmd += ["--wait", "0"] # virtinst écrit un journal de debug dans ~/.cache/virt-manager ; sous # sudo, HOME/cache peut être inaccessible -> l'écriture échoue et Python # déverse un « Logging error » (le pavé « Fetched capabilities … »). On @@ -1902,6 +3045,54 @@ def virt_install( runner.run(log_env + cmd, privileged=True) +def watch_and_restart(name: str, runner: Runner) -> None: + """Rallume la VM quand debian-installer a fini et l'a éteinte. + + virt-install mène l'installation en DEUX temps : un amorçage transitoire + sur kernel+initrd, puis la configuration définitive, qui démarre sur le + disque. Le passage de l'un à l'autre se fait par un arrêt — l'installateur + redémarre, libvirt détruit le domaine transitoire — et c'est virt-install + qui rallume ensuite. Avec « --wait 0 » il est déjà parti : le domaine + reste « shut off », disque installé et XML correct, mais éteint. + + On ne peut pas pour autant laisser virt-install attendre : sous émulation + l'installation dure des heures, et le déploiement rendrait la main à ce + rythme-là. Un veilleur détaché fait donc le dernier geste. + """ + if runner.dry_run: + print(f"[dry-run] veilleur de redémarrage pour {name}") + return + sudo = "sudo " if runner.use_sudo else "" + # 6 h de garde : bien au-delà d'une installation émulée, et le veilleur + # meurt de lui-même si quelque chose a mal tourné. + script = ( + f"for i in $(seq 1 720); do " + f" s=$({sudo}virsh -c {LIBVIRT_URI} domstate {name} 2>/dev/null); " + f' if [ "$s" = "shut off" ]; then ' + f" {sudo}virsh -c {LIBVIRT_URI} start {name} >/dev/null 2>&1; " + f" exit 0; " + f" fi; " + f" sleep 30; " + f"done" + ) + try: + subprocess.Popen( + ["/bin/sh", "-c", script], + stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + print(f" ⚠ veilleur non lancé ({exc}) ; démarrer à la main :") + print(f" sudo virsh start {name}") + return + print( + f" Veilleur lancé : {name} sera rallumée dès que l'installateur" + " l'aura éteinte." + ) + + def _ip_reachable(ip: str, port: int = 22, timeout: float = 3) -> bool: """Vrai si le port SSH répond (distingue le bail actif du bail périmé).""" try: @@ -2064,6 +3255,19 @@ def build_parser() -> argparse.ArgumentParser: "permet. Les paquets GNOME sont posés par la commande d'installation, " "pas ici.", ) + g_vm.add_argument( + "--gpu", + choices=("auto", "on", "off"), + default="auto", + help="Accélération 3D par le GPU de l'hôte : auto (défaut, activée " + "si un /dev/dri/renderD* existe), on (forcer), off (rendu logiciel).", + ) + g_vm.add_argument( + "--gpu-node", + default="", + help="Nœud de rendu à utiliser (défaut : le premier trouvé). Utile " + "sur un hôte à plusieurs cartes.", + ) g_vm.add_argument( "--osinfo", help="Force la valeur --osinfo (sinon déduite)." ) @@ -2149,6 +3353,36 @@ def build_parser() -> argparse.ArgumentParser: action="store_true", help="N'exécute pas package_upgrade au premier boot.", ) + g_cloud.add_argument( + "--lang", + choices=("fr", "en"), + default="fr", + help="Langue du guide affiché à la connexion SSH (défaut : fr). " + "todo.py passe la langue de son menu.", + ) + g_cloud.add_argument( + "--erplibre-dir", + default="", + metavar="CHEMIN", + help="Racine d'ERPLibre dans la VM (~/git/erplibre en dev, " + "/opt/erplibre en prod). Ajoute la section ERPLibre au guide de " + "connexion. Vide, elle est omise : une VM déployée sans installation " + "n'annonce pas un dépôt et un service qui n'existent pas.", + ) + g_cloud.add_argument( + "--erplibre-make", + default="", + metavar="CIBLE", + help="Cible make qui a installé la VM (ex. install_odoo_18), reprise " + "dans le guide pour la mettre à jour. Vide : le guide s'arrête à " + "« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.", + ) + g_cloud.add_argument( + "--no-git-identity", + action="store_true", + help="N'injecte pas l'identité git de l'hôte (user.name, user.email, " + "core.editor) dans le ~/.gitconfig de la VM.", + ) g_cloud.add_argument( "--apt-update", action="store_true", @@ -2382,22 +3616,80 @@ def main() -> None: "nettement plus lents que l'architecture native." ) - print(f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==") - download_image(urls, args.image_path, args.dry_run) - if do_verify: - verify_sha256(url, args.image_path, args.dry_run) + installer: tuple[Path, Path] | None = None + if uses_installer(args.distro, args.arch): + # Voie debian-installer : aucune image cloud n'existe pour cette + # combinaison, on télécharge l'installateur et on part d'un disque nu. + cache = args.image_path.parent + kernel = cache / f"debian-{args.version}-s390x-kernel" + initrd_src = cache / f"debian-{args.version}-s390x-initrd.gz" + initrd = cache / f"{args.name}-initrd.gz" + # Le dimensionnement du catalogue vient des images cloud, où le + # système est DÉJÀ installé. debian-installer, lui, déplie un système + # de fichiers complet en mémoire avant d'écrire quoi que ce soit : + # 1024 Mio est le plancher annoncé par Debian, sans marge, et un + # manque de mémoire s'y manifeste par un écran figé sans message. + # On relève le plancher, en le disant — un réglage explicite plus haut + # n'est jamais abaissé. + if args.memory < INSTALLER_MIN_RAM: + print( + f" Mémoire portée à {INSTALLER_MIN_RAM} Mio pour" + f" l'installateur (catalogue : {args.memory})." + ) + args.memory = INSTALLER_MIN_RAM + print(f"\n== 1/5 Installateur Debian {args.version} ({code}) s390x ==") + download_image( + [INSTALLER_URL.format(code=code, fichier="kernel.debian")], + kernel, + args.dry_run, + ) + download_image( + [INSTALLER_URL.format(code=code, fichier="initrd.debian")], + initrd_src, + args.dry_run, + ) - print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") - prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) + print(f"\n== 2-3/5 Disque vierge {disk} ({args.disk_size}) ==") + create_blank_disk(disk, args.disk_size, runner, args.force) - print(f"\n== 4/5 Seed cloud-init {seed} ==") - cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) - build_seed(cloud_cfg, args.hostname, seed, runner) + print(f"\n== 4/5 Preseed embarqué dans l'initrd ==") + static = static_net_plan( + network_name(args.network), not args.dry_run, args.name + ) + if static: + print(f" Adresse fixe retenue : {static['ip']}" + f" (passerelle {static['gateway']})") + else: + print(" ⚠ Aucune adresse fixe déterminée : netcfg-static posera" + " la question à l'écran et l'installation s'arrêtera.") + build_installer_initrd( + build_preseed(args, pw_hash, ssh_keys, static), + initrd_src, + initrd, + runner, + ) + installer = (kernel, initrd) + else: + print( + f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) ==" + ) + download_image(urls, args.image_path, args.dry_run) + if do_verify: + verify_sha256(url, args.image_path, args.dry_run) + + print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==") + prepare_disk(args.image_path, disk, args.disk_size, runner, args.force) + + print(f"\n== 4/5 Seed cloud-init {seed} ==") + cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys) + build_seed(cloud_cfg, args.hostname, seed, runner) resolved_osinfo = osinfo_arg(osinfo, args.distro) print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==") ensure_network(network_name(args.network), runner) - virt_install(args, disk, seed, resolved_osinfo, runner) + virt_install(args, disk, seed, resolved_osinfo, runner, installer) + if installer: + watch_and_restart(args.name, runner) has_key = bool(ssh_keys) print("\nTerminé. Suivi :") diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index d86f5e7..6e0d3f4 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -294,6 +294,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), + "vm_tools": tuple(form.get("vm_tools") or ()), "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], @@ -364,6 +365,16 @@ def run_deploy_form(ctx, run_app: bool = True): # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) + # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et + # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait + # que les afficher et rendre les cases cochées. + vm_tools = list(ctx.get("vm_tools") or []) + tool_disk = dict(ctx.get("vm_tool_disk") or {}) + tool_arches = dict(ctx.get("vm_tool_arches") or {}) + tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) + tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) + tool_families = dict(ctx.get("vm_tool_families") or {}) + distro_family = dict(ctx.get("distro_family") or {}) # Architectures pour lesquelles mise publie un binaire. mise_arches = set(ctx.get("mise_arches") or ()) # [(clé, libellé)] des magasins d'applications, et les distributions qui @@ -655,6 +666,20 @@ def run_deploy_form(ctx, run_app: bool = True): for i, (_k, label) in enumerate(app_stores): yield RadioButton(label, value=i == 0) yield Static("", id="storewarn") + if vm_tools: + # Une case par outil, et non une liste déroulante : ils + # sont indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools:"), classes="grouptitle" + ) + for key, label, hint in vm_tools: + gb = tool_disk.get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (defaults.get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") yield Static("ERPLibre", classes="grouptitle") yield Checkbox( t("Install ERPLibre"), @@ -816,9 +841,19 @@ def run_deploy_form(ctx, run_app: bool = True): # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. + tools = self._vm_tools() for row in self.rows: if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk + # Même règle pour les outils, et pour la même raison : ils ne + # pèsent que sur les VM qui les reçoivent réellement. Android + # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de + # sens que sous GNOME — une VM qui ne les aura pas ne doit pas + # se voir gonfler son disque. + row["disk_gb"] += sum( + tool_disk.get(k, 0) + for k in self._tools_for_vm(row["vm"], tools) + ) # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, # deux lignes aux ressources différentes n'ont aucune explication à # l'écran, et la surcharge est oubliée à la relecture. Le drapeau @@ -831,6 +866,73 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_plan() self._render_mise() self._render_store() + self._render_tools() + + def _vm_tools(self): + """Clés des outils cochés, dans l'ordre de la liste.""" + picked = [] + for key, _label, _hint in vm_tools: + try: + if self.query_one(f"#f_tool_{key}", Checkbox).value: + picked.append(key) + except Exception: + continue + return tuple(picked) + + def _tools_for_vm(self, vm, tools): + """Outils qu'une VM donnée recevra vraiment. + + Même filtre que todo.py côté déploiement : une VM ARM ne verra + jamais Android Studio, une VM Cinnamon jamais les extensions GNOME, + un serveur aucun des IDE — mais un serveur reçoit bien la + compilation mobile, qui n'a rien à afficher, et une distribution + sans apt ne la reçoit pas, son installateur n'existant que là.""" + out = [] + for key in tools: + arches = tool_arches.get(key) or () + desks = tool_desktops.get(key) or () + fams = tool_families.get(key) or () + if tool_needs_desktop.get(key) and not vm.get("desktop"): + continue + if arches and vm["arch"] not in arches: + continue + if desks and vm.get("desktop") not in desks: + continue + if fams and distro_family.get(vm["distro"], "") not in fams: + continue + out.append(key) + return out + + def _render_tools(self): + """Grise chaque case qu'AUCUNE VM retenue ne peut recevoir, et + NOMME ce qui sera écarté. + + Une case par outil, et non un blocage en bloc : sur un parc de + serveurs les IDE se grisent, la compilation mobile reste offerte. + Cocher Android Studio sur un parc ARM ne produit rien — le dire ici + évite de le découvrir dans le journal d'installation.""" + if not vm_tools: + return + for key, _label, _hint in vm_tools: + usable = any( + self._tools_for_vm(vm, (key,)) for vm in self.vms + ) + self.query_one(f"#f_tool_{key}", Checkbox).disabled = not usable + picked = self._vm_tools() + skipped = sorted( + { + vm["name"] + for vm in self.vms + for k in picked + if k not in self._tools_for_vm(vm, picked) + } + ) + self.query_one("#toolwarn", Static).update( + f" ⚠ {t('Partly skipped (arch or desktop):')} " + f"{', '.join(skipped)}" + if skipped + else "" + ) def _render_mise(self): """Grise le choix quand aucune VM retenue n'est servie par mise, @@ -1676,6 +1778,10 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() # le disque annoncé inclut le +5 G ERPLibre elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value + elif str(event.checkbox.id or "").startswith("f_tool_"): + # Un IDE de plus, c'est un disque plus grand : le plan doit le + # montrer AVANT de déployer, pas après une heure d'installation. + self._recompute() # -- actions ---------------------------------------------------- # def action_select_all(self) -> None: @@ -1734,6 +1840,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._default_desktop(), + "vm_tools": self._vm_tools(), "python_provider": self._python_provider(), "app_store": self._app_store(), "install": install, diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py new file mode 100644 index 0000000..700cfc4 --- /dev/null +++ b/script/todo/qemu_hardware.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Ressources matérielles d'une VM libvirt : lecture, et plan de modification. + +Le menu d'état de `todo.py` démarre des VM. C'est le seul moment où leur +matériel est modifiable : libvirt refuse de changer vCPU, RAM ou écran d'un +domaine allumé, et l'accélération 3D n'est lue qu'au démarrage de QEMU. Ce +module tient la logique pure de ce moment-là. + +- hw_state(xml) : ce que la VM a aujourd'hui, lu dans son XML. +- hw_plan(...) : les commandes qui l'amènent à l'état voulu, et RIEN de plus + — un plan vide quand rien ne change, et une ligne « skip » + expliquée quand une demande n'a pas d'objet. +- build_want(...) / run_hardware_form(...) : le formulaire Textual. + +Aucune commande n'est lancée ici : `todo.py` les exécute, sous sudo, et les +affiche avant. Le plan reste donc vérifiable sans hyperviseur. + +Deux pièges, appris sur l'hôte : + +- « virt-xml --memory N » ne change que , la cible du ballon. + Élever la RAM au-delà du maximum exige les deux champs à la fois, sinon la + VM plafonne en silence à son ancien maximum. +- « --add-device --graphics type=egl-headless » n'est PAS idempotent : appelé + deux fois, il pose deux affichages. D'où la lecture de l'état AVANT le plan. +""" +from __future__ import annotations + +import xml.etree.ElementTree as ET + +from script.todo.qemu_deploy_form import parse_ram, positive_int + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +# URI système : sous sudo, root y va de lui-même, mais l'expliciter écarte le +# piège documenté dans deploy_qemu.py — un appel non root visant +# qemu:///session, où les domaines du parc n'existent pas. +CONNECT = "qemu:///system" + +# Le virtio-gpu est le SEUL modèle qui porte virgl. Poser accel3d sur un qxl +# ou un cirrus est accepté par le schéma et ne fait rien. +GPU_VIDEO_MODEL = "virtio" + +# Modes CPU proposés. « host-passthrough » donne les instructions du +# processeur hôte telles quelles — c'est ce qui rend la virtualisation +# IMBRIQUÉE possible dans la VM, et ce que virt-install pose par défaut ; +# « host-model » décrit un modèle équivalent, migrable vers une autre machine. +# Les attributs check/migratable accompagnent le passthrough, comme +# virt-install les écrit : sans eux libvirt vérifie un modèle qu'il n'a pas +# calculé. +CPU_MODES = ("host-passthrough", "host-model") +CPU_EXTRA = {"host-passthrough": ",check=none,migratable=on"} + +# « vram » n'est PAS proposé : sur un virtio-gpu, libvirt l'écrit dans le XML +# et QEMU ne le reçoit jamais — vérifié par « virsh domxml-to-native », qui +# ne montre que « max_outputs » (les écrans). Ce serait un bouton sans effet. +# Seul qxl consomme vram, et le parc n'utilise pas qxl. + +# Affichages qui donnent un écran à la VM. « egl-headless » n'en est pas un : +# il n'ouvre aucun port et n'existe que pour porter le contexte OpenGL. +SCREEN_TYPES = ("vnc", "spice", "sdl", "desktop") +EGL = "egl-headless" + +# libvirt écrit KiB pour la mémoire, mais le schéma autorise les deux systèmes +# d'unités — KB vaut mille octets, KiB en vaut 1024. On convertit en octets +# d'abord : une table « à peu près » ferait dériver l'affichage de la VM. +_UNIT_BYTES = { + "b": 1, + "bytes": 1, + "kb": 1000, + "k": 1024, + "kib": 1024, + "mb": 1000**2, + "m": 1024**2, + "mib": 1024**2, + "gb": 1000**3, + "g": 1024**3, + "gib": 1024**3, + "tb": 1000**4, + "t": 1024**4, + "tib": 1024**4, +} + + +def _mib(node) -> int: + """Valeur d'un élément mémoire libvirt, en mébioctets.""" + if node is None: + return 0 + factor = _UNIT_BYTES.get((node.get("unit") or "KiB").lower(), 1024) + try: + return int(float((node.text or "0").strip()) * factor) // (1024 * 1024) + except ValueError: + return 0 + + +def ram_field(mib) -> str: + """Valeur du champ RAM : « 32G » plutôt que « 32768 ». + + parse_ram relit les deux, mais cinq chiffres ne tiennent pas dans le + champ : la VM de 32 Go y affichait « 3276 », et un nombre tronqué qu'on + valide sans regarder rétrécit la machine. + """ + try: + mib = int(mib or 0) + except (TypeError, ValueError): + return "" + if mib <= 0: + return "" + return f"{mib // 1024}G" if mib % 1024 == 0 else str(mib) + + +def fmt_mib(mib) -> str: + """1024 -> « 1 Go », 3072 -> « 3 Go », 1536 -> « 1,5 Go », 512 -> « 512 Mo ».""" + try: + mib = int(mib) + except (TypeError, ValueError): + return "?" + if mib <= 0: + return "?" + if mib % 1024 == 0: + return f"{mib // 1024} Go" + if mib > 1024: + return f"{mib / 1024:.1f}".replace(".", ",") + " Go" + return f"{mib} Mo" + + +def hw_state(xml: str, autostart=None) -> dict: + """État matériel lu dans le XML du domaine. + + `autostart` n'est pas dans le XML — il vit dans un lien symbolique côté + libvirt — donc l'appelant le passe (via « virsh dominfo »). + """ + state = { + "name": "", + "vcpus": 0, + "mem_mib": 0, + "max_mem_mib": 0, + "video": "", + "accel3d": False, + "egl": False, + "render": "", + "screen": False, + "heads": 1, + "cpu": "", + # PREMIÈRE interface seulement : c'est celle que « virt-xml --edit + # --network » modifie, et une VM du parc n'en a qu'une. + "net": "", + "autostart": bool(autostart), + } + try: + root = ET.fromstring(xml or "") + except ET.ParseError: + return state + state["name"] = (root.findtext("name") or "").strip() + try: + state["vcpus"] = int((root.findtext("vcpu") or "0").strip()) + except ValueError: + state["vcpus"] = 0 + state["max_mem_mib"] = _mib(root.find("memory")) + state["mem_mib"] = _mib(root.find("currentMemory")) or state["max_mem_mib"] + cpu = root.find("cpu") + if cpu is not None: + state["cpu"] = cpu.get("mode") or "" + iface = root.find("./devices/interface") + if iface is not None: + source = iface.find("source") + state["net"] = net_token( + iface.get("type") or "", + source if source is not None else None, + ) + model = root.find("./devices/video/model") + if model is not None: + state["video"] = model.get("type") or "" + try: + state["heads"] = int(model.get("heads") or 1) + except ValueError: + state["heads"] = 1 + accel = model.find("acceleration") + state["accel3d"] = accel is not None and accel.get("accel3d") == "yes" + for graphics in root.findall("./devices/graphics"): + kind = graphics.get("type") or "" + if kind in SCREEN_TYPES: + state["screen"] = True + elif kind == EGL: + state["egl"] = True + gl = graphics.find("gl") + state["render"] = ( + (gl.get("rendernode") or "") if gl is not None else "" + ) + return state + + +def net_token(kind: str, source) -> str: + """Identité du réseau d'une interface : « network:default », « bridge:br0 ». + + Un seul jeton pour comparer, choisir et appliquer : le type et le nom vont + toujours ensemble — « br0 » ne veut rien dire sans savoir que c'est un + pont, et libvirt refuse type='network' avec un pont pour source. + """ + if not kind: + return "" + name = "" + if source is not None: + name = ( + source.get("network") + or source.get("bridge") + or source.get("dev") + or "" + ) + return f"{kind}:{name}" if name else kind + + +def net_label(token: str) -> str: + """« network:default » -> « default », « bridge:br0 » -> « br0 (pont) ». + + Le réseau libvirt ne porte pas de suffixe : c'est le cas ordinaire, et le + nommer allongeait le libellé au-delà de la liste déroulante. Seul le pont + est marqué, parce que c'est lui qui change le comportement de la VM. + """ + if not token: + return "—" + kind, _, name = token.partition(":") + if kind == "network": + return name + if kind == "bridge": + return f"{name} ({t('bridge')})" + return token + + +# Les modes CPU s'affichent en court : la valeur écrite dans le XML reste +# entière, seul le libellé raccourcit. +CPU_LABELS = {"host-passthrough": "passthrough", "host-model": "model"} + + +def cpu_label(mode: str) -> str: + return CPU_LABELS.get(mode, mode or "—") + + +def net_spec(token: str) -> str: + """Jeton -> argument « --network » de virt-xml.""" + kind, _, name = token.partition(":") + if kind == "network": + return f"network={name}" + if kind == "bridge": + return f"bridge={name}" + return token + + +def _virt_xml(name: str, *args) -> list: + """Commande virt-xml qui ÉCRIT la définition persistante du domaine. + + « --define » explicite : sans lui, virt-xml interroge l'utilisateur quand + le domaine tourne, et une invite dans un menu piloté par un script bloque + sans rien dire. + """ + return ["virt-xml", "--connect", CONNECT, name, "--define", *args] + + +def hw_plan(state: dict, want: dict, node: str = "") -> list: + """Commandes menant de `state` à `want`, dans l'ordre. + + Chaque entrée est un dict : {'what': …, 'cmd': [...]} pour ce qui sera + lancé, {'what': …, 'skip': raison} pour ce qui est demandé mais sans + objet. Rien à changer -> liste vide, et l'appelant n'exécute rien. + """ + name = state.get("name") or want.get("name") or "" + plan = [] + if not name: + return plan + + vcpus = positive_int(want.get("vcpus"), 0) + if vcpus and vcpus != state.get("vcpus"): + plan.append( + { + "what": f"vCPU {state.get('vcpus')} → {vcpus}", + "cmd": _virt_xml(name, "--edit", "--vcpus", str(vcpus)), + } + ) + + ram = positive_int(want.get("ram"), 0) + if ram and ram != state.get("mem_mib"): + plan.append( + { + "what": f"RAM {fmt_mib(state.get('mem_mib'))} → {fmt_mib(ram)}", + "cmd": _virt_xml( + name, + "--edit", + "--memory", + f"memory={ram},currentMemory={ram}", + ), + } + ) + + cpu = (want.get("cpu") or "").strip() + if cpu and cpu != state.get("cpu"): + plan.append( + { + "what": f"CPU {state.get('cpu') or '—'} → {cpu}", + "cmd": _virt_xml( + name, "--edit", "--cpu", cpu + CPU_EXTRA.get(cpu, "") + ), + } + ) + + heads = positive_int(want.get("heads"), 0) + if heads and heads != state.get("heads"): + if not state.get("video"): + # « --edit --video » n'a aucun périphérique à modifier : virt-xml + # sortirait en erreur au milieu du lot. + plan.append( + { + "what": t("Screens"), + "skip": t("this VM has no virtual screen"), + } + ) + else: + plan.append( + { + "what": f"{t('Screens')} {state.get('heads')} → {heads}", + "cmd": _virt_xml( + name, "--edit", "--video", f"model.heads={heads}" + ), + } + ) + + net = (want.get("net") or "").strip() + if net and net != state.get("net"): + if not state.get("net"): + plan.append( + {"what": t("Network"), "skip": t("this VM has no interface")} + ) + else: + plan.append( + { + "what": f"{t('Network')} {net_label(state.get('net'))}" + f" → {net_label(net)}", + "cmd": _virt_xml( + name, "--edit", "--network", net_spec(net) + ), + } + ) + + gpu = want.get("gpu") + if gpu is not None: + plan += _gpu_plan(name, state, bool(gpu), node) + + auto = want.get("autostart") + if auto is not None and bool(auto) != bool(state.get("autostart")): + args = ["virsh", "--connect", CONNECT, "autostart"] + if not auto: + args.append("--disable") + plan.append( + { + "what": t("Autostart") + (" : on" if auto else " : off"), + "cmd": args + [name], + } + ) + return plan + + +def _gpu_plan(name: str, state: dict, gpu: bool, node: str) -> list: + """Volet 3D du plan : accélération sur l'écran, et contexte GL.""" + plan = [] + if gpu: + if not state.get("screen"): + return [ + { + "what": t("3D acceleration (host GPU)"), + "skip": t("this VM has no virtual screen"), + } + ] + if not node: + return [ + { + "what": t("3D acceleration (host GPU)"), + "skip": t("no render node on the host"), + } + ] + if not state.get("accel3d") or state.get("video") != GPU_VIDEO_MODEL: + plan.append( + { + "what": t("3D acceleration (host GPU)") + " : on", + "cmd": _virt_xml( + name, + "--edit", + "--video", + f"model.type={GPU_VIDEO_MODEL}" + ",model.acceleration.accel3d=on", + ), + } + ) + if state.get("render") != node: + if state.get("egl"): + # Déjà un affichage GL, mais sur un autre nœud : le corriger + # en place. L'ajouter une seconde fois en poserait DEUX. + plan.append( + { + "what": f"{t('Render node')} → {node}", + "cmd": _virt_xml( + name, + "--edit", + f"type={EGL}", + "--graphics", + f"gl.rendernode={node}", + ), + } + ) + else: + plan.append( + { + "what": f"{t('Render node')} : {node}", + "cmd": _virt_xml( + name, + "--add-device", + "--graphics", + f"type={EGL},gl.rendernode={node}", + ), + } + ) + return plan + if state.get("accel3d"): + plan.append( + { + "what": t("3D acceleration (host GPU)") + " : off", + "cmd": _virt_xml( + name, + "--edit", + "--video", + f"model.type={state.get('video') or GPU_VIDEO_MODEL}" + ",model.acceleration.accel3d=off", + ), + } + ) + if state.get("egl"): + # Ciblé par type : la console VNC de la VM, elle, doit survivre. + plan.append( + { + "what": t("Render node") + " : —", + "cmd": _virt_xml( + name, "--remove-device", "--graphics", f"type={EGL}" + ), + } + ) + return plan + + +def build_want( + state: dict, vcpus, ram, gpu, autostart, cpu="", heads="", net="" +) -> dict: + """Valeurs de widgets -> intention, en retombant sur l'état actuel. + + Un champ vidé ou illisible ne veut pas dire « zéro vCPU » : il veut dire + « n'y touche pas ». Sans ce repli, valider le formulaire sans rien saisir + proposerait de rétrécir la VM à néant. + """ + return { + "name": state.get("name") or "", + "vcpus": positive_int(vcpus, state.get("vcpus") or 0), + "ram": parse_ram(ram) or state.get("mem_mib") or 0, + "gpu": bool(gpu), + "autostart": bool(autostart), + "cpu": (cpu or state.get("cpu") or "").strip(), + "heads": positive_int(heads, state.get("heads") or 1), + "net": (net or state.get("net") or "").strip(), + } + + +def gpu_allowed(state: dict, node: str) -> str: + """'' si la 3D est proposable pour cette VM, sinon la raison du refus.""" + if not node: + return t("no render node on the host") + if not state.get("screen"): + return t("this VM has no virtual screen") + return "" + + +def hw_summary(state: dict) -> str: + """Ligne d'état lisible : « 8 vCPU, 32 Go, 3D on (renderD128) ».""" + bits = [f"{state.get('vcpus') or '?'} vCPU", fmt_mib(state.get("mem_mib"))] + if state.get("accel3d") or state.get("render"): + node = state.get("render") or "?" + bits.append(f"3D {node.rsplit('/', 1)[-1]}") + elif state.get("screen"): + bits.append(t("software rendering")) + if (state.get("heads") or 1) > 1: + bits.append(f"{state['heads']} {t('Screens').lower()}") + if state.get("net"): + bits.append(net_label(state["net"])) + # Le mode CPU n'est dit que s'il n'est PAS le passthrough : c'est le défaut + # du parc, et une ligne de résumé ne doit porter que l'inattendu. + if state.get("cpu") and state["cpu"] != "host-passthrough": + bits.append(f"CPU {state['cpu']}") + return ", ".join(bits) + + +def cpu_choices(states) -> list: + """Modes CPU à proposer, le mode courant compris s'il sort de la liste. + + Une VM en mode « custom » ne doit pas voir son réglage disparaître d'une + liste qui l'ignore : la liste déroulante afficherait alors un autre mode + que le sien, et valider le formulaire le changerait sans le dire. + """ + modes = list(CPU_MODES) + for state in states or (): + mode = (state or {}).get("cpu") + if mode and mode not in modes: + modes.append(mode) + return modes + + +def net_choices(states, nets=None) -> list: + """[(jeton, libellé)] des réseaux proposables, courants inclus.""" + tokens = [] + for token in list(nets or ()): + if token and token not in tokens: + tokens.append(token) + for state in states or (): + token = (state or {}).get("net") + if token and token not in tokens: + tokens.append(token) + return [(tok, net_label(tok)) for tok in tokens] + + +def run_hardware_form(rows, node: str = "", nets=None, run_app: bool = True): + """Formulaire d'ajustement matériel. Renvoie {nom: intention} ou None. + + `rows` est une liste d'états (hw_state), `nets` les réseaux que l'hôte + peut offrir. `run_app=False` renvoie l'instance sans la lancer — c'est + ainsi que les tests l'inspectent. + """ + from textual.app import App, ComposeResult + from textual.containers import Horizontal, VerticalScroll + from textual.widgets import ( + Button, + Checkbox, + Footer, + Header, + Input, + Select, + Static, + ) + + states = [dict(r) for r in rows if r] + cpus = cpu_choices(states) + reseaux = net_choices(states, nets) + + class HardwareForm(App): + TITLE = t("VM hardware") + # L'intention se lit SUR l'instance, pas dans une fermeture : c'est + # ainsi que les tests montent le formulaire et vérifient ce qu'il + # rend, sans passer par un terminal. + want = None + CSS = """ + #rows { height: 1fr; } + #host { padding: 0 1; } + .vm { padding: 1 1 0 1; text-style: bold; } + .row { height: auto; padding: 0 1; } + .lbl { width: 7; height: 3; content-align: right middle; } + .num { width: 10; } + /* « auto » plutôt qu'une largeur fixe : le libellé traduit change de + longueur, et une case tronquée ne dit plus ce qu'elle coche. */ + .cb3d { width: auto; margin: 0 2 0 1; } + .cbauto { width: auto; } + .warn { padding: 0 3; } + .sel { width: 22; } + /* Le réseau porte un suffixe (« br0 (pont) ») : deux colonnes de plus + que le mode CPU, qui s'affiche en un mot. */ + .selnet { width: 24; } + .lbl2 { width: 8; height: 3; content-align: right middle; } + /* 8 et pas 5 : sous cette largeur, Textual dessine le cadre du champ + mais PAS son contenu — la valeur devient invisible, ce qui est pire + qu'une troncature (on valide un champ qu'on croit vide). */ + .heads { width: 8; } + #bar { height: auto; padding: 1; } + """ + BINDINGS = [ + ("ctrl+s", "apply", t("Apply")), + ("escape", "quit", t("Cancel")), + ] + + def compose(self) -> ComposeResult: + yield Header() + head = t("Host GPU:") + " " + head += node if node else t("none (software rendering)") + yield Static(head, id="host") + with VerticalScroll(id="rows"): + for i, st in enumerate(states): + reason = gpu_allowed(st, node) + # Le nom sur SA ligne, avec l'état actuel : les noms du + # parc font trente caractères, en colonne ils seraient + # tronqués — et c'est le nom qui dit quelle VM on règle. + yield Static( + f"{st.get('name', '')} — {hw_summary(st)}", + classes="vm", + ) + with Horizontal(classes="row"): + yield Static("vCPU", classes="lbl") + yield Input( + value=str(st.get("vcpus") or ""), + id=f"vcpus{i}", + classes="num", + ) + yield Static("RAM", classes="lbl") + yield Input( + value=ram_field(st.get("mem_mib")), + id=f"ram{i}", + classes="num", + placeholder="Mo ou G", + ) + yield Checkbox( + t("3D"), + value=bool(st.get("accel3d")), + id=f"gpu{i}", + disabled=bool(reason), + classes="cb3d", + ) + yield Checkbox( + t("Autostart"), + value=bool(st.get("autostart")), + id=f"auto{i}", + classes="cbauto", + ) + with Horizontal(classes="row"): + yield Static("CPU", classes="lbl2") + yield Select( + [(cpu_label(m), m) for m in cpus], + value=st.get("cpu") or cpus[0], + allow_blank=False, + id=f"cpu{i}", + classes="sel", + ) + yield Static(t("Screens"), classes="lbl2") + yield Input( + value=str(st.get("heads") or 1), + id=f"heads{i}", + classes="heads", + ) + if reseaux: + yield Static(t("Network"), classes="lbl2") + yield Select( + [(lab, tok) for tok, lab in reseaux], + value=st.get("net") or reseaux[0][0], + allow_blank=False, + id=f"net{i}", + classes="selnet", + ) + if reason: + yield Static(f"⚠ {reason}", classes="warn") + with Horizontal(id="bar"): + yield Button(t("Apply"), variant="primary", id="apply") + yield Button(t("Cancel"), id="cancel") + yield Footer() + + def action_apply(self) -> None: + want = {} + for i, st in enumerate(states): + net = st.get("net") or "" + if reseaux: + net = self.query_one(f"#net{i}", Select).value or net + want[st.get("name", "")] = build_want( + st, + self.query_one(f"#vcpus{i}", Input).value, + self.query_one(f"#ram{i}", Input).value, + self.query_one(f"#gpu{i}", Checkbox).value, + self.query_one(f"#auto{i}", Checkbox).value, + cpu=self.query_one(f"#cpu{i}", Select).value or "", + heads=self.query_one(f"#heads{i}", Input).value, + net=net, + ) + self.want = want + self.exit() + + def on_button_pressed(self, event) -> None: + if event.button.id == "apply": + self.action_apply() + else: + self.exit() + + app = HardwareForm() + if not run_app: + return app + app.run() + return app.want diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index eb63c5c..a00420c 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -332,6 +332,41 @@ def read_status(log_path: str) -> tuple[str, int | None]: return "running", None +# Au-delà de ce silence, la colonne d'état le DIT. Ce n'est pas un verdict mais +# un chiffre : plusieurs étapes sont légitimement muettes, leur sortie partant +# ailleurs. Mesuré sur une installation réelle : le téléchargement d'Android +# Studio tient ~5 min sans une ligne, et l'étape « APK debug » davantage — son +# détail va dans le journal de la VM. Dix minutes passent donc au-dessus du +# premier sans attendre le second, qui reste bruyant par nature. +# +# À 48 minutes, le chiffre est accablant : une installation est morte ainsi, +# session ssh emportée, et le sablier tournait toujours. +IDLE_HINT_SECS = 600 + + +def log_idle(log_path: str) -> float: + """Secondes depuis la dernière écriture dans le journal. -1 s'il manque. + + La date de modification du fichier, et non un compte de lignes : c'est la + seule mesure qui distingue « rien n'avance » de « rien ne s'affiche ».""" + try: + return max(0.0, time.time() - os.path.getmtime(log_path)) + except OSError: + return -1.0 + + +def state_mark(icon: str, idle: float) -> str: + """Icône d'état, suivie du silence du journal quand il dépasse le seuil. + + Le silence est une INFORMATION, pas un diagnostic : plusieurs étapes sont + muettes longtemps sans rien avoir de cassé. Mais le sablier seul ne + distingue pas une installation qui travaille d'une qui est morte, et c'est + arrivé — 48 minutes de sablier sur une session ssh déjà emportée.""" + if idle > IDLE_HINT_SECS: + return f"{icon} {t('silent')} {_fmt_secs(idle)}" + return icon + + def run_progress(run: dict) -> dict: """Avancement d'un run : combien de VM tournent encore, et depuis quand plus rien n'a été écrit. `idle` sert à distinguer une install vivante d'un @@ -395,6 +430,133 @@ _LST_IGNORE_ERROR = ( ) +# Signaux d'échec qui ne contiennent NI « error » NI « warning ». Sans eux, le +# scan par sous-chaîne rate des installations franchement ratées : le journal de +# la VM erplibre-ubuntu-2604-gnome, dont la compilation de l'APK a été tuée par +# le noyau, ne portait AUCUNE ligne « error » — mesuré, 0 sur 8765 lignes — +# pendant que « ⚠ ÉCHEC : APK debug (gradle) », « FAILURE: Build failed » et +# « daemon disappeared unexpectedly » y étaient. Le détail des erreurs annonçait +# donc « aucune erreur détectée » sur une installation en échec. +# +# Chaque motif est là parce qu'il est apparu dans un vrai journal, pas par +# précaution : Gradle dit « FAILURE », Python « Traceback », git « fatal: », apt +# « Unable to locate package », le noyau « Killed » ou « Cannot allocate +# memory », et nos propres étapes « ⚠ ÉCHEC ». +_LST_HARD_MARKERS = ( + "⚠ échec", + "failed:", + "failure", + "traceback (most recent call last)", + "fatal:", + "command not found", + # PAS « no such file or directory » : sur le journal de référence, 5 de ses + # 7 occurrences étaient des sondes bénignes (« cat: .odoo-version »), et le + # bruit dilue un résumé dont l'intérêt est justement d'être court. Un + # fichier vraiment manquant fait échouer une ÉTAPE, elle-même captée. + "permission denied", + "unable to locate package", + "disappeared unexpectedly", + "outofmemory", + "cannot allocate memory", + "segmentation fault", + "core dumped", + "killed process", +) +# Étape en échec, telle que la pose « mstep » : « ⚠ ÉCHEC : ». C'est +# le signal AUTORITAIRE — il nomme l'étape, là où « FAILURE » ne nomme que +# l'outil. +_RE_FAILED_STEP = re.compile(r"⚠\s*(?:ÉCHEC|FAILED)\s*:?\s*(.+)") +# Début d'une autre étape ou d'une section : borne du diagnostic qui suit. +_RE_STEP_BOUND = re.compile(r"^\s*(?:->|==)\s") + + +def _is_hard_signal(line: str) -> bool: + low = line.lower() + return any(m in low for m in _LST_HARD_MARKERS) + + +def _error_signature(line: str) -> str: + """Ligne réduite à sa FORME, pour regrouper les répétitions. + + Un journal d'installation répète la même erreur des centaines de fois avec + un chemin ou un numéro qui change. Regrouper sur cette forme donne « ×342 » + au lieu de 342 lignes à faire défiler.""" + sig = re.sub(r"\d+", "#", line) + sig = re.sub(r"0x[0-9a-fA-F]+", "#", sig) + sig = re.sub(r"/\S+", "/…", sig) + return re.sub(r"\s+", " ", sig).strip()[:160] + + +def scan_log_summary(log_path: str, diag_cap: int = 14) -> dict: + """Résumé d'un journal d'installation : ce qui a échoué, puis le reste. + + Rend {steps, hard, groups, nerr, nwarn} où « steps » liste les étapes en + échec AVEC leur diagnostic, « hard » les autres signaux durs dédupliqués, et + « groups » les lignes « error »/« warning » regroupées par forme et comptées. + + L'ordre n'est pas cosmétique : une étape en échec nommée vaut mille lignes, + et c'est elle qu'on veut lire d'abord.""" + try: + lines = Path(log_path).read_text(errors="replace").splitlines() + except OSError: + return {"steps": [], "hard": [], "groups": [], "nerr": 0, "nwarn": 0} + + steps, hard, groups = [], {}, {} + nerr = nwarn = 0 + for i, line in enumerate(lines, 1): + if EXIT_MARKER in line: + continue + low = line.lower() + match = _RE_FAILED_STEP.search(line) + if match: + # Le diagnostic suit l'échec, jusqu'à l'étape suivante : c'est lui + # qui porte la cause, l'échec ne portant que le nom. + diag = [] + for nxt in lines[i : i + 60]: + if _RE_STEP_BOUND.match(nxt) or _RE_FAILED_STEP.search(nxt): + break + if EXIT_MARKER in nxt: + continue + if nxt.strip() and len(diag) < diag_cap: + diag.append(nxt.rstrip()) + steps.append( + {"line": i, "label": match.group(1).strip(), "diag": diag} + ) + continue + if _is_hard_signal(line): + sig = _error_signature(line) + entry = hard.setdefault( + sig, {"line": i, "text": line.strip(), "count": 0} + ) + entry["count"] += 1 + continue + if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR): + nerr += 1 + key = ("error", _error_signature(line)) + groups.setdefault( + key, {"line": i, "text": line.strip(), "count": 0} + )["count"] += 1 + if "warning" in low and not any( + ig in line for ig in _LST_IGNORE_WARNING + ): + nwarn += 1 + key = ("warning", _error_signature(line)) + groups.setdefault( + key, {"line": i, "text": line.strip(), "count": 0} + )["count"] += 1 + ordered = sorted( + ({"kind": k[0], **v} for k, v in groups.items()), + key=lambda g: (-g["count"], g["line"]), + ) + return { + "steps": steps, + "hard": sorted(hard.values(), key=lambda h: h["line"]), + "groups": ordered, + "nerr": nerr, + "nwarn": nwarn, + } + + def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]: """(lignes_erreur, lignes_avertissement) d'un log, même détection que scan_log_errors mais on RETIENT les lignes (bornées à `cap`) pour les @@ -408,6 +570,9 @@ def scan_log_error_lines(log_path: str, cap: int = 500) -> tuple[list, list]: if EXIT_MARKER in line: continue low = line.lower() + if _is_hard_signal(line) and len(errs) < cap: + errs.append(f"{i}: {line}") + continue if ( "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR) @@ -438,6 +603,13 @@ def scan_log_errors(log_path: str) -> tuple[int, int]: low = line.lower() if EXIT_MARKER in line: continue + # Un échec d'étape EST une erreur, même sans le mot « error » : sinon le + # tableau de bord affiche « 0 erreur » sur une installation ratée — + # mesuré sur erplibre-ubuntu-2604-gnome, 0 ligne « error » pour un APK + # tué par le noyau. + if _is_hard_signal(line): + nerr += 1 + continue if "error" in low and not any(ig in line for ig in _LST_IGNORE_ERROR): nerr += 1 if "warning" in low and not any( @@ -677,6 +849,54 @@ def _fmt_size(nbytes) -> str: return f"{nbytes // 1024}K" +def _host_mem() -> tuple: + """(total, disponible, swap_total, swap_libre) en octets, lus dans /proc. + + /proc/meminfo plutôt qu'une dépendance : psutil n'est pas garanti dans le + venv d'outils, et ce suivi tourne sur l'hyperviseur — donc sous Linux, d'où + viennent déjà getloadavg() et libvirt. + + « MemAvailable » et non « MemFree » : le noyau y répond ce qu'il peut + rendre sans échanger, cache réclamable compris. MemFree seul affiche + presque rien sur une machine qui travaille, et alarmerait pour rien. + """ + wanted = ("MemTotal", "MemAvailable", "SwapTotal", "SwapFree") + vals = {} + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for line in fh: + key, _, rest = line.partition(":") + if key in wanted: + vals[key] = int(rest.split()[0]) * 1024 + except (OSError, ValueError, IndexError): + return (0, 0, 0, 0) + return tuple(vals.get(k, 0) for k in wanted) + + +def _mem_tele(total, avail, sw_total, sw_free) -> str: + """Segment « RAM » de la barre de télémétrie. Vide si /proc n'a rien dit. + + Le swap n'apparaît que s'il existe : l'afficher à « 0/0 » sur une machine + qui n'en a pas occupe une place pour ne rien dire. Quand il existe, il est + montré même à zéro — une VM qui a commencé à échanger explique une lenteur, + et c'est précisément ce qu'on cherche dans un suivi d'installation. + """ + if not total: + return "" + used = max(0, total - avail) + out = ( + f"🧠 RAM {_fmt_size(used)}/{_fmt_size(total)}" + f" ({int(used / total * 100)}%)" + f" · {t('free space')} {_fmt_size(avail)}" + ) + if sw_total: + out += ( + f" · swap {_fmt_size(max(0, sw_total - sw_free))}" + f"/{_fmt_size(sw_total)}" + ) + return out + + def _fmt_secs(secs) -> str: """Secondes -> « 45s » / « 12m » / « 1h05 ».""" secs = int(secs) @@ -984,26 +1204,64 @@ def run_monitor(manifest_path: str, run_app: bool = True): ("q", "dismiss", "Fermer"), ] - def __init__(self, vm_name, errs, warns): + def __init__(self, vm_name, errs, warns, summary=None): super().__init__() self._vm = vm_name self._errs = errs self._warns = warns + self._sum = summary or {} def compose(self) -> ComposeResult: - with Vertical(id="errbox"): - yield Static( - f" {self._vm} — ⚠ {len(self._errs)} " + nsteps = len(self._sum.get("steps", [])) + head = ( + f" {self._vm} — ⚠ {len(self._errs)} " + f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}" + ) + # Le nombre d'étapes en échec passe DEVANT : c'est la seule ligne du + # bandeau qui dise si l'installation a abouti. + if nsteps: + head = ( + f" {self._vm} — 🛑 {nsteps} " + f"{t('failed steps')} · ⚠ {len(self._errs)} " f"{t('errors')} · ⚡ {len(self._warns)} {t('warnings')}" - f" ({t('Esc to close')})", - id="errtitle", ) + with Vertical(id="errbox"): + yield Static(f"{head} ({t('Esc to close')})", id="errtitle") yield RichLog( id="errlog", highlight=False, markup=False, wrap=True ) def on_mount(self) -> None: log = self.query_one("#errlog", RichLog) + steps = self._sum.get("steps", []) + hard = self._sum.get("hard", []) + groups = self._sum.get("groups", []) + + # -- Le résumé, d'abord. Une étape nommée vaut mille lignes. + if steps: + log.write(f"── {t('Failed steps')} ──") + for st in steps: + log.write(f"🛑 {st['label']} ({t('line')} {st['line']})") + for line in st["diag"]: + log.write(f" {line.strip()}") + log.write("") + if hard: + log.write(f"── {t('Hard signals')} ──") + for h in hard: + mult = f" ×{h['count']}" if h["count"] > 1 else "" + log.write(f"{h['line']}:{mult} {h['text']}") + log.write("") + if groups: + # Regroupé par FORME : un journal répète la même erreur des + # centaines de fois avec un chemin qui change. + log.write(f"── {t('Grouped by shape')} ──") + for g in groups[:60]: + mark = "⚠" if g["kind"] == "error" else "⚡" + mult = f" ×{g['count']}" if g["count"] > 1 else "" + log.write(f"{mark} {g['line']}:{mult} {g['text']}") + log.write("") + + # -- Puis le détail brut, pour qui veut tout lire. if self._errs: log.write(f"── {t('errors').capitalize()} ──") for line in self._errs: @@ -1012,7 +1270,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): log.write(f"── {t('warnings').capitalize()} ──") for line in self._warns: log.write(line) - if not self._errs and not self._warns: + if not (steps or hard or self._errs or self._warns): log.write(t("No error detected.")) def action_dismiss(self) -> None: @@ -1334,12 +1592,18 @@ def run_monitor(manifest_path: str, run_app: bool = True): load1 = os.getloadavg()[0] du = shutil.disk_usage(self._disk_dir) used_pct = int(du.used / du.total * 100) if du.total else 0 + # La RAM va entre le CPU et le disque : c'est la ressource dont + # l'épuisement ne se voit nulle part ailleurs. Une compilation + # mobile a été tuée par le noyau sur une VM de 12 Go sans swap, + # et ce suivi n'en montrait rien. + mem = _mem_tele(*_host_mem()) return ( f" ⚙ CPU {min(999, int(load1 / ncpu * 100))}% " - f"(charge {load1:.1f}/{ncpu}) " - f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" + f"({t('load')} {load1:.1f}/{ncpu}) " + + (f"{mem} " if mem else "") + + f"💽 {self._disk_dir}: {_fmt_size(du.used)}/" f"{_fmt_size(du.total)} ({used_pct}%) · " - f"libre {_fmt_size(du.free)}" + f"{t('free space')} {_fmt_size(du.free)}" ) except Exception: return "" @@ -1453,7 +1717,12 @@ def run_monitor(manifest_path: str, run_app: bool = True): table, name, "state", f"⏸ {t('paused')}" ) else: - self._set_cell(table, name, "state", ICON[state]) + self._set_cell( + table, + name, + "state", + state_mark(ICON[state], log_idle(vm["log"])), + ) ref = eta_reference(self._stats, vm.get("arch")) if ref is not None: remaining.append(max(0, ref - (now - started))) @@ -1782,7 +2051,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): if not vm: return errs, warns = scan_log_error_lines(vm["log"]) - self.push_screen(ErrorLinesScreen(vm["name"], errs, warns)) + summary = scan_log_summary(vm["log"]) + self.push_screen( + ErrorLinesScreen(vm["name"], errs, warns, summary) + ) def on_click(self, event) -> None: # Clic sur le sommaire de stats -> déplie / replie le détail. diff --git a/script/todo/todo.py b/script/todo/todo.py index 4614ffa..07ab2f4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1087,14 +1087,19 @@ class TODO: print(f"{t('Invalid selection, using')} {default}") return default - # Distros publiant des images cloud par architecture (cohérent avec - # S390X_DISTROS / ARM64_DISTROS de deploy_qemu.py). amd64 : toutes. + # Repli SEULEMENT : la table qui fait autorité est ARCH_DISTRO_SUPPORT de + # deploy_qemu.py, lue par _qemu_arch_distros. Ces tuples ont longtemps été + # une copie à la main, avec le commentaire « cohérent avec deploy_qemu » en + # guise de garantie — et la cohérence a rompu à la première évolution : + # Debian a gagné s390x là-bas sans l'obtenir ici, donc l'écran ne le + # proposait pas. On ne les garde que pour le cas où l'import échoue. _QEMU_S390X_DISTROS = ( "ubuntu", "almalinux", "rocky", "fedora", "opensuse", + "debian", ) _QEMU_ARM64_DISTROS = ( "ubuntu", @@ -1124,12 +1129,24 @@ class TODO: }.get(machine, "amd64") def _qemu_arch_distros(self, arch): - """Distros supportant `arch` (None = toutes, cas amd64).""" - if arch == "s390x": - return self._QEMU_S390X_DISTROS - if arch == "arm64": - return self._QEMU_ARM64_DISTROS - return None + """Distros supportant `arch` (None = toutes, cas amd64). + + Lu dans deploy_qemu.py, qui refuse aussi les combinaisons qu'il + n'annonce pas : une seule table, donc aucun écran ne peut proposer un + choix rejeté ensuite. « amd64 » n'y figure pas et rend None, ce qui + veut bien dire « toutes » — c'est le contrat attendu ici. + """ + try: + table = getattr(self._qemu_import_module(), "ARCH_DISTRO_SUPPORT") + except Exception: + # Repli sur les copies locales : mieux vaut un catalogue figé + # qu'un écran vide si deploy_qemu.py est absent ou cassé. + if arch == "s390x": + return self._QEMU_S390X_DISTROS + if arch == "arm64": + return self._QEMU_ARM64_DISTROS + return None + return table.get(arch) def _qemu_last_run_line(self): """Ligne « dernière install » (distro version [arch] en durée), depuis @@ -1298,6 +1315,11 @@ class TODO: "Remote desktop tunnel (VNC/RDP through SSH)" ) }, + { + "prompt_description": t( + "Android emulator (start, tunnel, scrcpy)" + ) + }, {"section": t("Catalog")}, {"prompt_description": t("List available images and specs")}, ] @@ -1340,6 +1362,8 @@ class TODO: elif status == "14": self._qemu_tunnel_menu() elif status == "15": + self._qemu_emulator_menu() + elif status == "16": self._qemu_list_images() else: cmd_no_found = True @@ -1552,10 +1576,20 @@ class TODO: print(f"\n {t('Remote desktop kind:')}") print(f" [1] RDP 3389 (xrdp) *") print(f" [2] VNC 5901 (TigerVNC, Arch)") - print(f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}") + print( + f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}" + ) + print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}") + print(f" [5] {t('Graphical console (virt-viewer, built-in tunnel)')}") kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" if kind_answer == "3": - self._qemu_console_tunnel() + self._qemu_console_tunnel(name, src) + return + if kind_answer == "4": + self._qemu_scrcpy_tunnel(name, src) + return + if kind_answer == "5": + self._qemu_virt_viewer(name, src) return port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") local = port + 1 @@ -1584,7 +1618,455 @@ class TODO: ) print(f" {t('The tunnel stays open as long as that ssh runs.')}") - def _qemu_console_tunnel(self): + @staticmethod + def _qemu_ssh_opts(src): + """Options ssh selon la provenance de la cible. + + Une VM libvirt locale est jointe par son IP, et son IP est recyclée d'un + déploiement à l'autre : sa clé d'hôte change sous le même adresse, et + ssh refuse alors de se connecter — « Host key verification failed », + vécu. C'est la raison pour laquelle le suivi d'installation et l'attente + de sshd emploient déjà ces deux options. + + Un hôte de ~/.ssh/config, lui, est une machine que l'utilisateur a + configurée : on ne touche PAS à sa politique de clés. Sa clé est un + garde-fou qui lui appartient.""" + if src == "ssh_config": + return ["-o", "BatchMode=yes", "-o", "ConnectTimeout=8"] + return [ + "-o", + "BatchMode=yes", + "-o", + "ConnectTimeout=8", + "-o", + "StrictHostKeyChecking=no", + "-o", + "UserKnownHostsFile=/dev/null", + ] + + def _qemu_ssh_target(self, name, src): + """Destination ssh d'une cible du menu, selon sa provenance. + + Un hôte de ~/.ssh/config se nomme tel quel — c'est lui qui porte le + ProxyJump, et le réécrire à la main reviendrait à le deviner. Un domaine + libvirt local, lui, n'a qu'une IP, et l'utilisateur des VM ERPLibre est + « erplibre ». Renvoie une chaîne vide quand l'IP manque.""" + if src == "ssh_config": + return name + ip = self._qemu_resolve_ips([name]).get(name) + return f"erplibre@{ip}" if ip else "" + + # Commande de l'émulateur dans la VM. Le chemin est ABSOLU : un + # « ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc. + _QEMU_EMULATOR_BIN = "$HOME/android/emulator/emulator" + + # Drapeaux passés à CHAQUE lancement, et non écrits dans le config.ini de + # l'AVD : l'émulateur réécrit ce fichier depuis le profil du téléphone au + # premier démarrage, et les hw.lcd.* y étaient effacés — l'AVD repartait en + # 1080x2400 densité 420, quatre fois les pixels voulus. Mesuré. + # + # La résolution et la DENSITÉ vont ensemble, et c'est contre-intuitif : + # 540x1140 en densité 420 est PIRE que le plein écran — 81 ms de médiane + # contre 40, et 57 % d'images en retard contre 37, tout étant rendu énorme. + # Avec la densité 240, la queue s'effondre : 99e centile à 250 ms contre + # 950, et 32 % d'images en retard. + # + # « -no-snapshot-save » : sans lui, un émulateur tué par pkill — ce que ce + # menu propose lui-même — laisse un instantané en cours, et le lancement + # SUIVANT meurt sur « A snapshot operation is pending and timeout has + # expired ». Vécu, et le message ne dit pas quoi faire. + # « -gpu » reste sur swangle par DÉFAUT, même quand la VM a la 3D : un + # « -gpu host » qui échoue ne rend pas la main, l'émulateur reste pendu, et + # ce n'est pas un défaut à imposer sans l'avoir mesuré sur la machine. + # EL_EMULATOR_GPU permet de l'essayer sans toucher au code, une fois le + # nœud de rendu présent dans l'invité (voir script/qemu/README). + _QEMU_EMULATOR_GPU = os.environ.get("EL_EMULATOR_GPU") or "swangle" + _QEMU_EMULATOR_FLAGS = ( + "-no-audio -no-boot-anim -no-snapshot-save" + f" -gpu {_QEMU_EMULATOR_GPU}" + " -skin 540x1140 -prop qemu.sf.lcd_density=240" + ) + _QEMU_AVD_NAME = "erplibre" + + def _qemu_emulator_running(self, target, src="virsh"): + """Nombre d'émulateurs en cours dans la VM. + + Deux sur le même AVD, et le second s'arrête sur « Running multiple + emulators with the same AVD is an experimental feature ». Le savoir + AVANT de lancer évite de lire cette phrase sans la comprendre — vécu, + deux fois.""" + try: + res = subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, "pgrep -c qemu-system 2>/dev/null || echo 0"], + capture_output=True, + text=True, + timeout=25, + ) + return int((res.stdout or "0").strip().splitlines()[-1]) + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + return -1 + + def _qemu_emulator_ready(self, target, src="virsh"): + """La VM a-t-elle de quoi émuler ? Rend (prêt, raison). + + Le binaire ET l'AVD, en une seule lecture : sans cette vérification le + démarrage détaché rendait 0 sur une VM sans SDK, et le menu annonçait + « Démarré » quand le journal disait « not found ». Une VM déployée sans + cocher l'outil Émulateur Android est le cas normal, pas une panne.""" + probe = ( + f"test -x {self._QEMU_EMULATOR_BIN} || echo NO_SDK; " + f"test -d $HOME/.android/avd/{self._QEMU_AVD_NAME}.avd" + " || echo NO_AVD" + ) + try: + res = subprocess.run( + ["ssh"] + self._qemu_ssh_opts(src) + [target, probe], + capture_output=True, + text=True, + timeout=25, + ) + except (OSError, subprocess.SubprocessError): + return False, t("Cannot reach this VM.") + out = res.stdout or "" + if "NO_SDK" in out: + return False, t("No Android SDK in this VM: no emulator binary.") + if "NO_AVD" in out: + return False, t("No AVD named erplibre in this VM.") + return True, "" + + def _qemu_emulator_menu(self): + """Démarre l'émulateur Android d'une VM, et donne la suite qui va avec. + + La question qui décide de tout est celle de la FENÊTRE : + - avec fenêtre, l'écran voyage en pixels bruts par X11, et la commande + doit partir du poste qui possède l'affichage — donc pas d'ici ; + - sans fenêtre, on peut la lancer d'ici, détachée, et l'image arrive + ensuite par scrcpy en H.264. C'est la voie fluide. + """ + print(f"\n📱 {t('Android emulator')}") + targets = [(h, "ssh_config") for h in self._ssh_config_hosts()] + if not targets: + targets = [(n, "virsh") for n in self._qemu_list_domains()] + if not targets: + print(f" {t('No host in ~/.ssh/config and no local VM.')}") + return + for i, (nm, sr) in enumerate(targets, 1): + mark = "" if sr == "ssh_config" else f" ({t('local VM')})" + print(f" [{i}] {nm}{mark}") + answer = input(f"{t('Which VM?')} [1]: ").strip() or "1" + if not answer.isdigit() or not (1 <= int(answer) <= len(targets)): + print(t("Cancelled.")) + return + name, src = targets[int(answer) - 1] + target = self._qemu_ssh_target(name, src) + if not target: + print(f" {t('No IP for this VM; is it running?')}") + return + + running = self._qemu_emulator_running(target, src) + if running > 0: + print(f"\n ⚠ {t('An emulator is already running on this VM.')}") + print(f" {t('Only one per AVD; close it first:')}") + print(f"\n ssh {target} 'pkill -f \"[q]emu-system-x86_64\"'\n") + if not self._is_yes(input(t("Close it now? (y/N): "))): + return + subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, 'pkill -f "[q]emu-system-x86_64"'], + capture_output=True, + timeout=30, + ) + print(f" {t('Closed.')}") + + ready, why = self._qemu_emulator_ready(target, src) + if not ready: + print(f"\n ⚠ {why}") + print(f" {t('Tick the Android emulator tool when deploying.')}") + return + + print(f"\n {t('Show a window?')}") + print(f" [1] {t('No window - stream with scrcpy (smoother)')} *") + print(f" [2] {t('Window over ssh -X (raw pixels, slower)')}") + kind = input(f"{t('Choice')} [1]: ").strip() or "1" + # Sans cette validation, TOUT ce qui n'est pas « 2 » démarrait + # l'émulateur : une frappe de travers (« n ») lançait le démarrage, + # observé. Un menu à deux crans n'a pas de troisième réponse. + if kind not in ("1", "2"): + print(t("Cancelled.")) + return + emu = self._QEMU_EMULATOR_BIN + avd = self._QEMU_AVD_NAME + + if kind == "2": + # L'affichage appartient au POSTE : cette commande ne peut pas + # partir d'ici, où il n'y a pas d'écran à lui donner. + print(f"\n {t('Run this on YOUR workstation:')}") + print( + f"\n ssh -XC {target} '{emu} -avd {avd} " + f"{self._QEMU_EMULATOR_FLAGS}'\n" + ) + print( + f" {t('X11 compression is on (-XC); the screen is 540x1140.')}" + ) + return + + print(f"\n {t('Starting the emulator without a window...')}") + # « sg kvm » : l'appartenance au groupe est posée à l'installation, mais + # une VM créée avant ce correctif ne l'a pas dans sa session — sans KVM + # l'émulateur refuse de démarrer. setsid le détache, pour qu'il survive + # à la fermeture de ce ssh. + start = ( + f'setsid -f sg kvm -c "{emu} -avd {avd} -no-window ' + f"{self._QEMU_EMULATOR_FLAGS}" + ' > /tmp/erplibre-emulator.log 2>&1"' + ) + res = subprocess.run( + ["ssh"] + self._qemu_ssh_opts(src) + [target, start], + capture_output=True, + text=True, + timeout=60, + ) + if res.returncode: + print(f" ⚠ {t('Could not start it:')} {res.stderr.strip()[:200]}") + return + # « setsid » détache : le code de retour ne dit RIEN de l'émulateur. + # Le menu annonçait « Démarré » pendant que le journal de la VM disait + # « not found » — mesuré sur une VM sans SDK. On attend donc de voir le + # processus, et à défaut on rapporte le journal. + for _ in range(5): + if self._qemu_emulator_running(target, src) > 0: + break + time.sleep(2) + else: + print(f" ⚠ {t('It did not start; the VM log says:')}") + log = subprocess.run( + ["ssh"] + + self._qemu_ssh_opts(src) + + [target, "tail -5 /tmp/erplibre-emulator.log 2>/dev/null"], + capture_output=True, + text=True, + timeout=25, + ) + for line in (log.stdout or "").strip().splitlines(): + print(f" {line}") + return + print( + f" {t('Started. Boot takes about a minute; log in the VM:')}" + " /tmp/erplibre-emulator.log" + ) + self._qemu_scrcpy_tunnel(name, src, started=True) + + def _qemu_scrcpy_tunnel(self, name, src, started=False): + """Tunnel adb vers l'émulateur Android d'une VM, pour scrcpy. + + Pourquoi cette voie plutôt que « ssh -X » : par X11, chaque image de + l'écran traverse le réseau en pixels bruts — 0,62 Mpixel par image même + après réduction, en rendu logiciel. scrcpy, lui, reçoit un flux H.264 + encodé PAR l'appareil et le décode sur le poste. L'émulateur tourne + alors SANS fenêtre : plus de X11 du tout, ni sur l'hôte ni dans la VM. + + Le port est celui de l'émulateur, pas celui du serveur adb. Un émulateur + écoute sur 5554 (console) et 5555 (adb), tous deux sur le localhost de + la VM — vérifié par « ss -ltn ». C'est 5555 qu'il faut, et non 5037 : + tunneler le serveur adb obligerait à tuer celui du poste, qui occupe le + même port. + + Vérifié de bout en bout à travers le tunnel : une poignée de main adb + (paquet CNXN) reçoit « device::ro.product.name=sdk_gphone64_x86 » de + l'émulateur lui-même — c'est exactement ce que fait « adb connect ». + """ + port = 5555 + target = self._qemu_ssh_target(name, src) + if not target: + print(f" {t('No IP for this VM; is it running?')}") + return + print(f"\n 📱 {t('Android emulator over adb + scrcpy')}") + if started: + # Inutile de redire comment le démarrer : on vient de le faire. + print(f"\n {t('1. Emulator started, without a window.')}") + else: + print( + f"\n {t('1. In the VM, start the emulator WITHOUT a window:')}" + ) + print( + f"\n ssh {target} '{self._QEMU_EMULATOR_BIN} " + f"-avd {self._QEMU_AVD_NAME} -no-window " + f"{self._QEMU_EMULATOR_FLAGS}'\n" + ) + print(f" {t('2. Open the tunnel from YOUR workstation:')}") + if src == "ssh_config": + # « localhost » est résolu par le DERNIER saut, donc par la VM + # elle-même : le ProxyJump de ssh_config traverse les niveaux. + print(f"\n ssh -N -L {port}:localhost:{port} {name}\n") + print(f" {t('(through the ProxyJump already in ~/.ssh/config)')}") + else: + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + vm_ip = target.split("@")[-1] + if not from_ssh: + print( + f" ⚠ {t('Not in an SSH session: check the host address.')}" + ) + # DEUX sauts, et non un seul vers l'hyperviseur : l'émulateur + # n'écoute que sur le 127.0.0.1 de la VM — « ss -ltn » le montre, et + # l'hyperviseur reçoit un refus sur IP_VM:5555. Or « localhost » se + # résout sur le DERNIER hôte de la chaîne : la VM doit donc être ce + # dernier saut, l'hyperviseur n'étant que le relais (-J). + print( + f"\n ssh -N -L {port}:localhost:{port}" + f" -J {user}@{host} erplibre@{vm_ip}\n" + ) + print( + f" {t('(the hypervisor only relays; -J puts the VM last)')}" + ) + print(f" {t('3. Then, still on your workstation:')}") + print(f"\n adb connect localhost:{port}") + print(f" scrcpy -s localhost:{port}\n") + print(f" {t('The tunnel stays open as long as that ssh runs.')}") + print(f" {t('scrcpy on Debian/Ubuntu:')} sudo apt install scrcpy adb") + + # Ouvrir le tunnel D'ICI n'a de sens que si scrcpy tournera ici : le + # port ressort sur CETTE machine. On le propose donc en le disant, + # plutôt que de le faire d'office depuis un hyperviseur sans écran. + print(f"\n {t('If scrcpy will run on THIS machine, I can open it.')}") + if not self._is_yes(input(t("Open the tunnel now? (y/N): "))): + return + if self._port_in_use(port): + print(f" ⚠ {t('Port already in use here:')} {port}") + print( + f" {t('Close the other tunnel first:')}" + f' pkill -f "{port}:localhost:{port}"' + ) + return + # « ExitOnForwardFailure » : sans lui, un ssh détaché rend 0 alors que + # la redirection a échoué — un succès annoncé pour un tunnel absent. + cmd = ( + ["ssh", "-f", "-N", "-o", "ExitOnForwardFailure=yes"] + + self._qemu_ssh_opts(src) + + ["-L", f"{port}:localhost:{port}", target] + ) + res = subprocess.run(cmd, capture_output=True, text=True, timeout=40) + if res.returncode: + print(f" ⚠ {t('Tunnel failed:')} {res.stderr.strip()[:200]}") + return + print(f" ✅ {t('Tunnel open on localhost:')}{port}") + print( + f" {t('Then:')} adb connect localhost:{port}" + f" && scrcpy -s localhost:{port}" + ) + print(f' {t("To close it:")} pkill -f "{port}:localhost:{port}"') + + @staticmethod + def _port_in_use(port): + """Le port est-il déjà pris sur CETTE machine ? + + Un second tunnel sur le même port échouerait, et le message d'ssh + (« bind: Address already in use ») se perd en mode détaché.""" + with socket.socket() as sock: + sock.settimeout(1) + return sock.connect_ex(("127.0.0.1", port)) == 0 + + # Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui + # est rare et bienvenu : seule la commande d'installation change. + _QEMU_VIRT_VIEWER_INSTALL = ( + ("apt-get", "sudo apt-get install -y virt-viewer"), + ("dnf", "sudo dnf install -y virt-viewer"), + ("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"), + ("zypper", "sudo zypper --non-interactive install virt-viewer"), + ) + + def _qemu_ensure_virt_viewer(self): + """virt-viewer sur CETTE machine, installé s'il manque. + + Installé seulement là où il va SERVIR : sur un hyperviseur sans écran, + poser un client graphique ne rendrait service à personne. C'est + l'appelant qui a vérifié l'affichage.""" + if shutil.which("virt-viewer"): + return True + print(f"\n {t('virt-viewer is missing here; installing it.')}") + for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL: + if shutil.which(tool): + print(f" {t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + break + else: + print(f" ⚠ {t('no known package manager here.')}") + return False + if shutil.which("virt-viewer"): + print(f" ✅ virt-viewer") + return True + print(f" ⚠ {t('virt-viewer still missing after the install.')}") + return False + + def _qemu_virt_viewer(self, name, src): + """Ouvre l'écran d'une VM avec virt-viewer, qui monte SON tunnel. + + C'est la voie la plus courte : virt-viewer parle à libvirt par + « qemu+ssh:// » et n'a besoin d'aucun « ssh -L » à tenir ouvert. Il lit + aussi le port de l'écran par libvirt, donc rien à deviner. + + La seule question qui compte est celle de l'AFFICHAGE. virt-viewer + ouvre une fenêtre : il doit tourner là où il y a un écran. Deux cas, et + c'est l'environnement qui tranche, pas une question de plus : + - un affichage est là (poste de travail, ou « ssh -X ») : on installe + virt-viewer au besoin et on le lance, détaché ; + - aucun affichage : on donne la commande à lancer sur le poste, sous + la forme qemu+ssh, avec l'adresse par laquelle cette machine a été + jointe. + """ + domain = name.rsplit("+", 1)[-1] if src == "ssh_config" else name + display = os.environ.get("DISPLAY") or os.environ.get( + "WAYLAND_DISPLAY" + ) + if src == "ssh_config": + # L'hyperviseur est le ProxyJump déclaré : c'est lui qui fait + # tourner le QEMU de cette VM, pas la VM elle-même. + jump = self._ssh_proxyjump(name) + if not jump: + print( + f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}" + ) + print(f" {t('Cannot tell which machine runs its QEMU.')}") + return + uri = f"qemu+ssh://{jump}/system" + else: + uri = "qemu:///system" + + if display: + if not self._qemu_ensure_virt_viewer(): + return + cmd = ["virt-viewer", "-c", uri, domain] + print(f"\n {t('Opening')} : {' '.join(cmd)}") + try: + with open("/tmp/erplibre-virt-viewer.log", "ab") as log: + subprocess.Popen( + cmd, + stdout=log, + stderr=log, + start_new_session=True, + ) + except OSError as exc: + print(f" ⚠ {t('Could not start it:')} {exc}") + return + print(f" {t('Window opening on your display')} ({display}).") + print(f" {t('Log:')} /tmp/erplibre-virt-viewer.log") + return + + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + print(f"\n {t('No display here; run this on YOUR workstation:')}") + print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print(f" {t('A ~/.ssh/config alias works there too.')}") + print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}") + print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman" + " / zypper") + + def _qemu_console_tunnel(self, name, src): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. Les deux autres choix du menu supposent un service DANS l'invité — @@ -1597,52 +2079,102 @@ class TODO: « listen=none » QEMU n'ouvre AUCUN socket, et aucun tunnel n'y peut rien tant que le domaine n'est pas redéfini. """ - names = self._qemu_list_domains() - if not names: - print(f" {t('No local VM.')}") - return - for i, name in enumerate(names, 1): - print(f" [{i}] {name}") - raw = input(f"{t('Which VM?')} [1]: ").strip() or "1" - if not raw.isdigit() or not (1 <= int(raw) <= len(names)): - print(t("Cancelled.")) - return - name = names[int(raw) - 1] - try: - res = subprocess.run( - ["sudo", "virsh", "vncdisplay", name], - capture_output=True, - text=True, - timeout=15, - ) - except (OSError, subprocess.SubprocessError): - res = None - # « 127.0.0.1:0 » désigne le port 5900, « :1 » le 5901, etc. - port = None - if res and res.returncode == 0: - disp = res.stdout.strip().rsplit(":", 1) - if len(disp) == 2 and disp[1].isdigit(): - port = 5900 + int(disp[1]) + if src != "ssh_config": + jump, domain = "", name + else: + # L'écran VNC appartient à QEMU, donc à l'HYPERVISEUR — pas à + # l'invité. Tunneler vers la VM elle-même ne trouve rien : le + # socket n'existe pas de ce côté. Vécu, et c'est aussi ce qui + # rendait le premier jet de ce menu inutile hors machine locale. + # + # L'hyperviseur est le ProxyJump déclaré dans ssh_config, lu par + # « ssh -G » : c'est la seule lecture qui couvre toutes les formes + # d'écriture (Host, Match, wildcards, includes). Le nom composé + # « saut+vm » n'est qu'un libellé, il ne fait pas autorité. + jump = self._ssh_proxyjump(name) + domain = name.rsplit("+", 1)[-1] + if not jump: + print(f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}") + print(f" {t('Cannot tell which machine runs its QEMU.')}") + return + port = self._qemu_vnc_port(domain, jump) + # Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe + # évite de les copier sur la mauvaise machine, l'erreur naturelle ici. + pre = f"ssh {jump} " if jump else "" if not port: print(f"\n ⚠ {t('This VM exposes no VNC port.')}") print(f" {t('Its display is likely spice with listen=none:')}") - print(f" sudo virsh dumpxml {name} | grep -A2 '") - print(f" sudo virsh start {name}") + print(f" {pre}sudo virsh start {domain}") print(f"\n {t('New VMs get this by default; see deploy_qemu.')}") return - host, from_ssh = self._qemu_self_address() - user = os.environ.get("USER", "user") - if not from_ssh: - print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + if jump: + target = jump + else: + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + target = f"{user}@{host}" print(f"\n {t('Run this on YOUR workstation:')}") - print(f"\n ssh -N -L {port}:127.0.0.1:{port} {user}@{host}\n") + print(f"\n ssh -N -L {port}:127.0.0.1:{port} {target}\n") + if jump: + print(f" {t('Target is the hypervisor')} ({jump}), " + f"{t('not the VM: the socket is QEMU-side.')}") print(f" {t('then point your VNC client at')} localhost:{port}") print(f" {t('The tunnel stays open as long as that ssh runs.')}") + @staticmethod + def _ssh_proxyjump(host): + """ProxyJump effectif d'un hôte, tel que ssh le calcule lui-même. + + « ssh -G » rend la configuration RÉSOLUE : Match, wildcards et Include + compris. Relire ~/.ssh/config à la main raterait tout cela. + """ + try: + res = subprocess.run( + ["ssh", "-G", host], capture_output=True, text=True, timeout=10 + ) + except (OSError, subprocess.SubprocessError): + return "" + for line in res.stdout.splitlines(): + if line.startswith("proxyjump "): + value = line.split(None, 1)[1].strip() + return "" if value.lower() == "none" else value + return "" + + @staticmethod + def _qemu_vnc_port(domain, jump=""): + """Port VNC réel d'un domaine, localement ou sur un hyperviseur distant. + + Il ne se devine pas : libvirt l'attribue au démarrage. « virsh + vncdisplay » rend « 127.0.0.1:0 », où le suffixe est le NUMÉRO d'écran + — 0 vaut 5900, 1 vaut 5901. + + Sans sudo d'abord : l'appartenance au groupe libvirt suffit souvent, et + « sudo -n » distant échouerait sur l'absence de TTY. On ne retombe sur + « sudo -n » que si le premier essai n'a rien donné. + """ + base = ["virsh", "--connect", "qemu:///system", "vncdisplay", domain] + for argv in (base, ["sudo", "-n"] + base): + cmd = (["ssh", "-o", "BatchMode=yes", jump] + argv) if jump else argv + try: + res = subprocess.run( + cmd, capture_output=True, text=True, timeout=25 + ) + except (OSError, subprocess.SubprocessError): + continue + if res.returncode != 0: + continue + disp = res.stdout.strip().rsplit(":", 1) + if len(disp) == 2 and disp[1].isdigit(): + return 5900 + int(disp[1]) + return 0 + def _qemu_ssh_config_menu(self): """Écrit les entrées ~/.ssh/config du parc QEMU. @@ -2356,14 +2888,25 @@ class TODO: print(f"\n{t('Target state:')}") print(f" [1] {t('Open (start)')}") print(f" [2] {t('Close (shut down)')}") + print(f" [3] {t('Adjust hardware only (vCPU, RAM, 3D)')}") st = input(t("Choice: ")).strip() if st == "1": action, verb = "start", t("start") elif st == "2": action, verb = "shutdown", t("shut down") + elif st == "3": + self._qemu_adjust_hardware(resolved) + return else: print(t("Cancelled.")) return + # Le matériel d'une VM ne se règle QUE pendant qu'elle est éteinte : + # démarrer est donc le dernier moment pour le faire, et le seul où la + # question tombe juste. + if action == "start" and self._is_yes( + input(f"\n{t('Adjust hardware before starting? (y/N): ')}") + ): + self._qemu_adjust_hardware(resolved) # DOUBLE validation avant d'appliquer. summary = f"{verb} -> {', '.join(resolved)}" if not self._is_yes(input(f"{t('Apply:')} {summary} ? (o/N) : ")): @@ -2377,6 +2920,252 @@ class TODO: print(f"\n{t('Will execute:')} {cmd}") self.execute.exec_command_live(cmd, source_erplibre=False) + @staticmethod + def _qemu_dumpxml(name): + """XML PERSISTANT du domaine, ou '' — source de son état matériel. + + « --inactive » n'est pas décoratif : sur une VM allumée, « dumpxml » + rend la vue VIVANTE, décorée de ce que libvirt a alloué au démarrage + (portid du réseau, vnetN, alias). C'est la définition persistante que + virt-xml modifie, et c'est donc elle qu'il faut lire. + """ + try: + res = subprocess.run( + ["sudo", "virsh", "dumpxml", "--inactive", name], + capture_output=True, + text=True, + timeout=20, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return "" + return res.stdout if res.returncode == 0 else "" + + @staticmethod + def _qemu_autostart(name): + """Démarrage automatique activé ? (absent du XML : virsh seul le sait)""" + try: + res = subprocess.run( + ["sudo", "virsh", "dominfo", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return False + for line in res.stdout.splitlines(): + if line.startswith("Autostart:"): + return line.split(":", 1)[1].strip() == "enable" + return False + + def _qemu_ask_bool(self, prompt, default): + """Question fermée dont le DÉFAUT est l'état actuel de la VM. + + Une réponse vide — ou incompréhensible — laisse la VM telle quelle : + sur un formulaire de matériel, le silence ne doit rien modifier. + """ + ans = input(prompt).strip() + if self._is_yes(ans): + return True + if self._is_no(ans): + return False + return default + + def _qemu_host_gpu_node(self): + """Nœud de rendu de l'hôte, vu par deploy_qemu (source unique), ou ''.""" + try: + return self._qemu_import_module().host_gpu_node() + except (OSError, AttributeError, ImportError): + return "" + + def _qemu_net_choices(self): + """Réseaux proposables : réseaux libvirt, puis ponts de l'hôte. + + Les ponts appartenant à un réseau libvirt (virbr0 pour « default ») + sont écartés : les proposer offrirait DEUX fois le même chemin, dont + un qui contourne la gestion du réseau par libvirt. + """ + tokens = [] + nets = self._qemu_cmd_lines( + ["sudo", "virsh", "net-list", "--all", "--name"] + ) + owned = set() + for net in nets: + tokens.append(f"network:{net}") + for line in self._qemu_cmd_lines( + ["sudo", "virsh", "net-info", net] + ): + if line.startswith("Bridge:"): + owned.add(line.split(":", 1)[1].strip()) + for line in self._qemu_cmd_lines( + ["ip", "-o", "link", "show", "type", "bridge"] + ): + # « 3: br0: br0 + parts = line.split(":") + bridge = parts[1].strip() if len(parts) > 1 else "" + if bridge and bridge not in owned: + tokens.append(f"bridge:{bridge}") + return tokens + + @staticmethod + def _qemu_cmd_lines(cmd): + """Lignes non vides d'une commande, ou [] si elle échoue.""" + try: + res = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return [] + if res.returncode != 0: + return [] + return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()] + + def _qemu_adjust_hardware(self, names): + """Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES. + + Les VM allumées sont écartées, en le disant : virt-xml y écrirait une + définition qui ne prendrait effet qu'au prochain démarrage — un + réglage qui paraît appliqué et ne l'est pas. + """ + from script.todo import qemu_hardware as hw + + off, busy = [], [] + for name in names: + state = self._qemu_domstate(name) + (off if state == "shut off" else busy).append(name) + if busy: + print( + f"\n ⚠ {t('Not shut off, hardware left untouched:')}" + f" {', '.join(busy)}" + ) + if not off: + return + node = self._qemu_host_gpu_node() + gpu_txt = node or t("none (software rendering)") + print(f"\n{t('Host GPU:')} {gpu_txt}") + rows = [ + r + for r in ( + hw.hw_state(self._qemu_dumpxml(n), self._qemu_autostart(n)) + for n in off + ) + if r.get("name") + ] + if not rows: + print(f" ⚠ {t('Unreadable VM definition.')}") + return + for r in rows: + print(f" {r['name']:<30} {hw.hw_summary(r)}") + nets = self._qemu_net_choices() + want = self._qemu_hw_form(rows, node, nets) + if want is None: + print(t("Cancelled.")) + return + if not want: + want = self._qemu_hw_prompts(rows, node, nets) + if not want: + print(t("Cancelled.")) + return + plan = [] + for r in rows: + plan += hw.hw_plan(r, want.get(r["name"]) or {}, node) + for entry in plan: + if entry.get("skip"): + print(f" ⚠ {entry['what']} : {entry['skip']}") + cmds = [e for e in plan if e.get("cmd")] + if not cmds: + print(f"\n{t('Nothing to change.')}") + return + print(f"\n{t('Changes:')}") + for entry in cmds: + print(f" - {entry['what']}") + if not self._is_yes(input(t("Apply these changes? (y/N): "))): + print(t("Cancelled.")) + return + for entry in cmds: + cmd = "sudo " + " ".join(shlex.quote(c) for c in entry["cmd"]) + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _qemu_hw_form(self, rows, node, nets=None): + """Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour + retomber sur les invites en ligne (textual absent), None si annulé.""" + from script.todo import textual_setup + + if not textual_setup.ensure(): + return {} + try: + from script.todo.qemu_hardware import run_hardware_form + + return run_hardware_form(rows, node, nets) + except ImportError: + return {} + + def _qemu_pick(self, title, values, current, labels=None): + """Liste numérotée dont le DÉFAUT est la valeur actuelle. + + Rendre la valeur actuelle sur une réponse vide, et sur une réponse + illisible : dans un formulaire de matériel, ne rien comprendre ne doit + rien changer. + """ + labels = labels or values + print(f"{title} :") + for i, (val, lab) in enumerate(zip(values, labels), 1): + mark = " ←" if val == current else "" + print(f" [{i}] {lab}{mark}") + ans = input(" " + t("Choice: ")).strip() + if not ans.isdigit(): + return current + idx = int(ans) + return values[idx - 1] if 1 <= idx <= len(values) else current + + def _qemu_hw_prompts(self, rows, node, nets=None): + """Même ajustement, en invites, quand Textual n'est pas disponible.""" + from script.todo import qemu_hardware as hw + + cpus = hw.cpu_choices(rows) + reseaux = hw.net_choices(rows, nets) + want = {} + for r in rows: + print(f"\n {r['name']} — {hw.hw_summary(r)}") + vcpus = input(f" vCPU [{r.get('vcpus')}] : ") + ram = input(f" RAM [{hw.fmt_mib(r.get('mem_mib'))}] : ") + reason = hw.gpu_allowed(r, node) + if reason: + print(f" ⚠ {t('3D acceleration (host GPU)')} : {reason}") + gpu = False + else: + gpu = self._qemu_ask_bool( + f" {t('3D acceleration (host GPU)')} ? (o/N) : ", + bool(r.get("accel3d")), + ) + auto = self._qemu_ask_bool( + f" {t('Autostart')} ? (o/N) : ", bool(r.get("autostart")) + ) + cpu = self._qemu_pick(f" {t('CPU mode')}", cpus, r.get("cpu")) + heads = "" + if r.get("video"): + heads = input(f" {t('Screens')} [{r.get('heads') or 1}] : ") + net = r.get("net") or "" + # Une seule possibilité : rien à demander. C'est le cas d'un hôte + # sans pont, où le réseau libvirt est la seule voie. + if len(reseaux) > 1: + net = self._qemu_pick( + f" {t('Network')}", + [tok for tok, _lab in reseaux], + net, + labels=[lab for _tok, lab in reseaux], + ) + want[r["name"]] = hw.build_want( + r, vcpus, ram, gpu, auto, cpu=cpu, heads=heads, net=net + ) + return want + @staticmethod def _qemu_dominfo(name): """(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0).""" @@ -2424,6 +3213,107 @@ class TODO: except (OSError, subprocess.SubprocessError, ValueError): return 0, 0 + @staticmethod + def _fmt_uptime(secs): + """Durée depuis le démarrage, en six caractères au plus. + + « _fmt_dur » s'arrête aux minutes — bon pour une installation, illisible + pour une VM debout depuis trois jours. Ici la précision décroît avec la + durée : personne ne lit les secondes d'un uptime de 19 heures.""" + secs = int(secs) + if secs < 60: + return f"{secs}s" + if secs < 3600: + return f"{secs // 60}m" + if secs < 86400: + return f"{secs // 3600}h{(secs % 3600) // 60:02d}" + days = secs // 86400 + # Au-delà de 99 jours, les heures ne rentrent plus dans la colonne — et + # personne ne les lit sur une machine debout depuis un an. + if days >= 100: + return f"{days}j" + return f"{days}j{(secs % 86400) // 3600:02d}h" + + @staticmethod + def _qemu_domain_uptime(name): + """Secondes depuis le démarrage du domaine, ou None. + + libvirt n'expose pas l'uptime d'un invité : ni dominfo, ni domstats, ni + l'agent. Mais le processus QEMU du domaine est né avec lui, et son âge + est donc exactement celui de la VM. « guest=, » est le motif que + libvirt met dans sa ligne de commande — la virgule évite qu'un nom + préfixe d'un autre matche à sa place.""" + try: + res = subprocess.run( + ["pgrep", "-f", f"guest={name},"], + capture_output=True, + text=True, + timeout=10, + ) + pid = (res.stdout or "").split()[0] + age = subprocess.run( + ["ps", "-o", "etimes=", "-p", pid], + capture_output=True, + text=True, + timeout=10, + ) + return int((age.stdout or "").strip()) + except (OSError, subprocess.SubprocessError, ValueError, IndexError): + return None + + @staticmethod + def _qemu_dommemstat(name): + """(utilisée, totale) en KiO vues par l'INVITÉ, ou (0, 0). + + « available » est ce que l'invité voit, « usable » ce qu'il peut encore + rendre : leur différence est son « used », à quelques mégaoctets près — + calibré contre le « free » de deux VM (1186 contre 1216, 4831 contre + 4838). « unused » ne convient pas : il ignore le cache, et donnait + 10,8 Go d'« utilisé » sur une VM qui en occupait 1,2. + + La période de collecte est posée d'abord, et c'est indispensable : sans + elle le ballon ne rafraîchit rien, et une VM qui occupait 4,8 Go en + annonçait 490 Mo — vécu. « --live » ne touche pas le XML : le réglage + disparaît au prochain démarrage du domaine.""" + try: + subprocess.run( + [ + "sudo", + "virsh", + "dommemstat", + name, + "--period", + "5", + "--live", + ], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + res = subprocess.run( + ["sudo", "virsh", "dommemstat", name], + capture_output=True, + text=True, + timeout=15, + env=TODO._qemu_c_env(), + ) + except (OSError, subprocess.SubprocessError): + return 0, 0 + stat = {} + for line in (res.stdout or "").splitlines(): + parts = line.split() + if len(parts) == 2: + try: + stat[parts[0]] = int(parts[1]) + except ValueError: + continue + total = stat.get("available", 0) + usable = stat.get("usable", 0) + if not total or not usable: + return 0, total + return max(0, total - usable), total + def _qemu_list_vms_advanced(self): """Tableau détaillé par VM : état, vCPU, RAM allouée, disque (virtuel + réel), plus l'espace total disponible du stockage des images.""" @@ -2432,9 +3322,12 @@ class TODO: print(f"\n{t('No VM found.')}") return g = 1 << 30 + # Largeurs serrées pour que la ligne tienne en 80 colonnes AVEC le nom + # entier : c'est lui qui distingue les machines, et « erplibre-ubuntu- + # 2604-gno » tronqué ne distingue plus rien. header = ( - f"\n{'VM':<28} {'État':<10} {'vCPU':>4} {'RAM':>8} " - f"{'Disque':>9} {'Réel':>9}" + f"\n{'VM':<26} {'État':<8} {'vCPU':>4} {'RAM':>10} " + f"{'Disque':>7} {'Réel':>7} {'Uptime':>6}" ) print(header) print("─" * len(header.strip())) @@ -2447,9 +3340,27 @@ class TODO: if disk: disk_dirs.add(os.path.dirname(disk)) ram_g = (mem_kib * 1024) / g if mem_kib else 0 + # « RAM » dit désormais l'USAGE et non la seule allocation : sur un + # hyperviseur, savoir qu'une VM de 32 Go n'en occupe que 4,7 décide + # s'il reste de la place pour la suivante. Deux nombres dans une + # colonne plutôt que deux colonnes — le tableau tient encore sur + # une ligne de terminal. + used_kib, _total_kib = self._qemu_dommemstat(name) + # Le total sans décimale quand il est entier — une allocation + # vaut 8, 12 ou 32 Go, jamais 32,0. + alloc = f"{ram_g:.0f}" if ram_g == int(ram_g) else f"{ram_g:.1f}" + ram = ( + f"{used_kib * 1024 / g:.1f}G/{alloc}G" + if used_kib + else f"-/{alloc}G" + ) + # L'uptime vient de l'âge du processus QEMU : libvirt ne l'expose + # nulle part, et ce processus est né avec le domaine. + up = self._qemu_domain_uptime(name) print( - f"{name:<28.28} {state:<10.10} {vcpus:>4} " - f"{ram_g:>7.1f}G {virt / g:>8.1f}G {actual / g:>8.1f}G" + f"{name:<26.26} {state:<8.8} {vcpus:>4} " + f"{ram:>10} {virt / g:>6.1f}G {actual / g:>6.1f}G " + f"{self._fmt_uptime(up) if up else '-':>6}" ) # Espace total disponible sur le(s) stockage(s) des disques. for d in sorted(disk_dirs) or ["/var/lib/libvirt/images"]: @@ -3957,13 +4868,22 @@ class TODO: ERPLIBRE_GIT_URL = "https://github.com/erplibre/erplibre" def _qemu_import_module(self): - """Importe deploy_qemu.py comme module (source de vérité des specs).""" + """Importe deploy_qemu.py comme module (source de vérité des specs). + + Mémorisé : le catalogue interroge cette source une fois par couple + (distro, version), et réexécuter un fichier de 2 700 lignes à chaque + passage se voyait à l'écran. + """ + cached = getattr(self, "_qemu_mod_cache", None) + if cached is not None: + return cached import importlib.util path = self._qemu_script_path() spec = importlib.util.spec_from_file_location("deploy_qemu", path) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) + self._qemu_mod_cache = mod return mod @classmethod @@ -4432,6 +5352,30 @@ class TODO: ~/git/erplibre (dev).""" return "/opt/erplibre" if prod else "$HOME/git/erplibre" + @staticmethod + def _qemu_guide_dir(prod): + """Répertoire d'ERPLibre tel que le GUIDE de connexion l'annonce. + + « ~/git/erplibre » plutôt que « $HOME/git/erplibre » : ce chemin n'est + pas exécuté par un script, il est lu par quelqu'un qui recopie la ligne + dans son shell — où les deux marchent — et le tilde est la forme qu'il + reconnaît. En production le chemin est absolu et la question ne se pose + pas.""" + return "/opt/erplibre" if prod else "~/git/erplibre" + + @staticmethod + def _qemu_make_target(install_cmd): + """Cible make qui installe Odoo dans `install_cmd`, pour le guide. + + Les profils s'écrivent « make install_os && make install_odoo_18 » : la + cible utile est la SECONDE, celle qui installe Odoo, et c'est aussi + celle qu'on relance après un « git pull ». Les profils qui n'en ont pas + (« ERPLibre seul », « mobile », « Déploiement ») rendent une chaîne + vide : le guide s'arrête alors à « git pull » plutôt que d'annoncer une + cible qui n'est pas celle de cette VM.""" + found = re.findall(r"make\s+(install_odoo\S*)", install_cmd or "") + return found[-1] if found else "" + def _qemu_odoo_service_cmd(self, prod=False): """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme service systemd puis l'active. N'est ajouté QUE pour les profils Odoo. @@ -4897,6 +5841,25 @@ class TODO: # images cloud démarrent en multi-user.target. "sudo systemctl set-default graphical.target || true; " f"sudo systemctl enable {de['service']} >/dev/null 2>&1 || true; " + # Et il faut le DÉMARRER, pas seulement l'activer. Deux raisons, + # toutes deux mesurées sur erplibre-ubuntu-2604-gnome : + # + # - graphical.target était DÉJÀ atteinte quand le paquet est + # arrivé, et une cible active ne rattrape pas un service ajouté + # après coup : display-manager.service est resté inactif ; + # - sur Debian et Ubuntu, « systemctl enable gdm » rend 0 sans + # rien faire — l'unité n'a pas de « WantedBy », seulement + # « Alias=display-manager.service » que le paquet a déjà posé. + # + # Résultat : GNOME installé, gdm3 installé, cible graphique par + # défaut… et la console de la VM restait en mode texte jusqu'au + # premier redémarrage. L'écran, c'est justement ce qu'on est venu + # chercher sur une VM graphique. + "if sudo systemctl start display-manager.service 2>/dev/null || " + f"sudo systemctl start {de['service']} 2>/dev/null; then " + f'echo " {t("graphical session started")}"; ' + f'else echo " ⚠ {t("graphical session not started; reboot the VM")}"; ' + "fi; " # xrdp là où il existe ; sur Arch c'est TigerVNC, qui se configure # par utilisateur et n'a pas de service à activer d'office. "if command -v xrdp >/dev/null 2>&1; then " @@ -4916,6 +5879,1269 @@ class TODO: + "fi; " ) + # ------------------------------------------------------------------ # + # Outils de développement d'une VM graphique + # ------------------------------------------------------------------ # + # Chacun est une case à cocher, indépendante des autres, et chacun pèse sur + # le disque — le plan l'annonce AVANT de déployer, sinon l'installation se + # termine sur un disque plein après une heure d'attente. + # + # « disk_gb » compte le PIC, pas l'installé : l'archive téléchargée vit sur + # le disque le temps de l'extraction. PyCharm, c'est 1,2 Go d'archive et + # ~3 Go déplié ; Android Studio 1,5 Go et 3,5 Go, plus la place du premier + # SDK que l'utilisateur téléchargera. + # + # « arches » n'est pas une précaution : Google ne publie Android Studio + # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404, + # et le product-info.json de l'archive ne déclare qu'une cible + # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64. + _QEMU_VM_TOOLS = { + "pycharm": { + "label": "PyCharm", + "hint": "Python IDE, opens the ERPLibre checkout", + "disk_gb": 5, + "arches": ("amd64", "arm64"), + "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", + }, + "android": { + "label": "Android Studio", + "hint": "ERPLibre mobile development (x86_64 only)", + "disk_gb": 8, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": True, + "families": (), + "phase": "before", + }, + "gnome_ext": { + "label": "GNOME extensions", + "hint": "suggested extensions + extension manager", + "disk_gb": 1, + "arches": (), + "desktops": ("gnome",), + "needs_desktop": True, + "families": (), + "phase": "before", + }, + # Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche + # rien. Une VM serveur le prend, une VM graphique aussi — et sur + # celle-ci le SDK est partagé avec Android Studio plutôt que doublé. + # + # « families » le borne à apt, et ce n'est pas un choix : l'installateur + # du dépôt mobile, install-android.sh, commence par + # « sudo apt install openjdk-17-jdk ». Ailleurs il s'arrête là. Lever + # cette limite se fait dans CE script-là, pas ici. + # + # Disque : ~1,5 Go de SDK et plateformes, ~2,5 Go de NDK, whisper.cpp + # et sentencepiece clonés, node_modules, et les artefacts Gradle. + "mobile": { + "label": "ERPLibre mobile (build)", + "hint": "APK debug + Vitest, validates the VM", + "disk_gb": 12, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + # APRÈS l'installation : le build a besoin du dépôt mobile, que le + # manifeste ajoute, et du venv d'outils pour le synchroniser. + "phase": "after", + }, + # Forgejo est un SERVICE, pas un outil de bureau : une VM serveur le + # prend aussi bien qu'une VM graphique. Son binaire est STATIQUE — le + # même fichier sur apt, dnf, pacman et zypper — donc aucune famille de + # paquets n'est exclue, et c'est ce qui le rend portable sur toutes les + # plateformes ERPLibre sans une branche par distribution. + # + # Les architectures, elles, sont bornées par l'amont : Forgejo publie + # amd64, arm64 et arm-6, et RIEN pour s390x. Sur celle-là il faudrait le + # bâtir en Go ; la case se grise plutôt que de poser un binaire qui ne + # s'exécute pas. + # + # Disque : ~115 Mo de binaire (34 Mo téléchargés en .xz), la base SQLite + # et les dépôts que l'utilisateur y poussera. + "forgejo": { + "label": "Forgejo (git forge)", + "hint": "self-hosted git forge on :3000, SQLite", + "disk_gb": 2, + "arches": ("amd64", "arm64"), + "desktops": (), + "needs_desktop": False, + "families": (), + # APRÈS l'installation : le script vit dans le dépôt, donc après le + # clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv + # ni d'Odoo. + "phase": "after", + }, + # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur + # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM + # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le + # bloc vérifie et annonce plutôt que de laisser découvrir. + # + # Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus + # l'émulateur lui-même. + "avd": { + "label": "Android emulator (Pixel)", + "hint": "AVD viewable over ssh -X", + "disk_gb": 6, + "arches": ("amd64",), + "desktops": (), + "needs_desktop": False, + "families": ("apt",), + "phase": "after", + }, + } + + # Famille de paquets de chaque distribution, pour borner un outil à ce qui + # sait l'installer. + _QEMU_DISTRO_FAMILY = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", + } + + @classmethod + def _qemu_vm_tool_choices(cls): + """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" + return [ + (key, t(spec["label"]), t(spec["hint"])) + for key, spec in cls._QEMU_VM_TOOLS.items() + ] + + @classmethod + def _qemu_tools_for(cls, tools, arch, desktop, distro="", phase=""): + """Outils RÉELLEMENT applicables à cette VM. + + Un outil demandé pour tout le parc ne convient pas forcément à chaque + machine : Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon, et la compilation mobile ne sait + s'installer que sur les distributions apt. Filtrer ici plutôt que dans + la commande distante évite d'annoncer une installation qui ne se fera + pas. + + `phase` restreint au moment d'exécution : « before » avant le clone, + « after » après l'installation. Vide, les deux sont rendus.""" + out = [] + for key in tools or (): + spec = cls._QEMU_VM_TOOLS.get(key) + if not spec: + continue + if spec["needs_desktop"] and not desktop: + continue + if spec["arches"] and arch not in spec["arches"]: + continue + if spec["desktops"] and desktop not in spec["desktops"]: + continue + family = cls._QEMU_DISTRO_FAMILY.get(distro, "") + if spec["families"] and distro and family not in spec["families"]: + continue + if phase and spec["phase"] != phase: + continue + out.append(key) + return out + + @classmethod + def _qemu_tools_disk_gb(cls, tools, arch, desktop, distro=""): + """Go à ajouter au disque pour les outils applicables à cette VM.""" + return sum( + cls._QEMU_VM_TOOLS[k]["disk_gb"] + for k in cls._qemu_tools_for(tools, arch, desktop, distro) + ) + + # Archive officielle JetBrains, et non un paquet de distribution : aucun ne + # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne + # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout). + # + # La ligne COMMUNITY, et non le produit unifié. Mesuré dans une VM : + # « code=PCC&latest » sert maintenant pycharm-2025.3, le build unifié, qui + # s'arrête sur sa licence — son journal dit « NoValidIdeLicense » puis + # « Get licenses: request requires authentication », et le projet ne + # s'ouvre jamais. Aucune ouverture, donc aucun .idea, donc rien à + # configurer ensuite. Community ne demande aucun compte, et elle est + # toujours publiée et corrigée : 2025.2.6.2 date du 2026-07-29. + # + # Aucun numéro figé ici : on prend la plus récente archive + # « pycharm-community- » du flux officiel des versions, pour + # l'architecture de la VM. + _QEMU_PYCHARM_FEED = ( + "https://data.services.jetbrains.com/products/releases" + "?code=PCC&type=release" + ) + # Repli quand le flux est injoignable : la redirection « dernière version ». + # Elle sert le build unifié, donc on le DIT — l'utilisateur devra ouvrir un + # compte JetBrains, et mieux vaut l'apprendre dans le journal qu'au premier + # lancement. + _QEMU_PYCHARM_URL = ( + "https://download.jetbrains.com/product?code=PCC&latest&distribution=" + ) + + # Android Studio n'a PAS d'URL « latest » : le répertoire de version + # (2026.1.3.8) et le nom de fichier (quail3-patch1) sont deux jetons + # INDÉPENDANTS, l'un ne se déduit pas de l'autre, et le flux updates.xml de + # Google ne publie ni l'un ni l'autre. On lit donc l'URL sur la page + # officielle, qui la porte en clair, et on retombe sur celle-ci si la page + # change de forme. Relevée et vérifiée (HTTP 200) le 2026-08-17. + _QEMU_ANDROID_URL = ( + "https://dl.google.com/dl/android/studio/ide-zips/2026.1.3.8/" + "android-studio-quail3-patch1-linux.tar.gz" + ) + _QEMU_ANDROID_PAGE = "https://developer.android.com/studio" + + @staticmethod + def _qemu_desktop_entry_cmd(name, label, exec_cmd, icon, categories): + """Écrit un lanceur .desktop. Sans lui, un outil déplié dans /opt + n'existe pas pour le bureau : il ne se lance qu'en tapant son chemin. + """ + return ( + f"sudo tee /usr/share/applications/{name}.desktop >/dev/null </dev/null " + "| head -1); " + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{link}; ' + + ( + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{alias}; ' + if alias + else "" + ) + ) + + def _qemu_pycharm_remote_cmd(self, prod=False): + """Installe PyCharm et lui donne le dépôt ERPLibre comme projet. + + « Configuré sur git/erplibre » veut dire deux choses, et les deux sont + faites ici : le lanceur du bureau OUVRE ce dépôt, et + pycharm_configuration.py y écrit le .idea/ du projet (interpréteur, + configurations d'exécution, dossiers exclus) — la même chose que + « make pycharm_configure », mais avec le python du venv d'outils, seul à + disposer de xmltodict. + + Tout le bloc est gardé : un IDE qui ne s'installe pas ne doit pas faire + échouer l'installation d'ERPLibre, qui elle a duré une heure.""" + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Installing PyCharm (long)")} =="; ' + "{ " + 'case "$(uname -m)" in x86_64) jb=linux;; ' + 'aarch64|arm64) jb=linuxARM64;; *) jb="";; esac; ' + # if/else et non « || { …; false; } » : dans un groupe, un échec + # n'interrompt PAS la suite (set -e est suspendu à gauche d'un + # « && »), et l'architecture non servie partait quand même + # télécharger une URL sans valeur de distribution. + 'if [ -z "$jb" ]; then ' + f'echo " {t("no JetBrains build for")} $(uname -m)"; false; ' + "else " + # Déjà posé ? On ne retélécharge pas. Rejouer une + # installation est le cas NORMAL — une qui est morte, un outil + # ajouté après coup — et le téléchargement en est la partie + # longue : mesuré, ~5 min pour Android Studio, autant pour + # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) + # rejoue de toute façon, lui est idempotent et bon marché. + "if [ -x /opt/pycharm/bin/pycharm.sh ]; then " + f'echo " {t("already there, download skipped")}"; ' + "else " + # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en + # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go. + # Le flux dit quelle archive Community prendre pour cette + # architecture. En python plutôt qu'en shell : il fait la requête, + # lit le JSON et rend une ligne — sans jq, absent des images cloud. + "url=$(python3 - \"$jb\" <<'ELPYJB'\n" + "import json, sys, urllib.request\n" + "key = sys.argv[1]\n" + "try:\n" + f' with urllib.request.urlopen("{self._QEMU_PYCHARM_FEED}",\n' + " timeout=30) as fh:\n" + " data = json.load(fh)\n" + "except Exception:\n" + " sys.exit(0)\n" + 'for rel in data.get("PCC", []):\n' + ' link = (rel.get("downloads") or {}).get(key, {}).get("link", "")\n' + ' if "pycharm-community-" in link:\n' + " print(link)\n" + " break\n" + "ELPYJB\n" + "); " + 'if [ -z "$url" ]; then ' + f'url="{self._QEMU_PYCHARM_URL}$jb"; ' + f'echo " {t("release feed unreachable: unified build, it will ask for a JetBrains account")}"; ' + "fi; " + "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && " + 'curl -fsSL "$url" -o "$tmp" && ' + "sudo mkdir -p /opt/pycharm && " + 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; ' + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd("/opt/pycharm", "pycharm") + + self._qemu_desktop_entry_cmd( + "pycharm", + "PyCharm (ERPLibre)", + f"/usr/local/bin/pycharm {el_dir}", + "/opt/pycharm/bin/pycharm.svg", + "Development;IDE;", + ) + # AUCUN appel à pycharm_configuration.py ici : l'installation + # ERPLibre le fait déjà. update_env_version.pycharm_update() teste + # « os.path.exists('.idea') » puis lance le script — une seule + # autorité, et elle sait se taire quand le projet n'existe pas + # encore. Doubler l'appel ne configurait rien de plus : ça écrivait + # « Missing ./.idea path » dans le journal d'une VM neuve, où + # PyCharm n'a évidemment jamais ouvert le dépôt. + + f'echo " {t("PyCharm installed:")} /opt/pycharm ' + f'({t("command")} pycharm, {t("project")} {el_dir})"; ' + f'echo " {t("open the project once and close PyCharm; the .idea " + "it writes is what the install configures")}"; ' + f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; ' + ) + + # Serveur X virtuel, par gestionnaire de paquets. Les noms ne se + # ressemblent pas d'une famille à l'autre — relevés dans chaque dépôt, pas + # devinés. + _QEMU_XVFB_PKG = { + "apt": "xvfb", + "dnf": "xorg-x11-server-Xvfb", + "zypper": "xorg-x11-server-Xvfb", + "pacman": "xorg-server-xvfb", + } + + # Attente maximale du .idea, en tours de 5 s — cinq minutes. Mesuré sur une + # VM Ubuntu 26.04 à 16 Go : le projet est écrit en 195 s, indexation du + # dépôt en cours. On n'attend donc PAS la fin de cette indexation, qui dure + # bien plus et dont personne n'a besoin ici : pycharm_configuration.py ne + # réclame que le .iml et misc.xml. + _QEMU_PYCHARM_OPEN_TRIES = 60 + + def _qemu_xvfb_install_cmd(self): + """Pose Xvfb avec le gestionnaire de paquets présent, sans bruit.""" + x = self._QEMU_XVFB_PKG + return ( + "if command -v apt-get >/dev/null 2>&1; then " + "sudo DEBIAN_FRONTEND=noninteractive apt-get " + f"-o DPkg::Lock::Timeout=600 install -y {x['apt']} " + ">/dev/null 2>&1 || true; " + "elif command -v dnf >/dev/null 2>&1; then " + f"sudo dnf install -y {x['dnf']} >/dev/null 2>&1 || true; " + "elif command -v zypper >/dev/null 2>&1; then " + "sudo zypper --non-interactive install --auto-agree-with-licenses " + f"{x['zypper']} >/dev/null 2>&1 || true; " + "elif command -v pacman >/dev/null 2>&1; then " + f"sudo pacman -S --needed --noconfirm {x['pacman']} " + ">/dev/null 2>&1 || true; fi; " + ) + + def _qemu_pycharm_project_cmd(self, prod=False): + """Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran. + + C'est PyCharm, et lui seul, qui écrit ce répertoire : ni le dépôt ni + pycharm_configuration.py ne savent le fabriquer — ce dernier exige un + .iml puis un misc.xml, et s'arrête sinon. Sans cette ouverture, l'étape + pycharm_update() de l'installation ne trouve rien à configurer. + + Xvfb parce que l'IDE réclame un affichage, même pour ouvrir un projet + et s'arrêter. Il tourne DANS la VM : l'hôte qui orchestre n'a besoin + d'aucune bibliothèque graphique, et rien ne transite par « ssh -X ». + + TROIS fenêtres bloqueraient une session où personne ne peut cliquer, et + chacune a été rencontrée avant d'être écartée : politique de + confidentialité, partage de données, et surtout « faites-vous confiance + à ce projet ? ». C'est celle-là qui figeait tout — le journal s'arrêtait + 1,3 s après le démarrage, sans jamais ouvrir le projet, et il a fallu + « idea.trust.all.projects » pour le débloquer. Le consentement, lui, est + écrit REFUSÉ : aucune statistique ne part. + + Mesuré sur une VM Ubuntu 26.04 à 16 Go : .idea complet en 195 s, et + pycharm_configuration.py écrit ensuite ses exclusions dans le .iml. + + Tout est gardé. Sans Xvfb, sans PyCharm, ou sans .idea au bout du + délai, on le dit et l'installation continue : elle n'en dépend pas, + elle en profite seulement. + """ + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Creating the PyCharm project (first open)")} =="; ' + "{ if ! command -v pycharm >/dev/null 2>&1; then " + f'echo " {t("PyCharm missing, step skipped")}"; false; ' + "else " + "command -v xvfb-run >/dev/null 2>&1 || { " + + self._qemu_xvfb_install_cmd() + + "}; " + "if ! command -v xvfb-run >/dev/null 2>&1; then " + f'echo " {t("no Xvfb here, open PyCharm by hand")}"; false; ' + "else " + # Réponses aux fenêtres de première ouverture. En python plutôt + # qu'en shell : l'horodatage en millisecondes et le « » de + # la propriété se passeraient mal de guillemets imbriqués. + "python3 - <<'ELPYC' || true\n" + "import pathlib, time\n" + "h = pathlib.Path.home()\n" + 'c = h / ".local/share/JetBrains/consentOptions"\n' + "c.mkdir(parents=True, exist_ok=True)\n" + '(c / "accepted").write_text(\n' + ' "rsch.send.usage.stat:1.1:0:%d\\n" % (time.time() * 1000)\n' + ")\n" + '(h / ".pycharm-headless.vmoptions").write_text(\n' + ' "-Djb.privacy.policy.text=\\n"\n' + ' "-Djb.consents.confirmation.enabled=false\\n"\n' + ' "-Didea.trust.all.projects=true\\n"\n' + ' "-Didea.suppress.statistics.report=true\\n"\n' + ")\n" + "ELPYC\n" + # « setsid » donne au tout son PROPRE groupe de processus, et + # c'est le groupe qu'on tuera. Sans lui, « $! » est le PID de + # xvfb-run — un script — et le tuer n'atteint ni PyCharm, ni Xvfb, + # ni les cef_server qu'il a lancés. Mesuré sur + # erplibre-ubuntu-2604-gnome : PyCharm tournait encore 45 minutes + # plus tard avec 1,9 Go, et la compilation de l'APK qui suivait + # s'est fait tuer par le noyau, faute de mémoire. + # Les watches inotify AVANT d'ouvrir : le dépôt mobile pose + # 123 000 fichiers d'assets, et la limite par défaut est dépassée + # dès l'analyse — « inotify_add_watch(...): No space left on + # device », puis « watch root cannot be watched: -2 », puis aucun + # .idea écrit. Mesuré sur erplibre-ubuntu-2604-gnome, deux fois. + # 524288 est la valeur que JetBrains documente lui-même. + "cur=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null " + '|| echo 0); if [ "$cur" -lt 524288 ] 2>/dev/null; then ' + 'echo "fs.inotify.max_user_watches=524288" ' + "| sudo tee /etc/sysctl.d/60-erplibre-inotify.conf >/dev/null && " + "sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf " + f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; ' + "fi; " + # DEUX tentatives, et c'est mesuré : la première ouverture d'un + # dépôt neuf indexe 212 000 fichiers, plante son configurateur + # d'interpréteur (« PythonSdkConfigurator - homeDir is null ») et + # n'écrit AUCUN .idea, même au bout de cinq minutes. La seconde, sur + # les caches que la première a laissés, l'écrit en 25 secondes — + # constaté sur deux VM différentes. + ": > /tmp/pycharm-first-run.log; " + "for attempt in 1 2; do " + 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" ' + f"setsid xvfb-run -a pycharm {el_dir} " + ">> /tmp/pycharm-first-run.log 2>&1 & " + "pid=$!; ok=0; " + f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do " + f"if ls {el_dir}/.idea/*.iml >/dev/null 2>&1 && " + f"[ -f {el_dir}/.idea/misc.xml ]; then ok=1; break; fi; " + "sleep 5; done; " + # Cinq secondes de plus : les fichiers apparaissent PENDANT leur + # écriture, et un TERM à l'instant où misc.xml naît le tronquerait. + "sleep 5; kill -TERM -$pid 2>/dev/null || " + "kill -TERM $pid 2>/dev/null; " + "for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; " + "sleep 5; done; kill -KILL -$pid 2>/dev/null; " + # Filet, et il a sa raison d'être : ce qui survit ici mange la + # mémoire de TOUTES les étapes suivantes. + # + # Par NOM de processus (« -x »), jamais par ligne de commande. Un + # « pkill -f /opt/pycharm » attrape aussi le ssh QUI PORTE cette + # installation — sa ligne de commande contient le script entier, + # donc ce chemin. Vécu : une installation est morte en silence, sa + # session ssh emportée, 48 minutes perdues. Mesuré ensuite : par + # nom, 3 processus réels attrapés et 0 faux ; par ligne de commande, + # 4 dont le ssh. Les noms sont ceux relevés dans la VM — pycharm, + # Xvfb, fsnotifier, cef_server — et « -u » borne au compte courant. + # + # « pgrep -c » IMPRIME 0 et rend 1 quand il ne trouve rien : un + # « || echo 0 » donnerait « 0\n0 », qui n'est pas « 0 ». « wc -l » + # rend un seul nombre et un code 0. + 'left=$(pgrep -u "$(id -u)" -x ' + '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null | wc -l); ' + '[ "$left" = 0 ] || { ' + f'echo " {t("closing what survived the first open:")} $left"; ' + 'pkill -u "$(id -u)" -x ' + '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null; sleep 2; }; ' + '[ "$ok" = 1 ] && break; ' + f'echo " {t("no project yet, second try on the warm caches")}"; ' + "done; " + '[ "$ok" = 1 ]; fi; fi; } && ' + f'echo " {t("project created, the install will configure it")}" ' + f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' + 'make pycharm_configure"; ' + ) + + def _qemu_android_studio_remote_cmd(self): + """Installe Android Studio, pour le développement mobile ERPLibre. + + L'émulateur, lui, exige KVM DANS la VM, donc la virtualisation + imbriquée : on le dit plutôt que de laisser découvrir l'échec au premier + lancement. Compiler et déployer sur un appareil réel par adb n'en + dépendent pas.""" + return ( + f'echo "== {t("Installing Android Studio (long)")} =="; ' + "{ " + 'if [ "$(uname -m)" != x86_64 ]; then ' + f'echo " {t("Android Studio: Google publishes x86_64 only")}"; ' + "false; " + "else " + # Déjà posé ? On ne retélécharge pas. Rejouer une + # installation est le cas NORMAL — une qui est morte, un outil + # ajouté après coup — et le téléchargement en est la partie + # longue : mesuré, ~5 min pour Android Studio, autant pour + # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) + # rejoue de toute façon, lui est idempotent et bon marché. + "if [ -x /opt/android-studio/bin/studio ]; then " + f'echo " {t("already there, download skipped")}"; ' + "else " + # La page officielle porte l'URL en clair ; le repli garde une + # version connue qui répond, pour le jour où sa forme change. + f"url=$(curl -fsSL --max-time 30 {self._QEMU_ANDROID_PAGE} " + "| grep -oE 'https://[a-z0-9.-]*gvt1\\.com/[^\"]*linux\\.tar\\.gz' " + "| head -1); " + f'[ -n "$url" ] || url="{self._QEMU_ANDROID_URL}"; ' + "tmp=$(mktemp -p /var/tmp android-XXXX.tar.gz) && " + 'curl -fsSL "$url" -o "$tmp" && ' + "sudo mkdir -p /opt/android-studio && " + 'sudo tar -xzf "$tmp" -C /opt/android-studio ' + "--strip-components=1; " + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd( + "/opt/android-studio", "studio", alias="android-studio" + ) + + self._qemu_desktop_entry_cmd( + "android-studio", + "Android Studio", + "/usr/local/bin/studio", + "/opt/android-studio/bin/studio.svg", + "Development;IDE;", + ) + + + # Le SDK partagé, vu depuis la SESSION graphique. install-android.sh + # écrit ses exports dans ~/.bashrc, que GNOME ne lit pas : Android + # Studio lancé depuis le menu ne verrait donc pas le SDK et + # proposerait d'en télécharger un second. environment.d est le + # canal que la session utilisateur lit vraiment. + "mkdir -p ~/.config/environment.d && " + "printf 'ANDROID_HOME=%s/android\\nANDROID_SDK_ROOT=%s/android\\n'" + ' "$HOME" "$HOME" ' + "> ~/.config/environment.d/10-erplibre-android.conf; " + # repositories.cfg absent, et l'assistant de première ouverture + # s'arrête sur une erreur au lieu de proposer quoi que ce soit. + "mkdir -p ~/.android && touch ~/.android/repositories.cfg; " + + f'echo " {t("Android Studio installed:")} /opt/android-studio ' + f'({t("command")} studio / android-studio)"; ' + f'echo " {t("SDK shared through ANDROID_HOME:")} $HOME/android"; ' + "grep -q vmx /proc/cpuinfo 2>/dev/null " + "|| grep -q svm /proc/cpuinfo 2>/dev/null " + f'|| echo " {t("no nested KVM: the emulator will not run")}"; ' + f'}} || echo " ⚠ {t("Android Studio not installed (see above)")}"; ' + ) + + # Extensions GNOME suggérées, par gestionnaire de paquets. Les noms ne sont + # pas les mêmes d'une famille à l'autre (« dashtodock » sur Debian, + # « dash-to-dock » sur Fedora), et aucune liste n'existe en entier partout. + # + # D'où l'installation UNE PAR UNE : apt, dnf, zypper et pacman échouent tous + # sur la commande ENTIÈRE dès qu'un seul nom est inconnu. Un paquet absent + # est donc annoncé et sauté, au lieu de faire tomber les autres avec lui. + _QEMU_GNOME_EXT_PKGS = { + "apt": ( + "gnome-shell-extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + "gnome-shell-extension-dashtodock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + ), + "dnf": ( + "gnome-extensions-app", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + "gnome-shell-extension-user-theme", + ), + "zypper": ( + "gnome-shell-extensions", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + ), + "pacman": ( + "extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + ), + } + + # Extensions demandées nommément, par leur UUID sur extensions.gnome.org. + # Aucune n'est empaquetée par une distribution : on passe donc par le site. + # + # L'archive dépend de la version de GNOME Shell, et ce n'est pas une + # précaution de principe : mesuré le 2026-08-17, le même point d'entrée + # sert gTile v59 pour GNOME 46, v62 pour GNOME 48 et v52 pour GNOME 3.38. + # Une URL figée poserait donc, tôt ou tard, une archive faite pour une + # autre version. + # + # Ce que le site fait d'une version qu'il ne connaît PAS : il sert la plus + # récente (vérifié — « shell_version=99 » rend l'archive des GNOME 49/50), + # il ne répond pas 404. Sans conséquence fâcheuse pour autant : GNOME Shell + # refuse de CHARGER une extension dont metadata.json ne déclare pas la + # version courante. Une archive mal appariée reste donc inerte et affichée + # « obsolète » dans le gestionnaire — elle ne casse pas la session. + _QEMU_GNOME_EXT_UUIDS = ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ) + _QEMU_GNOME_EXT_SITE = "https://extensions.gnome.org/download-extension" + + def _qemu_gnome_ext_site_cmd(self): + """Installe les extensions nommées depuis extensions.gnome.org. + + Celles-là, on les ACTIVE — à la différence des paquets de la + distribution, dont on ne connaît pas l'UUID. Deux raisons, l'une et + l'autre vérifiées : le site rend l'archive faite pour le GNOME Shell de + cette VM, et une archive mal appariée n'est de toute façon jamais + chargée par GNOME, qui compare metadata.json à sa propre version. Ce + n'est donc pas l'activation qui peut casser une session. + + Le tout dans un groupe gardé : ni une panne de réseau ni une extension + retirée du site ne doivent faire échouer une installation d'une heure. + """ + uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS) + site = self._QEMU_GNOME_EXT_SITE + return ( + "{ " + # « gnome-shell --version » rend « GNOME Shell 48.2 » : le dernier + # champ suffit, et évite une expression régulière à rallonge. + "v=$(gnome-shell --version 2>/dev/null | awk '{print $NF}'); " + 'if [ -z "$v" ]; then ' + + f'echo " {t("GNOME Shell not found, site extensions skipped")}"; ' + + "else " + # Le site attend le numéro MAJEUR depuis GNOME 40 (« 48 ») et + # « majeur.mineur » avant (« 3.38 ») : sans la bonne forme, il ne + # renvoie aucune archive. + "maj=${v%%.*}; " + 'if [ "$maj" -ge 40 ] 2>/dev/null; then sv="$maj"; ' + 'else sv=$(echo "$v" | cut -d. -f1,2); fi; ' + # gnome-extensions écrit dans ~/.local/share, mais l'activation + # passe par GSettings : sans bus de session — le cas d'un + # « ssh hôte commande » — dconf ne peut rien écrire. + # dbus-run-session en fournit un le temps de l'appel, et + # l'écriture atterrit bien dans le dconf de l'utilisateur. + 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && ' + "command -v dbus-run-session >/dev/null 2>&1; then " + 'dbus-run-session -- gnome-extensions "$@"; ' + 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do " + # « || echo » DANS la substitution : un mktemp qui échoue rendrait + # l'affectation non nulle, et « set -e » couperait toute la suite. + + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); " + + 'if curl -fsSL --max-time 120 "' + + site + + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" ' + + '&& gx install --force "$z" >/dev/null 2>&1; then ' + + 'gx enable "$u" >/dev/null 2>&1 || true; ' + + f'echo " {t("installed and enabled:")} $u"; else ' + + f'echo " {t("not available for this GNOME, skipped:")} ' + + '$u (GNOME $sv)"; fi; rm -f "$z"; done; ' + + f'echo " {t("log out and back in to load them")}"; ' + + "fi; } || true; " + ) + + def _qemu_gnome_ext_remote_cmd(self): + """Pose les extensions GNOME suggérées. + + Deux sources, et deux politiques, pour une raison : + - les paquets de la DISTRIBUTION sont installés sans être activés. On + ne connaît pas leur UUID de façon fiable, et activer à l'aveugle une + extension incompatible avec la version de GNOME Shell laisse la + session sur un écran noir — panne qu'on ne diagnostique pas depuis + une console série. Le gestionnaire graphique est posé pour choisir ; + - les extensions nommées par leur UUID sont, elles, ACTIVÉES : le site + rend l'archive faite pour ce GNOME-là, et une archive mal appariée + n'est jamais chargée par GNOME plutôt que de casser la session. + """ + pkgs = self._QEMU_GNOME_EXT_PKGS + return ( + f'echo "== {t("Suggested GNOME extensions")} =="; ' + "if command -v apt-get >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['apt'])}'; " + "I='sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y'; " + "elif command -v dnf >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['dnf'])}'; I='sudo dnf install -y'; " + "elif command -v zypper >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['zypper'])}'; " + "I='sudo zypper --non-interactive install " + "--auto-agree-with-licenses'; " + "elif command -v pacman >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['pacman'])}'; " + "I='sudo pacman -S --needed --noconfirm'; " + 'else EXT=""; fi; ' + 'for p in $EXT; do $I "$p" >/dev/null 2>&1 ' + f'|| echo " {t("not in the repos, skipped:")} $p"; done; ' + f'echo " {t("Enable them from Extension Manager, or:")} ' + 'gnome-extensions enable "; ' + + self._qemu_gnome_ext_site_cmd() + ) + + # Diagnostic de la compilation mobile : motif rencontré dans le journal + # détaillé -> cause nommée. Du plus précis au plus général, le premier qui + # correspond gagne. + # + # Cette liste est faite pour GRANDIR. Une compilation Android échoue de + # cent façons, et le journal fait des dizaines de mégaoctets : sans cette + # traduction, « la VM est rouge » n'apprend rien et il faut tout rouvrir. + # Chaque panne rencontrée sur une VM mérite d'y laisser sa ligne. + _QEMU_MOBILE_DIAG = ( + ("No space left on device", "disk full"), + ("Failed to find target with hash string", "SDK platform missing"), + ("SDK location not found", "SDK not found (ANDROID_HOME)"), + ("have not been accepted", "SDK licences not accepted"), + ("NDK not configured", "NDK missing"), + # Vécu : Capacitor 8 réclame un JDK 21 quand l'installateur amont pose + # un 17, et Gradle s'arrête là. + ( + "Cannot find a Java installation", + "JDK required by the project missing", + ), + # Vécu aussi : le JDK est là, mais Gradle TOURNE sur un plus ancien. + ("invalid source release", "Gradle running on too old a JDK"), + ("cannot overwrite", "SDK already there (upstream installer replays)"), + # Vécu : sentencepiece bâtit protoc pour la CIBLE et l'exécute sur + # l'hôte. Le message est cryptique ; la cause, non. + ("Exec format error", "cross-compiled protoc run on the host"), + ("Unsupported class file major version", "JDK/Gradle mismatch"), + ("Could not determine java version", "JDK/Gradle mismatch"), + ( + "Could not resolve all files for configuration", + "Gradle dependency unreachable (network?)", + ), + ("npm ERR!", "npm dependencies"), + ("Test Files", "Vitest tests failed"), + # Vécu : le manifeste rend 0 sans avoir cloné, et l'étape suivante + # tombe sur un cd impossible. Le motif nomme la vraie cause. + # + # SANS APOSTROPHE, et ce n'est pas cosmétique : ces motifs partent dans + # un « grep -q '' », entre apostrophes. « can't cd to » fermait + # la chaîne et rendait tout le bloc invalide — attrapé par bash -n. + ("cd: can", "mobile repository missing"), + # Vécu aussi : sans python3.12-venv, .venv.erplibre n'existe pas, et + # rien de ce qui suit ne peut synchroniser le manifeste. + ("virtual environment", "ERPLibre venv missing (incomplete install)"), + ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"), + # Vécu sur erplibre-ubuntu-2604-gnome : le noyau a tué le démon Gradle + # (6,8 Go de RSS sur 12 Go, sans swap), et Gradle n'en sait rien — il + # dit seulement que son démon « a disparu ». Le motif nomme la mémoire, + # et le contexte l'établit au lieu de le supposer. + # Vécu, et c'est en amont : « Too many zip entries 123678 (MAX=65535) ». + # Un APK est un ZIP classique, borné à 65 535 entrées, et le dépôt + # mobile embarque 122 684 fichiers sous assets/public/repos — des + # dépôts Odoo entiers — pour 337 fichiers qui sont l'application. Rien + # ici ne peut le corriger : c'est au projet mobile de ne pas les + # empaqueter. On le NOMME, avec le chiffre, plutôt que de laisser lire + # 5 000 lignes de Gradle. + ( + "Too many zip entries", + "too many asset files for one APK (ZIP limit: 65535 entries)", + ), + ("daemon disappeared", "Gradle daemon killed: out of memory", "mmem"), + ("Cannot allocate memory", "out of memory", "mmem"), + ("Java heap space", "Gradle heap too small", "mmem"), + ("FAILED", "Gradle task failed"), + ) + + def _qemu_mobile_diag_cmd(self): + """Fonction shell qui NOMME la cause d'un échec, à partir du journal. + + Un « la VM est rouge » n'apprend rien quand le journal fait des dizaines + de mégaoctets. On cherche donc les motifs connus, et à défaut on montre + les dernières lignes — c'est toujours mieux que rien. + + La recherche porte sur la FIN du journal, pas sur tout. Vécu : le + diagnostic a annoncé « licences SDK non acceptées » quand la panne était + un JDK manquant — le motif venait de la revue de licences d'une étape + RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus + cher que se taire.""" + lines = "" + for entry in self._QEMU_MOBILE_DIAG: + pat, cause = entry[0], entry[1] + extra = f"{entry[2]}; " if len(entry) > 2 else "" + lines += ( + f"grep -q '{pat}' \"$d\" && {{ " + f'echo " {t("probable cause:")} {t(cause)}"; ' + f'{extra}rm -f "$d"; return 0; }}; ' + ) + return ( + # Le contexte mémoire, lu dans /proc et dans le journal du noyau : + # une cause « mémoire » se PROUVE, l'affirmer sans le compte de + # l'oom-killer serait une supposition de plus. Pas d'awk ni de sed + # ici : leurs programmes demandent des guillemets, et tout ceci + # voyage déjà dans un ssh entre apostrophes. + "mmem() { m=$(grep MemTotal /proc/meminfo | tr -dc 0-9); " + "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " + "k=$(sudo dmesg 2>/dev/null | grep -c oom-kill); " + f'echo " {t("memory:")} $((m/1024)) {t("MB RAM,")} ' + f'$((w/1024)) {t("MB swap, kernel OOM kills:")} $k"; }}; ' + 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; ' + + lines + + f'echo " {t("no known pattern, last lines:")}"; ' + 'tail -12 "$1" | sed "s/^/ /"; rm -f "$d"; }; ' + ) + + def _qemu_android_prologue_cmd(self): + """Ce que la compilation mobile et l'émulateur partagent : le journal + détaillé, le coureur d'étapes, le diagnostic, et l'environnement du SDK. + + Écrit UNE fois même quand les deux options sont cochées — deux + prologues, ce serait deux journaux et deux SDK.""" + return ( + self._qemu_mobile_diag_cmd() + + # Le détail va dans un fichier À PART. Une compilation Gradle écrit + # des dizaines de milliers de lignes, dont des centaines portant le + # mot « error » sans qu'aucune ne soit une panne : les verser dans + # le journal d'installation rendrait son compteur d'erreurs + # inutilisable, et le diagnostic illisible. + 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' + f'echo " {t("detailed log in the VM:")} $M"; ' + 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' + 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' + f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' + # Le SDK vit dans $HOME/android, l'emplacement qu'emploie + # l'installateur du dépôt. Android Studio, s'il est là, le trouvera + # par ANDROID_HOME : un seul SDK sur la machine, pas deux. + 'export ANDROID_HOME="$HOME/android"; ' + 'export ANDROID_SDK_ROOT="$HOME/android"; ' + 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' + ':$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"; ' + # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que + # l'installateur y écrit ne sert qu'aux sessions futures, pas à la + # compilation qui suit immédiatement. + # + # Le JDK le PLUS RÉCENT installé, et non celui des alternatives. + # Mesuré : avec JAVA_HOME sur le 17 que pose l'installateur amont, + # Gradle tourne en 17 et s'arrête sur « invalid source release: 21 » + # — les modules de Capacitor 8 compilent en 21. Le tri est + # « sort -V », donc java-21 passe après java-17, pas avant. + "export JAVA_HOME=$(ls -d /usr/lib/jvm/java-*-openjdk-* " + "2>/dev/null | sort -V | tail -1); " + '[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(dirname $(dirname ' + "$(readlink -f $(command -v javac 2>/dev/null " + "|| command -v java 2>/dev/null) 2>/dev/null) 2>/dev/null) " + "2>/dev/null); " + 'export PATH="$JAVA_HOME/bin:$PATH"; ' + ) + + def _qemu_android_sdk_steps(self, el_dir): + """Les étapes qui posent le SDK : dépôt mobile, prérequis, installateur + amont, plateforme réclamée par le projet. Communes aux deux options.""" + return ( + # Le « test -f » n'est pas une ceinture de plus : c'est la seule + # vérité disponible. update_manifest_local_mobile.sh finit par + # « kill $DAEMON_PID » et rend donc 0 même quand il n'a rien cloné — + # vécu, faute de .venv.erplibre. L'étape passait, et c'est le « cd » + # suivant qui échouait, deux étapes plus loin. + # Le venv d'ERPLibre d'abord, et nommément : tout ce qui suit en + # dépend — c'est lui qui porte « repo », qui synchronise le + # manifeste. Vécu avec le profil « ERPLibre seul », dont le code + # note lui-même « problem installing with q, the script depend on + # odoo » : sans venv, le manifeste rendait 0 sans rien cloner et + # l'échec ne se voyait que deux étapes plus loin. + f'mstep "{t("ERPLibre venv (everything below needs it)")}" ' + # « activate », et non « bin/python » : sans python3-venv, le venv + # naît INFIRME — bin/python existe (un lien), mais ni pip ni + # activate ni site-packages. La sonde passait, et l'échec ne se + # voyait que deux étapes plus loin, en « No module named git ». + f"'test -f {el_dir}/.venv.erplibre/bin/activate' && " + f'mstep "{t("mobile repository (additive manifest)")}" ' + f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; " + "test -f mobile/erplibre_home_mobile/install-android.sh' && " + f'mstep "{t("prerequisites of the upstream installer")}" ' + # libpulse0 : l'émulateur a DEUX binaires qemu, et seul le + # « headless » se passe de PulseAudio. Celui qui ouvre une FENÊTRE — + # le cas d'un « ssh -X » — lie libpulse.so.0, absente des images + # cloud, et s'arrête sur « cannot open shared object file » même + # avec « -no-audio ». Mesuré : c'est la SEULE bibliothèque qui + # manque, tout le reste des dépendances Qt voyage dans le bundle. + # + # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré, + # Gradle s'arrête sur « Cannot find a Java installation matching + # {languageVersion=21} » — les modules de Capacitor 8 réclament 21. + # Les deux JDK cohabitent, et Gradle choisit par sa chaîne d'outils. + # unzip et xauth, eux, manquent des images cloud. + "'sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth " + "libpulse0 openjdk-21-jdk' && " + # L'installateur amont n'est PAS idempotent : au second passage il + # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré. + # On ne le rejoue donc que s'il reste quelque chose à poser — un + # déploiement qui se répète ne doit pas échouer sur une réussite + # précédente. + f'mstep "{t("Android SDK, licences, NDK")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + "{ [ -x $HOME/android/cmdline-tools/latest/bin/sdkmanager ] " + "|| ./install-android.sh; }' && " + f'mstep "{t("SDK platform required by the project")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && " + 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + 'android/variables.gradle) && [ -n "$v" ] && ' + 'yes | sdkmanager "platforms;android-$v" ' + '"build-tools;$v.0.0"\' && ' + ) + + def _qemu_mobile_build_steps(self, el_dir): + """Étapes de compilation de l'application mobile, puis ses tests. + + C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une + machine dont l'application ne compile pas n'est pas une machine prête. + Le code de sortie remonte donc jusqu'au tableau de bord. + + Le dépôt mobile porte son propre installateur Android — JDK, outils en + ligne de commande, licences acceptées, plateformes, NDK, whisper.cpp et + sentencepiece. On l'appelle plutôt que de le réécrire : une seconde + implémentation dériverait de la première sans prévenir. Deux choses lui + manquent pourtant, et on les ajoute ici : + - unzip et wget, qu'il suppose présents et qu'aucune image cloud ne + livre ; + - la plateforme que le projet réclame VRAIMENT. Son installateur pose + android-34 quand android/variables.gradle demande compileSdk 36 ; + plutôt que de figer 36 ici, on lit le chiffre dans le fichier. + + L'étape est bornée à apt (voir _QEMU_VM_TOOLS) : cet installateur + commence par « sudo apt install openjdk-17-jdk » et s'arrête là + ailleurs. La lever se fait dans ce script-là, pas ici. + """ + return ( + # Du swap AVANT de compiler, et ce n'est pas de la prudence : le + # démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m ne + # le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué + # deux fois de suite. « --max-workers=2 » n'y a rien changé : + # mesuré, le pic est passé de 10,3 à 11,2 Go. C'est donc de la marge + # qu'il faut, pas moins de parallélisme. + # + # Jamais bloquant : une image sur btrfs refuse un fichier d'échange + # ordinaire, et une compilation qui tient en mémoire n'en a pas + # besoin. On le dit et on continue. + "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " + 'if [ "$w" -lt 2000000 ]; then ' + "if sudo fallocate -l 4G /swapfile-erplibre 2>/dev/null && " + "sudo chmod 600 /swapfile-erplibre && " + "sudo mkswap -q /swapfile-erplibre >/dev/null 2>&1 && " + "sudo swapon /swapfile-erplibre 2>/dev/null; then " + "grep -q swapfile-erplibre /etc/fstab 2>/dev/null || " + 'echo "/swapfile-erplibre none swap sw 0 0" ' + "| sudo tee -a /etc/fstab >/dev/null; " + f'echo " {t("4 GB of swap added for the build")}"; ' + "else sudo rm -f /swapfile-erplibre 2>/dev/null; " + f'echo " {t("no swap could be added; build may run short")}"; ' + "fi; fi; " + f'mstep "{t("npm dependencies")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " + f'mstep "{t("web bundle (vite build)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " + # Le transfert des dépôts du manifeste DANS l'application est + # vérifié, et son compte-rendu se lit dans le journal + # d'installation — d'où l'appel HORS mstep, qui enverrait la sortie + # dans le journal détaillé de la VM. + # + # Ces dépôts entrent en PACKS, et c'est ce qui rend la chose + # possible : un APK est un ZIP borné à 65535 entrées, quand les + # 139 dépôts pèsent plus de 116 000 fichiers. Un fichier par source + # donnait « Too many zip entries 123678 (MAX=65535) » et rien du + # tout ; regroupés, ils tiennent en 391 tranches — mesuré, avec + # 3 002 entrées dans l'APK. + # + # Lié par « && » : un transfert vide fait échouer la VM, au même + # titre qu'un APK manquant. Une application qui ne porte pas le code + # qu'elle est censée montrer n'est pas l'application demandée. + f'echo " -> {t("repo transfer into the app")}" && ' + f"(cd {el_dir} && ./script/mobile/check_bundle_transfer.py" + f" --workspace {el_dir}) && " + f'mstep "{t("native sync (capacitor)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " + # UNE seule ABI, celle de la VM — qui est aussi celle de + # l'émulateur. Deux raisons, la seconde décisive : + # - quatre ABI, c'est quatre fois la compilation de whisper.cpp + # et de sentencepiece, pour trois qui ne serviront jamais ici ; + # - sentencepiece bâtit son « protoc » POUR LA CIBLE puis tente de + # l'exécuter sur l'hôte. En arm64 cela donne « Exec format + # error » et la compilation s'arrête — mesuré. En x86_64 la + # cible et l'hôte coïncident, et le défaut ne se manifeste pas. + # Un APK arm64 demandera un correctif au projet mobile. + f'mstep "{t("debug APK (gradle)")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " + "./gradlew --no-daemon assembleDebug " + "-Pandroid.injected.build.abi=x86_64' && " + f'mstep "{t("Vitest tests")}" ' + f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " + # L'APK est la preuve, pas le code de sortie de Gradle : une tâche + # peut rendre 0 sans avoir rien produit. + # DEUX emplacements, et il faut les deux. Avec une ABI injectée, + # AGP écrit dans « intermediates/apk/debug » et non dans + # « outputs/apk/debug » : mesuré, une compilation RÉUSSIE était + # rapportée « aucun APK produit » parce que je ne regardais que le + # second. Un contrôle qui cherche au mauvais endroit ne vaut pas + # mieux que pas de contrôle. + f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" + "/outputs/apk/debug/*.apk " + f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" + "/intermediates/apk/debug/*.apk 2>/dev/null | head -1); " + 'if [ -n "$apk" ]; then ' + f'echo " ✅ {t("APK built:")} $apk"; ' + # Capacitor sert la même application dans un navigateur : sur une + # VM graphique, c'est la voie de déverminage sans Android ni + # émulateur. On la NOMME plutôt que d'imposer Chromium — sur + # Ubuntu il n'existe qu'en snap, et snapd est justement coupé sur + # ces VM. Le navigateur du bureau fait l'affaire. + # + # DANS la branche de succès, et c'est tout l'enjeu : placé après le + # « fi », cet echo devenait la dernière commande du bloc et rendait + # 0 — une VM sans APK repassait au vert. + f'echo " {t("browser debugging (no Android):")} ' + f"cd {el_dir}/mobile/erplibre_home_mobile " + '&& npm start"; else ' + f'echo " ⚠ {t("no APK produced")}"; false; fi' + ) + + def _qemu_avd_steps(self, el_dir): + """Étapes créant un émulateur prêt à s'ouvrir depuis le poste de travail. + + Le modèle n'est pas figé : on demande au SDK la liste de ses profils et + on retient le Pixel le plus récent au plus petit écran — ni Pro, ni XL, + ni pliant, ni tablette. Sur un écran distant, chaque pixel traverse le + réseau : le petit modèle n'est pas une coquetterie. + + L'image système suit la plateforme du projet, et redescend si elle n'est + pas publiée — Google ne fournit pas d'image pour toutes les API. + + Le rendu est réglé en logiciel DANS la configuration de l'AVD plutôt + qu'en option de lancement : par « ssh -X » il n'y a pas de GLX direct, et + l'émulateur s'ouvrirait sur un écran noir. Ainsi « emulator -avd + erplibre » suffit, sans rien à retenir. + + Le mode est « swangle » — ANGLE sur SwiftShader — et non + « swiftshader_indirect », qui n'existe PLUS : l'émulateur 37.1 répond + « Selected GPU option 'swiftshader_indirect' is not valid, switching to + auto », puis « Your GPU drivers may have a bug », avant de retomber de + lui-même sur swangle. Il fonctionnait, en affichant deux erreurs qui + laissaient croire à une panne. Les modes valides sont exactement quatre, + que « emulator -help-gpu » énumère : auto, host, swiftshader, swangle. + """ + return ( + f'echo " == {t("Android emulator (AVD)")} =="; ' + # KVM dans la VM : sans lui l'émulateur x86 refuse de démarrer. On + # le dit ici, où c'est réparable (virtualisation imbriquée sur + # l'hôte), plutôt qu'au premier lancement. + "if [ ! -e /dev/kvm ]; then " + f'echo " ⚠ {t("no /dev/kvm: nested virtualisation is off on the host")}"; ' + "else " + # /dev/kvm est en root:kvm 0660 : sans appartenir au groupe, + # l'émulateur refuse de démarrer sur « ProbeKVM: This user doesn't + # have permissions to use KVM ». Mesuré. L'appartenance ne prend + # qu'à la prochaine session — ce qui tombe bien, la session utile + # est justement celle du « ssh -X » qui viendra ensuite. + "sudo usermod -aG kvm $(id -un) 2>/dev/null || true; " + f'echo " {t("user added to the kvm group (effective at next login)")}"; ' + "fi; " + f'mstep "{t("emulator and system image")}" ' + '\'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' + f"{el_dir}/mobile/erplibre_home_mobile/android/variables.gradle); " + "for a in $v 36 35 34; do " + 'img="system-images;android-$a;google_apis;x86_64"; ' + 'if yes | sdkmanager "emulator" "$img"; then ' + 'echo "$img" > $HOME/.erplibre-avd-image; break; fi; done; ' + "test -s $HOME/.erplibre-avd-image' && " + f'mstep "{t("Pixel profile, smallest screen")}" ' + # Le plus récent des Pixel simples : on trie sur le NUMÉRO, pas sur + # l'ordre d'affichage, et on écarte les grands modèles. + '\'avdmanager list device | grep -oE "pixel_[0-9]+a?" ' + '| grep -vE "pro|xl|fold|tablet" | sort -t_ -k2 -n | tail -1 ' + "> $HOME/.erplibre-avd-device; test -s $HOME/.erplibre-avd-device' && " + f'mstep "{t("create the AVD")}" ' + "'img=$(cat $HOME/.erplibre-avd-image); " + "dev=$(cat $HOME/.erplibre-avd-device); " + 'echo no | avdmanager create avd -n erplibre -k "$img" ' + '-d "$dev" --force && ' + # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas + # de GLX direct, et « auto » donnerait un écran noir. Ces deux + # clés-là SURVIVENT, elles ne viennent pas du profil du téléphone. + # + # L'écran, en revanche, ne s'écrit PAS ici : l'émulateur réécrit + # config.ini depuis le profil Pixel au premier démarrage, et les + # hw.lcd.* y étaient effacés — l'AVD repartait en 1080x2400 + # densité 420. C'est donc au LANCEMENT qu'il se règle, par + # _QEMU_EMULATOR_FLAGS, et la commande affichée plus bas les porte. + 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\n" ' + ">> $HOME/.android/avd/erplibre.avd/config.ini' && " + f'echo " ✅ {t("AVD ready:")} ' + "$(cat $HOME/.erplibre-avd-device) / " + '$(cat $HOME/.erplibre-avd-image)"; ' + # La commande à copier, avec l'adresse déjà remplie : un émulateur + # dont on ignore comment l'ouvrir ne sert à personne. + "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " + # Chemins ABSOLUS, et c'est le point : « ssh hôte 'commande' » + # ne lit NI ~/.profile NI ~/.bashrc — Ubuntu place même un + # « return » en tête du second pour les shells non interactifs. + # Le PATH que l'installateur y écrit ne s'applique donc jamais + # à ces commandes, et « emulator » y répond « command not + # found ». Vécu, sur la ligne que ce message affichait lui-même. + f'echo " {t("open it from your workstation:")} ' + # « -XC » et non « -X » : la compression X11 change tout sur un + # écran distant. Les autres drapeaux viennent de la même autorité + # que le lancement du menu : écran réduit, densité qui va avec, et + # pas d'instantané en attente si on tue l'émulateur. + 'ssh -XC erplibre@$ip \\"$HOME/android/emulator/emulator ' + f'-avd erplibre {self._QEMU_EMULATOR_FLAGS}\\""; ' + f'echo " {t("then install the APK:")} ' + # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP, + # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ». + # Mesuré sur l'émulateur. + 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t ' + f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" + '/outputs/apk/debug/app-debug.apk\\""; ' + # La voie scrcpy, nommée ici parce que c'est la première + # question qui vient après « ça se lance mais c'est lent » : + # X11 transporte des pixels bruts, scrcpy un flux H.264 encodé + # par l'appareil. Le détail du tunnel vit dans le menu + # « Remote desktop tunnel », choix 4. + + f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"' + ) + + def _qemu_forgejo_steps(self, el_dir): + """Pose Forgejo dans la VM, par le script dédié du dépôt. + + Tout le travail est DANS le script — architecture, version, somme de + contrôle, compte système, configuration, service, compte + administrateur. Ce bloc ne fait que l'appeler : une seule autorité, et + la même commande sert un déploiement de VM et une installation à la + main sur une machine existante. + + Pas de garde, comme la compilation mobile : une VM dont la forge + demandée n'existe pas n'est pas la VM demandée. Le script, lui, est + rejouable — il ne retélécharge pas un binaire déjà en place et ne + réécrit jamais une configuration existante. + """ + return ( + f'echo "== {t("Forgejo (git forge)")} =="; ' + f"{el_dir}/script/forgejo/install_forgejo.sh" + ) + + def _qemu_after_remote_cmd(self, tools, prod=False): + """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce + qui a été coché. + + Un seul prologue et un seul SDK même quand les deux options le sont : + deux prologues, et le second tronquerait le journal détaillé du premier. + + Les groupes sont joints par « && » et non par « ; ». C'est ce qui fait + qu'un APK manquant reste l'échec de la VM : collé par « ; », un + émulateur créé avec succès effacerait le verdict de la compilation.""" + picked = [ + k + for k in ("forgejo", "mobile", "avd") + if k in (tools or ()) and k in self._QEMU_VM_TOOLS + ] + if not picked: + return "" + el_dir = self._qemu_install_dir(prod) + parts = [] + # Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK. + # Un échec rapide se voit tôt plutôt qu'après le long. + if "forgejo" in picked: + parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}") + groups = [] + if "mobile" in picked: + groups.append(self._qemu_mobile_build_steps(el_dir)) + if "avd" in picked: + groups.append(self._qemu_avd_steps(el_dir)) + if groups: + # UN seul prologue et un seul SDK même quand les deux options le + # sont : deux prologues, et le second tronquerait le journal + # détaillé du premier. + parts.append( + "{ " + + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' + + self._qemu_android_prologue_cmd() + + self._qemu_android_sdk_steps(el_dir) + # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que + # la première commande du groupe suivant : mesuré, un APK + # manquant laissait tourner l'émulateur puis rendait 0 — la VM + # repassait au vert alors que rien n'avait compilé. + + " && ".join(f"{{ {g}; }}" for g in groups) + + "; }" + ) + return " && ".join(parts) + "; " + + def _qemu_mobile_remote_cmd(self, prod=False): + """Compilation mobile seule — la forme que testent les tests.""" + return self._qemu_after_remote_cmd(("mobile",), prod) + + def _qemu_avd_remote_cmd(self, prod=False): + """Émulateur seul.""" + return self._qemu_after_remote_cmd(("avd",), prod) + + def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"): + """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd. + + « before » : posé avant le clone. Chaque outil s'y garde lui-même — + aucun ne fait échouer les autres, ni l'installation d'ERPLibre. + + « after » : la compilation mobile, qui vient après l'installation dont + elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une + VM dont l'application ne compile pas doit être rouge.""" + if phase == "after": + # Un seul bloc pour les deux options : voir _qemu_after_remote_cmd. + return self._qemu_after_remote_cmd(tools, prod) + blocks = { + "gnome_ext": self._qemu_gnome_ext_remote_cmd, + "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), + "android": self._qemu_android_studio_remote_cmd, + } + return "".join(fn() for k, fn in blocks.items() if k in (tools or ())) + + def _qemu_editor_pkg(self): + """Paquet de l'éditeur de l'hôte, à installer dans la VM. + + L'éditeur atteint déjà la VM par deux chemins, tous deux posés par + deploy_qemu.py : « core.editor » dans son ~/.gitconfig, et la ligne + « éditer le serveur » du guide de connexion. Encore faut-il que le + binaire y soit — les images cloud n'ont ni nano ni vim garantis, et + certaines n'ont même pas vi. On l'ajoute donc aux outils d'amorçage, avec + curl, git et make, là où les dépôts viennent d'être rafraîchis. + + La table des éditeurs vit dans deploy_qemu.py : une seule autorité décide + du paquet installé, de la commande affichée et de core.editor. Sans + module importable, on n'installe rien plutôt que de deviner un nom.""" + try: + mod = self._qemu_import_module() + return mod.vm_editor(mod.invoking_home())[0] + except Exception: + return "" + + def _qemu_editor_suffix(self): + """« vim » -> « vim » précédé d'une espace, rien du tout sinon. + + La liste des outils d'amorçage est une chaîne shell entre apostrophes : + y concaténer une chaîne vide sans précaution laisserait une espace en + trop, inoffensive mais visible dans chaque log d'installation.""" + pkg = self._qemu_editor_pkg() + return f" {pkg}" if pkg else "" + # mise ne publie de binaire que pour ces architectures : 46 assets à la # v2026.8.4, aucun s390x — son propre script d'installation refuse cette # plateforme. Ailleurs, le choix « mise » est sans objet et on reste sur @@ -4962,6 +7188,7 @@ class TODO: desktop=False, python_provider="", app_store="deb", + tools=(), ): """Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau — le choix graphique ne dépend pas d'ERPLibre, et une VM peut être @@ -4971,18 +7198,38 @@ class TODO: installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service SELinux confiné. `desktop` : ajoute GNOME et son accès distant. `python_provider` : « mise » pour un CPython précompilé, sinon le - comportement par défaut du dépôt (pyenv, qui compile).""" + comportement par défaut du dépôt (pyenv, qui compile). `tools` : outils + de développement cochés (PyCharm, Android Studio, extensions GNOME), + posés APRÈS ERPLibre — PyCharm a besoin du venv du dépôt pour écrire la + configuration du projet.""" if not branch: # Bureau seul : ni clone ni make, mais on garde le prologue — # attente de cloud-init et coupure des mises à jour automatiques, # sans quoi le verrou apt ferait échouer l'installation du bureau. if not desktop: return "true" + # Les outils de la phase « after » vivent DANS le dépôt — la + # compilation mobile, l'AVD, le script Forgejo. Sans clone, ils + # n'existent pas ici. Les écarter en silence laissait croire qu'une + # case cochée avait été honorée : on la NOMME. + deferred = [ + k + for k in (tools or ()) + if self._QEMU_VM_TOOLS.get(k, {}).get("phase") == "after" + ] + note = ( + f'echo " ⚠ {t("needs the ERPLibre install, skipped:")}' + f' {" ".join(deferred)}"; ' + if deferred + else "" + ) return ( "set -e; " + self._qemu_cloud_init_wait() + self._qemu_no_auto_upgrade(prod, app_store) + self._qemu_desktop_remote_cmd(desktop, app_store) + + self._qemu_tools_remote_cmd(tools, prod) + + note ) if not final_cmd: final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}" @@ -5008,6 +7255,41 @@ class TODO: # apt pendant l'installation. En PROD on ne touche à rien : les # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) + tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before") + # La compilation mobile vient APRÈS l'installation : elle a besoin du + # dépôt, du venv d'outils qui synchronise le manifeste, et de node que + # « make install_os » installe. Liée par « && » et NON gardée, pour que + # son échec soit celui de la VM. + after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after") + # APRÈS le make, et c'est mesuré : sur un dépôt cloné mais pas installé, + # PyCharm n'écrit AUCUN .idea — son configurateur d'interpréteur Python + # échoue faute de venv, et il renonce. « ⚠ pas de .idea », deux fois de + # suite sur erplibre-ubuntu-2604-gnome. Le même appel sur un dépôt + # installé l'écrit en cinq minutes : erplibre.iml, misc.xml, + # modules.xml, vcs.xml. + # + # On ouvre donc quand l'interpréteur existe, puis on demande la + # configuration explicitement : l'installation est déjà passée, et + # pycharm_update() n'avait alors rien à configurer. + open_step = ( + self._qemu_pycharm_project_cmd(prod) + # Le venv du dépôt, comme le fait update_env_version. + # pycharm_update() : le script importe xmltodict, absent du python + # système. Mesuré : « make pycharm_configure » s'arrêtait sur + # « No module named 'xmltodict' ». + + "./.venv.erplibre/bin/python " + "./script/ide/pycharm_configuration.py --init || true; " + if "pycharm" in (tools or ()) + else "" + ) + # Le groupe de PyCharm rend toujours 0 — un bonus, pas une condition — + # là où la phase mobile porte le verdict de la VM. + chain = [final_cmd] + if open_step: + chain.append(f"{{ {open_step} }}") + if after_cmd: + chain.append(f"{{ {after_cmd} }}") + install_chain = " && ".join(chain) return ( "set -e; " + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre @@ -5027,7 +7309,14 @@ class TODO: # la VM soit la plus rapide possible (miroirs à jour / les plus # rapides), puis installe. Supporte apt (Debian/Ubuntu), dnf/yum # (Fedora) et pacman (Arch). - "PKGS='curl git make'; " + # + # L'éditeur de l'hôte voyage avec eux : deploy_qemu.py a déjà écrit + # « core.editor » dans le ~/.gitconfig de la VM et l'a nommé dans le + # guide de connexion, mais aucune image cloud ne garantit vim ni + # nano. Le poser ici plutôt que par cloud-init : les dépôts y sont + # déjà rafraîchis, et une installation de paquet au premier boot + # retarderait le démarrage sans laisser de trace dans le suivi. + f"PKGS='curl git make{self._qemu_editor_suffix()}'; " "if command -v apt-get >/dev/null 2>&1; then " # Au 1er boot, cloud-init (install qemu-guest-agent) et/ou # apt-daily.service tiennent le verrou apt. IMPORTANT : @@ -5091,6 +7380,16 @@ class TODO: '{ echo "Outil manquant apres installation: $t ' '(reseau de la VM ?)"; exit 1; }; done; ' + self._qemu_mise_remote_cmd(python_provider) + # Les outils AVANT le clone et le make, et l'ordre compte : c'est + # PyCharm qui écrit le .idea du dépôt, en l'ouvrant une fois, et + # c'est l'installation qui, ensuite, y lance + # pycharm_configuration.py. Posés après, ils arrivaient trop tard + # pour cette étape-là. + # + # Le code de sortie de la commande distante reste celui de + # l'installation : chaque bloc d'outil se garde lui-même et rend 0, + # donc aucun ne peut faire passer un make échoué pour un succès. + + tools_cmd # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + ( @@ -5100,7 +7399,7 @@ class TODO: f"sudo git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} /opt/erplibre; " "sudo chown -R $(id -un):$(id -gn) /opt/erplibre; fi; " - f"cd /opt/erplibre && {final_cmd}" + f"cd /opt/erplibre && {install_chain}" ) if prod else ( @@ -5108,7 +7407,7 @@ class TODO: "if [ ! -d ~/git/erplibre/.git ]; then " f"git clone --branch {shlex.quote(branch)} " f"{self.ERPLIBRE_GIT_URL} ~/git/erplibre; fi; " - f"cd ~/git/erplibre && {final_cmd}" + f"cd ~/git/erplibre && {install_chain}" ) ) ) @@ -5123,12 +7422,16 @@ class TODO: desktop=False, python_provider="", app_store="deb", + vm_tools=(), ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. - `prod` : install /opt/erplibre + service SELinux confiné.""" + `prod` : install /opt/erplibre + service SELinux confiné. + `vm_tools` : outils cochés pour tout le parc, filtrés machine par + machine (Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon).""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, @@ -5177,14 +7480,27 @@ class TODO: "version": v, "arch": a, } - if desk_map or branch_map or cmd_map: + # Les outils imposent une commande PAR VM même quand tout le + # reste est commun : ils dépendent de l'architecture de la + # machine et de sa saveur de bureau, que seule cette boucle + # connaît. + if desk_map or branch_map or cmd_map or vm_tools: + # Le bureau de CETTE VM : sa saveur propre si la carte en + # donne une, sinon celle du parc. Prendre « rien » quand la + # carte est vide privait de bureau toute VM dont seule la + # branche ou le profil différait — la commande par défaut, + # elle, l'a toujours porté. + vm_desktop = desk_map.get( + name, "" if desk_map else desktop + ) entry["remote_cmd"] = self._qemu_erplibre_remote_cmd( branch_map.get(name, branch_def), cmd_map.get(name, cmd_def), prod, - desk_map.get(name, ""), + vm_desktop, python_provider, app_store, + self._qemu_tools_for(vm_tools, a, vm_desktop, d), ) vms.append(entry) else: @@ -5230,10 +7546,12 @@ class TODO: desktop=False, python_provider="", app_store="deb", + vm_tools=(), ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; - `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné.""" + `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ; + `vm_tools` : outils de développement cochés.""" if ip is None: ip = self._qemu_vm_ip(name) if not ip: @@ -5250,8 +7568,25 @@ class TODO: f"{t('SSH not reachable, ERPLibre install skipped.')}" ) return + # Distribution et architecture de CETTE VM : les outils s'y filtrent + # (Android Studio n'existe qu'en x86_64, la compilation mobile qu'en + # apt). Sans module lisible on ne filtre plus sur la distribution + # plutôt que d'écarter à tort. + try: + mod = self._qemu_import_module() + vm_distro, _v, vm_arch = self._qemu_vm_meta(name, mod) + except Exception: + vm_distro, vm_arch = "", self._qemu_vm_arch(name) remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop, python_provider, app_store + branch, + final_cmd, + prod, + desktop, + python_provider, + app_store, + self._qemu_tools_for( + vm_tools, vm_arch or "amd64", desktop, vm_distro or "" + ), ) ssh_opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " @@ -5313,7 +7648,24 @@ class TODO: # plutôt que d'écrêter, contrairement au multiplicateur x1..x4 qui, lui, # est un calcul automatique et se borne aux cœurs de l'hôte. _QEMU_CPU_PRESETS = ( - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 24, 32 + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 24, + 32, ) @staticmethod @@ -5686,6 +8038,18 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) + tools = spec.get("vm_tools") or () + if tools: + # Les Go sont dits ici parce que c'est le dernier écran avant de + # créer les disques : un IDE de plus, c'est un disque plus grand, + # et cette page est celle qu'on relit pour s'en apercevoir. + named = ", ".join( + f"{t(self._QEMU_VM_TOOLS[k]['label'])} " + f"(+{self._QEMU_VM_TOOLS[k]['disk_gb']} Go)" + for k in tools + if k in self._QEMU_VM_TOOLS + ) + print(f" {t('Development tools:')} {named}") prov = spec.get("python_provider") if prov: print(f" {t('Python interpreter:')} {prov}") @@ -5711,6 +8075,9 @@ class TODO: timezone=None, locale=None, desktop=False, + prod=False, + install_cmd="", + vm_tools=(), ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -5746,6 +8113,15 @@ class TODO: parts += ["--locale", locale] if desktop: parts.append("--desktop") + # Guide affiché à la connexion SSH de la VM : dans la langue du menu, et + # avec la section ERPLibre seulement là où ERPLibre sera installé — une + # VM déployée nue n'annonce pas un dépôt qui n'existe pas. + parts += ["--lang", get_lang()] + if branch: + parts += ["--erplibre-dir", self._qemu_guide_dir(prod)] + target = self._qemu_make_target(install_cmd) + if target: + parts += ["--erplibre-make", target] extra = 0 if branch: # ERPLibre dépasse le minimum : +5 Go de disque. @@ -5754,6 +8130,11 @@ class TODO: # GNOME et ses dépendances pèsent autant qu'ERPLibre : sans cette # marge, le disque se remplit en pleine installation du bureau. extra += self.QEMU_DESKTOP_EXTRA_DISK_GB + # Les IDE pèsent plus lourd que tout le reste : PyCharm et Android + # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté + # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un + # disque plein après une heure. + extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d) if extra: bigger = self._parse_disk_gb(disk) + extra parts += ["--disk-size", f"{bigger}G"] @@ -5782,7 +8163,17 @@ class TODO: locale=spec.get("locale"), # Le type suit la VM. Repli sur la valeur de spec pour la CLI, # qui ne pose la question qu'une fois pour tout le parc. - desktop=bool(vm.get("desktop", spec.get("desktop"))), + # + # La SAVEUR, et non un booléen : les extensions GNOME n'ont pas de + # sens sous Cinnamon, et c'est ici que se calcule la place disque + # des outils. « --desktop » ne regarde que la vérité de la valeur, + # une chaîne non vide lui va aussi bien. + desktop=vm.get("desktop", spec.get("desktop")) or "", + # Les deux servent au guide de connexion : où ERPLibre sera posé, et + # quelle cible make le remettra à jour. + prod=bool(install and install.get("prod")), + install_cmd=(install or {}).get("cmd") or "", + vm_tools=spec.get("vm_tools") or (), ) # ---------------------------------------------------------------- # @@ -5794,11 +8185,13 @@ class TODO: « all » = uniquement celles que la distro publie réellement.""" if arch != "all": return [arch] + # Même source que _qemu_arch_distros : « all » ne doit jamais offrir + # une combinaison que deploy_qemu.py refusera. out = ["amd64"] - if distro in self._QEMU_ARM64_DISTROS: - out.append("arm64") - if distro in self._QEMU_S390X_DISTROS: - out.append("s390x") + for a in ("arm64", "s390x"): + supported = self._qemu_arch_distros(a) + if supported and distro in supported: + out.append(a) return out def _qemu_catalog_entries(self, mod, distros, arch): @@ -6024,6 +8417,23 @@ class TODO: self._qemu_host_timezone() ), "snap_distros": self.QEMU_SNAP_DISTROS, + "vm_tools": self._qemu_vm_tool_choices(), + "vm_tool_disk": { + k: v["disk_gb"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_arches": { + k: v["arches"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_desktops": { + k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_needs_desktop": { + k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "vm_tool_families": { + k: v["families"] for k, v in self._QEMU_VM_TOOLS.items() + }, + "distro_family": dict(self._QEMU_DISTRO_FAMILY), "desktop_suffixes": self._qemu_desktop_suffixes(), "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() @@ -6339,6 +8749,60 @@ class TODO: return self.QEMU_APP_STORES[int(answer) - 1][0] return "deb" + def _qemu_ask_vm_tools(self, vms): + """Outils de développement des VM graphiques : liste à cocher. + + Ne montre que ce qu'au moins une VM du parc peut recevoir : les IDE + graphiques disparaissent d'un parc de serveurs, où ils n'auraient rien + pour s'afficher, et la compilation mobile reste offerte — elle compile, + elle n'affiche pas. La réponse vaut pour tout le parc et sera filtrée + machine par machine. + + Saisie par numéros séparés par des espaces ou des virgules, « tous » + pour tout cocher, vide pour rien : quatre questions oui/non de plus + alourdiraient une séquence d'invites déjà longue.""" + choices = [ + c + for c in self._qemu_vm_tool_choices() + if any( + self._qemu_tools_for( + (c[0],), + vm.get("arch", "amd64"), + vm.get("desktop", ""), + vm.get("distro", ""), + ) + for vm in vms + ) + ] + if not choices: + return () + print(f"\n{t('Development tools:')}") + for i, (_key, label, hint) in enumerate(choices, 1): + print(f" [{i}] {label} — {hint}") + gb = ", ".join( + f"{label} +{self._QEMU_VM_TOOLS[key]['disk_gb']} Go" + for key, label, _hint in choices + ) + # Le mobile fait échouer la VM quand l'application ne compile pas : + # c'est le but, mais il vaut mieux le savoir avant de cocher. + if any(k == "mobile" for k, _l, _h in choices): + print(f" ⚠ {t('a failed mobile build marks the VM as failed')}") + print(f" {t('Disk needed:')} {gb}") + answer = input( + f"{t('Numbers separated by spaces, [all], blank = none:')} " + ).strip() + if not answer: + return () + if answer.lower() in ("all", "tous", "toutes", "*"): + return tuple(key for key, _l, _h in choices) + picked = [] + for token in answer.replace(",", " ").split(): + if token.isdigit() and 1 <= int(token) <= len(choices): + key = choices[int(token) - 1][0] + if key not in picked: + picked.append(key) + return tuple(picked) + def _qemu_ask_python_provider(self, arches): """mise (CPython précompilé) ou pyenv (compilation). @@ -6435,6 +8899,7 @@ class TODO: _vm.setdefault("desktop", desktop) _vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes) app_store = self._qemu_ask_app_store(vms) + vm_tools = self._qemu_ask_vm_tools(vms) python_provider = self._qemu_ask_python_provider( [vm["arch"] for vm in vms] ) @@ -6515,6 +8980,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, + "vm_tools": vm_tools, "python_provider": python_provider, "app_store": app_store, "install": install, @@ -6598,6 +9064,9 @@ class TODO: desktop = next((d for d in desktop_map.values() if d), "") python_provider = spec.get("python_provider") or "" app_store = spec.get("app_store") or "deb" + # Outils de développement : cochés une fois pour tout le parc, puis + # filtrés machine par machine (architecture, saveur de bureau). + vm_tools = tuple(spec.get("vm_tools") or ()) # Branche par VM : « » sur une VM veut dire « celle du formulaire ». branch_map = { vm["name"]: (vm.get("branch") or install_branch or "") @@ -6689,6 +9158,7 @@ class TODO: desktop=desktop_map, python_provider=python_provider, app_store=app_store, + vm_tools=vm_tools, ) else: print( @@ -6706,6 +9176,7 @@ class TODO: desktop=desktop_map.get(name, ""), python_provider=python_provider, app_store=app_store, + vm_tools=vm_tools, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6a5118a..daf155c 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2244,10 +2244,193 @@ TRANSLATIONS = { "fr": "À lancer sur VOTRE poste :", "en": "Run this on YOUR workstation:", }, + "Graphical console (virt-viewer, built-in tunnel)": { + "fr": "Console graphique (virt-viewer, tunnel intégré)", + "en": "Graphical console (virt-viewer, built-in tunnel)", + }, + "virt-viewer is missing here; installing it.": { + "fr": "virt-viewer manque ici ; installation.", + "en": "virt-viewer is missing here; installing it.", + }, + "no known package manager here.": { + "fr": "aucun gestionnaire de paquets connu ici.", + "en": "no known package manager here.", + }, + "virt-viewer still missing after the install.": { + "fr": "virt-viewer manque toujours après l'installation.", + "en": "virt-viewer still missing after the install.", + }, + "Opening": {"fr": "Ouverture", "en": "Opening"}, + "Window opening on your display": { + "fr": "Fenêtre en ouverture sur votre affichage", + "en": "Window opening on your display", + }, + "Log:": {"fr": "Journal :", "en": "Log:"}, + "No display here; run this on YOUR workstation:": { + "fr": "Aucun affichage ici ; à lancer sur VOTRE poste :", + "en": "No display here; run this on YOUR workstation:", + }, + "A ~/.ssh/config alias works there too.": { + "fr": "Un alias de ~/.ssh/config y fonctionne aussi.", + "en": "A ~/.ssh/config alias works there too.", + }, + "It builds its own tunnel; no ssh -L to keep open.": { + "fr": "Il monte son propre tunnel ; aucun ssh -L à tenir ouvert.", + "en": "It builds its own tunnel; no ssh -L to keep open.", + }, + "Missing? Install virt-viewer:": { + "fr": "Absent ? Installez virt-viewer :", + "en": "Missing? Install virt-viewer:", + }, + "repo transfer into the app": { + "fr": "transfert des dépôts dans l'application", + "en": "repo transfer into the app", + }, + "graphical session started": { + "fr": "session graphique démarrée", + "en": "graphical session started", + }, + "graphical session not started; reboot the VM": { + "fr": "session graphique non démarrée ; redémarrer la VM", + "en": "graphical session not started; reboot the VM", + }, + "Hypervisor console (QEMU screen, no guest server)": { + "fr": "Console de l'hyperviseur (écran QEMU, rien dans l'invitée)", + "en": "Hypervisor console (QEMU screen, no guest server)", + }, + "then point your VNC client at": { + "fr": "puis pointez votre client VNC sur", + "en": "then point your VNC client at", + }, + "Target is the hypervisor": { + "fr": "La cible est l'hyperviseur", + "en": "Target is the hypervisor", + }, + "not the VM: the socket is QEMU-side.": { + "fr": "et non la VM : le socket est du côté de QEMU.", + "en": "not the VM: the socket is QEMU-side.", + }, + "This VM exposes no VNC port.": { + "fr": "Cette VM n'expose aucun port VNC.", + "en": "This VM exposes no VNC port.", + }, + "Its display is likely spice with listen=none:": { + "fr": "Son affichage est probablement spice avec listen=none :", + "en": "Its display is likely spice with listen=none:", + }, + "To open it on the loopback (VM restart required):": { + "fr": "Pour l'ouvrir sur la boucle locale (redémarrage de la VM" + " requis) :", + "en": "To open it on the loopback (VM restart required):", + }, + "New VMs get this by default; see deploy_qemu.": { + "fr": "Les VM neuves l'ont par défaut ; voir deploy_qemu.", + "en": "New VMs get this by default; see deploy_qemu.", + }, + "No ProxyJump for this host in ~/.ssh/config.": { + "fr": "Aucun ProxyJump pour cet hôte dans ~/.ssh/config.", + "en": "No ProxyJump for this host in ~/.ssh/config.", + }, + "Cannot tell which machine runs its QEMU.": { + "fr": "Impossible de savoir quelle machine fait tourner son QEMU.", + "en": "Cannot tell which machine runs its QEMU.", + }, "(through the ProxyJump already in ~/.ssh/config)": { "fr": "(par le ProxyJump déjà dans ~/.ssh/config)", "en": "(through the ProxyJump already in ~/.ssh/config)", }, + "(the hypervisor only relays; -J puts the VM last)": { + "fr": "(l'hyperviseur ne fait que relayer ; -J met la VM en dernier)", + "en": "(the hypervisor only relays; -J puts the VM last)", + }, + "Choice": {"fr": "Choix", "en": "Choice"}, + "failed steps": {"fr": "étapes en échec", "en": "failed steps"}, + "Failed steps": {"fr": "Étapes en échec", "en": "Failed steps"}, + "Hard signals": {"fr": "Signaux durs", "en": "Hard signals"}, + "Grouped by shape": {"fr": "Regroupé par forme", "en": "Grouped by shape"}, + "line": {"fr": "ligne", "en": "line"}, + "free space": {"fr": "libre", "en": "free"}, + "silent": {"fr": "silence", "en": "silent"}, + "needs the ERPLibre install, skipped:": { + "fr": "exige l'installation ERPLibre, écarté :", + "en": "needs the ERPLibre install, skipped:", + }, + "Forgejo (git forge)": { + "fr": "Forgejo (forge git)", + "en": "Forgejo (git forge)", + }, + "self-hosted git forge on :3000, SQLite": { + "fr": "forge git auto-hébergée sur :3000, SQLite", + "en": "self-hosted git forge on :3000, SQLite", + }, + "no project yet, second try on the warm caches": { + "fr": "pas de projet encore ; seconde tentative sur les caches chauds", + "en": "no project yet, second try on the warm caches", + }, + "already there, download skipped": { + "fr": "déjà présent, téléchargement évité", + "en": "already there, download skipped", + }, + "inotify watches raised for the IDE": { + "fr": "watches inotify relevées pour l'IDE", + "en": "inotify watches raised for the IDE", + }, + "closing what survived the first open:": { + "fr": "fermeture de ce qui a survécu à la première ouverture :", + "en": "closing what survived the first open:", + }, + "Gradle daemon killed: out of memory": { + "fr": "démon Gradle tué : mémoire épuisée", + "en": "Gradle daemon killed: out of memory", + }, + "out of memory": {"fr": "mémoire épuisée", "en": "out of memory"}, + "Gradle heap too small": { + "fr": "tas Gradle trop petit", + "en": "Gradle heap too small", + }, + "too many asset files for one APK (ZIP limit: 65535 entries)": { + "fr": "trop de fichiers d'assets pour un APK (limite ZIP : 65535" + " entrées)", + "en": "too many asset files for one APK (ZIP limit: 65535 entries)", + }, + "4 GB of swap added for the build": { + "fr": "4 Go de swap ajoutés pour la compilation", + "en": "4 GB of swap added for the build", + }, + "no swap could be added; build may run short": { + "fr": "swap impossible à ajouter ; la compilation peut manquer de mémoire", + "en": "no swap could be added; build may run short", + }, + "memory:": {"fr": "mémoire :", "en": "memory:"}, + "manifest repos not bundled (ZIP entry limit)": { + "fr": "dépôts du manifeste non empaquetés (limite d'entrées ZIP)", + "en": "manifest repos not bundled (ZIP entry limit)", + }, + "MB RAM,": {"fr": "Mo de RAM,", "en": "MB RAM,"}, + "MB swap, kernel OOM kills:": { + "fr": "Mo de swap, tués par le noyau (OOM) :", + "en": "MB swap, kernel OOM kills:", + }, + "Tick the Android emulator tool when deploying.": { + "fr": "Cochez l'outil Émulateur Android au déploiement.", + "en": "Tick the Android emulator tool when deploying.", + }, + "Cannot reach this VM.": { + "fr": "VM injoignable.", + "en": "Cannot reach this VM.", + }, + "No Android SDK in this VM: no emulator binary.": { + "fr": "Pas de SDK Android dans cette VM : aucun binaire emulator.", + "en": "No Android SDK in this VM: no emulator binary.", + }, + "No AVD named erplibre in this VM.": { + "fr": "Aucun AVD nommé erplibre dans cette VM.", + "en": "No AVD named erplibre in this VM.", + }, + "It did not start; the VM log says:": { + "fr": "Il n'a pas démarré ; le journal de la VM dit :", + "en": "It did not start; the VM log says:", + }, "No ~/.ssh/config entry; see SSH configuration.": { "fr": "Aucune entrée ~/.ssh/config ; voir Configuration SSH.", "en": "No ~/.ssh/config entry; see SSH configuration.", @@ -2860,6 +3043,90 @@ TRANSLATIONS = { "fr": "VM à modifier (séparées par des virgules) : ", "en": "VMs to change (comma-separated): ", }, + "Adjust hardware only (vCPU, RAM, 3D)": { + "fr": "Régler le matériel seulement (vCPU, RAM, 3D)", + "en": "Adjust hardware only (vCPU, RAM, 3D)", + }, + "Adjust hardware before starting? (y/N): ": { + "fr": "Régler le matériel avant de démarrer ? (o/N) : ", + "en": "Adjust hardware before starting? (y/N): ", + }, + "Not shut off, hardware left untouched:": { + "fr": "Pas éteintes, matériel inchangé :", + "en": "Not shut off, hardware left untouched:", + }, + "Host GPU:": { + "fr": "GPU de l'hôte :", + "en": "Host GPU:", + }, + "none (software rendering)": { + "fr": "aucun (rendu logiciel)", + "en": "none (software rendering)", + }, + "software rendering": { + "fr": "rendu logiciel", + "en": "software rendering", + }, + "Unreadable VM definition.": { + "fr": "Définition de VM illisible.", + "en": "Unreadable VM definition.", + }, + "Nothing to change.": { + "fr": "Rien à changer.", + "en": "Nothing to change.", + }, + "Changes:": { + "fr": "Changements :", + "en": "Changes:", + }, + "Apply these changes? (y/N): ": { + "fr": "Appliquer ces changements ? (o/N) : ", + "en": "Apply these changes? (y/N): ", + }, + "Autostart": { + "fr": "Démarrage automatique", + "en": "Autostart", + }, + "VM hardware": { + "fr": "Matériel des VM", + "en": "VM hardware", + }, + "3D": { + "fr": "3D", + "en": "3D", + }, + "3D acceleration (host GPU)": { + "fr": "Accélération 3D (GPU de l'hôte)", + "en": "3D acceleration (host GPU)", + }, + "Render node": { + "fr": "Nœud de rendu", + "en": "Render node", + }, + "Screens": { + "fr": "Écrans", + "en": "Screens", + }, + "bridge": { + "fr": "pont", + "en": "bridge", + }, + "this VM has no interface": { + "fr": "cette VM n'a pas d'interface réseau", + "en": "this VM has no interface", + }, + "CPU mode": { + "fr": "Mode CPU", + "en": "CPU mode", + }, + "no render node on the host": { + "fr": "aucun nœud de rendu sur l'hôte", + "en": "no render node on the host", + }, + "this VM has no virtual screen": { + "fr": "cette VM n'a pas d'écran virtuel", + "en": "this VM has no virtual screen", + }, "Unknown VM(s):": { "fr": "VM inconnue(s) :", "en": "Unknown VM(s):", @@ -6191,6 +6458,10 @@ TRANSLATIONS = { "fr": "vue(s) portent encore une balise qu'Odoo 18", "en": "view(s) still carry a tag Odoo 18", }, + "failed at": { + "fr": "a échoué sur", + "en": "failed at", + }, "Census": { "fr": "Recensement", "en": "Census", @@ -7553,6 +7824,423 @@ TRANSLATIONS = { "en": "no recipient: nothing was sent", }, "mail_err_send_refused": {"fr": "envoi refusé :", "en": "send refused:"}, + # QEMU — outils de développement des VM graphiques + "Python IDE, ERPLibre project pre-configured": { + "fr": "IDE Python, projet ERPLibre pré-configuré", + "en": "Python IDE, ERPLibre project pre-configured", + }, + "ERPLibre mobile development (x86_64 only)": { + "fr": "développement mobile ERPLibre (x86_64 seulement)", + "en": "ERPLibre mobile development (x86_64 only)", + }, + "GNOME extensions": { + "fr": "Extensions GNOME", + "en": "GNOME extensions", + }, + "suggested extensions + extension manager": { + "fr": "extensions suggérées + gestionnaire d'extensions", + "en": "suggested extensions + extension manager", + }, + "Development tools for the graphical VMs:": { + "fr": "Outils de développement des VM graphiques :", + "en": "Development tools for the graphical VMs:", + }, + "Development tools (graphical VMs):": { + "fr": "Outils de développement (VM graphiques) :", + "en": "Development tools (graphical VMs):", + }, + "Development tools:": { + "fr": "Outils de développement :", + "en": "Development tools:", + }, + "Disk needed:": {"fr": "Disque nécessaire :", "en": "Disk needed:"}, + "Numbers separated by spaces, [all], blank = none:": { + "fr": "Numéros séparés par des espaces, [tous], vide = aucun :", + "en": "Numbers separated by spaces, [all], blank = none:", + }, + "No graphical VM: these tools are not installed.": { + "fr": "Aucune VM graphique : ces outils ne seront pas installés.", + "en": "No graphical VM: these tools are not installed.", + }, + "Partly skipped (arch or desktop):": { + "fr": "En partie écartés (architecture ou bureau) :", + "en": "Partly skipped (arch or desktop):", + }, + "Installing PyCharm (long)": { + "fr": "Installation de PyCharm (long)", + "en": "Installing PyCharm (long)", + }, + "no JetBrains build for": { + "fr": "aucune archive JetBrains pour", + "en": "no JetBrains build for", + }, + "PyCharm: project config skipped": { + "fr": "PyCharm : configuration du projet non écrite", + "en": "PyCharm: project config skipped", + }, + "PyCharm installed:": { + "fr": "PyCharm installé :", + "en": "PyCharm installed:", + }, + "PyCharm not installed (see above)": { + "fr": "PyCharm non installé (voir ci-dessus)", + "en": "PyCharm not installed (see above)", + }, + "Installing Android Studio (long)": { + "fr": "Installation d'Android Studio (long)", + "en": "Installing Android Studio (long)", + }, + "Android Studio: Google publishes x86_64 only": { + "fr": "Android Studio : Google ne publie que du x86_64", + "en": "Android Studio: Google publishes x86_64 only", + }, + "SDK shared through ANDROID_HOME:": { + "fr": "SDK partagé via ANDROID_HOME :", + "en": "SDK shared through ANDROID_HOME:", + }, + "Android Studio installed:": { + "fr": "Android Studio installé :", + "en": "Android Studio installed:", + }, + "Android Studio not installed (see above)": { + "fr": "Android Studio non installé (voir ci-dessus)", + "en": "Android Studio not installed (see above)", + }, + "no nested KVM: the emulator will not run": { + "fr": "pas de KVM imbriqué : l'émulateur ne démarrera pas", + "en": "no nested KVM: the emulator will not run", + }, + "Suggested GNOME extensions": { + "fr": "Extensions GNOME suggérées", + "en": "Suggested GNOME extensions", + }, + "not in the repos, skipped:": { + "fr": "absent des dépôts, ignoré :", + "en": "not in the repos, skipped:", + }, + "Enable them from Extension Manager, or:": { + "fr": "À activer depuis le gestionnaire d'extensions, ou :", + "en": "Enable them from Extension Manager, or:", + }, + "GNOME Shell not found, site extensions skipped": { + "fr": "GNOME Shell introuvable, extensions du site ignorées", + "en": "GNOME Shell not found, site extensions skipped", + }, + "installed and enabled:": { + "fr": "installée et activée :", + "en": "installed and enabled:", + }, + "not available for this GNOME, skipped:": { + "fr": "indisponible pour ce GNOME, ignorée :", + "en": "not available for this GNOME, skipped:", + }, + "log out and back in to load them": { + "fr": "se déconnecter puis se reconnecter pour les charger", + "en": "log out and back in to load them", + }, + "open the project once and close PyCharm; the .idea it writes is what the install configures": { + "fr": "ouvrir le projet une fois puis fermer PyCharm : le .idea qu'il écrit est ce que l'installation configure", + "en": "open the project once and close PyCharm; the .idea it writes is what the install configures", + }, + "command": {"fr": "commande", "en": "command"}, + "release feed unreachable: unified build, it will ask for a JetBrains account": { + "fr": "flux des versions injoignable : build unifié, il demandera un compte JetBrains", + "en": "release feed unreachable: unified build, it will ask for a JetBrains account", + }, + "Creating the PyCharm project (first open)": { + "fr": "Création du projet PyCharm (première ouverture)", + "en": "Creating the PyCharm project (first open)", + }, + "PyCharm missing, step skipped": { + "fr": "PyCharm absent, étape sautée", + "en": "PyCharm missing, step skipped", + }, + "no Xvfb here, open PyCharm by hand": { + "fr": "pas de Xvfb ici : ouvrir PyCharm à la main", + "en": "no Xvfb here, open PyCharm by hand", + }, + "ERPLibre mobile (build)": { + "fr": "ERPLibre mobile (compilation)", + "en": "ERPLibre mobile (build)", + }, + "APK debug + Vitest, validates the VM": { + "fr": "APK debug + Vitest, valide la VM", + "en": "APK debug + Vitest, validates the VM", + }, + "Building ERPLibre mobile (long)": { + "fr": "Compilation d'ERPLibre mobile (long)", + "en": "Building ERPLibre mobile (long)", + }, + "detailed log in the VM:": { + "fr": "journal détaillé dans la VM :", + "en": "detailed log in the VM:", + }, + "FAILED:": {"fr": "ÉCHEC :", "en": "FAILED:"}, + "probable cause:": {"fr": "cause probable :", "en": "probable cause:"}, + "no known pattern, last lines:": { + "fr": "aucun motif connu, dernières lignes :", + "en": "no known pattern, last lines:", + }, + "a failed mobile build marks the VM as failed": { + "fr": "une compilation mobile en échec marque la VM en échec", + "en": "a failed mobile build marks the VM as failed", + }, + "ERPLibre venv (everything below needs it)": { + "fr": "venv ERPLibre (tout ce qui suit en dépend)", + "en": "ERPLibre venv (everything below needs it)", + }, + "mobile repository (additive manifest)": { + "fr": "dépôt mobile (manifeste additif)", + "en": "mobile repository (additive manifest)", + }, + "prerequisites of the upstream installer": { + "fr": "prérequis de l'installateur amont", + "en": "prerequisites of the upstream installer", + }, + "Android SDK, licences, NDK": { + "fr": "SDK Android, licences, NDK", + "en": "Android SDK, licences, NDK", + }, + "SDK platform required by the project": { + "fr": "plateforme SDK réclamée par le projet", + "en": "SDK platform required by the project", + }, + "npm dependencies": { + "fr": "dépendances npm", + "en": "npm dependencies", + }, + "web bundle (vite build)": { + "fr": "bundle web (vite build)", + "en": "web bundle (vite build)", + }, + "native sync (capacitor)": { + "fr": "synchronisation native (capacitor)", + "en": "native sync (capacitor)", + }, + "debug APK (gradle)": { + "fr": "APK debug (gradle)", + "en": "debug APK (gradle)", + }, + "Vitest tests": {"fr": "tests Vitest", "en": "Vitest tests"}, + "APK built:": {"fr": "APK construit :", "en": "APK built:"}, + "browser debugging (no Android):": { + "fr": "déverminage au navigateur (sans Android) :", + "en": "browser debugging (no Android):", + }, + "no APK produced": { + "fr": "aucun APK produit", + "en": "no APK produced", + }, + "disk full": {"fr": "disque plein", "en": "disk full"}, + "SDK platform missing": { + "fr": "plateforme SDK absente", + "en": "SDK platform missing", + }, + "SDK not found (ANDROID_HOME)": { + "fr": "SDK introuvable (ANDROID_HOME)", + "en": "SDK not found (ANDROID_HOME)", + }, + "SDK licences not accepted": { + "fr": "licences SDK non acceptées", + "en": "SDK licences not accepted", + }, + "NDK missing": {"fr": "NDK absent", "en": "NDK missing"}, + "JDK required by the project missing": { + "fr": "JDK réclamé par le projet absent", + "en": "JDK required by the project missing", + }, + "Gradle running on too old a JDK": { + "fr": "Gradle tourne sur un JDK trop ancien", + "en": "Gradle running on too old a JDK", + }, + "SDK already there (upstream installer replays)": { + "fr": "SDK déjà là (l'installateur amont se rejoue)", + "en": "SDK already there (upstream installer replays)", + }, + "cross-compiled protoc run on the host": { + "fr": "protoc compilé pour la cible, exécuté sur l'hôte", + "en": "cross-compiled protoc run on the host", + }, + "JDK/Gradle mismatch": { + "fr": "JDK et Gradle incompatibles", + "en": "JDK/Gradle mismatch", + }, + "Gradle dependency unreachable (network?)": { + "fr": "dépendance Gradle injoignable (réseau ?)", + "en": "Gradle dependency unreachable (network?)", + }, + "Vitest tests failed": { + "fr": "tests Vitest en échec", + "en": "Vitest tests failed", + }, + "ERPLibre venv incomplete (no pip: python3-venv)": { + "fr": "venv ERPLibre incomplet (pas de pip : python3-venv)", + "en": "ERPLibre venv incomplete (no pip: python3-venv)", + }, + "mobile repository missing": { + "fr": "dépôt mobile absent", + "en": "mobile repository missing", + }, + "ERPLibre venv missing (incomplete install)": { + "fr": "venv ERPLibre absent (installation incomplète)", + "en": "ERPLibre venv missing (incomplete install)", + }, + "smoother, without X11:": { + "fr": "plus fluide, sans X11 :", + "en": "smoother, without X11:", + }, + "Android emulator": {"fr": "Émulateur Android", "en": "Android emulator"}, + "Android emulator (start, tunnel, scrcpy)": { + "fr": "Émulateur Android (démarrer, tunnel, scrcpy)", + "en": "Android emulator (start, tunnel, scrcpy)", + }, + "An emulator is already running on this VM.": { + "fr": "Un émulateur tourne déjà sur cette VM.", + "en": "An emulator is already running on this VM.", + }, + "Only one per AVD; close it first:": { + "fr": "Un seul par AVD ; le fermer d'abord :", + "en": "Only one per AVD; close it first:", + }, + "Close it now? (y/N): ": { + "fr": "Le fermer maintenant ? (o/N) : ", + "en": "Close it now? (y/N): ", + }, + "Closed.": {"fr": "Fermé.", "en": "Closed."}, + "Show a window?": { + "fr": "Afficher une fenêtre ?", + "en": "Show a window?", + }, + "No window - stream with scrcpy (smoother)": { + "fr": "Sans fenêtre — flux par scrcpy (plus fluide)", + "en": "No window - stream with scrcpy (smoother)", + }, + "Window over ssh -X (raw pixels, slower)": { + "fr": "Fenêtre par ssh -X (pixels bruts, plus lent)", + "en": "Window over ssh -X (raw pixels, slower)", + }, + "X11 compression is on (-XC); the screen is 540x1140.": { + "fr": "La compression X11 est active (-XC) ; l'écran fait 540x1140.", + "en": "X11 compression is on (-XC); the screen is 540x1140.", + }, + "Starting the emulator without a window...": { + "fr": "Démarrage de l'émulateur sans fenêtre…", + "en": "Starting the emulator without a window...", + }, + "Could not start it:": { + "fr": "Démarrage impossible :", + "en": "Could not start it:", + }, + "Started. Boot takes about a minute; log in the VM:": { + "fr": "Démarré. L'amorçage prend environ une minute ; journal dans la VM :", + "en": "Started. Boot takes about a minute; log in the VM:", + }, + "1. Emulator started, without a window.": { + "fr": "1. Émulateur démarré, sans fenêtre.", + "en": "1. Emulator started, without a window.", + }, + "If scrcpy will run on THIS machine, I can open it.": { + "fr": "Si scrcpy tournera sur CETTE machine, je peux l'ouvrir.", + "en": "If scrcpy will run on THIS machine, I can open it.", + }, + "Open the tunnel now? (y/N): ": { + "fr": "Ouvrir le tunnel maintenant ? (o/N) : ", + "en": "Open the tunnel now? (y/N): ", + }, + "Port already in use here:": { + "fr": "Port déjà occupé ici :", + "en": "Port already in use here:", + }, + "Close the other tunnel first:": { + "fr": "Fermer l'autre tunnel d'abord :", + "en": "Close the other tunnel first:", + }, + "Tunnel failed:": {"fr": "Tunnel en échec :", "en": "Tunnel failed:"}, + "Tunnel open on localhost:": { + "fr": "Tunnel ouvert sur localhost:", + "en": "Tunnel open on localhost:", + }, + "Then:": {"fr": "Puis :", "en": "Then:"}, + "To close it:": {"fr": "Pour le fermer :", "en": "To close it:"}, + "Android emulator (adb 5555, then scrcpy)": { + "fr": "Émulateur Android (adb 5555, puis scrcpy)", + "en": "Android emulator (adb 5555, then scrcpy)", + }, + "Android emulator over adb + scrcpy": { + "fr": "Émulateur Android par adb + scrcpy", + "en": "Android emulator over adb + scrcpy", + }, + "1. In the VM, start the emulator WITHOUT a window:": { + "fr": "1. Dans la VM, démarrer l'émulateur SANS fenêtre :", + "en": "1. In the VM, start the emulator WITHOUT a window:", + }, + "2. Open the tunnel from YOUR workstation:": { + "fr": "2. Ouvrir le tunnel depuis VOTRE poste :", + "en": "2. Open the tunnel from YOUR workstation:", + }, + "3. Then, still on your workstation:": { + "fr": "3. Puis, toujours sur votre poste :", + "en": "3. Then, still on your workstation:", + }, + "scrcpy on Debian/Ubuntu:": { + "fr": "scrcpy sur Debian/Ubuntu :", + "en": "scrcpy on Debian/Ubuntu:", + }, + "Android emulator (Pixel)": { + "fr": "Émulateur Android (Pixel)", + "en": "Android emulator (Pixel)", + }, + "AVD viewable over ssh -X": { + "fr": "AVD visible par ssh -X", + "en": "AVD viewable over ssh -X", + }, + "Android emulator (AVD)": { + "fr": "Émulateur Android (AVD)", + "en": "Android emulator (AVD)", + }, + "no /dev/kvm: nested virtualisation is off on the host": { + "fr": "pas de /dev/kvm : virtualisation imbriquée coupée sur l'hôte", + "en": "no /dev/kvm: nested virtualisation is off on the host", + }, + "user added to the kvm group (effective at next login)": { + "fr": "utilisateur ajouté au groupe kvm (effectif à la prochaine session)", + "en": "user added to the kvm group (effective at next login)", + }, + "emulator and system image": { + "fr": "émulateur et image système", + "en": "emulator and system image", + }, + "Pixel profile, smallest screen": { + "fr": "profil Pixel, plus petit écran", + "en": "Pixel profile, smallest screen", + }, + "create the AVD": {"fr": "création de l'AVD", "en": "create the AVD"}, + "AVD ready:": {"fr": "AVD prêt :", "en": "AVD ready:"}, + "open it from your workstation:": { + "fr": "à ouvrir depuis votre poste :", + "en": "open it from your workstation:", + }, + "then install the APK:": { + "fr": "puis installer l'APK :", + "en": "then install the APK:", + }, + "ERPLibre mobile, Android SDK (long)": { + "fr": "ERPLibre mobile, SDK Android (long)", + "en": "ERPLibre mobile, Android SDK (long)", + }, + "Gradle task failed": { + "fr": "tâche Gradle en échec", + "en": "Gradle task failed", + }, + "project created, the install will configure it": { + "fr": "projet créé, l'installation va le configurer", + "en": "project created, the install will configure it", + }, + "no .idea: open PyCharm once, then": { + "fr": "pas de .idea : ouvrir PyCharm une fois, puis", + "en": "no .idea: open PyCharm once, then", + }, + "project": {"fr": "projet", "en": "project"}, } diff --git a/test/test_mobile_bundle_transfer.py b/test/test_mobile_bundle_transfer.py new file mode 100644 index 0000000..a89e490 --- /dev/null +++ b/test/test_mobile_bundle_transfer.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Transfert des dépôts ERPLibre dans l'application mobile. + +L'application embarque le code des dépôts du manifeste pour le parcourir hors +ligne. Ils y entrent en PACKS, et c'est ce qui rend la chose possible : un APK +est un ZIP borné à 65535 entrées, quand les 139 dépôts pèsent plus de 116 000 +fichiers. Un fichier par source donnait « Too many zip entries 123678 +(MAX=65535) » — la compilation s'arrêtait là, et l'application ne portait rien. + +Regroupés en tranches de 4 Mo, ces fichiers tiennent en 391 entrées. Mesuré sur +la VM : 3 002 entrées dans l'APK, 282 Mo, et 20 fichiers relus depuis les packs +identiques octet pour octet à leur source. + +Ce que ces tests vérifient : qu'un transfert vide, tronqué ou incohérent est +DIT, et non pris pour bon. Les trois pannes correspondantes ont chacune leur +fixture. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +from script.mobile import check_bundle_transfer as cbt # noqa: E402 + +# Contenu des sources factices : le nom du fichier -> ses octets. +SOURCES = { + "odoo/release.py": b"version_info = (18, 0)\n", + "odoo/api.py": b"def method():\n return 1\n", + "addons/sale/i18n/fr.po": b'msgid "x"\nmsgstr "y"\n', + "README.md": b"# ERPLibre\n", +} + + +def build_bundle( + tmp: Path, + sources=None, + *, + with_workspace=True, + break_pack=False, + drop_pack=False, + drop_index=False, + no_manifest=False, +): + """Fabrique un faux bundle, et la source qui va avec. + + Les avaries sont paramétrées plutôt que codées en dur : chaque test nomme + celle qu'il éprouve, et la fixture reste unique.""" + sources = SOURCES if sources is None else sources + mobile = tmp / "mobile" / "erplibre_home_mobile" + repos = mobile / "dist" / "repos" + slug = "github-com-ERPLibre-odoo" + repo_dir = repos / slug + repo_dir.mkdir(parents=True) + if not no_manifest: + (repos / "manifest.json").write_text( + json.dumps( + [ + { + "url": "https://github.com/ERPLibre/odoo", + "name": "odoo", + "path": "odoo18.0/odoo", + "slug": slug, + "revision": "18.0", + } + ] + ) + ) + index = [{"path": "odoo", "type": "dir"}] + blob = b"" + items = list(sources.items()) + for pos, (rel, data) in enumerate(items): + # L'avarie ne touche que la DERNIÈRE entrée : gonfler toutes les + # tailles décalerait chaque lecture et ferait échouer la comparaison + # avant le contrôle de bornes — ce n'est pas la panne qu'on éprouve. + last = pos == len(items) - 1 + index.append( + { + "path": rel, + "type": "file", + "chunk": 0, + "offset": len(blob), + "size": len(data) + (7 if (break_pack and last) else 0), + } + ) + blob += data + if not drop_index: + (repo_dir / "index.json").write_text(json.dumps(index)) + if not drop_pack: + (repo_dir / "pack-000.bin").write_bytes(blob) + if with_workspace: + for rel, data in sources.items(): + src = tmp / "odoo18.0/odoo" / rel + src.parent.mkdir(parents=True, exist_ok=True) + src.write_bytes(data) + return mobile + + +class TestAGoodTransfer(unittest.TestCase): + def test_it_counts_repos_files_and_packs(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(1, rep["repos"]) + self.assertEqual(len(SOURCES), rep["files"]) + self.assertEqual(1, rep["packs"]) + + def test_it_reads_the_files_back_from_the_pack(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + + def test_it_compares_them_to_the_source(self): + """La seule vérification qui prouve un transfert FIDÈLE, et pas + seulement cohérent.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + rep = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(len(SOURCES), rep["compared"]) + + def test_without_a_workspace_it_still_reads_the_packs(self): + """Hors du checkout, la comparaison n'est pas possible ; la lecture, + elle, l'est toujours.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, with_workspace=False) + rep = cbt.check(mobile, None, min_files=1) + self.assertEqual(len(SOURCES), rep["checked"]) + self.assertEqual(0, rep["compared"]) + + def test_read_from_pack_returns_the_exact_bytes(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + repo_dir = mobile / "dist/repos/github-com-ERPLibre-odoo" + index = json.loads((repo_dir / "index.json").read_text()) + entry = next( + e for e in index if e["path"] == "addons/sale/i18n/fr.po" + ) + got = cbt.read_from_pack(repo_dir, entry) + self.assertEqual(SOURCES["addons/sale/i18n/fr.po"], got) + + +class TestTheThreeFailures(unittest.TestCase): + """Vide, tronqué, incohérent : trois pannes qu'un « build OK » ne dit pas.""" + + def test_no_manifest_names_the_build(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, no_manifest=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("build", str(ctx.exception)) + + def test_a_repo_without_index_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_index=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("odoo", str(ctx.exception)) + + def test_a_missing_pack_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, drop_pack=True) + with self.assertRaises(FileNotFoundError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_index_that_promises_too_much_is_refused(self): + """Index et pack d'une compilation différente : le message doit nommer + la tranche et les tailles, pas rendre un octet manquant en silence.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, break_pack=True) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("pack-000.bin", str(ctx.exception)) + + def test_an_empty_transfer_is_refused(self): + """C'est le cas qui a existé pendant un temps : le bundle compilait, + sans un seul dépôt dedans. « Réussi » ne voulait rien dire.""" + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources={"a.py": b"x\n"}) + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp) # seuil par défaut + self.assertIn("maigre", str(ctx.exception)) + + def test_a_file_that_differs_from_the_source_is_named(self): + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + (tmp / "odoo18.0/odoo/README.md").write_bytes(b"autre chose\n") + with self.assertRaises(ValueError) as ctx: + cbt.check(mobile, tmp, min_files=1) + self.assertIn("README.md", str(ctx.exception)) + + +class TestTheThreshold(unittest.TestCase): + def test_the_default_threshold_rules_out_an_empty_bundle(self): + """Le seul dépôt odoo en porte près de 40 000 : mille est un plancher + qu'un vrai transfert dépasse de deux ordres de grandeur.""" + self.assertGreaterEqual(cbt.MIN_FILES, 1000) + + def test_the_sample_is_deterministic(self): + """Une graine fixe : deux exécutions lisent les MÊMES fichiers, donc un + échec est reproductible.""" + self.assertIsInstance(cbt.SEED, int) + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp) + first = cbt.check(mobile, tmp, min_files=1) + second = cbt.check(mobile, tmp, min_files=1) + self.assertEqual(first, second) + + +class TestTheCommandLine(unittest.TestCase): + def test_it_says_the_counts_and_returns_zero(self): + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + mobile = build_bundle(tmp, sources=SOURCES) + argv = [ + "check_bundle_transfer.py", + str(mobile), + "--workspace", + str(tmp), + ] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv), mock_min(1): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(0, code) + self.assertIn("dépôts", buf.getvalue()) + + def test_a_failure_is_one_line_not_a_traceback(self): + """Le message part dans un journal d'installation : une trace Python y + serait illisible, et la cause noyée.""" + import io + import contextlib + + with tempfile.TemporaryDirectory() as d: + tmp = Path(d) + argv = ["check_bundle_transfer.py", str(tmp / "nulle-part")] + buf = io.StringIO() + with unittest.mock.patch.object(sys, "argv", argv): + with contextlib.redirect_stdout(buf): + code = cbt.main() + self.assertEqual(1, code) + self.assertIn("⚠", buf.getvalue()) + self.assertNotIn("Traceback", buf.getvalue()) + + +import contextlib as _contextlib # noqa: E402 +import unittest.mock # noqa: E402 + + +@_contextlib.contextmanager +def mock_min(value): + """Abaisse le plancher le temps d'un test de ligne de commande.""" + old = cbt.MIN_FILES + cbt.MIN_FILES = value + try: + yield + finally: + cbt.MIN_FILES = old + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py new file mode 100644 index 0000000..2e1e2d8 --- /dev/null +++ b/test/test_qemu_desktop_tools.py @@ -0,0 +1,1327 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Outils de développement des VM graphiques : filtrage, disque, commande. + +Ce qui se vérifie ici sans VM : qu'un outil demandé pour tout le parc n'atterrit +que sur les machines qui peuvent le recevoir, que la place disque annoncée suit +ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation +d'ERPLibre avec lui — celle-ci ayant duré une heure. +""" + +import pathlib +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +class TestToolFiltering(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_VM_TOOLS) + + def test_a_server_gets_no_graphical_tool(self): + """Un IDE sans bureau n'a rien pour s'afficher. La compilation mobile, + elle, reste : elle compile, elle n'affiche pas.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + for graphical in ("pycharm", "android", "gnome_ext"): + self.assertNotIn(graphical, got) + + def test_android_studio_is_x86_64_only(self): + """Google ne publie aucune archive Linux aarch64 : toutes les variantes + de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64. + """ + self.assertIn( + "android", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), + ) + for arch in ("arm64", "s390x"): + self.assertNotIn( + "android", + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), + arch, + ) + + def test_pycharm_follows_jetbrains_two_architectures(self): + for arch in ("amd64", "arm64"): + self.assertIn( + "pycharm", + self.todo._qemu_tools_for(self.all, arch, "gnome", "ubuntu"), + arch, + ) + self.assertNotIn( + "pycharm", + self.todo._qemu_tools_for(self.all, "s390x", "gnome", "ubuntu"), + ) + + def test_gnome_extensions_only_under_gnome(self): + self.assertIn( + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu"), + ) + self.assertNotIn( + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "cinnamon", "ubuntu"), + ) + + def test_unknown_key_is_ignored(self): + self.assertEqual( + [], + self.todo._qemu_tools_for(("nope",), "amd64", "gnome", "ubuntu"), + ) + + +class TestToolDisk(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_VM_TOOLS) + + def test_disk_follows_the_filtering(self): + """Une VM qui ne recevra pas un outil ne doit pas se voir gonfler son + disque de sa taille. En arm64 il en manque DEUX : Android Studio, que + Google ne publie qu'en x86_64, et la compilation mobile, qui en + dépend.""" + full = self.todo._qemu_tools_disk_gb( + self.all, "amd64", "gnome", "ubuntu" + ) + arm = self.todo._qemu_tools_disk_gb( + self.all, "arm64", "gnome", "ubuntu" + ) + self.assertEqual( + full - arm, + TODO._QEMU_VM_TOOLS["android"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] + + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], + ) + + def test_a_server_only_pays_for_what_it_gets(self): + """Un serveur ne porte aucun IDE, donc il n'en paie pas le disque — + mais il paie bien ce qu'il reçoit : compilation mobile, émulateur et + forge. La somme est calculée depuis la table plutôt qu'écrite en + chiffre : ajouter un outil sans écran ne doit pas casser ce test, il + doit le suivre.""" + expected = sum( + spec["disk_gb"] + for key, spec in TODO._QEMU_VM_TOOLS.items() + if not spec["needs_desktop"] + ) + self.assertEqual( + expected, + self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), + ) + + def test_the_deploy_command_grows_the_disk(self): + """Le disque demandé à deploy_qemu.py doit inclure les outils : c'est + la seule valeur qui compte, celle du qcow2 réellement créé.""" + spec = { + "ssh_key": "", + "desktop": "gnome", + "vm_tools": self.all, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make install_os && make install_odoo_18", + "label": "x", + "monitor": True, + }, + } + vm = { + "distro": "ubuntu", + "version": "24.04", + "arch": "amd64", + "name": "v", + "ram": 3072, + "vcpus": 4, + "disk": "20G", + "desktop": "gnome", + } + parts = self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True) + size = parts[parts.index("--disk-size") + 1] + expected = ( + 20 + + TODO.ERPLIBRE_EXTRA_DISK_GB + + TODO.QEMU_DESKTOP_EXTRA_DISK_GB + + sum(s["disk_gb"] for s in TODO._QEMU_VM_TOOLS.values()) + ) + self.assertEqual(f"{expected}G", size) + + +class TestToolRemoteCommand(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_VM_TOOLS) + + def _sh_ok(self, script): + """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas.""" + return subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ) + + def test_every_combination_is_valid_shell(self): + combos = [ + (), + ("pycharm",), + ("android",), + ("gnome_ext",), + self.all, + ] + for tools in combos: + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "mise", "deb", tools + ) + res = self._sh_ok(script) + self.assertEqual(0, res.returncode, f"{tools} : {res.stderr}") + + def test_tools_come_before_the_clone_and_the_make(self): + """L'ordre est ce qui fait marcher la configuration du projet : PyCharm + écrit le .idea du dépôt en l'ouvrant une fois, et c'est l'installation + qui, ENSUITE, y lance pycharm_configuration.py.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertLess(script.index("PyCharm"), script.index("git clone")) + self.assertLess( + script.index("PyCharm"), script.index("make install_os") + ) + + def test_the_configuration_is_asked_for_because_the_install_was_too_early( + self, + ): + """Le contraire de ce que ce test exigeait avant, et pour une raison + mesurée : pycharm_update() teste « os.path.exists('.idea') » et se tait + quand le projet n'existe pas encore. Or il s'exécute PENDANT + l'installation, alors que PyCharm ne s'ouvrira qu'après. Personne ne + configurait donc le projet — « Missing ./.idea path » dans le journal + d'une VM neuve. On le demande maintenant explicitement, après + l'ouverture.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertIn("pycharm_configuration.py --init", script) + self.assertLess( + script.index("xvfb-run"), + script.index("pycharm_configuration.py"), + ) + + def test_a_failing_tool_never_masks_a_failing_install(self): + """Le code de sortie doit rester celui de l'installation : c'est lui que + lit le tableau de bord pour dire ✅ ou ❌.""" + script = ( + "set -e\n" + "curl() { return 7; }; sudo() { return 7; }\n" + + self.todo._qemu_tools_remote_cmd(("pycharm", "android"), False) + + "\nexit 3\n" # l'installation qui suit, en échec + ) + res = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True + ) + self.assertEqual(3, res.returncode, res.stdout + res.stderr) + + def test_a_failing_tool_still_returns_zero(self): + """Le bloc d'outils est lui-même gardé : PyCharm indisponible ne doit + pas transformer une installation réussie en échec.""" + script = "set -e\n" + self.todo._qemu_tools_remote_cmd(self.all, False) + # Tout ce qui pourrait réussir est neutralisé : ni réseau, ni sudo. + stub = ( + "curl() { return 7; }; sudo() { return 7; }; tar() { return 7; }; " + "export -f curl sudo tar 2>/dev/null || true\n" + ) + res = subprocess.run( + ["bash", "-c", stub + script], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + self.assertIn("PyCharm", res.stdout) + + +class TestToolDiscoverability(unittest.TestCase): + """Ce qui est installé doit pouvoir être TROUVÉ. Vécu : Android Studio + posé dans /opt, lanceur nommé « studio », et l'utilisateur conclut à un + échec parce que « android-studio » ne répond pas.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_android_studio_answers_to_both_names(self): + cmd = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/usr/local/bin/studio;", cmd) + self.assertIn("/usr/local/bin/android-studio;", cmd) + + def test_the_sdk_is_shared_with_the_desktop_session(self): + """install-android.sh écrit ses exports dans ~/.bashrc, que GNOME ne + lit pas : sans environment.d, Android Studio lancé depuis le menu + proposerait de télécharger un SECOND SDK.""" + cmd = self.todo._qemu_android_studio_remote_cmd() + self.assertIn(".config/environment.d", cmd) + self.assertIn("ANDROID_HOME=", cmd) + # Le même emplacement que celui où la compilation mobile l'installe. + self.assertIn("/android", cmd) + self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd()) + + def test_the_log_says_where_it_landed(self): + pycharm = self.todo._qemu_pycharm_remote_cmd() + android = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/opt/pycharm", pycharm) + self.assertIn("/opt/android-studio", android) + + def test_pycharm_says_what_creates_the_project(self): + """Le .idea n'existe qu'après une première ouverture de PyCharm : le + journal le dit, plutôt que de laisser croire à un échec.""" + cmd = self.todo._qemu_pycharm_remote_cmd() + self.assertIn(".idea", cmd) + + +class TestMobileBuild(unittest.TestCase): + """Compilation ERPLibre mobile : la seule étape qui peut faire échouer la + VM, et la seule qui n'exige pas de bureau.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_VM_TOOLS) + + def test_it_runs_on_a_server_vm(self): + """Elle compile, elle n'affiche rien : un bureau serait du gaspillage. + L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu") + self.assertEqual(["mobile", "forgejo", "avd"], got) + # Forgejo est là pour la même raison que la compilation : un + # service ne demande pas d'écran. + + def test_it_is_bounded_to_apt(self): + """install-android.sh du dépôt mobile commence par « sudo apt install + openjdk-17-jdk » : ailleurs il s'arrête là.""" + for distro in ("fedora", "rocky", "opensuse", "arch"): + self.assertNotIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", distro), + distro, + ) + self.assertIn( + "mobile", + self.todo._qemu_tools_for(self.all, "amd64", "gnome", "debian"), + ) + + def test_it_coexists_with_android_studio(self): + """Combinaison croisée : la VM graphique reçoit les deux, et un seul + SDK — celui de $HOME/android, que ANDROID_HOME désigne.""" + got = self.todo._qemu_tools_for(self.all, "amd64", "gnome", "ubuntu") + self.assertIn("android", got) + self.assertIn("mobile", got) + self.assertIn("ANDROID_HOME", self.todo._qemu_mobile_remote_cmd()) + + def test_it_runs_after_the_install_not_before(self): + """Elle a besoin du dépôt, du venv qui synchronise le manifeste, et de + node que « make install_os » installe.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "", "", "deb", ("mobile",) + ) + self.assertLess( + script.index("make install_os"), + script.index("erplibre-mobile-build.log"), + ) + + # Banc d'essai des étapes mobiles : « mstep » est remplacé par une fonction + # qui réussit tout sauf l'étape nommée, et « sudo » par un no-op. Le contrat + # se MESURE alors au code de sortie, au lieu de se déduire de la présence + # ou de l'absence d'un « || » dans le texte — un « || echo » légitime, celui + # qui ajoute la ligne du fichier d'échange à /etc/fstab, faisait tomber + # l'ancienne version de ce test sans que rien ne soit cassé. + HARNESS = ( + 'mstep() { echo "-> $1"; case "$1" in *%s*) return 1;; esac; ' + "return 0; }\n" + "sudo() { return 0; }\n" + ) + + def _run_steps(self, fail_on="RIEN", apk=False, transfer_ok=True): + """Joue les étapes mobiles avec un « mstep » et un vérificateur de + transfert bouchonnés. Le vérificateur est un VRAI fichier dans l'arbre + d'essai : c'est ainsi qu'on éprouve le chaînage, code de sortie + compris.""" + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + el = pathlib.Path(tmp) / "el" + apk_dir = el / "mobile/erplibre_home_mobile/android/app/build" + (apk_dir / "outputs/apk/debug").mkdir(parents=True) + if apk: + (apk_dir / "outputs/apk/debug/app-debug.apk").write_text("x") + checker = el / "script/mobile/check_bundle_transfer.py" + checker.parent.mkdir(parents=True, exist_ok=True) + checker.write_text( + "#!/bin/bash\necho ' 139 depots'\n" + + ("exit 0\n" if transfer_ok else "exit 1\n") + ) + checker.chmod(0o755) + steps = self.todo._qemu_mobile_build_steps(str(el)) + return subprocess.run( + ["bash", "-c", (self.HARNESS % fail_on) + steps], + capture_output=True, + text=True, + env=dict(os.environ, HOME=tmp), + timeout=60, + ) + + def test_a_failed_build_fails_the_vm(self): + """Contrat explicite : « pour que ce soit bon », l'app doit compiler. + Une étape en échec doit donc remonter un code non nul.""" + res = self._run_steps(fail_on="gradle") + self.assertNotEqual(0, res.returncode, res.stdout[-400:]) + + def test_a_failed_step_stops_the_ones_after_it(self): + """La chaîne est en « && » d'un bout à l'autre. Un « ; » glissé au + milieu — celui qui écrivait le manifeste vide — laissait la compilation + web démarrer alors que « npm ci » venait d'échouer.""" + res = self._run_steps(fail_on="npm") + self.assertNotEqual(0, res.returncode) + self.assertNotIn("vite build", res.stdout) + self.assertNotIn("gradle", res.stdout) + + def test_the_manifest_repos_are_bundled_again(self): + """Le contournement a vécu : les dépôts entrent maintenant en PACKS, et + rien ne neutralise plus le manifeste. Mesuré sur la VM : 139 dépôts, + 116 156 fichiers en 391 tranches, 3 002 entrées dans l'APK — là où un + fichier par source en demandait 123 678 pour une limite de 65 535.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertNotIn("ERPLIBRE_MANIFEST_PATH", steps) + self.assertNotIn("empty-manifest", steps) + + def test_the_transfer_is_verified_after_the_bundle(self): + """Une application qui ne porte pas le code qu'elle est censée montrer + n'est pas l'application demandée : le transfert se vérifie.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("check_bundle_transfer.py", steps) + self.assertLess( + steps.index("npm run build"), + steps.index("check_bundle_transfer.py"), + ) + self.assertLess( + steps.index("check_bundle_transfer.py"), + steps.index("cap sync"), + ) + + def test_the_transfer_is_compared_to_the_source(self): + """« --workspace » : c'est la comparaison octet pour octet qui prouve un + transfert FIDÈLE, et pas seulement cohérent.""" + steps = self.todo._qemu_mobile_build_steps("/tmp/el") + self.assertIn("--workspace /tmp/el", steps) + + def test_a_failed_transfer_fails_the_vm(self): + """Une application qui ne porte pas le code qu'elle doit montrer n'est + pas l'application demandée. Mesuré au code de sortie, et non à la + présence d'un « && » dans le texte.""" + res = self._run_steps(apk=True, transfer_ok=False) + self.assertNotEqual(0, res.returncode, res.stdout[-300:]) + self.assertNotIn("gradle", res.stdout) + + def test_a_good_transfer_lets_the_build_go_on(self): + res = self._run_steps(apk=True, transfer_ok=True) + self.assertEqual(0, res.returncode, res.stdout[-300:]) + self.assertIn("139 depots", res.stdout) + + def test_the_transfer_line_is_read_in_the_install_log(self): + """Hors mstep, à dessein : mstep renvoie la sortie dans le journal + détaillé de la VM, et c'est le compte des dépôts qu'on veut voir dans + celui de l'installation. Le bouchon imprime une ligne : elle doit + remonter jusqu'à la sortie.""" + res = self._run_steps(apk=True) + self.assertIn("139 depots", res.stdout) + head = self.todo._qemu_mobile_build_steps("/tmp/el") + head = head[: head.index("check_bundle_transfer.py")] + self.assertNotIn("mstep", head[-160:]) + + def test_a_missing_apk_fails_even_when_gradle_returns_zero(self): + """L'APK est la preuve, pas le code de sortie de Gradle : une tâche peut + rendre 0 sans rien produire.""" + res = self._run_steps(apk=False) + self.assertNotEqual(0, res.returncode) + self.assertIn("APK", res.stdout) + + def test_a_complete_build_succeeds(self): + """L'autre sens du contrat : sans lui, un test qui échoue toujours + passerait pour un test qui vérifie quelque chose.""" + res = self._run_steps(apk=True) + self.assertEqual(0, res.returncode, res.stdout[-400:]) + + def test_the_swap_step_alone_never_fails_the_chain(self): + """Une image btrfs refuse un fichier d'échange ordinaire, et une + compilation qui tient en mémoire n'en a pas besoin.""" + res = self._run_steps(apk=True) + self.assertEqual(0, res.returncode) + self.assertNotIn("|| true", self.todo._qemu_mobile_build_steps("/x")) + + def test_the_build_covers_apk_and_tests(self): + cmd = self.todo._qemu_mobile_remote_cmd() + for step in ( + "update_manifest_local_mobile.sh", + "./install-android.sh", + "npm ci", + "npm run build", + "npx cap sync android", + "./gradlew --no-daemon assembleDebug", + "npm test", + ): + self.assertIn(step, cmd, step) + + def test_the_platform_comes_from_the_project(self): + """L'installateur amont pose android-34, variables.gradle demande + compileSdk 36 : on lit le chiffre plutôt que de le figer.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("android/variables.gradle", cmd) + self.assertIn("platforms;android-$v", cmd) + self.assertNotIn("platforms;android-36", cmd) + + def test_the_apk_is_the_proof(self): + """Une tâche Gradle peut rendre 0 sans rien produire.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("outputs/apk/debug/*.apk", cmd) + + def test_both_apk_locations_are_searched(self): + """Avec une ABI injectée, AGP écrit dans intermediates et non dans + outputs : mesuré, une compilation RÉUSSIE était rapportée « aucun APK + produit » parce qu'un seul des deux chemins était regardé.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn("intermediates/apk/debug/*.apk", cmd) + + def test_no_apk_means_non_zero(self): + """Éprouvé plutôt que relu : sans APK, le bloc DOIT rendre non nul. + Une ligne d'information placée après le « fi » suffisait à rendre 0 et + à faire repasser la VM au vert.""" + cmd = self.todo._qemu_mobile_remote_cmd() + tail = cmd[cmd.index("apk=$(ls") :] + res = subprocess.run( + ["bash", "-c", "set -e\n" + tail], capture_output=True, text=True + ) + self.assertNotEqual(0, res.returncode, res.stdout) + + def test_every_failure_names_a_cause(self): + """Un journal de dizaines de mégaoctets ne se relit pas : le diagnostic + doit dire pourquoi.""" + cmd = self.todo._qemu_mobile_remote_cmd() + # Une entrée peut porter un 3e élément : la commande de contexte. + for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG): + self.assertIn(pattern, cmd, pattern) + self.assertIn('tail -12 "$1"', cmd) + + def test_heavy_output_stays_out_of_the_install_log(self): + """Des centaines de lignes Gradle portant le mot « error » sans être + des pannes rendraient le compteur du tableau de bord inutilisable.""" + cmd = self.todo._qemu_mobile_remote_cmd() + self.assertIn('>> "$M" 2>&1', cmd) + + +class TestAndroidEmulator(unittest.TestCase): + """Émulateur Android : visible depuis le poste par « ssh -X ».""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_avd_remote_cmd() + + def test_no_desktop_needed_in_the_vm(self): + """Il s'affiche sur l'écran de qui s'y connecte, pas dans la VM.""" + self.assertFalse(TODO._QEMU_VM_TOOLS["avd"]["needs_desktop"]) + self.assertIn( + "avd", self.todo._qemu_tools_for(("avd",), "amd64", "", "ubuntu") + ) + + def test_the_screen_is_set_at_launch_not_in_the_config(self): + """Écrire hw.lcd.* dans config.ini ne SERT À RIEN : l'émulateur réécrit + ce fichier depuis le profil du téléphone au premier démarrage, et l'AVD + repartait en 1080x2400 densité 420 — constaté sur la VM. La taille se + règle donc au lancement, et la commande affichée la porte.""" + self.assertNotIn("hw.lcd.width", self.cmd) + self.assertIn("-skin 540x1140", self.cmd) + # Ces deux clés-là survivent : elles ne viennent pas du profil. + self.assertIn("hw.gpu.mode=swangle", self.cmd) + + def test_the_density_travels_with_the_resolution(self): + """Contre-intuitif, et mesuré : 540x1140 en densité 420 est PIRE que le + plein écran — 81 ms de médiane contre 40, et 57 % d'images en retard + contre 37, tout étant rendu énorme. Avec la densité 240 : 38 ms, 32 %, + et le 99e centile tombe de 950 ms à 250.""" + self.assertIn("qemu.sf.lcd_density=240", self.cmd) + + def test_a_killed_emulator_does_not_block_the_next_start(self): + """Ce menu propose lui-même de tuer l'émulateur par pkill. Sans + « -no-snapshot-save », le lancement suivant meurt sur « A snapshot + operation is pending and timeout has expired » — vécu, et le message ne + dit pas quoi faire.""" + self.assertIn("-no-snapshot-save", self.cmd) + + def test_the_printed_command_compresses_the_display(self): + """« -XC » plutôt que « -X » sur un écran distant.""" + self.assertIn("ssh -XC erplibre@$ip", self.cmd) + self.assertNotIn("ssh -X erplibre@$ip", self.cmd.replace("-XC", "")) + + def test_software_rendering_is_written_into_the_avd(self): + """Par « ssh -X » il n'y a pas de GLX direct : en « auto », + l'émulateur s'ouvre sur un écran noir. Le réglage va dans config.ini + pour qu'« emulator -avd erplibre » suffise.""" + self.assertIn("hw.gpu.mode=swangle", self.cmd) + self.assertIn("config.ini", self.cmd) + # « swiftshader_indirect » n'existe plus : l'émulateur 37.1 le refuse, + # affiche deux erreurs et retombe sur swangle de lui-même. Mesuré. + self.assertNotIn("swiftshader_indirect", self.cmd) + + def test_xauth_is_installed(self): + """Sans xauth dans la VM, « ssh -X » n'ouvre aucun affichage — et le + paquet manque des images cloud.""" + self.assertIn("xauth", self.cmd) + + def test_it_says_when_kvm_is_missing(self): + """Un émulateur x86 sans KVM refuse de démarrer : le dire là où c'est + réparable, sur l'hôte, plutôt qu'au premier lancement.""" + self.assertIn("/dev/kvm", self.cmd) + + def test_the_pixel_is_chosen_at_runtime(self): + """« le plus récent, le plus petit écran » se demande au SDK : figer un + modèle le rendrait faux à la prochaine génération.""" + self.assertIn("avdmanager list device", self.cmd) + self.assertIn("pixel_", self.cmd) + self.assertIn("pro|xl|fold|tablet", self.cmd) + self.assertIn("sort -t_ -k2 -n", self.cmd) + + def test_the_system_image_falls_back(self): + """Google ne publie pas d'image pour toutes les API : on descend.""" + self.assertIn("for a in $v 36 35 34", self.cmd) + + def test_it_prints_the_command_to_open_it(self): + """Un émulateur dont on ignore comment l'ouvrir ne sert à personne.""" + self.assertIn("ssh -XC erplibre@$ip", self.cmd) + self.assertIn("adb install -r", self.cmd) + + def test_the_printed_commands_use_absolute_paths(self): + """« ssh hôte 'commande' » ne lit ni ~/.profile ni ~/.bashrc — Ubuntu y + met même un « return » pour les shells non interactifs. Une commande + affichée qui compte sur le PATH répond « command not found ». Vécu.""" + self.assertIn("$HOME/android/emulator/emulator", self.cmd) + self.assertIn("$HOME/android/platform-tools/adb", self.cmd) + self.assertNotIn('"emulator -avd', self.cmd) + self.assertNotIn('"adb install', self.cmd) + + def test_the_windowed_emulator_gets_its_audio_library(self): + """Deux binaires qemu : seul le « headless » se passe de PulseAudio. + Celui qui ouvre une fenêtre lie libpulse.so.0, absente des images + cloud, et échoue même avec « -no-audio ».""" + self.assertIn("libpulse0", self.cmd) + + def test_one_prologue_and_one_sdk_for_both_options(self): + """Deux prologues, et le second tronquerait le journal du premier.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + self.assertEqual(1, both.count("mstep() {")) + self.assertEqual(1, both.count('M="$HOME/erplibre-mobile-build.log"')) + + def test_the_emulator_cannot_mask_a_build_failure(self): + """ÉPROUVÉ, pas relu. Sans accolades autour de chaque groupe, « && » ne + lie que la première commande du suivant : mesuré sur une VM, un APK + manquant laissait tourner l'émulateur puis rendait 0 — la VM repassait + au vert alors que rien n'avait compilé.""" + both = self.todo._qemu_after_remote_cmd(("mobile", "avd")) + # On neutralise les étapes : seul le CHAÎNAGE est en cause ici. + # + # « sudo » est neutralisé AUSSI, et ce n'est pas décoratif : le bloc + # ajoute un fichier d'échange de 4 Go et une ligne à /etc/fstab. Sans + # ce bouchon, un test le ferait sur la machine qui l'exécute. + stub = ( + 'mstep() { echo " -> $1"; return 0; }; mdiag() { :; }; ' + "sudo() { return 0; }; " + ) + # Ancre robuste : on part du journal mobile et on remonte à l'accolade + # qui ouvre son groupe. Chercher « { mstep » liait ce test à la forme + # de la PREMIÈRE étape, et l'ajout du swap devant l'a cassé. + marker = both.index("erplibre-mobile-build.log") + tail = both[both.rindex("{ ", 0, marker) :] + res = subprocess.run( + ["bash", "-c", "set -e; " + stub + tail], + capture_output=True, + text=True, + ) + self.assertNotEqual(0, res.returncode, res.stdout) + # Et l'émulateur ne doit PAS avoir été touché. + self.assertNotIn("Pixel", res.stdout) + + def test_valid_shell_in_every_combination(self): + for tools in (("mobile",), ("avd",), ("mobile", "avd")): + cmd = self.todo._qemu_after_remote_cmd(tools) + res = subprocess.run( + ["bash", "-n"], input=cmd, text=True, capture_output=True + ) + self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") + + def test_no_diagnostic_pattern_carries_an_apostrophe(self): + """Ces motifs partent dans un « grep -q '' » : une apostrophe + fermait la chaîne et rendait tout le bloc invalide. Vécu.""" + # Une entrée peut porter un 3e élément : la commande de contexte. + for pattern in (e[0] for e in TODO._QEMU_MOBILE_DIAG): + self.assertNotIn("'", pattern, pattern) + + +class TestPycharmCommunity(unittest.TestCase): + """PyCharm doit s'ouvrir sans compte : c'est toute la différence entre une + VM utilisable au premier démarrage et une VM qui demande une licence.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_remote_cmd() + + def test_the_community_line_is_what_is_looked_up(self): + """Mesuré dans une VM : le build unifié s'arrête sur + « NoValidIdeLicense » et n'ouvre jamais le projet.""" + self.assertIn("pycharm-community-", self.cmd) + self.assertIn("data.services.jetbrains.com", self.cmd) + + def test_no_version_is_frozen_in_the_repository(self): + """Le flux donne la plus récente : rien à mettre à jour ici quand + JetBrains publie un correctif.""" + self.assertNotIn("2025.2.6", self.cmd) + self.assertNotIn("pycharm-community-2", self.cmd) + + def test_both_architectures_are_asked_for(self): + self.assertIn("linuxARM64", self.cmd) + self.assertIn("jb=linux", self.cmd) + + def test_the_fallback_names_its_cost(self): + """Le repli sert le build unifié : le dire, plutôt que de le laisser + découvrir au premier lancement.""" + self.assertIn("code=PCC&latest", self.cmd) + self.assertIn("JetBrains", self.cmd) + + +class TestTheDesktopActuallyStarts(unittest.TestCase): + """Installer un bureau ne suffit pas : il faut le DÉMARRER. + + Vécu sur erplibre-ubuntu-2604-gnome, et le diagnostic ne sautait pas aux + yeux : GNOME installé, gdm3 installé, graphical.target par défaut, lien + display-manager.service en place — et la console de la VM restait en mode + texte. Deux causes superposées : + + - graphical.target était DÉJÀ atteinte quand le paquet est arrivé, et une + cible active ne rattrape pas un service ajouté après coup ; + - « systemctl enable gdm » rend 0 sans rien faire sur Debian et Ubuntu : + l'unité n'a pas de « WantedBy », seulement un alias que le paquet pose. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_desktop_remote_cmd("gnome", "deb") + + def _start_block(self, cmd=None): + """Le seul « if » qui démarre le bureau, extrait tel quel.""" + cmd = cmd or self.cmd + start = cmd.index("if sudo systemctl start display-manager") + return cmd[start : cmd.index("fi; ", start) + 4] + + def test_it_starts_and_does_not_only_enable(self): + self.assertIn("systemctl start display-manager.service", self.cmd) + + def test_it_falls_back_to_the_desktop_service(self): + """« display-manager.service » est un alias que les paquets Debian + posent ; ailleurs c'est « gdm » qui porte le WantedBy.""" + block = self._start_block() + self.assertIn("systemctl start gdm", block) + + def test_it_comes_after_the_default_target_and_before_xrdp(self): + self.assertLess( + self.cmd.index("set-default graphical.target"), + self.cmd.index("start display-manager.service"), + ) + self.assertLess( + self.cmd.index("start display-manager.service"), + self.cmd.index("command -v xrdp"), + ) + + def _run(self, systemctl_body): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + (bin_dir / "sudo").write_text('#!/bin/bash\nexec "$@"\n') + (bin_dir / "systemctl").write_text( + f"#!/bin/bash\n{systemctl_body}\n" + ) + for n in ("sudo", "systemctl"): + (bin_dir / n).chmod(0o755) + res = subprocess.run( + ["bash", "-c", self._start_block()], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=30, + ) + return res.stdout + + def test_the_alias_path_reports_a_started_session(self): + out = self._run("exit 0") + self.assertIn("session", out.lower()) + self.assertNotIn("⚠", out) + + def test_the_fallback_path_also_reports_started(self): + """display-manager absent, gdm présent : c'est le cas d'Arch.""" + out = self._run( + 'case "$*" in *display-manager*) exit 1;; *) exit 0;; esac' + ) + self.assertNotIn("⚠", out) + + def test_when_nothing_starts_it_says_to_reboot(self): + """Le pire serait de se taire : l'utilisateur cherche un écran.""" + out = self._run("exit 1") + self.assertIn("⚠", out) + self.assertIn("boot", out.lower() + "reboot") + + def test_the_block_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input=self._start_block(), + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + +class TestPycharmFirstOpen(unittest.TestCase): + """Ouverture sans écran, pour que le .idea existe avant l'installation. + + Deux défauts vécus sur erplibre-ubuntu-2604-gnome, tous deux silencieux : + l'IDE restait vivant 45 minutes après l'étape avec 1,9 Go — « $! » est le + PID de xvfb-run, un script, et le tuer n'atteint ni PyCharm ni Xvfb — puis + la compilation de l'APK qui suivait s'est fait tuer par le noyau. Et le + .idea n'était jamais écrit : 123 000 fichiers d'assets épuisent les watches + inotify, dont la limite valait 65 536. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_project_cmd() + + def test_it_runs_after_the_install_not_before(self): + """Mesuré : sur un dépôt cloné mais pas installé, PyCharm n'écrit AUCUN + .idea — son configurateur d'interpréteur échoue faute de venv, et il + renonce (« ⚠ pas de .idea », deux fois sur une VM réelle). Le même appel + sur un dépôt installé l'écrit en cinq minutes.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertLess(script.index("git clone"), script.index("xvfb-run")) + self.assertLess( + script.index("make install_os"), script.index("xvfb-run") + ) + + def test_the_configuration_uses_the_repo_venv(self): + """Le script importe xmltodict, qui vit dans .venv.erplibre. Appelé par + le python système — ce que faisait « make pycharm_configure » — il + s'arrête sur « No module named 'xmltodict' », mesuré sur la VM. + update_env_version.pycharm_update() l'appelle déjà avec le venv.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertIn( + "./.venv.erplibre/bin/python" + " ./script/ide/pycharm_configuration.py --init", + script, + ) + # « make pycharm_configure » reste cité dans le message d'aide — la + # cible est réparée, elle aussi — mais n'est plus ce qu'on EXÉCUTE. + self.assertNotIn("&& make pycharm_configure", script) + self.assertNotIn("; make pycharm_configure", script) + + def test_the_open_gets_a_second_chance(self): + """Mesuré sur deux VM : la première ouverture d'un dépôt neuf peut + n'écrire AUCUN .idea — son configurateur d'interpréteur plante + (« homeDir is null ») — là où la suivante l'écrit en 25 s.""" + self.assertIn("for attempt in 1 2", self.cmd) + self.assertIn('[ "$ok" = 1 ] && break', self.cmd) + + def test_both_attempts_keep_their_log(self): + """Tronquer à chaque tentative effacerait la trace de la première, la + seule qui porte la cause.""" + self.assertIn(": > /tmp/pycharm-first-run.log", self.cmd) + self.assertIn(">> /tmp/pycharm-first-run.log", self.cmd) + + def test_the_configuration_is_asked_for_after_the_open(self): + """L'installation est déjà passée quand le .idea naît : pycharm_update() + n'avait rien à configurer, donc on le demande explicitement.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertIn("make pycharm_configure", script) + self.assertLess( + script.index("xvfb-run"), script.index("make pycharm_configure") + ) + + def test_it_never_decides_the_verdict_of_the_vm(self): + """Un bonus : ni son échec ni celui de sa configuration ne doivent + rougir une VM dont tout le reste a réussi. La phase mobile, elle, porte + bien le verdict — et elle vient après.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm", "mobile") + ) + self.assertIn("pycharm_configuration.py --init || true", script) + self.assertLess( + script.index("pycharm_configuration.py"), + script.index("ERPLibre mobile"), + ) + + def test_only_when_pycharm_was_asked_for(self): + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("android",) + ) + self.assertNotIn("xvfb-run", script) + + def test_the_first_run_dialogs_are_answered_in_advance(self): + """Sans réponse, la session attend un clic que personne ne donnera. + + Celle de la CONFIANCE est la plus coûteuse à rater : mesuré, le journal + s'arrête 1,3 s après le démarrage et le projet ne s'ouvre jamais.""" + self.assertIn("idea.trust.all.projects=true", self.cmd) + self.assertIn("jb.consents.confirmation.enabled=false", self.cmd) + self.assertIn("consentOptions", self.cmd) + # Le consentement est écrit REFUSÉ : aucune statistique ne part. + self.assertIn("rsch.send.usage.stat:1.1:0:", self.cmd) + + def test_xvfb_package_names_are_per_family(self): + """« xvfb » n'existe que chez Debian : ailleurs le paquet s'appelle + autrement, et un nom inventé ne s'installerait pas.""" + self.assertEqual("xvfb", TODO._QEMU_XVFB_PKG["apt"]) + self.assertEqual("xorg-x11-server-Xvfb", TODO._QEMU_XVFB_PKG["dnf"]) + self.assertEqual("xorg-server-xvfb", TODO._QEMU_XVFB_PKG["pacman"]) + + def test_it_gives_up_rather_than_hangs(self): + """Un IDE qui ne s'ouvre pas ne doit pas retenir l'installation : le + budget est borné et le processus tué.""" + self.assertIn("kill -TERM", self.cmd) + self.assertIn("kill -KILL", self.cmd) + self.assertIn(f"seq 1 {TODO._QEMU_PYCHARM_OPEN_TRIES}", self.cmd) + + def test_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input="set -e\n" + self.cmd, + text=True, + capture_output=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_the_ide_gets_its_own_process_group(self): + """Sans « setsid », il n'y a pas de groupe à tuer.""" + self.assertIn("setsid xvfb-run", self.cmd) + + def test_the_whole_group_is_killed_not_just_the_wrapper(self): + """Le signe moins est tout le correctif : « -$pid » désigne le GROUPE, + donc xvfb-run, Xvfb, pycharm et les cef_server.""" + self.assertIn("kill -TERM -$pid", self.cmd) + self.assertIn("kill -KILL -$pid", self.cmd) + + def test_inotify_is_raised_before_opening(self): + """Après l'ouverture, il serait trop tard : l'analyse a déjà échoué.""" + pos_watch = self.cmd.index("max_user_watches") + pos_open = self.cmd.index("setsid xvfb-run") + self.assertLess(pos_watch, pos_open) + self.assertIn("524288", self.cmd) + + def test_the_leftover_count_is_a_single_number(self): + """« pgrep -fc » imprime 0 ET rend 1 quand il ne trouve rien : le + « || echo 0 » ajoutait un second zéro, et « 0\n0 » n'est pas « 0 ». + Le filet se déclenchait donc à chaque passage.""" + self.assertIn("| wc -l", self.cmd) + self.assertNotIn("pgrep -fc", self.cmd) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], input=self.cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_the_group_kill_really_reaps_the_children(self): + """Le test qui compte : on rejoue l'étape avec de FAUX pycharm et + xvfb-run, celui-ci laissant un enfant derrière lui comme le vrai le + fait avec Xvfb. Rien ne doit survivre.""" + import os + import tempfile + import time + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + repo = pathlib.Path(tmp) / "repo" + (repo / ".idea").mkdir(parents=True) + # .idea déjà là : l'attente sort au premier tour, et le test + # mesure la FERMETURE, pas la création. + (repo / ".idea" / "erplibre.iml").write_text("") + (repo / ".idea" / "misc.xml").write_text("") + marker = pathlib.Path(tmp) / "alive" + (bin_dir / "xvfb-run").write_text( + "#!/bin/bash\n" + # L'enfant qui survivait : un Xvfb que personne ne tuait. + f"( while true; do touch {marker}; sleep 1; done ) &\n" + 'shift; exec "$@"\n' + ) + (bin_dir / "pycharm").write_text( + "#!/bin/bash\nwhile true; do sleep 1; done\n" + ) + (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n") + (bin_dir / "python3").write_text("#!/bin/bash\ncat > /dev/null\n") + # pgrep et pkill sont BOUCHONNÉS, et c'est le point important : le + # filet de l'étape balaie les processus du compte courant. Exécuté + # sans bouchon sur la machine de développement, il fermerait le + # PyCharm de l'utilisateur. C'est le groupe qu'on teste ici, pas le + # filet — celui-ci est vérifié à part, sans rien tuer. + (bin_dir / "pgrep").write_text("#!/bin/bash\nexit 1\n") + (bin_dir / "pkill").write_text("#!/bin/bash\nexit 0\n") + for name in ( + "xvfb-run", + "pycharm", + "sudo", + "python3", + "pgrep", + "pkill", + ): + (bin_dir / name).chmod(0o755) + cmd = self.todo._qemu_pycharm_project_cmd(False).replace( + self.todo._qemu_install_dir(False), str(repo) + ) + env = dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin", HOME=tmp) + res = subprocess.run( + ["bash", "-c", cmd], + capture_output=True, + text=True, + env=env, + timeout=180, + ) + self.assertEqual(0, res.returncode, res.stdout + res.stderr) + marker.unlink(missing_ok=True) + time.sleep(3) + # L'enfant réveillait le marqueur chaque seconde : s'il vit + # encore, le fichier est revenu. + self.assertFalse( + marker.exists(), + "un enfant a survécu à la fermeture du groupe", + ) + + +class TestIdeInstallIsReplayable(unittest.TestCase): + """Rejouer une installation ne doit pas retélécharger 2 Go. + + C'est le cas NORMAL : une installation morte qu'on relance, un outil ajouté + après coup. Mesuré sur la VM, les deux étapes passent de ~5 min chacune à + 0,094 s au total quand /opt porte déjà l'IDE — le reste (lanceur, alias, + raccourci) rejoue quand même, il est idempotent et bon marché. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.py = self.todo._qemu_pycharm_remote_cmd() + self.st = self.todo._qemu_android_studio_remote_cmd() + + def test_pycharm_checks_before_downloading(self): + self.assertIn("[ -x /opt/pycharm/bin/pycharm.sh ]", self.py) + self.assertLess( + self.py.index("/opt/pycharm/bin/pycharm.sh"), + self.py.index("curl"), + ) + + def test_android_studio_checks_before_downloading(self): + self.assertIn("[ -x /opt/android-studio/bin/studio ]", self.st) + self.assertLess( + self.st.index("/opt/android-studio/bin/studio"), + self.st.index("curl"), + ) + + def test_the_launcher_still_runs_when_the_download_is_skipped(self): + """Sauter le téléchargement ne doit pas sauter l'alias : c'est lui qui + rend « pycharm » et « android-studio » appelables.""" + # rindex : le chemin du lanceur apparaît aussi dans la garde, tout au + # début. C'est la DERNIÈRE occurrence — l'installation du lanceur — qui + # doit suivre le bloc de téléchargement. + for cmd, marker in ( + (self.py, "/usr/local/bin"), + (self.st, "/usr/local/bin"), + ): + self.assertGreater(cmd.rindex(marker), cmd.index("curl"), marker) + + def test_a_real_download_failure_still_fails(self): + """La garde ne doit pas avaler l'échec du cas où il faut télécharger. + On force l'absence d'IDE et un curl qui échoue.""" + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + (bin_dir / "curl").write_text("#!/bin/bash\nexit 22\n") + (bin_dir / "sudo").write_text("#!/bin/bash\nexit 0\n") + (bin_dir / "python3").write_text("#!/bin/bash\ncat >/dev/null\n") + for n in ("curl", "sudo", "python3"): + (bin_dir / n).chmod(0o755) + res = subprocess.run( + ["bash", "-c", self.st], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=60, + ) + out = res.stdout + res.stderr + # Sur cette machine /opt/android-studio n'existe pas : la garde + # laisse donc passer, et l'échec du curl doit se voir. + self.assertIn("⚠", out, out[-300:]) + + def test_both_steps_are_valid_shell(self): + for cmd in (self.py, self.st): + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + +class TestPycharmNetIsNarrow(unittest.TestCase): + """Le filet de fermeture ne doit JAMAIS viser le ssh qui porte l'install. + + Vécu, et cher : le filet cherchait « /opt/pycharm » dans les LIGNES DE + COMMANDE. Or la commande d'installation est passée en argument à ssh, et + elle contient ce chemin — le pkill a donc tué la session ssh qui portait + l'installation en cours sur l'hyperviseur. Elle est morte en silence, sans + marqueur de sortie : 48 minutes perdues, et rien dans le journal. + + Mesuré ensuite dans une VM : par NOM de processus, 3 processus réels + (pycharm, Xvfb, fsnotifier) et aucun faux ; par ligne de commande, 4 — le + ssh compris. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_pycharm_project_cmd() + + def test_it_matches_by_process_name(self): + self.assertIn('pgrep -u "$(id -u)" -x', self.cmd) + self.assertIn('pkill -u "$(id -u)" -x', self.cmd) + + def test_it_never_matches_by_command_line(self): + """« -f » est exactement ce qui a tué l'installation.""" + self.assertNotIn("pkill -f", self.cmd) + self.assertNotIn("pgrep -f", self.cmd) + + def test_the_names_are_the_ones_measured_in_the_vm(self): + for name in ("pycharm", "cef_server", "fsnotifier", "Xvfb"): + self.assertIn(name, self.cmd) + + def test_a_command_line_that_merely_mentions_the_ide_is_spared(self): + """Le test qui compte, et il ne tue rien : un témoin dont la LIGNE + contient le chemin de l'IDE — comme le ssh lanceur — et dont le NOM est + « sleep ». L'ancien motif l'attrape, le nouveau l'épargne.""" + import os + import re + import time + + pattern = re.search(r'-x "([^"]+)"', self.cmd).group(1) + witness = subprocess.Popen( + [ + "bash", + "-c", + 'exec -a "ssh erplibre@vm bash -c /opt/pycharm/bin/pycharm.sh"' + " sleep 30", + ] + ) + try: + time.sleep(1.5) + uid = str(os.getuid()) + by_name = subprocess.run( + ["pgrep", "-u", uid, "-x", pattern], + capture_output=True, + text=True, + ).stdout.split() + by_cmdline = subprocess.run( + ["pgrep", "-u", uid, "-f", "[/]opt/pycharm"], + capture_output=True, + text=True, + ).stdout.split() + pid = str(witness.pid) + # Le témoin est un enfant de bash : on cherche le groupe entier. + spared = pid not in by_name + self.assertTrue(spared, "le motif par nom a attrapé le témoin") + self.assertIn( + pid, + by_cmdline, + "le témoin devrait être attrapé par l'ancien motif ;" + " sinon ce test ne prouve rien", + ) + finally: + witness.kill() + witness.wait(timeout=10) + + +class TestMobileSwap(unittest.TestCase): + """Le swap posé avant de compiler, et son refus de bloquer. + + Mesuré : le démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m + ne le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué deux + fois. « --max-workers=2 » n'a rien changé : le pic est passé de 10,3 à + 11,2 Go. C'est de la marge qu'il faut.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.steps = self.todo._qemu_mobile_build_steps("/tmp/el") + + def test_the_swap_comes_before_the_build(self): + self.assertLess( + self.steps.index("SwapTotal"), self.steps.index("npm ci") + ) + + def test_it_does_nothing_when_swap_is_already_there(self): + self.assertIn("SwapTotal", self.steps) + self.assertIn("-lt 2000000", self.steps) + + def test_a_failed_swap_leaves_no_stray_file(self): + """Un fichier d'échange à moitié fait occuperait 4 Go pour rien.""" + self.assertIn("rm -f /swapfile-erplibre", self.steps) + + def test_it_is_valid_shell(self): + res = subprocess.run( + ["bash", "-n"], + input="mstep() { :; }\n" + self.steps, + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_the_swap_block_is_not_linked_by_and(self): + """Lié par « && », un swap refusé arrêterait toute la compilation.""" + head = self.steps[: self.steps.index("npm ci")] + self.assertNotIn("fi; fi && ", head) + self.assertIn("fi; fi; ", head) + + +class TestMobileDiagMemory(unittest.TestCase): + """« Son démon a disparu » ne parle pas de mémoire ; le diagnostic, oui.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.cmd = self.todo._qemu_mobile_diag_cmd() + + def test_the_oom_pattern_names_memory(self): + pats = {p[0]: p[1] for p in TODO._QEMU_MOBILE_DIAG} + self.assertIn("daemon disappeared", pats) + self.assertIn("memory", pats["daemon disappeared"].lower()) + + def test_the_zip_entry_limit_is_named(self): + """La panne d'aujourd'hui, et elle est en amont : un APK est un ZIP + borné à 65 535 entrées, et le dépôt mobile en embarque 122 684 sous + assets/public/repos pour 337 qui sont l'application. Le diagnostic doit + le dire, pas laisser lire 5 000 lignes de Gradle.""" + pats = {e[0]: e[1] for e in TODO._QEMU_MOBILE_DIAG} + self.assertIn("Too many zip entries", pats) + self.assertIn("65535", pats["Too many zip entries"]) + + def test_the_zip_limit_is_named_before_the_generic_gradle_failure(self): + """« FAILED » attrape tout : placé avant, il masquerait la vraie + cause — l'ordre du tableau est le diagnostic.""" + keys = [e[0] for e in TODO._QEMU_MOBILE_DIAG] + self.assertLess( + keys.index("Too many zip entries"), keys.index("FAILED") + ) + + def test_the_cause_is_proven_not_assumed(self): + """Le compte de l'oom-killer et la RAM viennent avec : une cause + « mémoire » sans chiffre serait une supposition de plus.""" + self.assertIn("mmem()", self.cmd) + self.assertIn("MemTotal", self.cmd) + self.assertIn("oom-kill", self.cmd) + + def test_it_runs_and_names_the_cause(self): + import os + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + # Un dmesg qui rapporte un oom-kill, via un sudo neutre. + (bin_dir / "sudo").write_text('#!/bin/bash\nshift 0; exec "$@"\n') + (bin_dir / "dmesg").write_text( + "#!/bin/bash\necho 'oom-kill:constraint=CONSTRAINT_NONE'\n" + ) + for name in ("sudo", "dmesg"): + (bin_dir / name).chmod(0o755) + log = pathlib.Path(tmp) / "build.log" + log.write_text( + "> Task :app:compressDebugAssets\n" + "Gradle build daemon disappeared unexpectedly\n" + ) + res = subprocess.run( + ["bash", "-c", self.cmd + f'mdiag "{log}"'], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=60, + ) + out = res.stdout + # « RAM » et « OOM » traversent les deux langues ; le chiffre, + # lui, est ce qui distingue une cause prouvée d'une supposition. + self.assertIn("RAM", out) + self.assertRegex(out, r"\(OOM\)|OOM kills") + self.assertRegex(out, r"[0-9]+") + + +class TestAvdStep(unittest.TestCase): + """L'étape AVD, et ce qu'elle laisse comme piste dans le journal. + + Le tunnel adb lui-même est vérifié dans test_qemu_emulator_menu.py, qui + couvre aussi le démarrage sans fenêtre et la question de la fenêtre. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_the_avd_step_points_at_the_tunnel(self): + """La question vient juste après « c'est trop lent » : la réponse doit + être à portée de journal.""" + self.assertIn("tunnel > 4", self.todo._qemu_avd_remote_cmd()) + + +class TestGnomeSiteExtensions(unittest.TestCase): + """Extensions posées depuis extensions.gnome.org, par leur UUID.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.block = self.todo._qemu_gnome_ext_remote_cmd() + + def test_the_three_requested_extensions_are_there(self): + for uuid in ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ): + self.assertIn(uuid, self.block, uuid) + + def test_the_archive_follows_the_running_gnome(self): + """Le site sert une archive DIFFERENTE selon la version demandée — + gTile v59 en GNOME 46, v62 en 48 : figer une URL poserait une archive + faite pour une autre version.""" + self.assertIn("shell_version=$sv", self.block) + self.assertIn("gnome-shell --version", self.block) + + def test_valid_shell_and_never_fails_the_install(self): + script = "set -e\n" + self.block + self.assertEqual( + 0, + subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ).returncode, + ) + # Sans gnome-shell dans le PATH, le bloc doit se taire proprement. + res = subprocess.run( + ["bash", "-c", "PATH=/nonexistent; " + script], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_a_session_bus_is_provided_for_dconf(self): + """Un « ssh hote commande » n'a pas de bus de session : sans lui, + l'activation ne peut rien ecrire dans dconf.""" + self.assertIn("dbus-run-session", self.block) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_emulator_menu.py b/test/test_qemu_emulator_menu.py new file mode 100644 index 0000000..387446f --- /dev/null +++ b/test/test_qemu_emulator_menu.py @@ -0,0 +1,504 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Menu de l'émulateur Android : démarrage, fenêtre ou non, tunnel adb. + +Ce qui se vérifie ici sans VM tient à ce qui a réellement cassé sur la VM de +preuve : une commande privée de son chemin absolu (« emulator: command not +found », un ssh non interactif ne lisant aucun rc), un second émulateur sur le +même AVD (« Running multiple emulators »), une clé d'hôte refusée sur une IP +recyclée, et un tunnel visant une adresse que l'émulateur n'écoute pas. + +Le tunnel est le point délicat : l'émulateur n'écoute que sur le 127.0.0.1 de +la VM (« ss -ltn » dans l'invité ; l'hyperviseur reçoit un refus sur +IP_VM:5555). Une redirection vers l'IP de la VM ne peut donc PAS aboutir, et +seul un dernier saut dans la VM place « localhost » au bon endroit. +""" + +import os +import socket +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +def _run_ok(stdout="", returncode=0, stderr=""): + return subprocess.CompletedProcess( + args=[], returncode=returncode, stdout=stdout, stderr=stderr + ) + + +class TestSshOptions(unittest.TestCase): + """La politique de clés d'hôte n'est PAS la même selon la provenance.""" + + def test_local_vm_tolerates_a_recycled_host_key(self): + """Une IP libvirt est réattribuée d'un déploiement au suivant : la clé + change sous la même adresse et ssh refuse — « Host key verification + failed », vécu dans ce menu même.""" + opts = TODO._qemu_ssh_opts("virsh") + self.assertIn("StrictHostKeyChecking=no", opts) + self.assertIn("UserKnownHostsFile=/dev/null", opts) + + def test_a_configured_host_keeps_its_own_key_policy(self): + """Un hôte de ~/.ssh/config appartient à l'utilisateur : sa clé est un + garde-fou, et le désarmer en son nom serait une décision volée.""" + opts = TODO._qemu_ssh_opts("ssh_config") + self.assertNotIn("StrictHostKeyChecking=no", opts) + self.assertNotIn("UserKnownHostsFile=/dev/null", opts) + self.assertIn("BatchMode=yes", opts) + + def test_both_refuse_to_hang_on_a_password_prompt(self): + for src in ("virsh", "ssh_config"): + self.assertIn("BatchMode=yes", TODO._qemu_ssh_opts(src), src) + + +class TestSshTarget(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_configured_host_is_named_as_is(self): + """C'est l'entrée ssh_config qui porte le ProxyJump : la réécrire à la + main reviendrait à deviner la chaîne de sauts.""" + self.todo._qemu_resolve_ips = lambda *a, **k: {} + self.assertEqual( + self.todo._qemu_ssh_target("test-vm_02+proof", "ssh_config"), + "test-vm_02+proof", + ) + + def test_a_local_vm_is_reached_by_ip_as_erplibre(self): + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.assertEqual( + self.todo._qemu_ssh_target("vm-a", "virsh"), + "erplibre@192.168.123.81", + ) + + def test_no_ip_yields_no_target_rather_than_a_broken_one(self): + """Une VM éteinte n'a pas d'IP. Rendre « erplibre@None » enverrait ssh + résoudre un nom absurde au lieu de le dire.""" + self.todo._qemu_resolve_ips = lambda names, labels=None: {} + self.assertEqual(self.todo._qemu_ssh_target("vm-a", "virsh"), "") + + +class TestEmulatorRunning(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_counts_what_pgrep_reports(self): + with mock.patch("subprocess.run", return_value=_run_ok("1\n")): + self.assertEqual(self.todo._qemu_emulator_running("h"), 1) + + def test_reads_the_last_line_so_a_warning_does_not_fool_it(self): + """ssh écrit ses avertissements sur stdout dans certains cas ; le compte + est la DERNIÈRE ligne.""" + with mock.patch( + "subprocess.run", return_value=_run_ok("Warning: added key\n0\n") + ): + self.assertEqual(self.todo._qemu_emulator_running("h"), 0) + + def test_an_unreachable_vm_is_unknown_not_zero(self): + """Zéro voudrait dire « libre » et autoriserait un second émulateur sur + le même AVD. L'inconnu se distingue donc du vide.""" + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual(self.todo._qemu_emulator_running("h"), -1) + with mock.patch("subprocess.run", return_value=_run_ok("bavardage\n")): + self.assertEqual(self.todo._qemu_emulator_running("h"), -1) + + +class TestEmulatorReady(unittest.TestCase): + """Une seule lecture répond aux deux questions : binaire, puis AVD.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_a_complete_vm_is_ready(self): + with mock.patch("subprocess.run", return_value=_run_ok("")): + self.assertEqual(self.todo._qemu_emulator_ready("h"), (True, "")) + + def test_the_missing_piece_is_named(self): + for probe, word in (("NO_SDK\n", "SDK"), ("NO_AVD\n", "AVD")): + with mock.patch("subprocess.run", return_value=_run_ok(probe)): + ready, why = self.todo._qemu_emulator_ready("h") + self.assertFalse(ready, probe) + self.assertIn(word, why, probe) + + def test_the_sdk_is_reported_before_the_avd(self): + """Sans SDK, l'absence d'AVD n'est qu'une conséquence : nommer la cause + évite d'envoyer l'utilisateur créer un AVD qu'il ne peut pas créer.""" + with mock.patch( + "subprocess.run", return_value=_run_ok("NO_SDK\nNO_AVD\n") + ): + _, why = self.todo._qemu_emulator_ready("h") + self.assertIn("SDK", why) + + def test_an_unreachable_vm_is_not_declared_ready(self): + with mock.patch("subprocess.run", side_effect=OSError): + ready, why = self.todo._qemu_emulator_ready("h") + self.assertFalse(ready) + self.assertTrue(why) + + +class _MenuCase(unittest.TestCase): + """Socle commun : une VM locale, des réponses scriptées, aucun vrai ssh.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.calls = [] + + def _play( + self, + answers, + running=0, + start_rc=0, + port_taken=False, + probe="", + running_after=1, + log="rien", + ): + """Joue le menu avec des réponses données ; rend (sortie, commandes). + + « running » est le compte AVANT le démarrage, « running_after » celui + d'après : c'est cette distinction qui dit si l'émulateur a réellement + pris, le code de retour d'un « setsid » détaché ne valant rien. + """ + state = {"started": False} + + def fake_run(cmd, *a, **k): + self.calls.append(cmd) + joined = " ".join(cmd) + if "pgrep -c qemu-system" in joined: + n = running_after if state["started"] else running + return _run_ok(f"{n}\n") + if "NO_SDK" in joined: + return _run_ok(probe) + if "setsid" in joined: + state["started"] = True + return _run_ok(returncode=start_rc, stderr="boum") + if "tail -5" in joined: + return _run_ok(log) + return _run_ok() + + it = iter(answers) + with mock.patch("subprocess.run", side_effect=fake_run), mock.patch( + "builtins.input", lambda *a: next(it) + ), mock.patch.object( + TODO, "_port_in_use", staticmethod(lambda p: port_taken) + ), mock.patch( + "script.todo.todo.time.sleep", lambda *a: None + ), mock.patch( + "sys.stdout", new_callable=__import__("io").StringIO + ) as out: + self.todo._qemu_emulator_menu() + return out.getvalue(), self.calls + + @staticmethod + def _started(calls): + return [c for c in calls if any("setsid" in x for x in c)] + + @staticmethod + def _tunnels(calls): + return [c for c in calls if "-f" in c and "-N" in c] + + +class TestEmulatorMenu(_MenuCase): + def test_no_target_at_all_says_so_without_touching_ssh(self): + self.todo._qemu_list_domains = lambda: [] + out, calls = self._play([]) + self.assertIn("~/.ssh/config", out) + self.assertEqual(calls, []) + + def test_an_off_vm_is_reported_before_any_start(self): + self.todo._qemu_resolve_ips = lambda names, labels=None: {} + out, calls = self._play(["1"]) + self.assertIn("IP", out.upper()) + self.assertEqual(self._started(calls), []) + + def test_a_running_emulator_is_seen_and_refusing_stops_there(self): + """Deux émulateurs sur un même AVD, et le second meurt sur « Running + multiple emulators with the same AVD » — vécu deux fois. On le dit + AVANT, et un refus ne démarre rien.""" + out, calls = self._play(["1", "n"], running=1) + self.assertIn("pkill -f", out) + # « [q]emu » : la classe empêche le pkill de se trouver lui-même. + self.assertIn("[q]emu-system", out) + self.assertEqual(self._started(calls), []) + + def test_accepting_closes_the_other_one_then_starts(self): + out, calls = self._play(["1", "o", "1", "n"], running=1) + killed = [c for c in calls if any("pkill" in x for x in c)] + self.assertTrue(killed, "aucun pkill envoyé") + self.assertTrue(self._started(calls), "rien démarré après fermeture") + + def test_the_window_choice_is_delegated_to_the_workstation(self): + """L'écran appartient au poste : cette commande ne peut pas partir de + l'hyperviseur, qui n'a aucun affichage à lui prêter.""" + out, calls = self._play(["1", "2"]) + self.assertIn("ssh -XC", out) + self.assertEqual(self._started(calls), []) + self.assertEqual(self._tunnels(calls), []) + + def test_the_headless_start_carries_what_the_vm_needs(self): + out, calls = self._play(["1", "1", "n"]) + started = self._started(calls) + self.assertEqual(len(started), 1) + cmd = started[0][-1] + # Chemin absolu : « ssh hôte 'commande' » ne lit ni ~/.profile ni + # ~/.bashrc, et « emulator » seul rend « command not found » — vécu. + self.assertIn("$HOME/android/emulator/emulator", cmd) + self.assertIn("-no-window", cmd) + # sg kvm : sans le groupe, l'émulateur n'a pas /dev/kvm et renonce. + self.assertIn("sg kvm", cmd) + # setsid : il doit survivre à la fermeture de ce ssh. + self.assertIn("setsid -f", cmd) + self.assertIn("/tmp/erplibre-emulator.log", cmd) + + def test_the_start_carries_the_shared_flags(self): + """Une seule autorité pour ces drapeaux : le lancement du menu, la + commande fenêtrée et celle de l'étape AVD doivent dire la même chose. + La densité va avec la résolution — 540x1140 en densité 420 est mesuré + PIRE que le plein écran.""" + _, calls = self._play(["1", "1", "n"]) + cmd = self._started(calls)[0][-1] + for flag in ( + "-skin 540x1140", + "qemu.sf.lcd_density=240", + "-no-snapshot-save", + "-gpu swangle", + ): + self.assertIn(flag, cmd, flag) + + def test_the_windowed_command_carries_them_too(self): + """Qui ouvre la fenêtre depuis son poste doit avoir le même écran que + qui passe par scrcpy — sinon la comparaison ne veut rien dire.""" + out, _ = self._play(["1", "2"]) + self.assertIn("-skin 540x1140", out) + self.assertIn("qemu.sf.lcd_density=240", out) + + def test_the_start_command_is_valid_shell(self): + """Une apostrophe ou un guillemet de trop, et la VM répond par une + erreur de syntaxe — déjà rencontré dans ce même fichier.""" + _, calls = self._play(["1", "1", "n"]) + cmd = self._started(calls)[0][-1] + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(res.returncode, 0, res.stderr) + + def test_a_failed_start_does_not_offer_a_tunnel_to_nothing(self): + out, calls = self._play(["1", "1"], start_rc=1) + self.assertIn("boum", out) + self.assertEqual(self._tunnels(calls), []) + + def test_a_vm_without_the_sdk_is_diagnosed_before_anything_else(self): + """Une VM déployée sans cocher l'outil est le cas NORMAL. Le menu le + dit avant même de demander la fenêtre — mesuré sur une VM de migration, + où le démarrage détaché rendait 0 et le journal disait « not found ». + """ + out, calls = self._play(["1"], probe="NO_SDK\n") + self.assertIn("SDK", out) + self.assertNotIn("[1]", out.split("VM locale")[-1]) + self.assertEqual(self._started(calls), []) + + def test_a_vm_without_the_avd_is_named_as_such(self): + out, calls = self._play(["1"], probe="NO_AVD\n") + self.assertIn("AVD", out) + self.assertEqual(self._started(calls), []) + + def test_a_stray_answer_cancels_instead_of_starting(self): + """« n » à une question à deux crans partait démarrer l'émulateur : + tout ce qui n'était pas « 2 » valait « sans fenêtre ». Observé.""" + for stray in ("n", "3", "oui"): + self.calls = [] + out, calls = self._play(["1", stray]) + self.assertEqual(self._started(calls), [], stray) + + def test_a_start_that_never_appears_reports_the_log_not_a_success(self): + """Le code de retour d'un « setsid » détaché vaut 0 quoi qu'il arrive : + seule la présence du processus prouve le démarrage.""" + out, calls = self._play( + ["1", "1"], running_after=0, log="emulator: not found" + ) + self.assertIn("not found", out) + self.assertNotIn("scrcpy -s", out) + + def test_a_successful_start_chains_into_the_tunnel_help(self): + out, _ = self._play(["1", "1", "n"]) + self.assertIn("scrcpy", out) + self.assertIn("adb connect localhost:5555", out) + + +class TestScrcpyTunnel(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.calls = [] + + def _play( + self, + src="virsh", + answers=("n",), + rc=0, + port_taken=False, + started=False, + name="vm-a", + ): + def fake_run(cmd, *a, **k): + self.calls.append(cmd) + return _run_ok(returncode=rc, stderr="refus") + + it = iter(answers) + with mock.patch("subprocess.run", side_effect=fake_run), mock.patch( + "builtins.input", lambda *a: next(it) + ), mock.patch.object( + TODO, "_port_in_use", staticmethod(lambda p: port_taken) + ), mock.patch.dict( + "os.environ", {"USER": "poste"} + ), mock.patch( + "sys.stdout", new_callable=__import__("io").StringIO + ) as out: + self.todo._qemu_scrcpy_tunnel(name, src, started=started) + return out.getvalue(), self.calls + + def test_a_local_vm_needs_the_vm_as_the_LAST_hop(self): + """L'émulateur n'écoute que sur le 127.0.0.1 de la VM. Une redirection + vers IP_VM:5555 depuis l'hyperviseur est refusée (mesuré) : « localhost » + ne vaut que sur le dernier saut, d'où -J.""" + out, _ = self._play(src="virsh") + self.assertIn("-L 5555:localhost:5555", out) + self.assertIn("-J poste@10.0.0.2", out) + self.assertIn("erplibre@192.168.123.81", out) + self.assertNotIn("-L 5555:192.168.123.81:5555", out) + + def test_a_configured_host_rides_its_own_proxyjump(self): + out, _ = self._play(src="ssh_config", name="test-vm_02+proof") + self.assertIn("-L 5555:localhost:5555 test-vm_02+proof", out) + self.assertNotIn("-J", out) + self.assertIn("ProxyJump", out) + + def test_it_gives_the_three_workstation_commands(self): + """Le tunnel ne sert à rien seul : la connexion adb et scrcpy le + suivent, et scrcpy n'est pas installé par défaut.""" + out, _ = self._play() + self.assertIn("adb connect localhost:5555", out) + self.assertIn("scrcpy -s localhost:5555", out) + self.assertIn("apt install scrcpy", out) + + def test_this_path_never_falls_back_to_x11(self): + """Tout l'intérêt : plus de X11 nulle part, le flux est du H.264.""" + out, _ = self._play() + self.assertNotIn("ssh -X", out) + + def test_the_tunneled_port_is_the_device_not_the_adb_server(self): + """5037 est le serveur adb du poste : le tunneler obligerait à tuer + celui de l'utilisateur, qui occupe le même port. 5555 est l'appareil. + """ + out, _ = self._play() + self.assertNotIn("5037", out) + self.assertIn("5555", out) + + def test_it_does_not_repeat_the_start_command_after_starting(self): + out, _ = self._play(started=True) + self.assertNotIn("-no-window", out) + out, _ = self._play(started=False) + self.assertIn("-no-window", out) + self.assertIn("$HOME/android/emulator/emulator", out) + + def test_declining_opens_nothing(self): + _, calls = self._play(answers=("n",)) + self.assertEqual(calls, []) + + def test_accepting_opens_a_detached_tunnel_that_fails_loudly(self): + """« -f » sans « ExitOnForwardFailure » rend 0 alors que la redirection + a échoué : un succès annoncé pour un tunnel absent.""" + out, calls = self._play(answers=("o",)) + self.assertEqual(len(calls), 1) + cmd = calls[0] + self.assertIn("-f", cmd) + self.assertIn("-N", cmd) + self.assertIn("ExitOnForwardFailure=yes", cmd) + self.assertIn("5555:localhost:5555", cmd) + self.assertIn("erplibre@192.168.123.81", cmd) + self.assertIn("✅", out) + + def test_an_occupied_port_is_named_instead_of_a_silent_bind_error(self): + """Le « bind: Address already in use » d'ssh se perd en mode détaché.""" + out, calls = self._play(answers=("o",), port_taken=True) + self.assertEqual(calls, []) + self.assertIn("5555", out) + self.assertIn("pkill", out) + + def test_a_refused_tunnel_is_reported(self): + out, _ = self._play(answers=("o",), rc=255) + self.assertIn("refus", out) + self.assertNotIn("✅", out) + + +class TestPortInUse(unittest.TestCase): + def test_a_listening_socket_is_seen(self): + with socket.socket() as srv: + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + self.assertTrue(TODO._port_in_use(srv.getsockname()[1])) + + def test_a_closed_port_is_free(self): + with socket.socket() as probe: + probe.bind(("127.0.0.1", 0)) + port = probe.getsockname()[1] + self.assertFalse(TODO._port_in_use(port)) + + +class TestEmulatorGpuOverride(unittest.TestCase): + """« -gpu host » doit être essayable sans toucher au code. + + Les drapeaux sont figés à l'import de todo.py : la variable doit donc être + lue AVANT, ce qu'un sous-processus est seul à démontrer. Le défaut reste + swangle — mesuré — car un contexte GL qui échoue laisse l'émulateur pendu + au lieu de retomber. + """ + + def _flags(self, env=None): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + environ = dict(os.environ, PYTHONPATH=root) + environ.update(env or {}) + res = subprocess.run( + [ + sys.executable, + "-c", + "import sys; sys.argv=['todo.py']\n" + "from script.todo.todo import TODO\n" + "print(TODO._QEMU_EMULATOR_FLAGS)", + ], + capture_output=True, + text=True, + cwd=root, + env=environ, + timeout=120, + ) + self.assertEqual(0, res.returncode, res.stderr[-400:]) + return res.stdout + + def test_the_default_is_the_measured_one(self): + self.assertIn("-gpu swangle", self._flags()) + + def test_the_variable_replaces_it(self): + out = self._flags({"EL_EMULATOR_GPU": "host"}) + self.assertIn("-gpu host", out) + self.assertNotIn("swangle", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py new file mode 100644 index 0000000..71e2152 --- /dev/null +++ b/test/test_qemu_forgejo.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Option Forgejo : la case, son filtrage, et le script qui fait le travail. + +Forgejo est un service, pas un outil de bureau : une VM serveur le prend comme +une VM graphique. Son binaire est statique, donc le même fichier sert apt, dnf, +pacman et zypper — c'est ce qui le rend portable sans une branche par +distribution. Les architectures, elles, sont bornées par l'amont : Forgejo +publie amd64, arm64 et arm-6, et rien pour s390x. + +Le script est vérifié en l'EXÉCUTANT sur ses chemins de refus — architecture +inconnue, version introuvable — qui précèdent toute élévation de privilège et +ne touchent donc à rien. +""" + +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 + +SCRIPT = ( + pathlib.Path(__file__).resolve().parent.parent + / "script/forgejo/install_forgejo.sh" +) + + +class TestTheCheckbox(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.spec = TODO._QEMU_VM_TOOLS["forgejo"] + + def test_it_is_offered_in_the_form(self): + keys = [k for k, _l, _h in TODO._qemu_vm_tool_choices()] + self.assertIn("forgejo", keys) + + def test_a_plain_server_gets_it(self): + """Une forge n'affiche rien : elle n'a pas besoin de bureau.""" + self.assertFalse(self.spec["needs_desktop"]) + got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu") + self.assertIn("forgejo", got) + + def test_every_package_family_gets_it(self): + """Le binaire est statique : aucune famille n'est exclue, à la + différence de la compilation mobile que son installateur borne à apt. + """ + self.assertEqual(self.spec["families"], ()) + for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"): + self.assertIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), "amd64", "", distro), + distro, + ) + + def test_arm64_yes_s390x_no(self): + """Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir + en Go : la case se grise plutôt que de poser un binaire inexécutable. + """ + for arch in ("amd64", "arm64"): + self.assertIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), arch, "", "ubuntu"), + arch, + ) + self.assertNotIn( + "forgejo", + self.todo._qemu_tools_for(("forgejo",), "s390x", "", "ubuntu"), + ) + + def test_its_disk_cost_is_counted_in_the_plan(self): + self.assertGreater( + self.todo._qemu_tools_disk_gb(("forgejo",), "amd64", "", "ubuntu"), + 0, + ) + + +class TestTheInstallBlock(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_it_calls_the_dedicated_script(self): + """Une seule autorité : la même commande sert le déploiement d'une VM et + une installation à la main.""" + for prod, root in ( + (False, "$HOME/git/erplibre"), + (True, "/opt/erplibre"), + ): + block = self.todo._qemu_forgejo_steps( + self.todo._qemu_install_dir(prod) + ) + self.assertIn(f"{root}/script/forgejo/install_forgejo.sh", block) + + def test_alone_it_does_not_drag_the_android_prologue(self): + """Cocher Forgejo seul ne doit pas installer un SDK Android.""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo",), False) + self.assertIn("install_forgejo.sh", cmd) + self.assertNotIn("sdkmanager", cmd) + self.assertNotIn("mstep", cmd) + + def test_with_the_mobile_build_forgejo_comes_first(self): + """Une minute contre une heure : un échec rapide se voit tôt.""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False) + self.assertLess(cmd.index("install_forgejo.sh"), cmd.index("gradlew")) + + def test_a_forgejo_failure_still_fails_the_vm(self): + """Même contrat que la compilation mobile : une VM dont la forge + demandée n'existe pas n'est pas la VM demandée. Les groupes sont donc + liés par « && », jamais par « ; ».""" + cmd = self.todo._qemu_after_remote_cmd(("forgejo", "mobile"), False) + between = cmd[ + cmd.index("install_forgejo.sh") : cmd.index("ERPLibre mobile") + ] + self.assertIn("&&", between) + self.assertNotIn("|| true", between) + + def test_valid_shell_in_every_combination(self): + for tools in ( + ("forgejo",), + ("forgejo", "mobile"), + ("forgejo", "avd"), + ("forgejo", "mobile", "avd"), + ): + cmd = self.todo._qemu_after_remote_cmd(tools, False) + res = subprocess.run( + ["bash", "-n"], + input="mstep() { :; }; mdiag() { :; };\n" + cmd, + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, f"{tools}: {res.stderr}") + + +class TestDesktopOnlyVm(unittest.TestCase): + """Une VM sans ERPLibre : le script Forgejo vit dans le dépôt, donc nulle + part. L'écarter en silence laisserait croire qu'une case cochée a été + honorée.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_it_says_what_it_skips(self): + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("forgejo",) + ) + self.assertIn("forgejo", cmd) + self.assertIn(t("needs the ERPLibre install, skipped:"), cmd) + self.assertNotIn("install_forgejo.sh", cmd) + + def test_it_stays_quiet_when_nothing_was_deferred(self): + """Assertion visée sur LA note, et non sur tout « ⚠ » : la commande en + porte d'autres, légitimes — dont celui du bureau qui ne démarre pas.""" + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("gnome_ext",) + ) + self.assertNotIn(t("needs the ERPLibre install, skipped:"), cmd) + + def test_the_note_is_valid_shell(self): + cmd = self.todo._qemu_erplibre_remote_cmd( + None, None, False, "gnome", "", "deb", ("forgejo", "mobile") + ) + res = subprocess.run( + ["bash", "-n"], input=cmd, capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + +class TestTheScript(unittest.TestCase): + """Le script lui-même, exécuté sur ses chemins de refus.""" + + def test_it_is_executable_and_valid_shell(self): + self.assertTrue(os.access(SCRIPT, os.X_OK), "pas exécutable") + res = subprocess.run( + ["bash", "-n", str(SCRIPT)], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_help_explains_the_knobs_and_exits_clean(self): + res = subprocess.run( + ["bash", str(SCRIPT), "--help"], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + for knob in ( + "FORGEJO_VERSION", + "FORGEJO_HTTP_PORT", + "FORGEJO_ADMIN_USER", + ): + self.assertIn(knob, res.stdout) + + def _run_with_stubs(self, stubs, env=None): + """Lance le script avec un PATH bouchonné. Les chemins testés ici + s'arrêtent AVANT tout sudo : rien n'est installé nulle part.""" + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for name, body in stubs.items(): + (bin_dir / name).write_text(f"#!/bin/bash\n{body}\n") + (bin_dir / name).chmod(0o755) + return subprocess.run( + ["bash", str(SCRIPT)], + capture_output=True, + text=True, + env=dict( + os.environ, + PATH=f"{bin_dir}:/usr/bin:/bin", + **(env or {}), + ), + timeout=120, + ) + + def test_an_unpublished_architecture_is_refused_by_name(self): + """Sur s390x, Forgejo n'a pas de binaire. Le dire vaut mieux que + télécharger un fichier qui ne s'exécutera pas.""" + res = self._run_with_stubs({"uname": "echo s390x"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("s390x", res.stdout + res.stderr) + + def test_an_unreachable_release_feed_is_named(self): + res = self._run_with_stubs({"uname": "echo x86_64", "curl": "exit 7"}) + self.assertNotEqual(0, res.returncode) + self.assertIn("Version", res.stdout + res.stderr) + + def test_a_pinned_version_needs_no_feed(self): + """FORGEJO_VERSION évite l'appel réseau : utile hors ligne, et c'est ce + qui rend ce test rapide.""" + res = self._run_with_stubs( + {"uname": "echo x86_64", "curl": "exit 7", "sudo": "exit 0"}, + env={"FORGEJO_VERSION": "9.9.9"}, + ) + out = res.stdout + res.stderr + self.assertIn("9.9.9", out) + # Il échoue plus loin (le téléchargement est bouchonné), pas sur la + # version : c'est bien le réseau du flux qui a été évité. + self.assertNotIn("Version de Forgejo introuvable", out) + + +class TestHostAddress(unittest.TestCase): + """L'adresse qui va dans ROOT_URL et SSH_DOMAIN, sur trois terrains. + + « hostname -I » vient de net-tools : l'inetutils d'Arch ne connaît pas ce + drapeau et peut rendre le NOM de la machine. Une ROOT_URL bâtie sur un nom + non résolvable est pire qu'un repli, d'où la validation de la forme. + """ + + def _host_address(self, stubs): + """Extrait la fonction du script et l'exécute avec un PATH bouchonné.""" + body = SCRIPT.read_text() + start = body.index("host_address() {") + end = body.index("\n}", start) + 2 + fn = body[start:end] + with tempfile.TemporaryDirectory() as tmp: + bin_dir = pathlib.Path(tmp) / "bin" + bin_dir.mkdir() + for name, script in stubs.items(): + (bin_dir / name).write_text(f"#!/bin/bash\n{script}\n") + (bin_dir / name).chmod(0o755) + res = subprocess.run( + ["bash", "-c", fn + "\nhost_address"], + capture_output=True, + text=True, + env=dict(os.environ, PATH=f"{bin_dir}:/usr/bin:/bin"), + timeout=30, + ) + return res.stdout.strip() + + def test_it_takes_the_address_hostname_gives(self): + got = self._host_address({"hostname": "echo 10.1.2.3"}) + self.assertEqual("10.1.2.3", got) + + def test_a_hostname_that_returns_a_name_is_rejected(self): + """Le cas Arch : on tombe alors sur « ip », et non sur un nom.""" + got = self._host_address( + { + "hostname": "echo erplibre-arch", + "ip": "echo '1.0.0.1 via 10.0.0.1 dev eth0 src 10.9.9.9 uid 0'", + } + ) + self.assertEqual("10.9.9.9", got) + + def test_without_hostname_nor_ip_it_falls_back_to_localhost(self): + """Une forge joignable en local vaut mieux qu'un script qui s'arrête.""" + got = self._host_address({"hostname": "exit 1", "ip": "exit 1"}) + self.assertEqual("localhost", got) + + def test_it_never_returns_an_empty_string(self): + """Une ROOT_URL « http://:3000/ » ne mène nulle part.""" + for stubs in ( + {"hostname": "echo", "ip": "echo"}, + {"hostname": "exit 2", "ip": "exit 2"}, + ): + self.assertTrue(self._host_address(stubs), stubs) + + +class TestTheScriptGuards(unittest.TestCase): + """Quatre pièges rencontrés en le mettant au point, tous mesurés.""" + + def setUp(self): + self.body = SCRIPT.read_text() + + @property + def code_lines(self): + """Les lignes de CODE : le piège est expliqué en commentaire, et un + test qui cherche dans les commentaires trébuche sur sa propre + documentation — vécu à l'écriture de ce fichier.""" + return [ + ln + for ln in self.body.splitlines() + if ln.strip() and not ln.lstrip().startswith("#") + ] + + def test_it_never_probes_with_exec_on_dev_tcp(self): + """« exec » est un builtin spécial : une redirection qui échoue termine + le shell. Le script mourait au premier tour de la boucle d'attente, + code 1 et pas un mot.""" + guilty = [ln for ln in self.code_lines if "exec 3<>" in ln] + self.assertEqual([], guilty) + self.assertIn("/api/v1/version", self.body) + + def test_the_config_test_goes_through_sudo(self): + """/etc/forgejo est en 770 root:git : « [ -f ] » échouait toujours, et + chaque passage réécrivait la configuration avec des secrets neufs.""" + self.assertIn("sudo test -f", self.body) + + def test_all_four_secrets_are_written(self): + """Sans oauth2.JWT_SECRET, Forgejo tente de l'écrire dans app.ini, + n'y arrive pas, et boucle sur « [F] save oauth2.JWT_SECRET failed ».""" + for key in ( + "SECRET_KEY", + "INTERNAL_TOKEN", + "JWT_SECRET", + "LFS_JWT_SECRET", + ): + self.assertIn(key, self.body, key) + + def test_the_default_admin_name_is_not_reserved(self): + """Forgejo refuse « admin » : « CreateUser: name is reserved ».""" + self.assertIn("FORGEJO_ADMIN_USER:-erplibre", self.body) + self.assertNotIn("FORGEJO_ADMIN_USER:-admin}", self.body) + + def test_it_restarts_when_something_changed(self): + """« enable --now » ne touche PAS un service déjà actif : il garde alors + sa configuration en mémoire. Vécu, et le symptôme ne désignait pas la + cause — le serveur comparait son ancien INTERNAL_TOKEN à celui que le + hook venait de lire, répondait 403 à son propre hook, et tout push + finissait sur « Internal Server Error Decoding Failed ».""" + self.assertIn("systemctl restart forgejo.service", self.body) + self.assertNotIn("enable --now forgejo", self.body) + + def test_the_restart_is_conditional(self): + """Rejouer le script sur une forge saine ne doit pas l'interrompre, + même deux secondes.""" + self.assertIn("CHANGED=0", self.body) + self.assertIn('[ "$CHANGED" = 1 ]', self.body) + # Trois évènements le lèvent : binaire posé, config écrite, unité + # modifiée. + self.assertEqual(3, self.body.count("CHANGED=1")) + + def test_the_unit_is_compared_before_being_written(self): + """Sans comparaison, l'unité serait réécrite à l'identique et le + service redémarrerait pour rien à chaque passage.""" + self.assertIn("cmp -s", self.body) + + def test_the_readiness_loop_stays_quiet_while_retrying(self): + """« Failed to connect » au premier tour est normal — le service vient + de redémarrer. C'est le die final qui parle.""" + # La commande est coupée sur deux lignes : on regarde le BLOC de la + # boucle, pas la ligne qui porte l'URL. + start = self.body.index("ready=0") + block = self.body[start : self.body.index('[ "$ready" = 1 ]', start)] + self.assertIn("curl -fs -o /dev/null", block) + self.assertNotIn("-fsS", block) + + def test_it_touches_no_package_manager(self): + """C'est ce qui le rend portable : le binaire est statique.""" + for pm in ("apt-get install", "dnf install", "pacman -S", "zypper"): + self.assertNotIn(pm, self.body, pm) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py new file mode 100644 index 0000000..071fddc --- /dev/null +++ b/test/test_qemu_gpu.py @@ -0,0 +1,875 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""GPU de l'hôte : détection, déploiement, et réglage d'une VM éteinte. + +Une VM graphique sans accélération rend tout par le processeur — le bureau +comme l'émulateur Android qui tourne dedans. L'hôte a un GPU ou non ; s'il en +a un, la VM doit le prendre, et c'est le défaut. + +Ce que ces tests gardent, appris en le cassant : + +- UN SEUL « --video » : la 3D remplace le « --video virtio », elle ne s'y + ajoute pas — deux écrans, et l'invité n'en peuple qu'un. +- « --add-device --graphics egl-headless » n'est PAS idempotent : deux appels, + deux affichages. D'où l'état lu avant tout plan. +- « virt-xml --memory N » ne touche que : la RAM plafonnait en + silence à l'ancien maximum. +- Le retrait de la 3D cible le TYPE egl-headless : sans ce ciblage, c'est la + console VNC de la VM qui disparaît. +- « --define » sur chaque commande : sans lui, virt-xml POSE UNE QUESTION et + le menu se bloque sans rien dire. +""" + +import contextlib +import importlib.util +import io +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo import qemu_hardware as hw # noqa: E402 +from script.todo.todo import TODO # noqa: E402 + + +def _deploy_qemu(): + """deploy_qemu.py chargé comme module, comme le fait todo.py.""" + path = Path(__file__).resolve().parents[1] / "script/qemu/deploy_qemu.py" + spec = importlib.util.spec_from_file_location("deploy_qemu", path) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +DQ = _deploy_qemu() + +# XML réel d'une VM du parc, réduit à ce qui décide du matériel. +XML_SANS_3D = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + +""" + +XML_AVEC_3D = XML_SANS_3D.replace( + "", + "" + "", +).replace( + "", + "\n " + "", +) + +# XML persistant complet : mode CPU, écrans, interface réseau. C'est cette +# forme-là que « virsh dumpxml --inactive » rend, sans les décorations que +# libvirt ajoute au démarrage (portid, vnetN, alias). +XML_COMPLET = """ + erplibre-ubuntu-2604-gnome + 33554432 + 33554432 + 8 + + + + + + + + + + + +""" + +XML_SERVEUR = """ + erplibre-serveur + 2097152 + 2 + + +""" + +NODE = "/dev/dri/renderD128" + + +class TestDetection(unittest.TestCase): + """Le GPU se lit dans /dev/dri, pas dans une liste de cartes connues.""" + + def _dri(self, *names): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + for n in names: + Path(tmp.name, n).touch() + return tmp.name + + def test_it_keeps_render_nodes_and_ignores_the_rest(self): + """« card0 » est le nœud d'affichage, pas de rendu : QEMU ouvre + renderD*, et lui donner card0 échouerait au démarrage.""" + found = DQ.host_render_nodes( + self._dri("card0", "renderD128", "by-path") + ) + self.assertEqual(1, len(found)) + self.assertTrue(found[0].endswith("renderD128")) + + def test_several_cards_come_out_sorted(self): + found = DQ.host_render_nodes(self._dri("renderD129", "renderD128")) + self.assertEqual( + ["renderD128", "renderD129"], [Path(f).name for f in found] + ) + + def test_a_host_without_dri_answers_empty(self): + """Cet hyperviseur-ci est lui-même une VM sans GPU : /dev/dri n'existe + pas du tout. Une exception ici ferait échouer TOUT déploiement.""" + self.assertEqual([], DQ.host_render_nodes("/nexistepas/dri")) + self.assertEqual("", DQ.host_gpu_node("/nexistepas/dri")) + + def test_presence_is_the_test_not_our_own_access(self): + """Le nœud appartient au groupe « render » ; libvirt donne l'accès au + démarrage du domaine. Tester nos droits rejetterait un hôte valable.""" + d = self._dri("renderD128") + os.chmod(Path(d, "renderD128"), 0o000) + self.assertTrue(DQ.host_gpu_node(d).endswith("renderD128")) + + +class TestDecision(unittest.TestCase): + """« Par défaut avec GPU s'il existe » — et le silence n'est pas permis.""" + + def test_auto_takes_the_gpu_when_there_is_one(self): + on, msg = DQ.gpu_decision("auto", NODE, True) + self.assertTrue(on) + self.assertIn(NODE, msg) + + def test_auto_without_gpu_says_why_it_falls_back(self): + """Sans ce message, une VM en rendu logiciel ne s'explique pas — et on + cherche la lenteur ailleurs pendant des heures.""" + on, msg = DQ.gpu_decision("auto", "", True) + self.assertFalse(on) + self.assertTrue(msg.strip()) + + def test_forcing_it_without_a_node_refuses_and_warns(self): + """libvirt refuse de démarrer un domaine dont le rendernode manque : + obéir aveuglément à « --gpu on » livrerait une VM qui ne démarre pas. + """ + on, msg = DQ.gpu_decision("on", "", True) + self.assertFalse(on) + self.assertIn("⚠", msg) + + def test_off_stays_off_and_silent(self): + self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, True)) + + def test_no_screen_no_3d(self): + """Une VM serveur n'a pas d'écran : la 3D n'y accélérerait rien, et le + « --edit --video » échouerait faute de périphérique vidéo.""" + on, _ = DQ.gpu_decision("auto", NODE, False) + self.assertFalse(on) + + +class TestDeployArgs(unittest.TestCase): + def test_the_args_carry_both_halves(self): + """L'accélération sur le virtio-gpu ET un affichage capable de + contexte GL : l'une sans l'autre ne donne aucune 3D.""" + args = DQ.gpu_install_args(NODE) + self.assertIn("model.acceleration.accel3d=on", " ".join(args)) + self.assertIn(f"gl.rendernode={NODE}", " ".join(args)) + + def test_only_one_video_device_survives(self): + """Le bug qui donne deux écrans : garder « --video virtio » à côté du + « --video » de la 3D. L'invité n'en peuple alors qu'un.""" + video, gpu_args, _ = DQ.gpu_apply( + ["--video", "virtio"], "auto", NODE, True + ) + self.assertEqual([], video) + self.assertEqual(1, (video + gpu_args).count("--video")) + + def test_without_gpu_the_plain_video_stays(self): + video, gpu_args, _ = DQ.gpu_apply( + ["--video", "virtio"], "auto", "", True + ) + self.assertEqual(["--video", "virtio"], video) + self.assertEqual([], gpu_args) + + def test_the_egl_display_is_added_beside_vnc_not_instead(self): + """« egl-headless » n'ouvre aucun port : il ne remplace pas la console + VNC, il porte le contexte OpenGL. Les deux cohabitent.""" + args = DQ.gpu_install_args(NODE) + self.assertIn("type=egl-headless", " ".join(args)) + self.assertNotIn("vnc", " ".join(args)) + + +class TestReadState(unittest.TestCase): + def test_it_reads_what_the_vm_has(self): + st = hw.hw_state(XML_SANS_3D) + self.assertEqual("erplibre-ubuntu-2604-gnome", st["name"]) + self.assertEqual(8, st["vcpus"]) + self.assertEqual(32768, st["mem_mib"]) + self.assertEqual("virtio", st["video"]) + self.assertFalse(st["accel3d"]) + self.assertTrue(st["screen"]) + + def test_it_sees_an_existing_3d_setup(self): + st = hw.hw_state(XML_AVEC_3D) + self.assertTrue(st["accel3d"]) + self.assertTrue(st["egl"]) + self.assertEqual(NODE, st["render"]) + + def test_egl_headless_alone_is_not_a_screen(self): + """Il n'affiche rien et n'ouvre aucun port. Le compter comme écran + proposerait la 3D à une VM qui n'a rien à accélérer.""" + xml = XML_SERVEUR.replace( + "", + "", + ) + self.assertFalse(hw.hw_state(xml)["screen"]) + + def test_the_balloon_target_is_what_the_vm_gets(self): + """ est le maximum, ce que la VM voit. Lire le + premier annoncerait 32 Go à une VM qui en a 4.""" + xml = XML_SANS_3D.replace( + "33554432", + "4194304", + ) + self.assertEqual(4096, hw.hw_state(xml)["mem_mib"]) + + def test_units_are_exact(self): + """KB vaut mille octets, KiB en vaut 1024 : le schéma libvirt autorise + les deux, et « à peu près » se voit dans le tableau.""" + for unit, value, mib in ( + ("KiB", 1048576, 1024), + ("MiB", 2048, 2048), + ("GiB", 4, 4096), + ("bytes", 1073741824, 1024), + ): + xml = XML_SERVEUR.replace( + "2097152", + f"{value}", + ) + self.assertEqual(mib, hw.hw_state(xml)["mem_mib"], unit) + + def test_broken_xml_gives_an_empty_state(self): + st = hw.hw_state(" : la VM plafonne + à son ancien maximum, sans un mot.""" + plan = self._plan(XML_SANS_3D, {"ram": 65536}) + arg = [c for c in plan[0]["cmd"] if c.startswith("memory=")][0] + self.assertIn("memory=65536", arg) + self.assertIn("currentMemory=65536", arg) + + def test_3d_without_a_host_node_is_refused_with_a_reason(self): + plan = hw.hw_plan(hw.hw_state(XML_SANS_3D), {"gpu": True}, "") + self.assertEqual(1, len(plan)) + self.assertNotIn("cmd", plan[0]) + self.assertTrue(plan[0]["skip"]) + + def test_3d_on_a_screenless_vm_is_refused(self): + """« --edit --video » échouerait : il n'y a pas de périphérique vidéo + à modifier. Mieux vaut le dire que laisser virt-xml protester.""" + plan = self._plan(XML_SERVEUR, {"gpu": True}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_autostart_only_moves_when_it_differs(self): + self.assertEqual( + [], self._plan(XML_SANS_3D, {"autostart": True}, autostart=True) + ) + plan = self._plan(XML_SANS_3D, {"autostart": False}, autostart=True) + self.assertIn("--disable", plan[0]["cmd"]) + + def test_every_command_defines_and_names_its_uri(self): + """Sans « --define », virt-xml INTERROGE l'utilisateur quand le domaine + tourne : le menu se bloque sur une question qu'on ne voit pas. Sans + « --connect », un appel non root viserait qemu:///session, où les VM du + parc n'existent pas.""" + plan = self._plan(XML_SANS_3D, {"vcpus": 4, "ram": 8192, "gpu": True}) + self.assertTrue(plan) + for entry in plan: + self.assertIn("--define", entry["cmd"]) + self.assertIn("qemu:///system", entry["cmd"]) + + def test_a_state_without_a_name_plans_nothing(self): + """Un dumpxml illisible ne doit pas produire une commande sans cible.""" + self.assertEqual([], hw.hw_plan({}, {"vcpus": 4}, NODE)) + + +class TestWant(unittest.TestCase): + def test_empty_fields_keep_the_current_values(self): + """Valider sans rien saisir ne doit pas rétrécir la VM à néant.""" + st = hw.hw_state(XML_SANS_3D) + want = hw.build_want(st, "", "", False, False) + self.assertEqual(8, want["vcpus"]) + self.assertEqual(32768, want["ram"]) + + def test_gigabytes_are_understood(self): + st = hw.hw_state(XML_SANS_3D) + self.assertEqual(8192, hw.build_want(st, "", "8G", 0, 0)["ram"]) + + def test_nonsense_does_not_shrink_the_vm(self): + st = hw.hw_state(XML_SANS_3D) + want = hw.build_want(st, "beaucoup", "gros", 0, 0) + self.assertEqual(8, want["vcpus"]) + self.assertEqual(32768, want["ram"]) + + +class TestDisplay(unittest.TestCase): + def test_the_ram_field_stays_short_enough_to_read(self): + """« 32768 » ne tient pas dans le champ et s'affichait « 3276 » : un + nombre tronqué qu'on valide sans regarder rétrécit la machine.""" + self.assertEqual("32G", hw.ram_field(32768)) + self.assertEqual("1536", hw.ram_field(1536)) + for mib in (1024, 12288, 32768, 65536): + self.assertLessEqual(len(hw.ram_field(mib)), 4) + + def test_sizes_read_like_sizes(self): + self.assertEqual("1 Go", hw.fmt_mib(1024)) + self.assertEqual("1,5 Go", hw.fmt_mib(1536)) + self.assertEqual("512 Mo", hw.fmt_mib(512)) + + def test_the_summary_names_the_render_node(self): + self.assertIn("renderD128", hw.hw_summary(hw.hw_state(XML_AVEC_3D))) + + def test_a_screen_without_3d_says_software_rendering(self): + summary = hw.hw_summary(hw.hw_state(XML_SANS_3D)) + self.assertIn("8 vCPU", summary) + self.assertIn(hw.t("software rendering"), summary) + + +class TestMenuGlue(unittest.TestCase): + """Le raccord dans todo.py : ce qui est éteint, et ce qui s'exécute.""" + + def _todo(self, states, xml=XML_SANS_3D, node=NODE): + todo = TODO.__new__(TODO) + todo._qemu_domstate = lambda name: states[name] + todo._qemu_dumpxml = lambda name: xml.replace( + "erplibre-ubuntu-2604-gnome", name + ) + todo._qemu_autostart = lambda name: False + todo._qemu_host_gpu_node = lambda: node + # Aucun test ne doit atteindre virsh : la liste des réseaux est fournie. + todo._qemu_net_choices = lambda: ["network:default"] + todo.launched = [] + todo.execute = mock.Mock() + todo.execute.exec_command_live = ( + lambda cmd, **kw: todo.launched.append(cmd) + ) + return todo + + def _run(self, todo, names, answers): + it = iter(answers) + out = io.StringIO() + with mock.patch("builtins.input", lambda *a: next(it, "")): + with contextlib.redirect_stdout(out): + todo._qemu_adjust_hardware(names) + return out.getvalue() + + def test_a_running_vm_is_left_alone_and_said_so(self): + """virt-xml y écrirait une définition qui ne prend effet qu'au + prochain démarrage : un réglage qui paraît appliqué et ne l'est pas.""" + todo = self._todo({"vm-a": "running"}) + todo._qemu_hw_form = lambda rows, node, nets=None: {} + out = self._run(todo, ["vm-a"], []) + self.assertIn("vm-a", out) + self.assertEqual([], todo.launched) + + def test_a_shut_off_vm_is_adjusted(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True} + } + self._run(todo, ["vm-a"], ["o"]) + joined = " ".join(todo.launched) + self.assertIn("--vcpus 4", joined) + self.assertIn("accel3d=on", joined) + self.assertTrue(all(c.startswith("sudo ") for c in todo.launched)) + + def test_nothing_to_change_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"vcpus": 8, "ram": 32768, "gpu": False} + } + out = self._run(todo, ["vm-a"], []) + self.assertEqual([], todo.launched) + self.assertIn(hw.t("Nothing to change."), out) + + def test_refusing_the_confirmation_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"vcpus": 4} + } + self._run(todo, ["vm-a"], ["n"]) + self.assertEqual([], todo.launched) + + def test_cancelling_the_form_launches_nothing(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: None + self._run(todo, ["vm-a"], []) + self.assertEqual([], todo.launched) + + def test_the_skipped_3d_is_explained_not_silent(self): + todo = self._todo({"vm-a": "shut off"}, node="") + todo._qemu_hw_form = lambda rows, node, nets=None: { + "vm-a": {"gpu": True} + } + out = self._run(todo, ["vm-a"], []) + self.assertIn(hw.t("no render node on the host"), out) + self.assertEqual([], todo.launched) + + def test_the_host_gpu_is_announced_before_anything_else(self): + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: None + out = self._run(todo, ["vm-a"], []) + self.assertIn(NODE, out) + + def test_the_prompts_take_over_when_textual_is_absent(self): + """Le repli en ligne n'est pas décoratif : sans Textual, c'est la SEULE + voie, et un {} mal interprété annulerait tout.""" + todo = self._todo({"vm-a": "shut off"}) + todo._qemu_hw_form = lambda rows, node, nets=None: {} + # vCPU, RAM, 3D, démarrage auto, mode CPU, écrans, puis la validation. + self._run(todo, ["vm-a"], ["6", "", "o", "n", "", "", "o"]) + self.assertIn("--vcpus 6", " ".join(todo.launched)) + + def test_an_empty_answer_keeps_the_current_state(self): + """Le défaut d'une question fermée est l'état ACTUEL de la VM : sur un + formulaire de matériel, le silence ne modifie rien.""" + todo = TODO.__new__(TODO) + with mock.patch("builtins.input", lambda *a: ""): + self.assertTrue(todo._qemu_ask_bool("? ", True)) + self.assertFalse(todo._qemu_ask_bool("? ", False)) + with mock.patch("builtins.input", lambda *a: "n'importe quoi"): + self.assertTrue(todo._qemu_ask_bool("? ", True)) + + def test_autostart_is_read_from_virsh(self): + out = "Id: -\nName: vm-a\nAutostart: enable\n" + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, out, ""), + ): + self.assertTrue(TODO._qemu_autostart("vm-a")) + with mock.patch("subprocess.run", side_effect=OSError): + self.assertFalse(TODO._qemu_autostart("vm-a")) + + +class TestForm(unittest.IsolatedAsyncioTestCase): + """Le formulaire monté pour de vrai : ce qu'il propose et ce qu'il rend.""" + + async def _mount(self, rows, node): + app = hw.run_hardware_form(rows, node, run_app=False) + return app + + async def test_it_returns_the_intention_on_apply(self): + from textual.widgets import Checkbox, Input + + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#vcpus0", Input).value = "6" + app.query_one("#ram0", Input).value = "8G" + app.query_one("#gpu0", Checkbox).value = True + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual(6, want["vcpus"]) + self.assertEqual(8192, want["ram"]) + self.assertTrue(want["gpu"]) + + async def test_escape_returns_nothing(self): + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.press("escape") + await pilot.pause() + self.assertIsNone(app.want) + + async def test_3d_is_out_of_reach_without_a_host_node(self): + """Cocher une case qui ne peut rien produire ferait attendre une + accélération que l'hôte ne sait pas donner.""" + from textual.widgets import Checkbox + + app = await self._mount([hw.hw_state(XML_SANS_3D)], "") + async with app.run_test() as pilot: + await pilot.pause() + self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) + + async def test_3d_is_out_of_reach_for_a_screenless_vm(self): + from textual.widgets import Checkbox + + app = await self._mount([hw.hw_state(XML_SERVEUR)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + self.assertTrue(app.query_one("#gpu0", Checkbox).disabled) + + async def test_the_fields_start_on_the_current_values(self): + from textual.widgets import Input + + app = await self._mount([hw.hw_state(XML_SANS_3D)], NODE) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual("8", app.query_one("#vcpus0", Input).value) + self.assertEqual("32G", app.query_one("#ram0", Input).value) + + async def test_the_second_row_carries_cpu_screens_and_network(self): + from textual.widgets import Input, Select + + app = hw.run_hardware_form( + [hw.hw_state(XML_COMPLET)], + NODE, + nets=["network:default", "bridge:br0"], + run_app=False, + ) + async with app.run_test() as pilot: + await pilot.pause() + app.query_one("#cpu0", Select).value = "host-model" + app.query_one("#heads0", Input).value = "2" + app.query_one("#net0", Select).value = "bridge:br0" + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual("host-model", want["cpu"]) + self.assertEqual(2, want["heads"]) + self.assertEqual("bridge:br0", want["net"]) + + async def test_the_screens_value_is_actually_visible(self): + """Sous six colonnes, Textual dessine le cadre du champ mais PAS son + contenu : la valeur devient invisible, et on valide un champ qu'on + croit vide. Pire qu'une troncature, donc vérifié à l'écran.""" + import re + + app = hw.run_hardware_form( + [dict(hw.hw_state(XML_COMPLET), heads=3)], NODE, run_app=False + ) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + svg = app.export_screenshot() + rendu = re.findall(r">([^<>]+)", svg) + self.assertIn("3", [txt.strip() for txt in rendu]) + + async def test_without_networks_to_offer_there_is_no_network_field(self): + """Un hôte sans pont n'a qu'une voie : une liste à un seul choix ne + vaut pas la place qu'elle prend.""" + app = hw.run_hardware_form( + [dict(hw.hw_state(XML_COMPLET), net="")], NODE, run_app=False + ) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual(0, len(app.query("#net0"))) + await pilot.press("ctrl+s") + await pilot.pause() + self.assertEqual("", app.want["erplibre-ubuntu-2604-gnome"]["net"]) + + async def test_the_current_network_stays_selected(self): + """La liste montre ce que la VM a : sans cela, valider sans y toucher + la basculerait sur le premier choix de la liste.""" + from textual.widgets import Select + + app = hw.run_hardware_form( + [hw.hw_state(XML_COMPLET)], + NODE, + nets=["bridge:br0", "network:default"], + run_app=False, + ) + async with app.run_test() as pilot: + await pilot.pause() + self.assertEqual( + "network:default", app.query_one("#net0", Select).value + ) + await pilot.press("ctrl+s") + await pilot.pause() + want = app.want["erplibre-ubuntu-2604-gnome"] + self.assertEqual("network:default", want["net"]) + self.assertEqual([], hw.hw_plan(hw.hw_state(XML_COMPLET), want, NODE)) + + async def test_it_fits_in_eighty_columns(self): + """Un terminal de 80 colonnes est le plus petit qu'on rencontre ; + au-delà, les libellés se tronquent en « Démarrage automatiq… ».""" + import re + + rows = [hw.hw_state(XML_COMPLET), hw.hw_state(XML_SERVEUR)] + app = hw.run_hardware_form( + rows, NODE, nets=["network:default", "bridge:br0"], run_app=False + ) + async with app.run_test(size=(80, 24)) as pilot: + await pilot.pause() + svg = app.export_screenshot() + texte = " ".join(re.findall(r">([^<>]+)", svg)) + self.assertNotIn("…", texte) + + +class TestCpuScreensNetwork(unittest.TestCase): + """Les trois réglages ajoutés, et pourquoi chacun est celui-là. + + Mesuré sur l'hôte avant de les offrir : « heads » atteint QEMU + (max_outputs), « vram » NON sur un virtio-gpu — il n'est donc pas proposé. + """ + + def _plan(self, xml, want, node=NODE): + return hw.hw_plan(hw.hw_state(xml), want, node) + + def test_it_reads_the_cpu_mode_screens_and_network(self): + st = hw.hw_state(XML_COMPLET) + self.assertEqual("host-passthrough", st["cpu"]) + self.assertEqual(1, st["heads"]) + self.assertEqual("network:default", st["net"]) + + def test_a_vm_without_an_interface_has_no_network(self): + self.assertEqual("", hw.hw_state(XML_SERVEUR)["net"]) + + def test_a_bridge_is_read_as_a_bridge(self): + """libvirt refuse type='network' avec un pont pour source : le type et + le nom doivent voyager ensemble.""" + xml = XML_COMPLET.replace( + "", "" + ).replace("", "") + self.assertEqual("bridge:br0", hw.hw_state(xml)["net"]) + + def test_the_label_marks_the_bridge_only(self): + """Le réseau libvirt est le cas ordinaire ; le suffixe allongeait le + libellé au-delà de la liste déroulante, qui se repliait.""" + self.assertEqual("default", hw.net_label("network:default")) + self.assertIn("br0", hw.net_label("bridge:br0")) + self.assertIn(hw.t("bridge"), hw.net_label("bridge:br0")) + + def test_the_spec_names_the_right_virt_xml_key(self): + self.assertEqual("network=default", hw.net_spec("network:default")) + self.assertEqual("bridge=br0", hw.net_spec("bridge:br0")) + + def test_passthrough_keeps_check_and_migratable(self): + """C'est ce que virt-install écrit, et ce que veut la virtualisation + imbriquée : sans eux libvirt vérifie un modèle qu'il n'a pas calculé. + """ + xml = XML_COMPLET.replace( + "", + "", + ) + plan = self._plan(xml, {"cpu": "host-passthrough"}) + arg = plan[0]["cmd"][-1] + self.assertIn("check=none", arg) + self.assertIn("migratable=on", arg) + + def test_host_model_carries_nothing_extra(self): + plan = self._plan(XML_COMPLET, {"cpu": "host-model"}) + self.assertEqual("host-model", plan[0]["cmd"][-1]) + + def test_the_same_cpu_mode_changes_nothing(self): + self.assertEqual( + [], self._plan(XML_COMPLET, {"cpu": "host-passthrough"}) + ) + + def test_screens_go_through_the_video_model(self): + """« heads » devient max_outputs sur la ligne QEMU — vérifié par + domxml-to-native. C'est le seul réglage vidéo qui y arrive.""" + plan = self._plan(XML_COMPLET, {"heads": 2}) + self.assertIn("model.heads=2", plan[0]["cmd"]) + + def test_screens_on_a_screenless_vm_are_refused_not_attempted(self): + """« --edit --video » sortirait en erreur au milieu du lot, et les + commandes suivantes ne partiraient pas.""" + plan = self._plan(XML_SERVEUR, {"heads": 2}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_screens_and_3d_are_two_separate_edits(self): + """Vérifié sur un domaine réel : le second « --edit --video » ne + remet pas heads à 1, et le premier ne perd pas l'accélération.""" + plan = self._plan(XML_COMPLET, {"heads": 2, "gpu": True}) + videos = [e for e in plan if "--video" in e.get("cmd", [])] + self.assertEqual(2, len(videos)) + heads = [e for e in videos if "model.heads=2" in e["cmd"]] + self.assertEqual(1, len(heads)) + self.assertNotIn("accel3d", " ".join(heads[0]["cmd"])) + + def test_switching_to_a_bridge(self): + """Le MAC et l'adresse PCI survivent — vérifié sur un domaine réel, + démarré : sans cela l'invité verrait une carte neuve, et son bail + DHCP comme son nom d'interface changeraient.""" + plan = self._plan(XML_COMPLET, {"net": "bridge:br0"}) + self.assertIn("bridge=br0", plan[0]["cmd"]) + self.assertNotIn("mac", " ".join(plan[0]["cmd"])) + + def test_switching_back_to_a_libvirt_network(self): + xml = XML_COMPLET.replace( + "", "" + ).replace("", "") + plan = hw.hw_plan(hw.hw_state(xml), {"net": "network:default"}, NODE) + self.assertIn("network=default", plan[0]["cmd"]) + + def test_the_same_network_changes_nothing(self): + self.assertEqual( + [], self._plan(XML_COMPLET, {"net": "network:default"}) + ) + + def test_a_vm_without_an_interface_is_told_not_attempted(self): + plan = self._plan(XML_SERVEUR, {"net": "bridge:br0"}) + self.assertEqual(1, len(plan)) + self.assertIn("skip", plan[0]) + + def test_an_unknown_current_cpu_mode_stays_offered(self): + """Une VM en mode « custom » ne doit pas voir son réglage disparaître + d'une liste qui l'ignore : la liste afficherait un AUTRE mode que le + sien, et valider le formulaire le changerait sans le dire.""" + modes = hw.cpu_choices([{"cpu": "custom"}]) + self.assertIn("custom", modes) + self.assertIn("host-passthrough", modes) + + def test_the_network_list_merges_the_host_and_the_current_value(self): + choices = hw.net_choices( + [{"net": "bridge:br9"}], ["network:default", "bridge:br9"] + ) + self.assertEqual( + ["network:default", "bridge:br9"], [tok for tok, _ in choices] + ) + + def test_empty_answers_keep_the_current_hardware(self): + st = hw.hw_state(XML_COMPLET) + want = hw.build_want( + st, "", "", False, False, cpu="", heads="", net="" + ) + self.assertEqual("host-passthrough", want["cpu"]) + self.assertEqual(1, want["heads"]) + self.assertEqual("network:default", want["net"]) + self.assertEqual([], hw.hw_plan(st, want, "")) + + +class TestHostNetworks(unittest.TestCase): + """Ce que l'hôte propose : ses réseaux libvirt, et ses ponts à lui.""" + + def _choices(self, nets, infos, bridges): + todo = TODO.__new__(TODO) + sorties = {} + sorties["net-list"] = nets + sorties["bridge"] = bridges + sorties.update(infos) + + def fake(cmd): + if "net-list" in cmd: + return sorties["net-list"] + if "net-info" in cmd: + return sorties.get(cmd[-1], []) + return sorties["bridge"] + + todo._qemu_cmd_lines = fake + return todo._qemu_net_choices() + + def test_a_libvirt_owned_bridge_is_not_offered_twice(self): + """virbr0 EST le réseau « default » : l'offrir aussi comme pont + proposerait deux fois le même chemin, dont un qui contourne la + gestion du réseau par libvirt.""" + got = self._choices( + ["default"], + {"default": ["Name: default", "Bridge: virbr0"]}, + ["3: virbr0: "], + ) + self.assertEqual(["network:default"], got) + + def test_a_real_bridge_is_offered(self): + got = self._choices( + ["default"], + {"default": ["Bridge: virbr0"]}, + [ + "3: virbr0: ", + "4: br0: ", + ], + ) + self.assertEqual(["network:default", "bridge:br0"], got) + + def test_a_host_without_libvirt_answers_nothing(self): + todo = TODO.__new__(TODO) + todo._qemu_cmd_lines = lambda cmd: [] + self.assertEqual([], todo._qemu_net_choices()) + + def test_the_persistent_definition_is_what_gets_read(self): + """Sur une VM allumée, « dumpxml » sans --inactive rend la vue VIVANTE + (portid, vnetN, alias) — pas la définition que virt-xml modifie.""" + vu = {} + + def fake_run(cmd, **kw): + vu["cmd"] = cmd + return subprocess.CompletedProcess([], 0, "", "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_dumpxml("vm-a") + self.assertIn("--inactive", vu["cmd"]) + + def test_a_numbered_pick_defaults_to_the_current_value(self): + todo = TODO.__new__(TODO) + out = io.StringIO() + for reponse, attendu in ( + ("", "b"), + ("mille", "b"), + ("9", "b"), + ("1", "a"), + ("2", "b"), + ): + with mock.patch("builtins.input", lambda *a, r=reponse: r): + with contextlib.redirect_stdout(out): + got = todo._qemu_pick("t", ["a", "b"], "b") + self.assertEqual(attendu, got, reponse) + + +if __name__ == "__main__": + unittest.main(verbosity=1) diff --git a/test/test_qemu_install_summary.py b/test/test_qemu_install_summary.py new file mode 100644 index 0000000..0ddd710 --- /dev/null +++ b/test/test_qemu_install_summary.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Résumé d'un journal d'installation : ce qui a échoué doit se voir. + +Le détail des erreurs cherchait la sous-chaîne « error ». Or le journal de +l'installation qui a réellement échoué — erplibre-ubuntu-2604-gnome, APK tué +par le noyau — ne contient AUCUNE ligne « error » : 0 sur 8765, mesuré. Le +volet annonçait donc « aucune erreur détectée » sur une installation ratée, +et le tableau de bord comptait 0 erreur. + +Ces tests fixent la règle inverse : une étape en échec, un « FAILURE » de +Gradle, une trace Python ou une mort par mémoire se voient, et le résumé les +présente AVANT les centaines de lignes du détail. +""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo")) +import qemu_install_monitor as m # noqa: E402 + +# Journal réduit à sa forme réelle : les marqueurs de l'installation, puis +# l'échec tel que Gradle l'écrit. Aucune ligne ne contient « error ». +LOG_GRADLE_OOM = """\ +== ERPLibre mobile, SDK Android (long) == + -> venv ERPLibre (tout ce qui suit en dépend) + -> dépendances npm + -> APK debug (gradle) + ⚠ ÉCHEC : APK debug (gradle) + aucun motif connu, dernières lignes : + + FAILURE: Build failed with an exception. + + * What went wrong: + Gradle build daemon disappeared unexpectedly (it may have been killed) + ⚠ aucun APK produit +__ERPLIBRE_EXIT__ 1 +""" + + +def _log(text): + fh = tempfile.NamedTemporaryFile( + "w", suffix=".log", delete=False, encoding="utf-8" + ) + fh.write(text) + fh.close() + return fh.name + + +class TestFailedStepsAreSeen(unittest.TestCase): + def setUp(self): + self.path = _log(LOG_GRADLE_OOM) + + def tearDown(self): + Path(self.path).unlink(missing_ok=True) + + def test_the_reference_log_has_no_line_saying_error(self): + """La prémisse de tout le reste : la détection par sous-chaîne ne + pouvait RIEN trouver ici.""" + self.assertNotIn("error", LOG_GRADLE_OOM.lower()) + + def test_the_failed_step_is_named(self): + got = m.scan_log_summary(self.path) + self.assertEqual( + [s["label"] for s in got["steps"]], ["APK debug (gradle)"] + ) + + def test_the_step_carries_its_diagnostic(self): + """L'échec nomme l'étape ; c'est le diagnostic qui porte la cause.""" + diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"]) + self.assertIn("FAILURE: Build failed", diag) + self.assertIn("daemon disappeared", diag) + + def test_the_exit_marker_is_not_a_diagnostic(self): + diag = "\n".join(m.scan_log_summary(self.path)["steps"][0]["diag"]) + self.assertNotIn(m.EXIT_MARKER, diag) + + def test_the_diagnostic_stops_at_the_next_step(self): + """Sinon le diagnostic avale la suite de l'installation et ne désigne + plus rien.""" + text = LOG_GRADLE_OOM + " -> étape suivante\n bruit\n" + path = _log(text) + try: + diag = "\n".join(m.scan_log_summary(path)["steps"][0]["diag"]) + finally: + Path(path).unlink(missing_ok=True) + self.assertNotIn("bruit", diag) + + def test_hard_signals_are_listed(self): + hard = " ".join( + h["text"] for h in m.scan_log_summary(self.path)["hard"] + ) + self.assertIn("FAILURE", hard) + self.assertIn("disappeared unexpectedly", hard) + + def test_the_dashboard_no_longer_counts_zero_errors(self): + """Le compte alimente le tableau de bord : « 0 erreur » sur une + installation morte est un mensonge, pas une nuance.""" + nerr, _ = m.scan_log_errors(self.path) + self.assertGreater(nerr, 0) + + def test_the_detail_pane_is_no_longer_empty(self): + errs, _ = m.scan_log_error_lines(self.path) + self.assertTrue(errs) + self.assertTrue(any("ÉCHEC" in e or "FAILURE" in e for e in errs)) + + +class TestOtherRealFailures(unittest.TestCase): + """Chaque motif dur est là parce qu'il est apparu dans un vrai journal.""" + + def _first_hard(self, line): + path = _log(f" -> étape\n{line}\n") + try: + return m.scan_log_summary(path)["hard"] + finally: + Path(path).unlink(missing_ok=True) + + def test_python_traceback(self): + self.assertTrue(self._first_hard("Traceback (most recent call last):")) + + def test_git_fatal(self): + self.assertTrue(self._first_hard("fatal: repository not found")) + + def test_apt_missing_package(self): + self.assertTrue( + self._first_hard("E: Unable to locate package python3.12-venv") + ) + + def test_kernel_oom(self): + self.assertTrue( + self._first_hard("Out of memory: Killed process 37603 (java)") + ) + + def test_missing_command(self): + self.assertTrue(self._first_hard("bash: emulator: command not found")) + + def test_a_benign_probe_is_not_a_hard_signal(self): + """« No such file or directory » sortait 5 fois sur 7 d'une sonde + bénigne (« cat: .odoo-version ») : le bruit dilue un résumé dont tout + l'intérêt est d'être court.""" + self.assertFalse( + self._first_hard("cat: .odoo-version: No such file or directory") + ) + + +class TestGrouping(unittest.TestCase): + def test_repeats_are_counted_not_repeated(self): + """Un journal répète la même erreur des centaines de fois avec un + chemin qui change : on veut « ×200 », pas 200 lignes.""" + lines = "\n".join( + f"ERROR: cannot read /var/lib/x/file{i}.txt" for i in range(200) + ) + path = _log(lines + "\n") + try: + groups = m.scan_log_summary(path)["groups"] + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(len(groups), 1) + self.assertEqual(groups[0]["count"], 200) + + def test_the_most_frequent_comes_first(self): + path = _log( + "ERROR: rare thing\n" + + "\n".join(f"ERROR: common {i}" for i in range(5)) + + "\n" + ) + try: + groups = m.scan_log_summary(path)["groups"] + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(groups[0]["count"], 5) + + def test_warnings_are_grouped_apart_from_errors(self): + path = _log("WARNING: a\nERROR: b\n") + try: + kinds = {g["kind"] for g in m.scan_log_summary(path)["groups"]} + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(kinds, {"error", "warning"}) + + +class TestQuietLogs(unittest.TestCase): + def test_a_clean_log_stays_clean(self): + """Le résumé ne doit pas inventer d'échec là où il n'y en a pas.""" + path = _log("== installation ==\n -> étape\n ✅ terminé\n") + try: + got = m.scan_log_summary(path) + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(got["steps"], []) + self.assertEqual(got["hard"], []) + self.assertEqual(got["groups"], []) + + def test_a_missing_log_is_not_a_crash(self): + got = m.scan_log_summary("/nonexistent/erplibre.log") + self.assertEqual(got["steps"], []) + self.assertEqual(got["nerr"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_monitor_telemetry.py b/test/test_qemu_monitor_telemetry.py new file mode 100644 index 0000000..0ec786a --- /dev/null +++ b/test/test_qemu_monitor_telemetry.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Barre de télémétrie du suivi d'installation : CPU, RAM, disque. + +La RAM manquait, et son absence a coûté : une compilation mobile s'est fait +tuer par le noyau sur une VM de 12 Go sans swap, pendant que le suivi affichait +sereinement le CPU et le disque. L'épuisement mémoire ne se voit nulle part +ailleurs — le disque va bien, la charge CPU aussi, et la machine meurt. + +Ce qui se vérifie ici : le chiffre lu est celui que le noyau dit pouvoir +rendre, le swap n'occupe la barre que s'il existe, et une lecture impossible +laisse la barre utile au lieu de la vider. +""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "script/todo")) +import qemu_install_monitor as m # noqa: E402 + +GB = 1 << 30 + + +class TestHostMem(unittest.TestCase): + def test_it_reads_the_real_proc(self): + total, avail, sw_total, sw_free = m._host_mem() + self.assertGreater(total, 0, "aucune RAM lue dans /proc/meminfo") + self.assertLessEqual(avail, total) + self.assertLessEqual(sw_free, sw_total) + + def test_it_reports_what_the_kernel_can_give_back(self): + """« MemAvailable » et non « MemFree » : sur une machine qui travaille, + MemFree est presque nul parce que le cache occupe le reste, et + alarmerait pour rien.""" + proc = ( + "MemTotal: 12000000 kB\n" + "MemFree: 100000 kB\n" + "MemAvailable: 8000000 kB\n" + "SwapTotal: 0 kB\n" + "SwapFree: 0 kB\n" + ) + with mock.patch("builtins.open", mock.mock_open(read_data=proc)): + total, avail, _, _ = m._host_mem() + self.assertEqual(avail, 8000000 * 1024) + self.assertEqual(total, 12000000 * 1024) + + def test_an_unreadable_proc_gives_zeros_not_a_crash(self): + """Le suivi tourne pendant une heure d'installation : il ne meurt pas + parce qu'une lecture a échoué.""" + with mock.patch("builtins.open", side_effect=OSError): + self.assertEqual(m._host_mem(), (0, 0, 0, 0)) + + def test_a_malformed_line_is_not_fatal(self): + with mock.patch( + "builtins.open", mock.mock_open(read_data="MemTotal: beaucoup\n") + ): + self.assertEqual(m._host_mem(), (0, 0, 0, 0)) + + +class TestMemSegment(unittest.TestCase): + def test_the_used_share_is_total_minus_available(self): + got = m._mem_tele(12 * GB, 3 * GB, 0, 0) + self.assertIn("9.0G/12.0G", got) + self.assertIn("(75%)", got) + + def test_the_available_figure_is_shown_as_such(self): + self.assertIn("3.0G", m._mem_tele(12 * GB, 3 * GB, 0, 0)) + + def test_swap_appears_only_when_the_machine_has_some(self): + """Un « swap 0/0 » occuperait la barre pour ne rien dire. Mais dès + qu'il existe, il est montré même à zéro : une machine qui commence à + échanger explique une lenteur, et c'est ce qu'on cherche ici.""" + self.assertNotIn("swap", m._mem_tele(12 * GB, 3 * GB, 0, 0)) + self.assertIn( + "swap 0K/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 4 * GB) + ) + self.assertIn( + "swap 1.0G/4.0G", m._mem_tele(12 * GB, 3 * GB, 4 * GB, 3 * GB) + ) + + def test_nothing_read_means_no_segment_not_a_zero_segment(self): + """La barre garde alors le CPU et le disque, qui eux ont répondu.""" + self.assertEqual(m._mem_tele(0, 0, 0, 0), "") + + def test_more_available_than_total_does_not_show_negative_use(self): + """Cas absurde mais possible entre deux lectures : on ne veut pas + « -1.0G » dans la barre.""" + self.assertIn("0K/12.0G", m._mem_tele(12 * GB, 13 * GB, 0, 0)) + + +class TestLogSilence(unittest.TestCase): + """Une installation morte et une qui travaille portent le même sablier. + + Vécu : une session ssh emportée, l'installation morte sans marqueur de + sortie, et le tableau de bord a montré « ⏳ » pendant 54 minutes. Le + marqueur manque dans les deux cas — seule la date d'écriture du journal + les sépare. + """ + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.log = Path(self.tmp.name) / "vm.log" + self.log.write_text("== installation ==\n") + + def tearDown(self): + self.tmp.cleanup() + + def test_a_fresh_log_is_not_silent(self): + self.assertLess(m.log_idle(str(self.log)), 5) + + def test_an_old_log_reports_its_age(self): + import os + import time + + old = time.time() - 3000 + os.utime(self.log, (old, old)) + self.assertAlmostEqual(m.log_idle(str(self.log)), 3000, delta=5) + + def test_a_missing_log_is_not_reported_as_silent(self): + """-1 plutôt que 0 : « absent » n'est pas « à l'instant », et surtout + pas « silencieux depuis toujours ».""" + self.assertEqual(m.log_idle("/nonexistent/erplibre.log"), -1.0) + self.assertEqual(m.state_mark("⏳", -1.0), "⏳") + + def test_below_the_threshold_the_cell_stays_bare(self): + """Plusieurs étapes sont muettes quelques minutes — un téléchargement + d'Android Studio, une compilation dont la sortie va ailleurs.""" + self.assertEqual(m.state_mark("⏳", 0), "⏳") + self.assertEqual(m.state_mark("⏳", m.IDLE_HINT_SECS), "⏳") + + def test_above_the_threshold_the_cell_says_how_long(self): + mark = m.state_mark("⏳", 2900) + self.assertNotEqual(mark, "⏳") + self.assertIn("48", mark) + + def test_the_threshold_clears_the_longest_measured_silence(self): + """Mesuré : le téléchargement d'Android Studio reste ~5 min sans une + ligne. Un seuil en dessous transformerait chaque installation en alerte, + et l'alerte cesserait d'être lue.""" + studio_download = 5 * 60 + self.assertGreater(m.IDLE_HINT_SECS, studio_download) + # Et pas si haut qu'une installation morte passe la demi-heure. + self.assertLessEqual(m.IDLE_HINT_SECS, 1200) + + +class TestTheRealBar(unittest.TestCase): + """La barre telle que le suivi la construit, sans lancer la TUI.""" + + def setUp(self): + try: + import textual # noqa: F401 + except ImportError: + self.skipTest("textual absent de ce venv") + self.tmp = tempfile.TemporaryDirectory() + log = Path(self.tmp.name) / "vm-a.log" + log.write_text("== installation ==\n") + manifest = Path(self.tmp.name) / "session.json" + manifest.write_text( + json.dumps( + { + "branch": "develop", + "started": 0, + "vms": [ + { + "name": "vm-a", + "ip": "192.168.123.2", + "log": str(log), + "ssh": "ssh erplibre@192.168.123.2", + } + ], + } + ) + ) + self.app = m.run_monitor(str(manifest), run_app=False) + + def tearDown(self): + self.tmp.cleanup() + + def test_the_bar_carries_the_three_resources(self): + bar = self.app._collect_tele() + self.assertIn("CPU", bar) + self.assertIn("RAM", bar) + self.assertIn("💽", bar) + + def test_ram_sits_between_cpu_and_disk(self): + """L'ordre est celui du coût : le CPU se voit ailleurs, la RAM nulle + part, le disque partout.""" + bar = self.app._collect_tele() + self.assertLess(bar.index("CPU"), bar.index("RAM")) + self.assertLess(bar.index("RAM"), bar.index("💽")) + + def test_the_bar_survives_a_mute_proc(self): + with mock.patch.object(m, "_host_mem", return_value=(0, 0, 0, 0)): + bar = self.app._collect_tele() + self.assertIn("CPU", bar) + self.assertIn("💽", bar) + self.assertNotIn("RAM", bar) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py new file mode 100644 index 0000000..8156cd9 --- /dev/null +++ b/test/test_qemu_motd.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Guide de connexion SSH des VM QEMU et identité git injectée. + +Ces fonctions sont PURES : elles rendent du texte. Les tester ne demande donc +ni VM ni réseau, alors qu'une erreur y coûte cher — un user-data invalide fait +rejeter TOUTE la configuration cloud-init, et la VM démarre sans utilisateur ni +clé SSH, donc inaccessible. +""" + +import importlib.util +import os +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DEPLOY_QEMU = os.path.join(REPO, "script", "qemu", "deploy_qemu.py") + +# script/qemu/ n'est pas un paquet : todo.py importe déjà ce fichier de cette +# façon (_qemu_import_module), le test fait pareil. +_spec = importlib.util.spec_from_file_location("deploy_qemu", DEPLOY_QEMU) +dq = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(dq) + +try: + import yaml +except ImportError: # pragma: no cover - PyYAML absent du venv d'outils + yaml = None + +# Une combinaison par distribution du catalogue, dont les DEUX produits +# openSUSE : ils n'ont pas la même commande de mise à jour. +COMBOS = ( + ("ubuntu", "24.04", "amd64"), + ("debian", "12", "s390x"), + ("fedora", "43", "amd64"), + ("almalinux", "9", "arm64"), + ("rocky", "10", "amd64"), + ("opensuse", "16.0", "amd64"), + ("opensuse", "tumbleweed", "amd64"), + ("arch", "latest", "amd64"), +) + +# Largeur d'un terminal standard. Au-delà, le guide se replie et devient +# illisible — c'est le seul défaut qui ne se voit qu'une fois la VM déployée. +TERM_WIDTH = 80 + + +class TestMotdContent(unittest.TestCase): + def test_each_distro_gets_its_package_manager(self): + expected = { + "ubuntu": "apt", + "debian": "apt", + "fedora": "dnf", + "almalinux": "dnf", + "rocky": "dnf", + "opensuse": "zypper", + "arch": "pacman", + } + for distro, version, arch in COMBOS: + motd = dq.build_motd(distro, version, arch) + mgr = expected[distro] + self.assertIn(f"Paquets — {mgr}", motd, distro) + for other in set(expected.values()) - {mgr}: + self.assertNotIn(f" {other} install", motd, distro) + + def test_leap_updates_with_up_and_tumbleweed_with_dup(self): + """La distinction coûte cher à rater : « up » sur Tumbleweed laisse + traîner des paquets retirés des dépôts.""" + leap = dq.build_motd("opensuse", "16.0", "amd64") + rolling = dq.build_motd("opensuse", "tumbleweed", "amd64") + self.assertIn("sudo zypper up", leap) + self.assertNotIn("sudo zypper dup", leap) + self.assertIn("sudo zypper dup", rolling) + self.assertNotIn("sudo zypper up ", rolling) + + def test_pacman_never_suggests_a_bare_sy(self): + motd = dq.build_motd("arch", "latest", "amd64") + for line in motd.splitlines(): + self.assertNotIn("pacman -Sy ", line) + self.assertNotIn("pacman -Sy\n", line) + + def test_header_names_the_distribution(self): + self.assertIn( + "openSUSE Leap 16.0", dq.build_motd("opensuse", "16.0", "x") + ) + self.assertIn( + "openSUSE Tumbleweed", dq.build_motd("opensuse", "tumbleweed", "x") + ) + # Rolling release : le numéro « latest » n'apprend rien. + self.assertIn("Arch Linux ·", dq.build_motd("arch", "latest", "amd64")) + + +class TestMotdErplibreSection(unittest.TestCase): + def test_absent_without_install_dir(self): + """Une VM déployée sans ERPLibre ne doit pas annoncer un dépôt ni un + service qui n'existent pas.""" + motd = dq.build_motd("ubuntu", "24.04", "amd64") + self.assertNotIn("ERPLibre\n", motd.split("╯", 1)[1]) + self.assertNotIn("erplibre.service", motd) + self.assertNotIn("make todo", motd) + # En l'absence de section ERPLibre, les commandes de service doivent + # apparaître dans le bloc système : sinon elles manqueraient partout. + self.assertIn("systemctl status ", motd) + self.assertIn("journalctl -u -f", motd) + + def test_covers_what_an_operator_needs(self): + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + for needed in ( + "cd ~/git/erplibre", # aller au dépôt + "make todo", # menu ERPLibre + "vim config.conf", # éditer le serveur + "sudo systemctl restart erplibre", # redémarrer + "systemctl status erplibre", # inspecter + "journalctl -u erplibre -f", # inspecter + "update_addons_all.sh ", # mise à jour des modules + "git pull && make install_odoo_18", # mise à jour Odoo + "http://:8069", # interface web + ): + self.assertIn(needed, motd) + + def test_no_editor_names_no_command(self): + """Sans éditeur connu, on nomme le fichier : « vi » n'est pas garanti + sur toutes les images cloud, et un guide qui propose une commande + absente est pire que muet.""" + motd = dq.build_motd("arch", "latest", "amd64", "fr", "/opt/erplibre") + self.assertIn("config.conf", motd) + self.assertNotIn("vi config.conf", motd) + self.assertNotIn("nano config.conf", motd) + + def test_no_make_target_stops_at_git_pull(self): + """Les profils sans Odoo (« ERPLibre seul », « mobile ») ne doivent pas + se voir annoncer une cible make qui n'est pas la leur.""" + motd = dq.build_motd( + "ubuntu", "24.04", "amd64", "fr", "~/git/erplibre", "", "vim" + ) + self.assertIn("git pull", motd) + self.assertNotIn("git pull && make", motd) + + +class TestMotdLayout(unittest.TestCase): + def test_never_wider_than_a_standard_terminal(self): + for distro, version, arch in COMBOS: + for lang in ("fr", "en"): + for el_dir in ("", "~/git/erplibre", "/opt/erplibre"): + motd = dq.build_motd( + distro, + version, + arch, + lang, + el_dir, + "install_odoo_18" if el_dir else "", + "vim" if el_dir else "", + ) + for line in motd.splitlines(): + self.assertLessEqual( + len(line), + TERM_WIDTH, + f"{distro} {version} {lang} {el_dir} : {line}", + ) + + def test_the_frame_is_never_narrower_than_what_it_frames(self): + for distro, version, arch in COMBOS: + motd = dq.build_motd( + distro, + version, + arch, + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + lines = motd.splitlines() + frame = len(lines[0]) + self.assertTrue(lines[0].startswith("╭")) + for line in lines: + self.assertLessEqual(len(line), frame, f"{distro} : {line}") + + def test_no_tab_anywhere(self): + """Une tabulation en tête de ligne est une erreur FATALE dans un + scalaire bloc YAML : cloud-init rejette alors tout le user-data.""" + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + self.assertNotIn("\t", motd) + + def test_english_is_really_english(self): + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "en", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + self.assertIn("Packages — apt", motd) + self.assertIn("upgrade the system", motd) + self.assertNotIn("mettre à jour", motd) + + +class TestGitConfig(unittest.TestCase): + def test_sections_and_values(self): + cfg = dq.build_gitconfig("Ada Lovelace", "ada@example.org", "vim") + self.assertIn("[user]", cfg) + self.assertIn("name = Ada Lovelace", cfg) + self.assertIn("email = ada@example.org", cfg) + self.assertIn("[core]", cfg) + self.assertIn("editor = vim", cfg) + + def test_indented_with_spaces_never_tabs(self): + """git accepte les deux ; le scalaire bloc YAML qui transporte ce texte, + non — une tabulation y fait rejeter tout le user-data.""" + self.assertNotIn("\t", dq.build_gitconfig("A", "a@b.c", "vim")) + + def test_empty_when_the_host_has_nothing_to_pass(self): + self.assertEqual("", dq.build_gitconfig("", "", "")) + + def test_partial_identity_omits_the_missing_key(self): + cfg = dq.build_gitconfig("Ada", "", "") + self.assertIn("name = Ada", cfg) + self.assertNotIn("email", cfg) + self.assertNotIn("[core]", cfg) + + +class TestEditorResolution(unittest.TestCase): + def test_known_editors_map_to_package_and_binary(self): + self.assertEqual(("vim", "vim"), dq.EDITOR_PACKAGES["vi"]) + self.assertEqual(("neovim", "nvim"), dq.EDITOR_PACKAGES["nvim"]) + self.assertEqual(("nano", "nano"), dq.EDITOR_PACKAGES["nano"]) + + def test_unknown_editor_is_ignored_not_guessed(self): + """« code » n'est dans aucun dépôt de distribution : l'annoncer + donnerait un core.editor qui fait échouer « git commit ».""" + self.assertNotIn("code", dq.EDITOR_PACKAGES) + self.assertEqual(("", ""), dq.EDITOR_PACKAGES.get("code", ("", ""))) + + def test_binary_drops_the_path_and_the_options(self): + self.assertEqual("code", dq.editor_binary("/usr/bin/code --wait")) + self.assertEqual("vim", dq.editor_binary("vim")) + self.assertEqual("", dq.editor_binary(" ")) + + +class TestWriteFilesBlock(unittest.TestCase): + def _block(self): + return dq.write_files_lines( + [ + ( + "/etc/motd", + "0644", + dq.build_motd("ubuntu", "24.04", "amd64"), + "", + ), + ( + "/home/erplibre/.gitconfig", + "0644", + dq.build_gitconfig("Ada", "ada@example.org", "vim"), + "erplibre", + ), + ] + ) + + def test_permissions_are_quoted(self): + """« permissions: 644 » non quoté est lu en DÉCIMAL et appliqué tel + quel : 0o1204, soit le bit setuid, sans le moindre avertissement.""" + block = "\n".join(self._block()) + self.assertIn("permissions: '0644'", block) + self.assertNotIn("permissions: 0644", block) + + def test_defer_only_for_owned_files(self): + """write_files tourne AVANT la création des utilisateurs : sans + « defer », le chown vers le compte de la VM échoue.""" + block = "\n".join(self._block()) + self.assertEqual(1, block.count("defer: true")) + self.assertEqual(1, block.count("owner: erplibre:erplibre")) + + @unittest.skipIf(yaml is None, "PyYAML absent") + def test_yaml_round_trip_is_byte_identical(self): + """Le scalaire bloc doit rendre EXACTEMENT le texte d'origine : une + indentation mal calculée passerait la validation en abîmant le fichier + écrit dans la VM.""" + motd = dq.build_motd( + "ubuntu", + "24.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + # « + "\n" » comme build_cloud_config, qui termine toujours le + # document : sans ce saut final, un scalaire bloc en fin de flux perd sa + # dernière fin de ligne. + doc = ( + "\n".join(dq.write_files_lines([("/etc/motd", "0644", motd, "")])) + + "\n" + ) + self.assertEqual( + motd, yaml.safe_load(doc)["write_files"][0]["content"] + ) + + @unittest.skipIf(yaml is None, "PyYAML absent") + def test_every_distro_produces_parsable_yaml(self): + for distro, version, arch in COMBOS: + motd = dq.build_motd( + distro, + version, + arch, + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + ) + doc = ( + "\n".join( + dq.write_files_lines([("/etc/motd", "0644", motd, "")]) + ) + + "\n" + ) + self.assertEqual( + motd, yaml.safe_load(doc)["write_files"][0]["content"], distro + ) + + +class TestInstallerGuideNames(unittest.TestCase): + def test_names_are_flat(self): + """Le cpio est déplié séquentiellement et ne crée pas les répertoires + parents manquants : une entrée « erplibre/etc-motd » sans entrée + « erplibre » ferait échouer le dépliage de l'initrd entier.""" + for path in ("/etc/motd", "/home/erplibre/.gitconfig"): + name = dq.installer_guide_name(path) + self.assertNotIn("/", name) + self.assertTrue(name.startswith("erplibre-")) + + def test_two_paths_never_collide(self): + self.assertNotEqual( + dq.installer_guide_name("/etc/motd"), + dq.installer_guide_name("/home/erplibre/.gitconfig"), + ) + + +class TestDesktopBlock(unittest.TestCase): + """Le bloc « Bureau » : présent seulement là où un bureau existe. + + Vécu : une VM graphique restait sur une console texte, GNOME installé et + gdm3 installé — graphical.target était déjà atteinte quand le paquet est + arrivé. La commande qui répare tient sur une ligne, encore faut-il la lire + quelque part. Sur un serveur, elle ne mènerait à aucune unité : le bloc + n'y apparaît pas. + """ + + def _motd(self, desktop): + return dq.build_motd( + "ubuntu", + "26.04", + "amd64", + "fr", + "~/git/erplibre", + "install_odoo_18", + "vim", + desktop, + ) + + def test_a_server_gets_no_desktop_block(self): + self.assertNotIn("Bureau", self._motd(False)) + + def test_a_graphical_vm_gets_it(self): + self.assertIn("Bureau", self._motd(True)) + + def test_it_carries_the_command_that_repairs(self): + """« --now » et non « enable » seul : sur Debian et Ubuntu, l'unité n'a + pas de WantedBy, et « enable » rend 0 sans rien faire.""" + motd = self._motd(True) + self.assertIn("systemctl enable --now gdm", motd) + self.assertIn("systemctl status display-manager", motd) + + def test_it_says_why_now_matters(self): + self.assertIn("--now", self._motd(True)) + + def test_it_stays_inside_the_frame(self): + """Le guide est encadré : une ligne trop longue casse la boîte.""" + lines = self._motd(True).splitlines() + width = max(len(line) for line in lines) + border = [line for line in lines if line.startswith("╭")][0] + self.assertEqual(len(border), width) + + def test_the_default_is_no_block(self): + """Un appelant qui n'en sait rien n'annonce pas un bureau.""" + motd = dq.build_motd("ubuntu", "26.04", "amd64", "fr") + self.assertNotIn("Bureau", motd) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py new file mode 100644 index 0000000..211a775 --- /dev/null +++ b/test/test_qemu_tunnel_menu.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Menu du tunnel de bureau distant : ses quatre choix, et où ils mènent. + +Ce menu n'avait aucun test, et c'est ainsi qu'un appel à deux arguments vers +une fonction qui n'en prenait aucun a pu être livré : le choix « console de +l'hyperviseur » levait un TypeError au lieu d'ouvrir quoi que ce soit. Chaque +choix est donc atteint ici pour de vrai, jusqu'à la commande imprimée. + +La ligne de partage est celle du dernier saut : xrdp et TigerVNC écoutent sur +toutes les interfaces de l'invité, donc l'hyperviseur les atteint par l'IP de +la VM ; l'émulateur Android, lui, n'écoute que sur son 127.0.0.1, ce qui exige +un saut de plus. Les deux formes coexistent, et ce n'est pas une incohérence. +""" + +import io +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +class _MenuCase(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + self.todo._qemu_resolve_ips = lambda names, labels=None: { + "vm-a": "192.168.123.81" + } + self.todo._qemu_self_address = staticmethod(lambda: ("10.0.0.2", True)) + self.todo._ssh_proxyjump = lambda name: "hyperviseur" + self.todo._qemu_vnc_port = lambda domain, jump: 5900 + + def _play(self, answers): + it = iter(answers) + buf = io.StringIO() + with mock.patch("builtins.input", lambda *a: next(it)), mock.patch( + "sys.stdout", buf + ): + self.todo._qemu_tunnel_menu() + return buf.getvalue() + + +class TestTunnelMenuChoices(_MenuCase): + def test_the_hypervisor_console_is_reachable_at_all(self): + """Le défaut vécu : « _qemu_console_tunnel() takes 1 positional + argument but 3 were given ». Le choix 3 doit aboutir, pas lever.""" + out = self._play(["1", "3"]) + self.assertIn("5900", out) + self.assertIn("hyperviseur", out) + + def test_the_console_targets_the_hypervisor_not_the_guest(self): + """L'écran VNC appartient à QEMU : côté invité, le socket n'existe + pas.""" + out = self._play(["1", "3"]) + self.assertIn("ssh -N -L 5900:127.0.0.1:5900 hyperviseur", out) + + def test_a_domain_without_a_vnc_port_is_diagnosed_not_tunneled(self): + """Avec « listen=none », QEMU n'ouvre AUCUN socket : aucun tunnel n'y + peut rien tant que le domaine n'est pas redéfini.""" + self.todo._qemu_vnc_port = lambda domain, jump: 0 + out = self._play(["1", "3"]) + self.assertIn("virsh edit", out) + self.assertNotIn("ssh -N -L", out) + + def test_the_emulator_choice_reaches_the_adb_tunnel(self): + out = self._play(["1", "4", "n"]) + self.assertIn("scrcpy", out) + self.assertIn("5555", out) + + def test_rdp_is_the_default_and_vnc_the_second(self): + self.assertIn("3389", self._play(["1", ""])) + self.assertIn("5901", self._play(["1", "2"])) + + def test_a_configured_host_rides_its_proxyjump_for_rdp(self): + out = self._play(["1", "1"]) + self.assertIn("-L 3390:localhost:3389 saut+vm-a", out) + + def test_an_out_of_range_choice_cancels_without_a_command(self): + out = self._play(["9"]) + self.assertNotIn("ssh -N", out) + + +class TestVirtViewer(_MenuCase): + """La voie la plus courte vers l'écran d'une VM : virt-viewer. + + Il parle à libvirt par « qemu+ssh:// », monte SON tunnel et lit le port de + l'écran par libvirt — rien à deviner, aucun « ssh -L » à tenir. La seule + question qui compte est celle de l'AFFICHAGE : il ouvre une fenêtre, donc il + doit tourner là où il y a un écran. C'est l'environnement qui tranche. + """ + + def _play_kind5(self, env=None, which=None, popen=None): + it = iter(["1", "5"]) + buf = io.StringIO() + stack = [ + mock.patch("builtins.input", lambda *a: next(it)), + mock.patch("sys.stdout", buf), + mock.patch.dict("os.environ", env or {}, clear=False), + ] + if which is not None: + stack.append(mock.patch("shutil.which", which)) + if popen is not None: + stack.append(mock.patch("subprocess.Popen", popen)) + for ctx in stack: + ctx.__enter__() + try: + self.todo._qemu_tunnel_menu() + finally: + for ctx in reversed(stack): + ctx.__exit__(None, None, None) + return buf.getvalue() + + def setUp(self): + super().setUp() + # Une VM libvirt LOCALE : l'URI est alors qemu:///system. + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + + def test_no_display_hands_the_command_to_the_workstation(self): + """Sur un hyperviseur sans écran, ouvrir une fenêtre ici ne servirait à + personne : on donne la commande, sous sa forme qemu+ssh.""" + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_no_display_installs_nothing(self): + """Poser un client graphique sur une machine sans écran serait du + gaspillage — et une surprise. Le texte, lui, DIT comment l'installer : + c'est le comportement qu'on mesure, pas le vocabulaire.""" + ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = lambda cmd, **kw: ran.append(cmd) + out = self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}, + which=lambda c: None, + ) + self.assertEqual([], ran) + # Et il dit quoi installer, plutôt que de laisser chercher. + self.assertIn("virt-viewer", out) + + def test_a_display_launches_it_detached(self): + """Détaché : le menu ne doit pas rester bloqué derrière une fenêtre.""" + spawned = {} + + def fake_popen(cmd, **kw): + spawned["cmd"] = cmd + spawned["kw"] = kw + return mock.Mock() + + out = self._play_kind5( + env={"DISPLAY": ":0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=fake_popen, + ) + self.assertEqual( + ["virt-viewer", "-c", "qemu:///system", "vm-a"], spawned["cmd"] + ) + self.assertTrue(spawned["kw"].get("start_new_session")) + self.assertIn(":0", out) + + def test_wayland_counts_as_a_display(self): + spawned = {} + self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=lambda cmd, **kw: spawned.setdefault("cmd", cmd) + and mock.Mock(), + ) + self.assertIn("virt-viewer", spawned.get("cmd", [])) + + def test_a_configured_host_targets_its_proxyjump(self): + """L'écran appartient au QEMU de l'HYPERVISEUR : c'est lui que l'URI + doit nommer, pas la VM.""" + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_a_configured_host_without_proxyjump_is_refused(self): + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + self.todo._ssh_proxyjump = lambda name: "" + out = self._play_kind5(env={"DISPLAY": ":0"}) + self.assertIn("ProxyJump", out) + self.assertNotIn("virt-viewer -c", out) + + +class TestEnsureVirtViewer(unittest.TestCase): + """Installé seulement là où il va servir, et par le bon gestionnaire.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = ( + lambda cmd, **kw: self.ran.append(cmd) + ) + + def test_present_means_nothing_to_do(self): + with mock.patch("shutil.which", lambda c: "/usr/bin/virt-viewer"): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual([], self.ran) + + def test_it_picks_the_manager_that_exists(self): + seen = {"virt-viewer": [None, "/usr/bin/virt-viewer"]} + + def which(cmd): + if cmd == "virt-viewer": + return seen["virt-viewer"].pop(0) + return "/usr/bin/dnf" if cmd == "dnf" else None + + with mock.patch("shutil.which", which), mock.patch( + "sys.stdout", io.StringIO() + ): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + self.assertIn("dnf install -y virt-viewer", self.ran[0]) + + def test_no_manager_is_said_not_guessed(self): + with mock.patch("shutil.which", lambda c: None), mock.patch( + "sys.stdout", io.StringIO() + ) as out: + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertIn("paquets", out.getvalue().lower() + "paquets") + self.assertEqual([], self.ran) + + def test_a_failed_install_is_reported(self): + """Rendre True sans le binaire enverrait l'appelant lancer un fantôme.""" + with mock.patch( + "shutil.which", + lambda c: "/usr/bin/apt-get" if c == "apt-get" else None, + ), mock.patch("sys.stdout", io.StringIO()): + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + + def test_every_family_is_covered(self): + tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL] + self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools) + + +class TestTunnelMenuTargets(_MenuCase): + def test_local_domains_fill_in_when_ssh_config_is_empty(self): + """Une VM libvirt locale reste joignable même sans entrée ssh_config ; + xrdp écoutant sur toutes les interfaces, son IP suffit.""" + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + out = self._play(["1", "1"]) + self.assertIn("-L 3390:192.168.123.81:3389", out) + + def test_nothing_anywhere_is_said_plainly(self): + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: [] + out = self._play([]) + self.assertIn("~/.ssh/config", out) + + def test_an_off_local_vm_is_reported_before_any_command(self): + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-b"] + out = self._play(["1", "1"]) + self.assertIn("IP", out.upper()) + self.assertNotIn("ssh -N", out) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_qemu_vm_table.py b/test/test_qemu_vm_table.py new file mode 100644 index 0000000..b479f19 --- /dev/null +++ b/test/test_qemu_vm_table.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Tableau des infos avancées : RAM utilisée, uptime, largeur. + +Deux colonnes ont changé de sens. « RAM » disait l'allocation, elle dit +maintenant l'usage — sur un hyperviseur, savoir qu'une VM de 32 Go n'en occupe +que 4,7 décide s'il reste de la place pour la suivante. Et l'uptime est apparu : +libvirt ne l'expose nulle part, mais le processus QEMU du domaine est né avec +lui. + +Ce que ces tests gardent : la formule de la RAM, calibrée contre le « free » de +deux VM réelles, et la largeur de la ligne — un tableau qui déborde de 80 +colonnes se replie et devient illisible. +""" + +import io +import contextlib +import subprocess +import sys +import unittest +from unittest import mock + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +# Sortie réelle de « virsh dommemstat » sur la VM de migration, en KiO. Le +# « free » de l'invité disait alors : total 11955, used 1216. +DOMMEMSTAT = """actual 12582912 +swap_in 0 +swap_out 0 +major_fault 0 +minor_fault 0 +unused 1197056 +available 12242432 +usable 11027456 +last_update 1787359366 +disk_caches 8211456 +""" + + +class TestUptimeFormat(unittest.TestCase): + def test_seconds_then_minutes_then_hours_then_days(self): + self.assertEqual("45s", TODO._fmt_uptime(45)) + self.assertEqual("12m", TODO._fmt_uptime(12 * 60 + 30)) + self.assertEqual("19h55", TODO._fmt_uptime(19 * 3600 + 55 * 60)) + self.assertEqual("10j13h", TODO._fmt_uptime(10 * 86400 + 13 * 3600)) + + def test_it_never_exceeds_six_characters(self): + """La colonne fait six caractères : au-delà, le tableau se décale.""" + for secs in (0, 59, 60, 3599, 3600, 86399, 86400, 400 * 86400): + self.assertLessEqual(len(TODO._fmt_uptime(secs)), 6, secs) + + def test_precision_drops_as_the_duration_grows(self): + """Personne ne lit les secondes d'un uptime de dix jours.""" + self.assertNotIn("s", TODO._fmt_uptime(19 * 3600)) + self.assertNotIn("m", TODO._fmt_uptime(10 * 86400)) + + +class TestMemStat(unittest.TestCase): + def _stat(self, out): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 0, out, ""), + ): + return TODO._qemu_dommemstat("vm") + + def test_used_is_available_minus_usable(self): + """Calibré contre le « free » de deux VM : 1186 contre 1216 Mo lus dans + l'invité, et 4831 contre 4838 sur l'autre. « available - unused » + donnait 10,8 Go pour une VM qui en occupait 1,2 — il compte le cache. + """ + used, total = self._stat(DOMMEMSTAT) + self.assertAlmostEqual(used / 1024, 1186, delta=5) + self.assertAlmostEqual(total / 1024, 11955, delta=5) + + def test_a_stopped_vm_gives_zero_without_crashing(self): + used, total = self._stat("") + self.assertEqual((0, 0), (used, total)) + + def test_it_survives_virsh_failing(self): + with mock.patch("subprocess.run", side_effect=OSError): + self.assertEqual((0, 0), TODO._qemu_dommemstat("vm")) + + def test_missing_usable_is_not_taken_for_zero_use(self): + """Sans « usable », on ne sait pas : mieux vaut ne rien dire que + d'annoncer une VM qui n'utiliserait rien.""" + used, total = self._stat("available 12242432\nunused 1197056\n") + self.assertEqual(0, used) + self.assertGreater(total, 0) + + def test_it_asks_for_a_collection_period_first(self): + """Sans période, le ballon ne rafraîchit rien : une VM qui occupait + 4,8 Go en annonçait 490 Mo — vécu. « --live » ne touche pas le XML.""" + calls = [] + + def fake_run(cmd, **kw): + calls.append(cmd) + return subprocess.CompletedProcess([], 0, DOMMEMSTAT, "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_dommemstat("vm") + self.assertIn("--period", calls[0]) + self.assertIn("--live", calls[0]) + + +class TestDomainUptime(unittest.TestCase): + def test_it_reads_the_age_of_the_qemu_process(self): + outs = [ + subprocess.CompletedProcess([], 0, "1137455\n", ""), + subprocess.CompletedProcess([], 0, " 1195\n", ""), + ] + with mock.patch("subprocess.run", side_effect=outs): + self.assertEqual(1195, TODO._qemu_domain_uptime("vm")) + + def test_the_pattern_ends_with_a_comma(self): + """« guest=vm, » et non « guest=vm » : sinon « vm » matcherait aussi + « vm-2 », et l'uptime affiché serait celui d'une autre machine.""" + seen = {} + + def fake_run(cmd, **kw): + seen.setdefault("cmd", cmd) + return subprocess.CompletedProcess([], 0, "", "") + + with mock.patch("subprocess.run", side_effect=fake_run): + TODO._qemu_domain_uptime("vm") + self.assertIn("guest=vm,", seen["cmd"]) + + def test_a_stopped_domain_has_no_uptime(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess([], 1, "", ""), + ): + self.assertIsNone(TODO._qemu_domain_uptime("vm")) + + +class TestTheTable(unittest.TestCase): + def _render(self, uptime=1195, mem=(1214 * 1024, 11955 * 1024)): + todo = TODO.__new__(TODO) + todo._qemu_list_domains = lambda: ["erplibre-ubuntu-2604-gnome"] + todo._qemu_domstate = lambda n: "running" + todo._qemu_dominfo = staticmethod(lambda n: (8, 12 * 1024 * 1024)) + todo._qemu_main_disk = lambda n: "/var/lib/libvirt/images/x.qcow2" + todo._qemu_disk_sizes = staticmethod( + lambda d: (65 * (1 << 30), 62 * (1 << 30)) + ) + todo._qemu_dommemstat = staticmethod(lambda n: mem) + todo._qemu_domain_uptime = staticmethod(lambda n: uptime) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + todo._qemu_list_vms_advanced() + return buf.getvalue() + + def test_every_line_fits_in_eighty_columns(self): + """Le tableau est lu dans un terminal : au-delà de 80, il se replie.""" + for line in self._render().splitlines(): + if line.startswith("Stockage") or not line.strip(): + continue + self.assertLessEqual(len(line), 80, line) + + def test_ram_shows_use_over_allocation(self): + out = self._render() + self.assertIn("1.2G/12G", out) + + def test_the_uptime_column_is_there(self): + out = self._render() + self.assertIn("Uptime", out) + self.assertIn("19m", out) + + def test_a_vm_without_stats_shows_a_dash_not_a_zero(self): + """« 0.0G/12G » ferait croire à une VM au repos ; « -/12G » dit qu'on + ne sait pas.""" + out = self._render(uptime=None, mem=(0, 0)) + self.assertIn("-/12G", out) + self.assertNotIn("0.0G/12G", out) + + def test_the_full_vm_name_survives_when_it_fits(self): + """C'est le nom qui distingue les machines : le tronquer trop tôt les + rend indiscernables.""" + self.assertIn("erplibre-ubuntu-2604-gnome", self._render()) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_smoke_final_url.py b/test/test_smoke_final_url.py new file mode 100644 index 0000000..6913b34 --- /dev/null +++ b/test/test_smoke_final_url.py @@ -0,0 +1,301 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'URL qui a échoué n'est pas toujours celle qu'on a demandée. + +Sur un site Odoo, chaque page traverse deux ou trois redirections — +mesuré, 146 pour 55 pages, entre la langue et le slug canonique. Quand +la DERNIÈRE rend 500, l'outil nommait la première : on allait vérifier +une page parfaitement saine et l'on concluait que le test se trompait. + +Et un dépassement de délai ne rend PAS 500 : `fetch` rend 0, que le +rapport écrit « aucune réponse ». Confondre les deux enverrait chercher +une lenteur là où le serveur a répondu par une erreur. +""" + +import http.server +import io +import os +import socketserver +import sys +import threading +import unittest + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import smoke_public_url as smoke # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +class Chaine(http.server.BaseHTTPRequestHandler): + """/depart → 303 → /milieu → 303 → /fin, qui décide.""" + + fin_status = 500 + + def do_GET(self): + if self.path == "/depart": + self.send_response(303) + self.send_header("Location", "/milieu") + self.end_headers() + elif self.path == "/milieu": + self.send_response(303) + self.send_header("Location", "/fin") + self.end_headers() + elif self.path == "/direct": + self.send_response(500) + self.end_headers() + self.wfile.write(b"boum") + else: + self.send_response(self.fin_status) + self.end_headers() + self.wfile.write(b"boum") + + def log_message(self, *args): + pass + + +class TestFetchFollowsTheChain(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.srv = socketserver.TCPServer(("127.0.0.1", 0), Chaine) + cls.port = cls.srv.server_address[1] + cls.fil = threading.Thread(target=cls.srv.serve_forever, daemon=True) + cls.fil.start() + + @classmethod + def tearDownClass(cls): + cls.srv.shutdown() + cls.srv.server_close() + + def url(self, chemin): + return f"http://127.0.0.1:{self.port}{chemin}" + + def test_it_reports_the_url_that_actually_failed(self): + # LE point : la 500 est au bout de la chaîne, pas au départ. + statut, _corps, finale = smoke.fetch(self.url("/depart"), timeout=5) + self.assertEqual(statut, 500) + self.assertTrue(finale.endswith("/fin"), finale) + + def test_without_a_redirect_both_are_the_same(self): + statut, _corps, finale = smoke.fetch(self.url("/direct"), timeout=5) + self.assertEqual(statut, 500) + self.assertEqual(finale, self.url("/direct")) + + def test_a_success_also_carries_its_final_url(self): + Chaine.fin_status = 200 + try: + statut, _corps, finale = smoke.fetch( + self.url("/depart"), timeout=5 + ) + self.assertEqual(statut, 200) + self.assertTrue(finale.endswith("/fin"), finale) + finally: + Chaine.fin_status = 500 + + def test_a_dead_host_is_zero_NOT_five_hundred(self): + # C'est ce qui distingue « le serveur a répondu par une erreur » + # de « il n'a pas répondu ». Les confondre envoie chercher une + # lenteur là où il y a une exception. + statut, corps, finale = smoke.fetch( + "http://127.0.0.1:1/jamais", timeout=1 + ) + self.assertEqual(statut, 0) + self.assertEqual(corps, "") + self.assertEqual(finale, "http://127.0.0.1:1/jamais") + + def test_check_urls_keeps_the_final_url(self): + echecs = smoke.check_urls([self.url("/depart")], timeout=5) + self.assertEqual(len(echecs), 1) + url, statut, parents, finale = echecs[0] + self.assertEqual(url, self.url("/depart")) + self.assertEqual(statut, 500) + self.assertEqual(parents, []) + self.assertTrue(finale.endswith("/fin")) + + def test_a_page_that_answers_is_not_a_failure(self): + Chaine.fin_status = 200 + try: + self.assertEqual( + smoke.check_urls([self.url("/depart")], timeout=5), [] + ) + finally: + Chaine.fin_status = 500 + + +class TestTheReport(unittest.TestCase): + def test_it_names_the_final_url_when_it_differs(self): + texte = smoke.render( + ["a", "b"], [("http://x/depart", 500, [], "http://x/fin")] + ) + self.assertIn("http://x/depart", texte) + self.assertIn(todo_i18n.t("failed at"), texte) + self.assertIn("http://x/fin", texte) + + def test_it_stays_quiet_when_they_are_the_same(self): + # Répéter la même URL sur deux lignes n'apprend rien et allonge + # un rapport qui peut compter trente-quatre entrées. + texte = smoke.render( + ["a"], [("http://x/page", 500, [], "http://x/page")] + ) + self.assertNotIn(todo_i18n.t("failed at"), texte) + + def test_no_answer_is_worded_apart_from_a_status(self): + texte = smoke.render( + ["a"], [("http://x/page", 0, [], "http://x/page")] + ) + self.assertIn(todo_i18n.t("no answer"), texte) + self.assertNotIn("[500]", texte) + + +class TestNothingUnpacksTheFailureTupleBlindly(unittest.TestCase): + """Ajouter un champ au tuple d'échec a cassé une migration en cours. + + Le tuple est passé de trois à quatre éléments et deux sites + dépaquetaient encore trois — `too many values to unpack`, en plein + milieu, APRÈS la réinitialisation d'une copie COW. Le commentaire + « TOUJOURS quatre éléments » ne protège de rien : il faut ne pas + dépaqueter quand on ne veut qu'un champ. + """ + + CHEMIN = os.path.join( + os.path.dirname(__file__), + "..", + "script", + "odoo", + "migration", + "smoke_public_url.py", + ) + + def source(self): + with io.open(self.CHEMIN, encoding="utf-8") as handle: + return handle.read() + + def test_no_three_element_unpack_survives(self): + import re + + motif = re.compile( + r"for\s+[a-z_]+,\s*[a-z_]+,\s*[a-z_]+\s+in\s+lst_failure" + ) + trouves = motif.findall(self.source()) + self.assertEqual(trouves, [], f"dépaquetage à trois : {trouves}") + + def test_taking_only_the_url_uses_an_index(self): + # Indexer survit au prochain champ ajouté ; dépaqueter non. + self.assertIn("[echec[0] for echec in lst_failure]", self.source()) + + +class TestRecheckingAfterAReset(unittest.TestCase): + """La passe qui a cassé, exercée pour de vrai. + + Elle ne tournait sous aucun test : c'est pourquoi le dépaquetage à + trois y a survécu à la suite complète, aux mutations, et n'est + tombé qu'en production. + """ + + def setUp(self): + self.vrais = { + nom: getattr(smoke, nom) + for nom in ( + "start_server", + "wait_ready", + "check_urls", + "internal_needs_retry", + "stop_server", + ) + if hasattr(smoke, nom) + } + self.vus = [] + + class FauxServeur: + def __init__(self): + self.arrete = False + + smoke.start_server = lambda *a, **k: FauxServeur() + smoke.wait_ready = lambda *a, **k: True + smoke.internal_needs_retry = lambda rapport: False + if hasattr(smoke, "stop_server"): + smoke.stop_server = lambda *a, **k: None + + def faux_check(lst_url, timeout=30): + self.vus.append(list(lst_url)) + return [] + + smoke.check_urls = faux_check + + def tearDown(self): + for nom, valeur in self.vrais.items(): + setattr(smoke, nom, valeur) + + def test_it_rechecks_exactly_the_urls_that_had_failed(self): + echecs = [ + ("http://h/contactus", 500, ["2837"], "http://h/en/contactus"), + ("http://h/blog", 500, [], "http://h/blog"), + ] + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + echecs, + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [["http://h/contactus", "http://h/blog"]]) + + def test_it_rechecks_the_REQUESTED_url_not_the_final_one(self): + # On revérifie ce que le sitemap publie : c'est cette adresse-là + # que les visiteurs demandent. + echecs = [("http://h/a", 500, [], "http://h/z")] + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + echecs, + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [["http://h/a"]]) + + def test_an_empty_failure_list_rechecks_nothing(self): + smoke.recheck_after_reset( + "db", + 8169, + "./config.conf", + "http://h", + None, + [], + {"failures": []}, + internal=False, + ) + self.assertEqual(self.vus, [[]]) + + +class TestTheLogSurvives(unittest.TestCase): + def test_the_previous_run_is_kept(self): + # Le journal était ouvert en « w » : relancer le test effaçait la + # trace de l'échec qu'on venait de voir. + with io.open( + os.path.join( + os.path.dirname(__file__), + "..", + "script", + "odoo", + "migration", + "smoke_public_url.py", + ), + encoding="utf-8", + ) as handle: + src = handle.read() + debut = src.index("def start_server") + fin = src.index("subprocess.Popen", debut) + self.assertIn("os.replace(log_path, log_path", src[debut:fin]) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_smoke_public_url.py b/test/test_smoke_public_url.py index 8d2c9db..3c0977c 100755 --- a/test/test_smoke_public_url.py +++ b/test/test_smoke_public_url.py @@ -53,7 +53,15 @@ class TestReadingTheSitemap(unittest.TestCase): def setUp(self): self.answers = {} self.original = smoke.fetch - smoke.fetch = lambda url, timeout=30: self.answers.get(url, (404, "")) + + # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale. + # Un faux resté à deux casse chaque appelant sur un « not enough + # values to unpack » qui n'apprend rien de la panne réelle. + def faux(url, timeout=30): + statut, corps = self.answers.get(url, (404, "")) + return statut, corps, url + + smoke.fetch = faux self.addCleanup(setattr, smoke, "fetch", self.original) def test_a_plain_sitemap(self): @@ -101,13 +109,22 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def setUp(self): self.answers = {} self.original = smoke.fetch - smoke.fetch = lambda url, timeout=30: self.answers.get(url, (200, "")) + + # `fetch` rend TROIS valeurs depuis qu'il porte l'URL finale. + # Un faux resté à deux casse chaque appelant sur un « not enough + # values to unpack » qui n'apprend rien de la panne réelle. + def faux(url, timeout=30): + statut, corps = self.answers.get(url, (200, "")) + return statut, corps, url + + smoke.fetch = faux self.addCleanup(setattr, smoke, "fetch", self.original) def test_a_500_fails(self): self.answers["http://h/bad"] = (500, "") self.assertEqual( - smoke.check_urls(["http://h/bad"]), [("http://h/bad", 500, [])] + smoke.check_urls(["http://h/bad"]), + [("http://h/bad", 500, [], "http://h/bad")], ) def test_a_404_fails_too(self): @@ -119,7 +136,8 @@ class TestWhatCountsAsAFailure(unittest.TestCase): def test_no_answer_at_all_fails(self): self.answers["http://h/dead"] = (0, "") self.assertEqual( - smoke.check_urls(["http://h/dead"]), [("http://h/dead", 0, [])] + smoke.check_urls(["http://h/dead"]), + [("http://h/dead", 0, [], "http://h/dead")], ) def test_a_200_passes(self): @@ -141,7 +159,9 @@ class TestTheReport(unittest.TestCase): self.assertIn("✅", text) def test_a_failure_shows_the_status_and_the_url(self): - text = smoke.render(["a"], [("http://h/blog/x", 500, [])]) + text = smoke.render( + ["a"], [("http://h/blog/x", 500, [], "http://h/blog/x")] + ) self.assertIn("500", text) self.assertIn("http://h/blog/x", text) @@ -211,7 +231,7 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): def test_a_late_context_is_still_attached(self): # Odoo vide son tampon à l'arrêt : le journal se lit APRÈS, et rien # ne doit dépendre du moment où la ligne est apparue. - lst_failure = [("http://h/a", 500, [])] + lst_failure = [("http://h/a", 500, [], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertIn("2841", rebuilt[0][2]) @@ -221,20 +241,20 @@ class TestTheCulpritViewsAreNamed(unittest.TestCase): # à sa vue module, et c'est l'enfant (3282) qui portait l'arch # périmée. Ne nommer que le parent envoyait réinitialiser une copie # qui allait déjà bien, et la page restait en 500. - lst_failure = [("http://h/contactus", 500, [])] + lst_failure = [("http://h/contactus", 500, [], "http://h/contactus")] log = ["[view_id: 3282, model: n/a, parent_id: 3281]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2], ["3281", "3282"]) def test_the_parent_comes_first(self): # C'est le cas le plus fréquent — le blogue — donc en tête de liste. - lst_failure = [("http://h/a", 500, [])] + lst_failure = [("http://h/a", 500, [], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2][0], "2841") def test_an_already_attributed_id_is_not_duplicated(self): - lst_failure = [("http://h/a", 500, ["2841"])] + lst_failure = [("http://h/a", 500, ["2841"], "http://h/a")] log = ["[view_id: 3288, model: n/a, parent_id: 2841]"] rebuilt = smoke.attach_missing_parents(lst_failure, log) self.assertEqual(rebuilt[0][2].count("2841"), 1) @@ -359,7 +379,7 @@ class TestOfferingTheFix(unittest.TestCase): with contextlib.redirect_stdout(out): done = smoke.prompt( "db", - [("http://h/a", 500, ["2841"])], + [("http://h/a", 500, ["2841"], "http://h/a")], lst_key, ask=lambda prompt: answer, ) diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 02f02cb..bfa76cd 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -136,6 +136,105 @@ class TestExecuteMenuNumbering(unittest.TestCase): self.assertEqual(set(self.EXPECTED) - shown_keys, set()) +class TestQemuMenuNumbering(unittest.TestCase): + """Le menu QEMU : même piège, autre forme. + + Il ne s'écrit pas en f-string mais en liste de dictionnaires, où seules les + entrées « prompt_description » consomment un numéro — les « section » sont + des titres. Le décalage y est donc encore moins visible à l'œil : insérer + une entrée avant la dernière renumérote tout ce qui suit, et le dispatch ne + proteste pas. C'est arrivé en ajoutant l'émulateur Android avant + « List available images ». + """ + + RE_ENTRY = re.compile( + r'"(section|prompt_description)": t\(\s*\n?\s*"([^"]+)"' + ) + RE_DISPATCH_CALL = re.compile( + r'(?:el)?if status == "(\d+)":\s*\n\s*(?:status = )?self\.(\w+)\(' + ) + + def setUp(self): + source = TODO_PY.read_text(encoding="utf-8") + start = source.index("def prompt_execute_qemu(self):") + end = source.index("def _qemu_tunnel_menu(self):", start) + self.body = source[start:end] + num = 0 + self.shown = [] + for kind, label in self.RE_ENTRY.findall(self.body): + if kind == "prompt_description": + num += 1 + self.shown.append((num, label)) + self.dispatch = [ + (int(n), m) for n, m in self.RE_DISPATCH_CALL.findall(self.body) + ] + + def test_the_menu_was_actually_parsed(self): + """Sur une liste vide, tout test passe : mieux vaut tomber ici.""" + self.assertGreater(len(self.shown), 10) + self.assertEqual(len(self.shown), len(self.dispatch)) + + def test_numbering_is_contiguous_from_one(self): + self.assertEqual( + [n for n, _ in self.shown], + list(range(1, len(self.shown) + 1)), + ) + + def test_every_shown_entry_has_the_matching_dispatch(self): + self.assertEqual( + [n for n, _ in self.shown], [n for n, _ in self.dispatch] + ) + + # Où mène chaque entrée, par le début de son libellé. Une renumérotation ne + # touche PAS cette table ; ajouter une entrée l'exige, et c'est le seul + # moment où quelqu'un doit dire où elle mène. + EXPECTED = { + "Deploy VM(s)": "_qemu_deploy", + "Preview a deployment": "_qemu_deploy", + "Download a cloud image only": "_qemu_download_image", + "Reopen": "_qemu_reopen_monitor", + "List VMs": "_qemu_list_vms", + "Show a VM IP address": "_qemu_show_ip", + "Open the console on a VM": "_qemu_console", + "Resize a VM disk": "_qemu_resize_disk", + "Delete VM(s)": "_qemu_delete_vm", + "Clean up QEMU": "_qemu_cleanup", + "Test": "_qemu_test_vm", + "Statistics": "_qemu_stats", + "SSH configuration": "_qemu_ssh_config_menu", + "Remote desktop tunnel": "_qemu_tunnel_menu", + "Android emulator": "_qemu_emulator_menu", + "List available images": "_qemu_list_images", + } + + def _key(self, label): + for key in self.EXPECTED: + if label.startswith(key): + return key + return label + + def test_every_entry_reaches_the_method_it_names(self): + dct = dict(self.dispatch) + for num, label in self.shown: + key = self._key(label) + self.assertIn( + key, + self.EXPECTED, + f"entrée [{num}] « {label} » absente d'EXPECTED :" + " déclarez où elle mène", + ) + self.assertEqual( + dct.get(num), + self.EXPECTED[key], + f"[{num}] « {label} » mène à {dct.get(num)}" + f" au lieu de {self.EXPECTED[key]}", + ) + + def test_expected_table_has_no_stale_entry(self): + keys = {self._key(label) for _, label in self.shown} + self.assertEqual(set(self.EXPECTED) - keys, set()) + + class TestMenuLabels(unittest.TestCase): """Toute méthode de menu doit avoir son étiquette de fil d'Ariane.