[FIX] nixos : le service ERPLibre déclaré, démarré quand Odoo est là

Trois obstacles enchaînés, aucun visible sans une vraie machine. /etc est
généré depuis le store : le « tee » d'une unité y rend « Read-only file
system », et l'installation échouait à sa DERNIÈRE étape, après que le
clone, le venv et un démarrage d'Odoo avaient réussi. ExecStart=/bin/bash
rendait 203/EXEC, /bin étant un montage FUSE que systemd ne voit pas ; et
le PATH d'une unité n'a pas bash, que réclament les shebangs de run.sh.
Déclarée, l'unité est démarrée par la reconstruction — qui a lieu pendant
« make install_os », quand la source d'Odoo n'arrive qu'après : une
condition la fait sauter plutôt qu'échouer 21 fois de suite. Mesuré :
service actif, 8069 servi par lui, login en 303.

--- EN ---

Three chained obstacles, none visible without a real machine. /etc is
generated from the store: « tee » of a unit there returns « Read-only file
system », and the install failed at its LAST step, after the clone, the
venv and an Odoo start had all succeeded. ExecStart=/bin/bash returned
203/EXEC, /bin being a FUSE mount systemd does not see; and a unit's PATH
carries no bash, which run.sh's shebangs ask for.
Declared, the unit is started by the rebuild — which happens during
« make install_os », while Odoo's source lands later: a condition makes it
skip rather than fail 21 times over. Measured: service active, 8069 served
by it, login in 303.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 21:31:50 -04:00
parent 2f08ec0890
commit 64a8e6400f
6 changed files with 279 additions and 5 deletions

View file

@ -177,4 +177,67 @@
LIBRARY_PATH = "/run/current-system/sw/lib";
PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig";
};
# Le service ERPLibre, DÉCLARÉ et non écrit.
#
# Sur toute autre distribution l'installation dépose l'unité par
# « tee /etc/systemd/system/erplibre.service ». Ici /etc est généré depuis
# le store et monté en lecture seule : le tee échoue sur « Read-only file
# system », et l'installation entière rend 1 à sa dernière étape, après que
# tout le reste a réussi.
#
# « wantedBy » est l'équivalent déclaratif de « systemctl enable » :
# l'activation par lien symbolique écrirait elle aussi dans /etc.
#
# L'interpréteur vient du STORE et non de /bin. /bin et /usr/bin sont ici
# un montage FUSE d'envfs, et systemd résout l'exécutable d'ExecStart
# lui-même, hors de portée de ce montage : « /bin/bash » y rend
# « 203/EXEC, Unable to locate executable ». Avec Restart=always, l'unité
# boucle alors indéfiniment. La même raison vaut pour /usr/bin/env, donc
# le shebang de run.sh ne suffirait pas davantage.
#
# La première reconstruction déclare le service AVANT qu'ERPLibre ne soit
# installé, et son démarrage échoue alors — c'est attendu, et c'est
# exactement le cas que « nixos-rebuild rend 4 » recouvre. L'installation
# le relance une fois le dépôt en place.
systemd.services.erplibre = {
description = "ERPLibre";
requires = [ "postgresql.service" ];
after = [ "network.target" "network-online.target" "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
# Une unité systemd ne reçoit PAS le PATH d'une session : le sien ne
# porte que coreutils, findutils, grep, sed et systemd.
#
# bash — run.sh lance odoo_bin.sh et lib_db_select.sh, dont le shebang est
# « #!/usr/bin/env bash ». env est là, bash non : « env: 'bash': No such
# file or directory », et run.sh s'arrête avant Odoo.
#
# python3 — la sonde de réveil tourne AVANT que odoo_bin.sh n'active le
# venv, donc avec le python du système. Son échec est silencieux
# (« 2>/dev/null ») : sans elle, la première page ouverte attendrait le
# chargement du registre sans que rien ne le dise.
path = with pkgs; [ bash python312 ];
# L'unité est déclarée par le module, donc démarrée par la
# reconstruction — qui a lieu PENDANT « make install_os », alors que la
# source d'Odoo n'arrive qu'à « make install_odoo_18 ». Sans condition,
# run.sh échoue sur un odoo-bin absent et « Restart = always » le rejoue
# toutes les cinq secondes jusqu'à ce que l'installation le pose — vingt
# et un échecs mesurés sur une pose ordinaire, et un « nixos-rebuild »
# qui rend 4 parce qu'une unité n'a pas démarré.
#
# Une CONDITION, et non une dépendance : systemd saute l'unité en le
# disant une fois, sans la marquer en échec, et la démarre d'elle-même
# au prochain déclenchement une fois le fichier là. Le motif évite de
# figer la version d'Odoo ici, où elle vieillirait en silence.
unitConfig.ConditionPathExistsGlob = "@EL_DIR@/odoo*/odoo/odoo-bin";
serviceConfig = {
Type = "simple";
User = "@EL_USER@";
WorkingDirectory = "@EL_DIR@";
ExecStart = "${pkgs.bash}/bin/bash @EL_DIR@/run.sh";
Restart = "always";
RestartSec = 5;
StandardOutput = "journal+console";
};
};
}

