[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:
parent
2f08ec0890
commit
64a8e6400f
6 changed files with 279 additions and 5 deletions
|
|
@ -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";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue