From 952c92b52a03d95ac4be3aae138d99b0d9c98488 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 19 Aug 2026 19:41:39 -0400 Subject: [PATCH] [ADD] script forgejo: installer une forge git en option cochable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une case au déploiement, et une forge git auto-hébergée répond sur le port 3000, git par SSH sur 2222. Le travail vit dans un script dédié, appelable seul sur une machine existante : une seule autorité pour les deux usages. Le binaire officiel est statique, donc le même fichier sert apt, dnf, pacman et zypper — c'est ce qui rend l'option portable sans une branche par distribution. Les architectures suivent l'amont (amd64, arm64, arm-6) ; la case se grise sur s390x plutôt que de poser un binaire inexécutable. Les quatre secrets sont écrits par le script : sans oauth2.JWT_SECRET, Forgejo tente de les persister lui-même et boucle sur un app.ini qu'il n'a pas le droit d'écrire. Vérifié sur une VM : somme de contrôle validée, service actif, API qui répond, dépôt créé puis cloné par git, et relance en 1,5 s sans rien réécrire. --- EN --- One checkbox at deploy time, and a self-hosted git forge answers on port 3000, git over SSH on 2222. The work lives in a dedicated script, callable on its own for an existing machine: one authority for both uses. The official binary is static, so the same file serves apt, dnf, pacman and zypper — that is what makes the option portable without a branch per distribution. Architectures follow upstream (amd64, arm64, arm-6); the checkbox greys out on s390x rather than dropping a binary that cannot run. The script writes all four secrets itself: without oauth2.JWT_SECRET, Forgejo tries to persist them and loops on an app.ini it is not allowed to write. Verified on a VM: checksum validated, service active, API answering, a repo created then cloned over git, and a replay in 1.5 s rewriting nothing. Assisted-by: Claude Opus 5 --- script/forgejo/install_forgejo.sh | 301 ++++++++++++++++++++++++++++++ script/qemu/README.base.md | 39 ++++ script/qemu/README.fr.md | 20 ++ script/qemu/README.md | 19 ++ script/todo/todo.py | 81 ++++++-- script/todo/todo_i18n.py | 8 + test/test_qemu_desktop_tools.py | 17 +- test/test_qemu_forgejo.py | 260 ++++++++++++++++++++++++++ 8 files changed, 728 insertions(+), 17 deletions(-) create mode 100755 script/forgejo/install_forgejo.sh create mode 100644 test/test_qemu_forgejo.py diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh new file mode 100755 index 0000000..a86b9ef --- /dev/null +++ b/script/forgejo/install_forgejo.sh @@ -0,0 +1,301 @@ +#!/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}" + +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; } + +# --- 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" + 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=$(hostname -I 2>/dev/null | awk '{print $1}') + [ -n "$host" ] || host=localhost + # 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 </dev/null </dev/null 2>&1 \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" + +# --- 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 + if curl -fsS -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=$(hostname -I 2>/dev/null | awk '{print $1}') +[ -n "$host" ] || host=localhost +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/qemu/README.base.md b/script/qemu/README.base.md index 14496b3..757ae02 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -337,6 +337,25 @@ 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. @@ -483,6 +502,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai é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éé. diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index dfe74d7..212dbbd 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -228,6 +228,26 @@ Le rendu est « swangle » dans le `config.ini` de l'AVD — « auto » ouvrirai é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éé. diff --git a/script/qemu/README.md b/script/qemu/README.md index 81f673c..b03f336 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -211,6 +211,25 @@ 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. diff --git a/script/todo/todo.py b/script/todo/todo.py index b302720..e24423b 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -5422,6 +5422,32 @@ class TODO: # 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 @@ -6474,6 +6500,25 @@ class TODO: + 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é. @@ -6486,29 +6531,39 @@ class TODO: émulateur créé avec succès effacerait le verdict de la compilation.""" picked = [ k - for k in ("mobile", "avd") + 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)) - return ( - 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é. C'est le même piège que le - # bloc de service systemd, quelques centaines de lignes plus haut. - + " && ".join(f"{{ {g}; }}" for g in groups) - + "; " - ) + 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.""" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index cdb7ad2..699f631 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2260,6 +2260,14 @@ TRANSLATIONS = { "line": {"fr": "ligne", "en": "line"}, "free space": {"fr": "libre", "en": "free"}, "silent": {"fr": "silence", "en": "silent"}, + "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", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 889c0f4..293355d 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -99,10 +99,17 @@ class TestToolDisk(unittest.TestCase): 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 et émulateur.""" + 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( - TODO._QEMU_VM_TOOLS["mobile"]["disk_gb"] - + TODO._QEMU_VM_TOOLS["avd"]["disk_gb"], + expected, self.todo._qemu_tools_disk_gb(self.all, "amd64", "", "ubuntu"), ) @@ -278,7 +285,9 @@ class TestMobileBuild(unittest.TestCase): """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", "avd"], got) + 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 diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py new file mode 100644 index 0000000..d62edc0 --- /dev/null +++ b/test/test_qemu_forgejo.py @@ -0,0 +1,260 @@ +#!/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 + +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 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 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_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()