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
260 lines
10 KiB
Python
260 lines
10 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
|
|
|
|
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()
|