erplibre/test/test_qemu_forgejo.py
Mathieu Benoit 7452c581db [ADD] script todo: ouvrir l'écran d'une VM avec virt-viewer
C'est la voie la plus courte : virt-viewer parle à libvirt par « qemu+ssh », lit
le port de l'écran par libvirt et monte SON tunnel — aucun « ssh -L » à tenir
ouvert, rien à deviner. Le menu tunnel le propose donc en cinquième choix.

La seule question qui compte est celle de l'affichage, et c'est l'environnement
qui tranche, pas une question de plus. Un affichage présent (poste, ou ssh -X) :
virt-viewer est installé s'il manque — apt, dnf, pacman ou zypper — puis lancé
détaché. Aucun affichage : la commande est donnée pour le poste, et rien n'est
installé sur une machine sans écran.

--- EN ---

The shortest path: virt-viewer talks to libvirt over "qemu+ssh", reads the
display port from libvirt and builds its OWN tunnel — no "ssh -L" to keep open,
nothing to guess. The tunnel menu offers it as a fifth choice.

The only question that matters is the display, and the environment answers it
rather than one more prompt. A display present (workstation, or ssh -X):
virt-viewer gets installed if missing — apt, dnf, pacman or zypper — then
launched detached. No display: the command is handed over for the workstation,
and nothing is installed on a screenless machine.

Assisted-by: Claude Opus 5
2026-08-23 02:07:42 -04:00

385 lines
16 KiB
Python

#!/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()