From 3ec7eb36f1c8ab602ff469b85421ca0c9b1f4a71 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 20 Aug 2026 02:04:50 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20script=20forgejo:=20red=C3=A9marrer=20l?= =?UTF-8?q?e=20service=20quand=20la=20configuration=20change?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/forgejo/install_forgejo.sh | 41 +++++++++++++++++++++++++++---- test/test_qemu_forgejo.py | 33 +++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/script/forgejo/install_forgejo.sh b/script/forgejo/install_forgejo.sh index a7c31bf..d2f873b 100755 --- a/script/forgejo/install_forgejo.sh +++ b/script/forgejo/install_forgejo.sh @@ -41,6 +41,11 @@ ADMIN_EMAIL="${FORGEJO_ADMIN_EMAIL:-admin@erplibre.local}" RUN_USER="${FORGEJO_USER:-git}" SKIP_ADMIN="${FORGEJO_SKIP_ADMIN:-0}" +# Ce qui a changé sur le disque pendant ce passage. Le service ne redémarre que +# si quelque chose a bougé : rejouer le script sur une forge saine ne doit pas +# l'interrompre, même deux secondes. +CHANGED=0 + BIN=/usr/local/bin/forgejo CONF_DIR=/etc/forgejo CONF="$CONF_DIR/app.ini" @@ -152,6 +157,7 @@ else esac chmod +x "$src" sudo install -m 0755 "$src" "$BIN" + CHANGED=1 say "${Green}binaire posé : $BIN${Color_Off}" rm -rf "$tmp" trap - EXIT @@ -249,11 +255,13 @@ LEVEL = info CONFEOF sudo chown root:"$RUN_USER" "$CONF" sudo chmod 640 "$CONF" + CHANGED=1 say "${Green}configuration écrite : $CONF${Color_Off}" fi # --- 6. Service ------------------------------------------------------------ -sudo tee "$UNIT" >/dev/null < "$unit_tmp" </dev/null 2>&1 \ - || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" +if ! sudo cmp -s "$unit_tmp" "$UNIT" 2>/dev/null; then + sudo install -m 0644 "$unit_tmp" "$UNIT" + sudo systemctl daemon-reload + CHANGED=1 + say "service défini : $UNIT" +fi +rm -f "$unit_tmp" + +sudo systemctl enable forgejo.service >/dev/null 2>&1 || true +# « restart » et NON « enable --now » quand quelque chose a changé : « --now » +# ne touche pas à un service déjà actif, qui garde alors sa configuration en +# MÉMOIRE. Vécu, et le symptôme ne désigne pas la cause : le serveur comparait +# son ancien INTERNAL_TOKEN à celui que le hook venait de lire sur le disque, et +# répondait 403 à son propre hook. Tout push finissait sur « Forgejo: Internal +# Server Error Decoding Failed », le hook ne sachant pas décoder un 403. +if [ "$CHANGED" = 1 ]; then + sudo systemctl restart forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" + say "service redémarré (configuration ou binaire modifié)" +elif ! systemctl is-active --quiet forgejo.service; then + sudo systemctl start forgejo.service \ + || die "le service refuse de démarrer : sudo journalctl -u forgejo -n 40" +fi # --- 7. Attendre qu'il RÉPONDE -------------------------------------------- # Une requête HTTP, pas un « systemctl is-active » : le service est « active » @@ -286,7 +314,10 @@ sudo systemctl enable --now forgejo.service >/dev/null 2>&1 \ # 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 \ + # « -fs » sans « -S » : dans une boucle de réessai, le message de curl est + # du bruit — « Failed to connect » au premier tour est normal, le service + # vient de redémarrer. C'est le die final qui parle si rien ne répond. + if curl -fs -o /dev/null --max-time 3 \ "http://127.0.0.1:$HTTP_PORT/api/v1/version"; then ready=1 break diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index c50bba0..a30dbc5 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -339,6 +339,39 @@ class TestTheScriptGuards(unittest.TestCase): 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"):