View file

@ -312,10 +312,21 @@ def verifier(cible, journal, jump=""):
"""
resultats = {}
# Par NOM EXACT, jamais par motif. envfs est un système de fichiers qui
# RÉSOUT à la demande et n'ÉNUMÈRE pas : « ls /usr/bin » y rend un
# répertoire vide alors que « /usr/bin/python3.12 » s'ouvre. Un glob passe
# par readdir, donc ne rend rien, et le contrôle déclarait absent ce qui
# est là — il éprouvait envfs de la seule façon dont envfs ne peut pas
# répondre.
#
# La version vient du dépôt plutôt que d'être écrite ici : c'est elle que
# l'installation a suivie, et une constante vieillirait à côté.
chemins = sonder(
cible,
"for f in /bin/bash /usr/bin/env; do [ -e $f ] || exit 1; done;"
" ls /usr/bin/python3.* >/dev/null 2>&1 || exit 1; echo ok",
" v=$(cut -d. -f1,2 git/erplibre/.python-odoo-version 2>/dev/null);"
' [ -n "$v" ] || v=3;'
' [ -e "/usr/bin/python$v" ] || exit 1; echo ok',
jump,
)
resultats["envfs"] = chemins == "ok"

View file

@ -17,6 +17,11 @@ set -e
. ./env_var.sh
EL_USER=${USER}
# Le répertoire du service, pris du checkout QUI POSE le module : « make
# install_os » tourne à la racine du dépôt, et c'est ce dépôt-là que le
# service doit lancer. Deviner « /home/$USER/git/erplibre » se tromperait sur
# une installation de production, qui vit sous /opt.
EL_DIR=${EL_DIR:-${PWD}}
MODULE_SRC="conf/nixos/erplibre.nix"
MODULE_DST="/etc/nixos/erplibre.nix"
CONFIG="/etc/nixos/configuration.nix"
@ -34,8 +39,9 @@ fi
echo -e "\n---- Module ERPLibre pour NixOS ----"
# Le nom du compte est substitué comme le user-data cloud-init l'est : le
# module déclare un rôle PostgreSQL, et un rôle porte un nom.
sed "s/@EL_USER@/${EL_USER}/g" "${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null
echo " posé : ${MODULE_DST} (compte ${EL_USER})"
sed -e "s/@EL_USER@/${EL_USER}/g" -e "s#@EL_DIR@#${EL_DIR}#g" \
"${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null
echo " posé : ${MODULE_DST} (compte ${EL_USER}, dépôt ${EL_DIR})"
if [ ! -f "${CONFIG}" ]; then
echo "Configuration introuvable : ${CONFIG}"

View file

@ -205,8 +205,28 @@ class QemuInstallMixin:
"sudo sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' "
"/etc/selinux/config 2>/dev/null || true; fi; "
)
# NixOS est reconnu DANS la VM, et non d'après ce que l'hôte croit
# savoir : la même commande sert au déploiement, au test long et à un
# « --hote » qu'on n'a pas créé. /etc y est généré depuis le store et
# monté en lecture seule — le tee plus bas échoue sur « Read-only file
# system », et l'installation rend 1 à sa dernière étape après que
# tout le reste a réussi. L'unité vient du module déclaratif posé par
# « make install_os » ; il ne reste qu'à la relancer, une fois le
# dépôt en place.
nixos = (
"if [ -f /etc/os-release ] && grep -q '^ID=nixos' "
"/etc/os-release; then "
"if systemctl cat erplibre.service >/dev/null 2>&1; then "
"sudo systemctl restart erplibre.service; "
"else "
'echo "erplibre.service non déclaré : '
'« make install_os » pose le module NixOS qui le porte." >&2; '
"exit 1; fi; "
"else "
)
return (
f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
nixos
+ f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
+ pre
+ selinux_shell
+ "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
@ -230,7 +250,8 @@ class QemuInstallMixin:
"WantedBy=multi-user.target\n"
"UNIT\n"
"sudo systemctl daemon-reload; "
"sudo systemctl enable --now erplibre.service"
"sudo systemctl enable --now erplibre.service; "
"fi"
)
# Bureaux disponibles, par gestionnaire de paquets. Une seule source pour

View file

@ -277,5 +277,151 @@ class LHoteSansGestionnaire(unittest.TestCase):
self.assertIn("conseil_sans_gestionnaire", src[i : i + 400])
class LeServiceEstDeclare(unittest.TestCase):
"""Sur NixOS, /etc est généré depuis le store et monté en lecture seule.
L'installation dépose l'unité par « tee /etc/systemd/system/
erplibre.service » sur toute autre distribution ; ici le tee échoue sur
« Read-only file system », et l'installation entière rend 1 à sa dernière
étape, après que le dépôt, le venv, les modules compilés et un démarrage
d'Odoo ont tous réussi. L'unité vient donc de la configuration.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
self.script = SCRIPT.read_text(encoding="utf-8")
def test_the_unit_is_declared_in_the_module(self):
self.assertIn("systemd.services.erplibre", self.src)
self.assertIn('Type = "simple";', self.src)
self.assertIn('User = "@EL_USER@";', self.src)
def test_it_starts_at_boot_without_a_symlink(self):
"""« systemctl enable » activerait par un lien dans /etc, que le
système refuse d'écrire. « wantedBy » en est l'équivalent déclaratif,
et c'est la seule forme d'activation qui tienne ici.
L'absence de « systemctl enable » se vérifie sur la COMMANDE du menu,
plus bas : un module Nix n'exécute rien, et l'y chercher interdirait
au commentaire de nommer ce qu'il explique."""
self.assertIn('wantedBy = [ "multi-user.target" ];', self.src)
def test_the_interpreter_comes_from_the_store(self):
"""/bin et /usr/bin sont un montage FUSE d'envfs, et systemd résout
l'exécutable d'ExecStart lui-même, hors de portée de ce montage :
« /bin/bash » y rend « 203/EXEC ». Avec Restart=always, l'unité boucle
indéfiniment."""
self.assertIn('ExecStart = "${pkgs.bash}/bin/bash', self.src)
self.assertNotIn('ExecStart = "/bin/bash', self.src)
self.assertNotIn('ExecStart = "/usr/bin/env', self.src)
def test_the_unit_carries_the_path_its_scripts_need(self):
"""Une unité ne reçoit pas le PATH d'une session. run.sh lance des
scripts dont le shebang est « env bash » : env est dans le PATH par
défaut, bash non, et run.sh s'arrête avant Odoo."""
self.assertIn("path = with pkgs; [ bash python312 ];", self.src)
def test_the_repository_is_not_guessed(self):
"""Le service lance le dépôt QUI A POSÉ le module. Écrire
« /home/$USER/git/erplibre » se tromperait sur une installation de
production, qui vit sous /opt."""
self.assertIn('WorkingDirectory = "@EL_DIR@";', self.src)
self.assertIn("EL_DIR=${EL_DIR:-${PWD}}", self.script)
def test_both_placeholders_are_substituted(self):
"""Un marqueur non substitué partirait tel quel dans /etc/nixos et
ferait échouer l'évaluation du module."""
for marqueur in ("@EL_USER@", "@EL_DIR@"):
with self.subTest(marqueur=marqueur):
self.assertIn(marqueur, self.src)
self.assertIn(marqueur, self.script)
class LeMenuNEcritPasDansEtcSurNixos(unittest.TestCase):
def _cmd(self, prod=False):
import sys
sys.argv = ["todo.py"]
sys.path.insert(0, str(RACINE))
from script.todo.todo import TODO
return TODO.__new__(TODO)._qemu_odoo_service_cmd(prod=prod)
def test_nixos_is_recognised_before_the_write(self):
"""Reconnu DANS la VM : la même commande sert au déploiement, au test
long et à un « --hote » qu'on n'a pas créé."""
for prod in (False, True):
with self.subTest(prod=prod):
cmd = self._cmd(prod)
self.assertLess(
cmd.index("ID=nixos"),
cmd.index("tee /etc/systemd/system"),
)
def test_the_declarative_branch_only_restarts(self):
"""L'unité existe déjà — le module l'a déclarée. « enable » écrirait
un lien dans /etc, que le système refuse."""
cmd = self._cmd()
nix = cmd[cmd.index("ID=nixos") : cmd.index("tee /etc/systemd/system")]
self.assertIn("systemctl restart erplibre.service", nix)
self.assertNotIn("systemctl enable", nix)
def test_a_missing_unit_is_named_and_fails(self):
"""Sans le module, « restart » rendrait une erreur de systemd sans
dire ce qui manque ni où le prendre."""
cmd = self._cmd()
self.assertIn("systemctl cat erplibre.service", cmd)
self.assertIn("make install_os", cmd)
class LeServiceAttendQuOdooSoitLa(unittest.TestCase):
"""L'unité est déclarée par le module, donc démarrée par la
reconstruction — laquelle a lieu PENDANT « make install_os ».
La source d'Odoo, elle, n'arrive qu'à « make install_odoo_18 ». Entre les
deux, run.sh échoue sur un odoo-bin absent et « Restart = always » le
rejoue toutes les cinq secondes : vingt et un échecs mesurés sur une pose
ordinaire, et un « nixos-rebuild » qui rend 4 au lieu de 0 parce qu'une
unité n'a pas démarré. Le journal d'une machine neuve s'ouvre alors sur
une avalanche qui n'accuse rien de réel.
Une CONDITION plutôt qu'une dépendance : mesuré sur une VM, systemd saute
l'unité en le disant une fois — « was skipped because of an unmet
condition check » — sans la marquer en échec, puis la démarre d'elle-même
une fois le fichier là.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def test_the_unit_waits_for_what_run_sh_executes(self):
self.assertIn(
'unitConfig.ConditionPathExistsGlob = "@EL_DIR@/odoo*/odoo/'
'odoo-bin";',
self.src,
)
def test_the_odoo_version_is_not_frozen_in_the_module(self):
"""Une version écrite ici vieillirait en silence : l'unité cesserait
de démarrer le jour où le dépôt passe à la suivante."""
condition = [l for l in self.src.splitlines() if "ConditionPath" in l]
self.assertEqual(len(condition), 1)
self.assertNotIn("odoo18", condition[0])
def test_it_is_a_condition_and_not_a_dependency(self):
"""« requires » sur un chemin n'existe pas, et « after » ne
garantirait rien : seule une condition SAUTE au lieu d'échouer."""
self.assertNotIn('odoo-bin" ]', self.src)
self.assertIn("ConditionPathExistsGlob", self.src)
def test_the_marker_is_substituted_everywhere(self):
"""Le chemin du dépôt apparaît désormais deux fois dans le module ;
un sed sans « g » n'en remplacerait qu'une, et la condition porterait
un « @EL_DIR@ » littéral que rien ne satisfait jamais."""
script = SCRIPT.read_text(encoding="utf-8")
self.assertIn("s#@EL_DIR@#${EL_DIR}#g", script)
self.assertGreater(self.src.count("@EL_DIR@"), 1)
if __name__ == "__main__":
unittest.main()

