[ADD] script forgejo: installer une forge git en option cochable
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
2026-08-19 19:41:39 -04:00
|
|
|
#!/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
|
[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-20 03:33:11 -04:00
|
|
|
from script.todo.todo_i18n import t # noqa: E402
|
[ADD] script forgejo: installer une forge git en option cochable
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
2026-08-19 19:41:39 -04:00
|
|
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
[FIX] script todo: nommer les outils qu'une VM sans ERPLibre ne peut pas poser
Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile,
l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone,
ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait
donc pour honorée.
La commande le dit désormais, en nommant les outils concernés. Trois lignes qui
évitent de chercher pourquoi la forge demandée n'est nulle part.
--- EN ---
The "after" phase tools live IN the repository: the mobile build, the AVD, and
now the Forgejo script. On a desktop-only VM, with no clone, they do not exist —
and they were dropped in silence. A ticked checkbox therefore passed for
honoured.
The command now says so, naming the tools concerned. Three lines that save
hunting for why the requested forge is nowhere to be found.
Assisted-by: Claude Opus 5
2026-08-19 19:45:33 -04:00
|
|
|
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)
|
[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-20 03:33:11 -04:00
|
|
|
self.assertIn(t("needs the ERPLibre install, skipped:"), cmd)
|
[FIX] script todo: nommer les outils qu'une VM sans ERPLibre ne peut pas poser
Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile,
l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone,
ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait
donc pour honorée.
La commande le dit désormais, en nommant les outils concernés. Trois lignes qui
évitent de chercher pourquoi la forge demandée n'est nulle part.
--- EN ---
The "after" phase tools live IN the repository: the mobile build, the AVD, and
now the Forgejo script. On a desktop-only VM, with no clone, they do not exist —
and they were dropped in silence. A ticked checkbox therefore passed for
honoured.
The command now says so, naming the tools concerned. Three lines that save
hunting for why the requested forge is nowhere to be found.
Assisted-by: Claude Opus 5
2026-08-19 19:45:33 -04:00
|
|
|
self.assertNotIn("install_forgejo.sh", cmd)
|
|
|
|
|
|
|
|
|
|
def test_it_stays_quiet_when_nothing_was_deferred(self):
|
[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-20 03:33:11 -04:00
|
|
|
"""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."""
|
[FIX] script todo: nommer les outils qu'une VM sans ERPLibre ne peut pas poser
Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile,
l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone,
ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait
donc pour honorée.
La commande le dit désormais, en nommant les outils concernés. Trois lignes qui
évitent de chercher pourquoi la forge demandée n'est nulle part.
--- EN ---
The "after" phase tools live IN the repository: the mobile build, the AVD, and
now the Forgejo script. On a desktop-only VM, with no clone, they do not exist —
and they were dropped in silence. A ticked checkbox therefore passed for
honoured.
The command now says so, naming the tools concerned. Three lines that save
hunting for why the requested forge is nowhere to be found.
Assisted-by: Claude Opus 5
2026-08-19 19:45:33 -04:00
|
|
|
cmd = self.todo._qemu_erplibre_remote_cmd(
|
|
|
|
|
None, None, False, "gnome", "", "deb", ("gnome_ext",)
|
|
|
|
|
)
|
[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-20 03:33:11 -04:00
|
|
|
self.assertNotIn(t("needs the ERPLibre install, skipped:"), cmd)
|
[FIX] script todo: nommer les outils qu'une VM sans ERPLibre ne peut pas poser
Les outils de la phase « après » vivent DANS le dépôt : la compilation mobile,
l'AVD, et maintenant le script Forgejo. Sur une VM « bureau seul », sans clone,
ils n'existent pas — et ils étaient écartés en silence. Une case cochée passait
donc pour honorée.
La commande le dit désormais, en nommant les outils concernés. Trois lignes qui
évitent de chercher pourquoi la forge demandée n'est nulle part.
--- EN ---
The "after" phase tools live IN the repository: the mobile build, the AVD, and
now the Forgejo script. On a desktop-only VM, with no clone, they do not exist —
and they were dropped in silence. A ticked checkbox therefore passed for
honoured.
The command now says so, naming the tools concerned. Three lines that save
hunting for why the requested forge is nowhere to be found.
Assisted-by: Claude Opus 5
2026-08-19 19:45:33 -04:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script forgejo: installer une forge git en option cochable
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
2026-08-19 19:41:39 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2026-08-19 19:43:43 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[ADD] script forgejo: installer une forge git en option cochable
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
2026-08-19 19:41:39 -04:00
|
|
|
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)
|
|
|
|
|
|
[FIX] script forgejo: redémarrer le service quand la configuration change
Tout push finissait sur « Forgejo: Internal Server Error Decoding Failed », et
le message ne désigne pas la cause. Le journal, lui, la donne : 403 sur
/api/internal/hook/pre-receive, refusé par le contrôle du jeton interne. Le
serveur comparait l'INTERNAL_TOKEN qu'il tenait EN MÉMOIRE à celui que le hook
venait de lire sur le disque — deux valeurs différentes — et répondait 403 à son
propre hook, que celui-ci ne sait pas décoder.
La cause est ici : « systemctl enable --now » ne touche pas un service déjà
actif. Le script redémarre donc quand le binaire, la configuration ou l'unité
ont changé, et se tait sinon. Vérifié sur la VM : configuration régénérée
service actif -> redémarrage -> push accepté ; relance sur forge saine ->
aucun redémarrage, push toujours accepté.
--- EN ---
Every push ended on "Forgejo: Internal Server Error Decoding Failed", and the
message does not name the cause. The log does: 403 on
/api/internal/hook/pre-receive, refused by the internal token check. The server
was comparing the INTERNAL_TOKEN it held IN MEMORY with the one the hook had
just read from disk — two different values — and answered 403 to its own hook,
which cannot decode a 403.
The cause is here: "systemctl enable --now" does not touch an already active
service. The script now restarts when the binary, the configuration or the unit
changed, and stays quiet otherwise. Verified on the VM: config regenerated with
the service active -> restart -> push accepted; replay on a healthy forge -> no
restart, push still accepted.
Assisted-by: Claude Opus 5
2026-08-20 02:04:50 -04:00
|
|
|
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)
|
|
|
|
|
|
[ADD] script forgejo: installer une forge git en option cochable
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
2026-08-19 19:41:39 -04:00
|
|
|
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()
|