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
398 lines
15 KiB
Python
398 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Le test long qui installe ERPLibre sur NixOS.
|
|
|
|
Lui crée une VM et prend des heures ; ceux-ci gardent sa forme en quelques
|
|
millisecondes. Ce qu'ils tiennent :
|
|
|
|
- il envoie la commande du MENU, pas une chaîne à lui. Un test qui
|
|
installerait par ses propres soins prouverait SON chemin, et c'est
|
|
précisément là que se cachaient les pannes ;
|
|
- le verdict porte sur l'ÉTAT de la machine, pas sur un code de retour :
|
|
« nixos-rebuild switch » rend 4 sur un système pourtant activé, et chaque
|
|
bloc d'outil du menu rend 0 par construction ;
|
|
- l'installation part en UNE session ssh, comme le déploiement le fait. C'est
|
|
la condition qui expose la panne du premier passage — celle qui disparaît
|
|
dès qu'on rejoue dans une session neuve ;
|
|
- il est dans long_test/ et le lanceur unitaire ne le ramasse pas ;
|
|
- « --dry-run » ne crée rien et le dit.
|
|
"""
|
|
|
|
import ast
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
SCRIPT = RACINE / "long_test/install_nixos.py"
|
|
SRC = SCRIPT.read_text(encoding="utf-8")
|
|
|
|
|
|
class LaPlace(unittest.TestCase):
|
|
def test_it_lives_out_of_the_unit_runner(self):
|
|
"""Le lanceur balaie « test/test_*.py » et doit rester lançable en
|
|
quelques secondes, même sans virtualisation."""
|
|
self.assertTrue(SCRIPT.exists())
|
|
self.assertFalse((RACINE / "test/test_install_nixos_long.py").exists())
|
|
|
|
def test_it_is_executable(self):
|
|
"""Le menu et le README l'appellent directement."""
|
|
self.assertTrue(os.access(SCRIPT, os.X_OK))
|
|
|
|
def test_it_parses(self):
|
|
ast.parse(SRC)
|
|
|
|
|
|
class LeCheminEprouve(unittest.TestCase):
|
|
def test_the_install_command_comes_from_the_menu(self):
|
|
"""Deux copies d'une chaîne d'installation divergent, et c'est la
|
|
copie du test qui reste verte pendant que le produit casse."""
|
|
self.assertIn("_qemu_erplibre_remote_cmd", SRC)
|
|
# Aucune chaîne d'installation écrite ici.
|
|
self.assertNotIn("make install_odoo", SRC)
|
|
self.assertNotIn("git clone", SRC)
|
|
|
|
def test_the_block_goes_in_one_ssh_session(self):
|
|
"""La session est ouverte AVANT que « make install_os » n'applique le
|
|
module : c'est ce qui expose la panne du premier passage."""
|
|
self.assertIn('["bash -s"]', SRC.replace("'", '"'))
|
|
|
|
def test_the_verdict_is_the_state_not_the_return_code(self):
|
|
"""Le code de retour est noté, pas cru."""
|
|
self.assertIn("all(controles.values())", SRC)
|
|
|
|
def test_what_must_compile_is_named(self):
|
|
"""Les paquets sans roue amont sont ceux qui tombent quand les
|
|
en-têtes manquent, et eux seuls."""
|
|
for module in ("psycopg2", "ldap", "cups", "MySQLdb"):
|
|
with self.subTest(module=module):
|
|
self.assertIn(module, SRC)
|
|
|
|
def test_a_missing_answer_is_not_a_success(self):
|
|
"""« 000 » est l'absence de réponse : l'accepter ferait passer une VM
|
|
où rien n'écoute."""
|
|
self.assertNotIn('"000"', SRC)
|
|
self.assertIn('"303"', SRC)
|
|
|
|
|
|
class LePlan(unittest.TestCase):
|
|
"""Ce que la machine doit avoir AVANT qu'on crée quoi que ce soit."""
|
|
|
|
def test_the_free_space_asked_for_is_what_gets_written(self):
|
|
"""Un qcow2 n'est pas préalloué : il ne prend que ce qu'on y écrit.
|
|
Comparer les 40 Go du disque VIRTUEL à l'espace libre faisait refuser
|
|
le test sur une machine qui pouvait parfaitement le mener."""
|
|
self.assertIn("DISQUE_ECRIT_GO", SRC)
|
|
i = SRC.index("if disque <")
|
|
self.assertIn("DISQUE_ECRIT_GO", SRC[i : i + 120])
|
|
self.assertNotIn("if disque < DISQUE_GO", SRC)
|
|
|
|
def test_the_written_figure_is_smaller_than_the_virtual_disk(self):
|
|
"""Si l'un rattrapait l'autre, la distinction n'aurait plus d'objet et
|
|
le seuil serait faux dans l'autre sens."""
|
|
vals = {}
|
|
for ligne in SRC.splitlines():
|
|
for nom in ("DISQUE_GO", "DISQUE_ECRIT_GO"):
|
|
if ligne.startswith(f"{nom} = "):
|
|
vals[nom] = int(ligne.split("=")[1])
|
|
self.assertLess(vals["DISQUE_ECRIT_GO"], vals["DISQUE_GO"])
|
|
|
|
def test_it_refuses_before_creating_anything(self):
|
|
"""Annoncer un plan qui ne tient pas coûte une heure pour rien : le
|
|
contrôle passe AVANT la création."""
|
|
self.assertLess(SRC.index("def plan_tient"), SRC.index("def mener"))
|
|
i = SRC.index("if not args.dry_run and not plan_tient")
|
|
self.assertLess(i, SRC.index("nom, uuid = creer_vm"))
|
|
|
|
|
|
class LEssaiABlanc(unittest.TestCase):
|
|
def test_it_creates_nothing_and_says_so(self):
|
|
fini = subprocess.run(
|
|
[
|
|
str(RACINE / ".venv.erplibre/bin/python"),
|
|
str(SCRIPT),
|
|
"--dry-run",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
cwd=RACINE,
|
|
)
|
|
self.assertEqual(0, fini.returncode, fini.stderr)
|
|
self.assertIn("--dry-run", fini.stdout)
|
|
# Le plan montre la commande de création, sans la lancer.
|
|
self.assertIn("deploy_qemu.py", fini.stdout)
|
|
self.assertIn("--distro nixos", fini.stdout)
|
|
|
|
def test_the_dry_run_report_is_named_apart(self):
|
|
"""Un rapport d'essai à blanc n'a rien créé : le confondre avec un
|
|
vrai ferait détruire d'après une liste vide."""
|
|
self.assertIn("-dryrun.json", SRC)
|
|
|
|
|
|
class RienNInterrompLaCourse(unittest.TestCase):
|
|
"""Un test lancé pour des heures sans surveillance doit rendre un
|
|
VERDICT, pas une trace d'exception.
|
|
|
|
`subprocess.run(..., timeout=...)` lève TimeoutExpired quand la commande
|
|
dépasse son délai — et c'est le cas normal ici, pas l'exception : un ssh
|
|
pendu, une création qui traîne, une installation qui n'en finit pas."""
|
|
|
|
def test_every_bounded_call_is_caught(self):
|
|
arbre = ast.parse(SRC)
|
|
# Les appels bornés, et la ligne de chacun.
|
|
bornes = []
|
|
for noeud in ast.walk(arbre):
|
|
if not isinstance(noeud, ast.Call):
|
|
continue
|
|
cible = ast.unparse(noeud.func)
|
|
if cible != "subprocess.run":
|
|
continue
|
|
if any(m.arg == "timeout" for m in noeud.keywords):
|
|
bornes.append(noeud.lineno)
|
|
self.assertGreaterEqual(len(bornes), 4)
|
|
# Les lignes couvertes par un « try ».
|
|
couvertes = set()
|
|
for noeud in ast.walk(arbre):
|
|
if isinstance(noeud, ast.Try):
|
|
for corps in noeud.body:
|
|
for interne in ast.walk(corps):
|
|
if hasattr(interne, "lineno"):
|
|
couvertes.add(interne.lineno)
|
|
for ligne in bornes:
|
|
with self.subTest(ligne=ligne):
|
|
self.assertIn(ligne, couvertes)
|
|
|
|
def test_a_timeout_is_reported_not_raised(self):
|
|
"""Ce que le journal doit porter à la place de la trace."""
|
|
self.assertIn("installation interrompue", SRC)
|
|
|
|
|
|
class LeDefaire(unittest.TestCase):
|
|
"""La forme du rapport n'est pas libre : c'est le CONTRAT du défaire
|
|
partagé de descente.py, et rien dans le langage ne l'impose.
|
|
|
|
Le rapport écrit d'abord n'avait pas d'« etages » — dernier_rapport
|
|
l'écartait comme « rien créé », « --detruire » répondait « rien à
|
|
défaire », et la VM survivait à ce qui devait l'effacer."""
|
|
|
|
ETAGE = {
|
|
"niveau": 1,
|
|
"nom": "long-nixos-000000",
|
|
"uuid": "aaaa-bbbb",
|
|
"alias": "long-nixos-000000",
|
|
"cree": True,
|
|
}
|
|
|
|
def setUp(self):
|
|
self.maison = tempfile.mkdtemp(prefix="longtest-nixos-")
|
|
self.dossier = os.path.join(self.maison, ".erplibre/longtest")
|
|
os.makedirs(self.dossier)
|
|
self.addCleanup(shutil.rmtree, self.maison, ignore_errors=True)
|
|
|
|
def _defaire(self, rapport):
|
|
"""Le script, à blanc, sur un HOME qui ne contient que ce rapport."""
|
|
with open(
|
|
os.path.join(self.dossier, "long-nixos-19990101-000000.json"),
|
|
"w",
|
|
encoding="utf-8",
|
|
) as fh:
|
|
json.dump(rapport, fh)
|
|
env = dict(os.environ, HOME=self.maison)
|
|
fini = subprocess.run(
|
|
[
|
|
str(RACINE / ".venv.erplibre/bin/python"),
|
|
str(SCRIPT),
|
|
"--detruire",
|
|
"--dry-run",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=180,
|
|
cwd=RACINE,
|
|
env=env,
|
|
)
|
|
return fini.stdout
|
|
|
|
def test_the_machine_named_is_the_one_the_report_carries(self):
|
|
"""Et non un nom recalculé : le script nomme ses VM avec l'heure, là
|
|
où le repli du défaire partagé dirait « long-nixos-1 »."""
|
|
sortie = self._defaire(
|
|
{
|
|
"outil": "install_nixos",
|
|
"dry_run": False,
|
|
"pid": 1,
|
|
"etages": [self.ETAGE],
|
|
}
|
|
)
|
|
self.assertIn("long-nixos-000000", sortie)
|
|
self.assertNotIn("long-nixos-1 ", sortie)
|
|
# Une seule machine : aucune imbriquée.
|
|
self.assertIn("0 VM imbriquée(s)", sortie)
|
|
self.assertIn("rien ne sera détruit", sortie)
|
|
|
|
def test_a_report_without_that_shape_finds_nothing(self):
|
|
"""La preuve que le contrat compte : la forme écrite d'abord."""
|
|
sortie = self._defaire(
|
|
{
|
|
"outil": "install_nixos",
|
|
"dry_run": False,
|
|
"vm": {"nom": "long-nixos-000000", "uuid": "aaaa"},
|
|
}
|
|
)
|
|
self.assertIn("rien à défaire", sortie)
|
|
|
|
def test_a_dry_run_report_is_never_used_to_destroy(self):
|
|
"""Un plan n'a rien créé : détruire d'après lui viserait des machines
|
|
qui n'existent pas, ou pire, celles d'une autre course."""
|
|
sortie = self._defaire(
|
|
{
|
|
"outil": "install_nixos",
|
|
"dry_run": True,
|
|
"pid": 1,
|
|
"etages": [self.ETAGE],
|
|
}
|
|
)
|
|
self.assertIn("rien à défaire", sortie)
|
|
|
|
def test_the_report_carries_the_pid_of_its_run(self):
|
|
"""Sans lui, « --detruire » lancé pendant une installation prendrait
|
|
le rapport de la course EN COURS — le plus récent — et détruirait la
|
|
machine sous elle."""
|
|
self.assertIn('"pid": os.getpid()', SRC)
|
|
|
|
|
|
class LeVerrouEtLeMenu(unittest.TestCase):
|
|
def test_it_shares_the_lock_of_the_other_long_tests(self):
|
|
"""Deux tests longs se disputent la RAM, le disque et ~/.ssh/config
|
|
aussi sûrement que deux descentes de la même pile."""
|
|
src = (RACINE / "long_test/descente.py").read_text(encoding="utf-8")
|
|
self.assertIn('"install_nixos.py"', src)
|
|
|
|
def test_the_menu_offers_it_dry_and_for_real(self):
|
|
from script.todo.longtest_menu import SCRIPTS_DEFAISABLES
|
|
|
|
menu = (RACINE / "script/todo/longtest_menu.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
self.assertIn("ERPLibre on NixOS: run it", menu)
|
|
self.assertIn("ERPLibre on NixOS: plan only (dry-run)", menu)
|
|
self.assertIn("install_nixos.py", SCRIPTS_DEFAISABLES)
|
|
|
|
def test_the_menu_does_not_pass_a_depth(self):
|
|
"""Une seule machine : « --depth » n'a pas de sens ici, et le script
|
|
le refuserait."""
|
|
menu = (RACINE / "script/todo/longtest_menu.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
bloc = menu.split('if script == "install_nixos.py":')[1][:400]
|
|
self.assertNotIn("--depth", bloc)
|
|
|
|
def test_every_label_is_translated(self):
|
|
from script.todo.todo_i18n import TRANSLATIONS
|
|
|
|
for cle in (
|
|
"ERPLibre on NixOS: plan only (dry-run)",
|
|
"ERPLibre on NixOS: run it",
|
|
"Where does the install run?",
|
|
"Create a fresh NixOS VM",
|
|
"Use a NixOS machine you already have",
|
|
):
|
|
with self.subTest(cle=cle):
|
|
self.assertIn(cle, TRANSLATIONS)
|
|
self.assertTrue(TRANSLATIONS[cle].get("fr"))
|
|
self.assertTrue(TRANSLATIONS[cle].get("en"))
|
|
|
|
|
|
class LaCreationDeLaVmEstPrivilegiee(unittest.TestCase):
|
|
"""Le dossier des images appartient à root en 755 sur une installation
|
|
ordinaire de libvirt.
|
|
|
|
Sans sudo, la CLI s'arrête à l'étape 1 sur « Permission refusée » avant
|
|
d'avoir rien créé : le test ne peut pas tourner du tout, pas même échouer
|
|
utilement. Le menu — le chemin du produit — passe sudo dès que ce n'est
|
|
pas un essai à blanc, et c'est cette forme qui est reprise ici.
|
|
"""
|
|
|
|
def _module(self):
|
|
sys.path.insert(0, str(RACINE / "long_test"))
|
|
import install_nixos
|
|
|
|
return install_nixos
|
|
|
|
def test_the_real_run_asks_for_privilege(self):
|
|
mod = self._module()
|
|
vu = {}
|
|
|
|
class Fini:
|
|
returncode = 1
|
|
|
|
def faux_run(argv, **kw):
|
|
vu["argv"] = argv
|
|
return Fini()
|
|
|
|
vrai = mod.subprocess.run
|
|
self.addCleanup(setattr, mod.subprocess, "run", vrai)
|
|
mod.subprocess.run = faux_run
|
|
mod.creer_vm("essai", None, dry_run=False)
|
|
self.assertEqual("sudo", vu["argv"][0])
|
|
self.assertIn("deploy_qemu.py", " ".join(vu["argv"]))
|
|
|
|
def test_the_dry_run_asks_for_none(self):
|
|
"""Un essai qui n'écrit rien n'a aucune raison de demander un mot de
|
|
passe, et le menu ne le demande pas non plus."""
|
|
mod = self._module()
|
|
vu = []
|
|
vrai = mod.dire
|
|
self.addCleanup(setattr, mod, "dire", vrai)
|
|
mod.dire = lambda texte, journal=None: vu.append(texte)
|
|
nom, uuid = mod.creer_vm("essai", None, dry_run=True)
|
|
self.assertEqual(("essai", ""), (nom, uuid))
|
|
ligne = " ".join(vu)
|
|
self.assertIn("deploy_qemu.py", ligne)
|
|
self.assertNotIn("sudo", ligne)
|
|
|
|
def test_its_sibling_does_the_same(self):
|
|
"""descente.py crée l'étage 1 de la même façon, et l'oubli y était
|
|
le même : un correctif posé d'un seul côté laisse l'autre au mur."""
|
|
src = (RACINE / "long_test/descente.py").read_text(encoding="utf-8")
|
|
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()
|