Une machine, une question binaire : le chemin que le MENU emprunte aboutit-il sur un système déclaratif. La commande d'installation est celle du menu, prise telle quelle — un test qui installerait par ses propres soins prouverait SON chemin, pas celui du produit, et c'est là que les pannes se cachaient. Le verdict est l'ÉTAT de la machine, non un code de retour : « nixos-rebuild » rend 4 quand une unité n'a pas redémarré alors que le système est activé. Il vit dans long_test/ parce qu'il crée une VM et prend des heures ; le lanceur unitaire doit rester lançable en secondes. --- EN --- One machine, one binary question: does the path the MENU takes succeed on a declarative system. The install command is the menu's, taken as is — a test installing by its own means would prove ITS path, not the product's, and that is where the failures hid. The verdict is the machine's STATE, not an exit code: "nixos-rebuild" returns 4 when a unit failed to restart while the system is active. It lives in long_test/ because it creates a VM and takes hours; the unit runner must stay runnable in seconds. Assisted-by: Claude Opus 5
286 lines
12 KiB
Python
286 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
"""Les tests LONGS : de vraies machines, des heures.
|
|
|
|
Ils vivent dans `long_test/` et non dans `test/`, et ce n'est pas un rangement
|
|
de confort : le lanceur unitaire balaie `test/test_*.py` et doit rester
|
|
lançable en quelques secondes, partout. Un test qui crée dix VM n'a rien à y
|
|
faire — il le ferait échouer sur toute machine sans virtualisation, et
|
|
personne ne l'attendrait.
|
|
|
|
Ce menu ne fait que les lancer, en montrant leur sortie en direct : ces
|
|
scripts durent des heures, et une sortie capturée jusqu'à la fin ne dirait
|
|
rien pendant tout ce temps.
|
|
"""
|
|
|
|
import os
|
|
|
|
import click
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
# Les tests longs qui CRÉENT quelque chose, donc qui savent le défaire.
|
|
#
|
|
# Seconde copie de `long_test/descente.py::SCRIPTS`, et elle l'est faute de
|
|
# mieux : ce module-ci vit dans script/todo/ et lance les tests longs en
|
|
# sous-processus, sans jamais importer long_test/ — qui traîne avec lui le
|
|
# module Proxmox. Là-bas la liste sert au VERROU, ici au défaire.
|
|
SCRIPTS_DEFAISABLES = (
|
|
"deep_proxmox.py",
|
|
"deep_qemu.py",
|
|
"qemu_cache.py",
|
|
"install_nixos.py",
|
|
)
|
|
|
|
# Le répertoire des tests longs, à la racine du dépôt.
|
|
LONGTEST_DIR = "long_test"
|
|
|
|
|
|
class LongTestMenuMixin:
|
|
def _longtest_script(self, nom):
|
|
"""Chemin d'un test long, ou "" s'il n'est pas là."""
|
|
chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom)
|
|
return chemin if os.path.exists(chemin) else ""
|
|
|
|
# Ce qui ne crée aucune machine : un plan, un rapport, une liste. Ces
|
|
# commandes-là ne méritent pas de question — une invite qu'on apprend à
|
|
# confirmer sans lire ne protège plus rien le jour où elle compte.
|
|
_LONGTEST_SANS_EFFET = ("--dry-run", "--rapport")
|
|
|
|
@staticmethod
|
|
def _longtest_question(args):
|
|
"""L'avertissement et la question qui vont avec ces arguments.
|
|
|
|
Rend un couple de CLÉS de traduction, jamais du texte : l'invite est
|
|
bilingue comme le reste du menu.
|
|
"""
|
|
if "--detruire" in (args or ""):
|
|
return (
|
|
"This destroys the machines of this test and their disks.",
|
|
"Destroy the machines of this test?",
|
|
)
|
|
return (
|
|
"This creates real VMs and takes a while.",
|
|
"Run this long test?",
|
|
)
|
|
|
|
def _longtest_run(self, nom, args="", demander=None):
|
|
"""Lance un test long, sortie en DIRECT.
|
|
|
|
En direct parce qu'il dure des heures : capturer sa sortie pour
|
|
l'afficher à la fin, c'est ne rien montrer pendant tout ce temps —
|
|
et c'est justement la progression étage par étage qui intéresse.
|
|
|
|
`demander` : None laisse la commande décider — on confirme dès qu'elle
|
|
peut créer de vraies machines. Un appelant qui a DÉJÀ posé sa question
|
|
passe False, sans quoi l'opérateur répondrait deux fois à la même.
|
|
"""
|
|
chemin = self._longtest_script(nom)
|
|
if not chemin:
|
|
print(f" ✗ {t('Script not found:')} {LONGTEST_DIR}/{nom}")
|
|
return
|
|
cmd = f"./.venv.erplibre/bin/python {chemin}"
|
|
if args:
|
|
cmd += f" {args}"
|
|
print(f"\n{t('Will execute:')} {cmd}")
|
|
if demander is None:
|
|
demander = not any(
|
|
d in (args or "") for d in self._LONGTEST_SANS_EFFET
|
|
)
|
|
if demander:
|
|
# Une frappe ne doit suffire ni à créer de vraies machines, ni à
|
|
# en effacer. La question doit dire LAQUELLE des deux on fait :
|
|
# confirmer « lancer ce test long » devant une destruction fait
|
|
# répondre oui à autre chose que ce qui va arriver.
|
|
avertissement, question = self._longtest_question(args)
|
|
print(f" {t(avertissement)}")
|
|
if not click.confirm(t(question)):
|
|
return
|
|
self.execute.exec_command_live(cmd, source_erplibre=False)
|
|
|
|
def prompt_execute_longtest(self):
|
|
print(f"⏳ {t('Long tests: real VMs, hours. Not the unit suite.')}")
|
|
choices = [
|
|
{
|
|
"prompt_description": t(
|
|
"Nested Proxmox depth: plan only (dry-run)"
|
|
)
|
|
},
|
|
{"prompt_description": t("Nested Proxmox depth: run it")},
|
|
{
|
|
"prompt_description": t(
|
|
"Nested QEMU depth: plan only (dry-run)"
|
|
)
|
|
},
|
|
{"prompt_description": t("Nested QEMU depth: run it")},
|
|
{"prompt_description": t("Download cache: plan only (dry-run)")},
|
|
{"prompt_description": t("Download cache: two VMs, measure")},
|
|
{
|
|
"prompt_description": t(
|
|
"Download cache: measure, then cut the upstream"
|
|
)
|
|
},
|
|
{
|
|
"prompt_description": t(
|
|
"ERPLibre on NixOS: plan only (dry-run)"
|
|
)
|
|
},
|
|
{"prompt_description": t("ERPLibre on NixOS: run it")},
|
|
{"prompt_description": t("Undo what the descent created")},
|
|
]
|
|
# Le cache n'est pas une descente : ni profondeur, ni hôte de départ.
|
|
# Ses entrées sont donc traitées à part plutôt que pliées dans la
|
|
# table des piles imbriquées.
|
|
cache = {
|
|
"5": "--dry-run",
|
|
"6": "",
|
|
"7": "--hors-ligne",
|
|
}
|
|
# Chaque choix : le script, et s'il faut demander d'où l'on part.
|
|
scripts = {
|
|
"1": ("deep_proxmox.py", True),
|
|
"2": ("deep_proxmox.py", True),
|
|
"3": ("deep_qemu.py", True),
|
|
"4": ("deep_qemu.py", True),
|
|
# 8 et 9, et non 5 et 6 : le cache occupe 5 à 7, et sa table est
|
|
# interrogée AVANT celle-ci. Les y laisser aurait rendu les
|
|
# entrées NixOS inatteignables — le menu aurait lancé le cache.
|
|
"8": ("install_nixos.py", True),
|
|
"9": ("install_nixos.py", True),
|
|
}
|
|
help_info = self.fill_help_info(choices)
|
|
while True:
|
|
status = click.prompt(help_info)
|
|
print()
|
|
if status == "0":
|
|
return False
|
|
if status in cache:
|
|
self._longtest_run("qemu_cache.py", cache[status])
|
|
continue
|
|
if status in scripts:
|
|
script, demander = scripts[status]
|
|
if script == "install_nixos.py":
|
|
# Pas de profondeur : une seule machine, et la question
|
|
# est binaire — l'installation aboutit ou non.
|
|
args = self._longtest_depart_nixos() if demander else ""
|
|
else:
|
|
# La profondeur est DEMANDÉE : c'est le réglage qui décide
|
|
# de la durée — au-delà de trois étages, tout est 15 à 30
|
|
# fois plus lent, et cinq se comptent en heures.
|
|
args = f"--depth {self._longtest_depth()}"
|
|
if demander:
|
|
args += self._longtest_depart(script)
|
|
if status in ("1", "3", "8"):
|
|
args += " --dry-run"
|
|
self._longtest_run(script, args)
|
|
elif status == "10":
|
|
self._longtest_defaire()
|
|
else:
|
|
print(t("Command not found !"))
|
|
|
|
def _longtest_defaire(self):
|
|
"""Défaire, chaque pile la sienne.
|
|
|
|
Les deux scripts partagent le dossier des rapports mais chacun ne
|
|
connaît que les siens : lancer les deux ne peut pas faire détruire à
|
|
l'un ce que l'autre a créé.
|
|
|
|
Le script demande « OUI » avant de détruire, mais il LISTE d'abord :
|
|
on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne
|
|
mène pas directement à un « qm destroy --purge ».
|
|
"""
|
|
for script in SCRIPTS_DEFAISABLES:
|
|
self._longtest_run(script, "--detruire --dry-run", demander=False)
|
|
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
|
|
self._longtest_run(script, "--detruire", demander=False)
|
|
|
|
def _longtest_depart_nixos(self):
|
|
"""D'où part l'installation : une VM neuve, ou une machine NixOS
|
|
qu'on possède déjà.
|
|
|
|
La question n'est pas celle des descentes — il n'y a pas d'étage ici,
|
|
et un hôte fourni doit DÉJÀ porter NixOS : le script y installe
|
|
ERPLibre, il n'y installe pas le système.
|
|
"""
|
|
print(f"\n{t('Where does the install run?')}")
|
|
print(f" [1] {t('Create a fresh NixOS VM')} *")
|
|
print(f" [2] {t('Use a NixOS machine you already have')}")
|
|
if input(t("Choice (1-2, default 1): ")).strip() != "2":
|
|
return ""
|
|
hote = self._longtest_hote_manuel()
|
|
if hote:
|
|
return self._longtest_args_hote(hote)
|
|
print(t("Cancelled."))
|
|
return ""
|
|
|
|
def _longtest_depart(self, script):
|
|
"""D'où part la descente : une VM neuve, ou un hôte qu'on a déjà.
|
|
|
|
Créer une machine de tête pour héberger un hyperviseur qu'on possède
|
|
déjà coûte cinq minutes ET un étage d'imbrication — donc de la
|
|
lenteur, puisque c'est justement elle qu'on mesure.
|
|
|
|
L'hôte déjà retenu est proposé sans qu'on ait à le rechercher : c'est
|
|
`_pve_host(ask=False)`, qui ne demande rien et ne dit rien s'il n'y en
|
|
a pas.
|
|
"""
|
|
connu = None
|
|
if script == "deep_proxmox.py":
|
|
try:
|
|
connu = self._pve_host(ask=False)
|
|
except Exception: # noqa: BLE001 - une préférence illisible
|
|
connu = None
|
|
print(f"\n{t('Where does the descent start?')}")
|
|
print(f" [1] {t('Create a fresh QEMU VM as level one')} *")
|
|
if connu:
|
|
print(f" [2] {t('Start from:')} {self._pve_label(connu)}")
|
|
print(f" [3] {t('Start from another existing host')}")
|
|
choix = input(t("Choice (1-3, default 1): ")).strip()
|
|
if choix == "2" and connu:
|
|
return self._longtest_args_hote(connu)
|
|
if choix == "3":
|
|
hote = (
|
|
self._pve_pick_host()
|
|
if script == "deep_proxmox.py"
|
|
else self._longtest_hote_manuel()
|
|
)
|
|
if hote:
|
|
return self._longtest_args_hote(hote)
|
|
print(t("Cancelled."))
|
|
return ""
|
|
|
|
@staticmethod
|
|
def _longtest_args_hote(hote):
|
|
"""Les options que le script attend, à partir d'un dict d'hôte."""
|
|
args = f" --hote {hote['target']}"
|
|
if hote.get("jump"):
|
|
args += f" --jump {hote['jump']}"
|
|
return args
|
|
|
|
def _longtest_hote_manuel(self):
|
|
"""Un hôte libvirt de départ, saisi à la main.
|
|
|
|
Pas de sélecteur vérifié comme pour Proxmox : ce qu'on veut ici, c'est
|
|
un hôte qui porte KVM, et c'est le script qui le CONSTATE au premier
|
|
contrôle — /dev/kvm et l'imbrication — plutôt que le menu qui le
|
|
suppose.
|
|
"""
|
|
cible = input(t("Address (user@host, blank = cancel): ")).strip()
|
|
if not cible:
|
|
return None
|
|
return {
|
|
"target": cible,
|
|
"jump": input(t("SSH jump host (blank = none): ")).strip(),
|
|
}
|
|
|
|
def _longtest_depth(self):
|
|
"""Profondeur demandée. Trois par défaut, parce que trois marche.
|
|
|
|
Mesuré sur cette machine : les trois premiers étages prennent 280, 495
|
|
et 1 064 secondes — une demi-heure. Le quatrième a demandé 7 h 18
|
|
d'installation et 4 h 20 d'amorçage, et les suivants se comptent en
|
|
jours. Dix par défaut promettait ce qu'aucune machine ne tient.
|
|
"""
|
|
brut = input(f"{t('Depth (default 3): ')}").strip()
|
|
return int(brut) if brut.isdigit() and int(brut) > 0 else 3
|