View file

@ -367,5 +367,32 @@ class LaCreationDeLaVmEstPrivilegiee(unittest.TestCase):
self.assertIn('([] if self.dry_run else ["sudo"]) + [', src)
class LaSondeEnvfsNommeSesChemins(unittest.TestCase):
"""envfs RÉSOUT à la demande et n'ÉNUMÈRE pas.
« ls /usr/bin » y rend un répertoire vide alors que
« /usr/bin/python3.12 » s'ouvre : un glob passe par readdir, donc ne rend
rien. Le contrôle déclarait absent ce qui est là — il éprouvait envfs de
la seule façon dont envfs ne peut pas répondre.
"""
def test_no_glob_is_used_against_that_filesystem(self):
self.assertNotIn("ls /usr/bin/python3.*", SRC)
self.assertNotIn("/usr/bin/python3.*", SRC)
def test_the_path_is_named_exactly(self):
self.assertIn('[ -e "/usr/bin/python$v" ]', SRC)
def test_the_version_comes_from_the_repository(self):
"""Une version écrite ici vieillirait à côté de celle que
l'installation a suivie."""
self.assertIn(".python-odoo-version", SRC)
def test_a_missing_version_file_still_probes_something(self):
"""Sans le fichier, « /usr/bin/python » tout court : mieux vaut une
épreuve plus faible qu'un chemin vide, qui passerait toujours."""
self.assertIn('[ -n "$v" ] || v=3', SRC)
if __name__ == "__main__":
unittest.main()