[ADD] long_test : ERPLibre installé sur NixOS, de bout en bout

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
This commit is contained in:
Mathieu Benoit 2026-09-16 00:42:30 -04:00
parent a9f91dda87
commit 5c87238dd5
13 changed files with 1258 additions and 32 deletions

View file

@ -202,10 +202,50 @@ One limit the counter-proof exposes: with upstream cut, the repository
database SIGNATURES are missing from the cache, the mirror answering 404 for database SIGNATURES are missing from the cache, the mirror answering 404 for
them, so the cache returns its named 504. pacman treats them as optional and them, so the cache returns its named 504. pacman treats them as optional and
carries on. A distribution that required them would stop there. carries on. A distribution that required them would stop there.
## install_nixos.py — ERPLibre s'installe-t-il sur NixOS ?
Not a depth: one machine, one binary question. The other two measure how far
nesting goes; this one asks whether the path the menu takes reaches the end on
a **declarative** system, where nothing is installed one command at a time.
It sends the menu's own remote command, `_qemu_erplibre_remote_cmd`, taken as
it is. A test that installed by its own means would prove *its* path, not the
product's — and that is exactly where the failures hid: a bootstrap with no
nix branch, a Makefile assuming `/bin/bash`, compile paths read from a session
older than the module it had just applied.
The block goes in **one** ssh session, as the deployment does. That is the
condition that exposes the first-pass failure: the session opens before
`make install_os` applies the module, so before `pam_env` sets `CPATH`, and
the packages with no upstream wheel stopped there. Replaying the install in a
fresh session succeeds and proves the wrong thing.
The verdict is the **state of the machine**, not a return code:
`nixos-rebuild switch` returns 4 on a system that is nonetheless activated,
and every tool block of the menu returns 0 by construction. So it checks what
`envfs` makes (`/bin/bash`, `/usr/bin/env`, `/usr/bin/python3.x`), the venv,
the four modules that have no wheel and must compile — psycopg2, python-ldap,
pycups, mysqlclient — the absence of the HTML manuals, and Odoo answering.
```
./long_test/install_nixos.py # create the VM, install, judge
./long_test/install_nixos.py --dry-run # the plan and the commands
./long_test/install_nixos.py --hote nixos-1 # on a machine you already have
./long_test/install_nixos.py --detruire # undo it
```
`--hote` expects a machine that **already runs NixOS**: the script installs
ERPLibre there, it does not install the system.
It clones from the **published** repository, on the branch asked for
(`develop` by default). That is deliberate — the test measures what a user
receives, not what a local checkout holds. Say it before running: a fix still
on an unmerged branch is *not* in the VM, and the test will fail on whatever
that fix repairs.
## Starting from a host you already have ## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you The three scripts take `--hote`. Creating a head VM to host a hypervisor you
already own costs five minutes *and* one level of nesting — that is, slowness, already own costs five minutes *and* one level of nesting — that is, slowness,
which is the very thing being measured. which is the very thing being measured.
@ -438,10 +478,52 @@ Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES
des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache
rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit.
Une distribution qui les exigerait s'arrêterait là. Une distribution qui les exigerait s'arrêterait là.
## install_nixos.py — ERPLibre s'installe-t-il sur NixOS ?
Pas une profondeur : une machine, une question binaire. Les deux autres
mesurent jusqu'où l'imbrication tient ; celui-ci demande si le chemin que le
menu emprunte aboutit sur un système **déclaratif**, où rien ne s'installe
commande par commande.
Il envoie la commande distante du menu, `_qemu_erplibre_remote_cmd`, prise
telle quelle. Un test qui installerait par ses propres soins prouverait *son*
chemin, pas celui du produit — et c'est justement là que se cachaient les
pannes : un amorçage sans branche nix, un Makefile qui présumait `/bin/bash`,
des chemins de compilation lus d'une session plus vieille que le module
qu'elle venait d'appliquer.
Le bloc part en **une** session ssh, comme le déploiement le fait. C'est la
condition qui expose la panne du premier passage : la session est ouverte
avant que `make install_os` n'applique le module, donc avant que `pam_env` ne
pose `CPATH`, et les paquets sans roue amont s'y arrêtaient. Rejouer
l'installation dans une session neuve réussit et donne raison à tort.
Le verdict est l'**état de la machine**, pas 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. On contrôle donc ce que fabrique
`envfs` (`/bin/bash`, `/usr/bin/env`, `/usr/bin/python3.x`), le venv, les
quatre modules sans roue qui doivent se compiler — psycopg2, python-ldap,
pycups, mysqlclient —, l'absence des manuels HTML, et Odoo qui répond.
```
./long_test/install_nixos.py # crée la VM, installe, juge
./long_test/install_nixos.py --dry-run # le plan et les commandes
./long_test/install_nixos.py --hote nixos-1 # sur une machine qu'on a déjà
./long_test/install_nixos.py --detruire # défaire ce qui a été posé
```
`--hote` attend une machine qui porte **déjà** NixOS : le script y installe
ERPLibre, il n'y installe pas le système.
Le clone vient du dépôt **publié**, sur la branche demandée (`develop` par
défaut). C'est voulu : le test mesure ce qu'un utilisateur reçoit, pas ce
qu'un checkout local contient. À dire avant de lancer : un correctif encore
sur une branche non fusionnée n'est *pas* dans la VM, et le test échouera sur
ce que ce correctif répare.
## Partir d'un hôte qu'on possède déjà ## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un Les trois scripts acceptent `--hote`. Créer une VM de tête pour héberger un
hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage
d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure.

View file

@ -207,10 +207,52 @@ Une limite que la contre-épreuve met au jour : amont coupé, les SIGNATURES
des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache des bases de dépôt manquent au cache, le miroir y répondant 404, et le cache
rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit.
Une distribution qui les exigerait s'arrêterait là. Une distribution qui les exigerait s'arrêterait là.
## install_nixos.py — ERPLibre s'installe-t-il sur NixOS ?
Pas une profondeur : une machine, une question binaire. Les deux autres
mesurent jusqu'où l'imbrication tient ; celui-ci demande si le chemin que le
menu emprunte aboutit sur un système **déclaratif**, où rien ne s'installe
commande par commande.
Il envoie la commande distante du menu, `_qemu_erplibre_remote_cmd`, prise
telle quelle. Un test qui installerait par ses propres soins prouverait *son*
chemin, pas celui du produit — et c'est justement là que se cachaient les
pannes : un amorçage sans branche nix, un Makefile qui présumait `/bin/bash`,
des chemins de compilation lus d'une session plus vieille que le module
qu'elle venait d'appliquer.
Le bloc part en **une** session ssh, comme le déploiement le fait. C'est la
condition qui expose la panne du premier passage : la session est ouverte
avant que `make install_os` n'applique le module, donc avant que `pam_env` ne
pose `CPATH`, et les paquets sans roue amont s'y arrêtaient. Rejouer
l'installation dans une session neuve réussit et donne raison à tort.
Le verdict est l'**état de la machine**, pas 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. On contrôle donc ce que fabrique
`envfs` (`/bin/bash`, `/usr/bin/env`, `/usr/bin/python3.x`), le venv, les
quatre modules sans roue qui doivent se compiler — psycopg2, python-ldap,
pycups, mysqlclient —, l'absence des manuels HTML, et Odoo qui répond.
```
./long_test/install_nixos.py # crée la VM, installe, juge
./long_test/install_nixos.py --dry-run # le plan et les commandes
./long_test/install_nixos.py --hote nixos-1 # sur une machine qu'on a déjà
./long_test/install_nixos.py --detruire # défaire ce qui a été posé
```
`--hote` attend une machine qui porte **déjà** NixOS : le script y installe
ERPLibre, il n'y installe pas le système.
Le clone vient du dépôt **publié**, sur la branche demandée (`develop` par
défaut). C'est voulu : le test mesure ce qu'un utilisateur reçoit, pas ce
qu'un checkout local contient. À dire avant de lancer : un correctif encore
sur une branche non fusionnée n'est *pas* dans la VM, et le test échouera sur
ce que ce correctif répare.
## Partir d'un hôte qu'on possède déjà ## Partir d'un hôte qu'on possède déjà
Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un Les trois scripts acceptent `--hote`. Créer une VM de tête pour héberger un
hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage
d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure.

View file

@ -197,10 +197,50 @@ One limit the counter-proof exposes: with upstream cut, the repository
database SIGNATURES are missing from the cache, the mirror answering 404 for database SIGNATURES are missing from the cache, the mirror answering 404 for
them, so the cache returns its named 504. pacman treats them as optional and them, so the cache returns its named 504. pacman treats them as optional and
carries on. A distribution that required them would stop there. carries on. A distribution that required them would stop there.
## install_nixos.py — ERPLibre s'installe-t-il sur NixOS ?
Not a depth: one machine, one binary question. The other two measure how far
nesting goes; this one asks whether the path the menu takes reaches the end on
a **declarative** system, where nothing is installed one command at a time.
It sends the menu's own remote command, `_qemu_erplibre_remote_cmd`, taken as
it is. A test that installed by its own means would prove *its* path, not the
product's — and that is exactly where the failures hid: a bootstrap with no
nix branch, a Makefile assuming `/bin/bash`, compile paths read from a session
older than the module it had just applied.
The block goes in **one** ssh session, as the deployment does. That is the
condition that exposes the first-pass failure: the session opens before
`make install_os` applies the module, so before `pam_env` sets `CPATH`, and
the packages with no upstream wheel stopped there. Replaying the install in a
fresh session succeeds and proves the wrong thing.
The verdict is the **state of the machine**, not a return code:
`nixos-rebuild switch` returns 4 on a system that is nonetheless activated,
and every tool block of the menu returns 0 by construction. So it checks what
`envfs` makes (`/bin/bash`, `/usr/bin/env`, `/usr/bin/python3.x`), the venv,
the four modules that have no wheel and must compile — psycopg2, python-ldap,
pycups, mysqlclient — the absence of the HTML manuals, and Odoo answering.
```
./long_test/install_nixos.py # create the VM, install, judge
./long_test/install_nixos.py --dry-run # the plan and the commands
./long_test/install_nixos.py --hote nixos-1 # on a machine you already have
./long_test/install_nixos.py --detruire # undo it
```
`--hote` expects a machine that **already runs NixOS**: the script installs
ERPLibre there, it does not install the system.
It clones from the **published** repository, on the branch asked for
(`develop` by default). That is deliberate — the test measures what a user
receives, not what a local checkout holds. Say it before running: a fix still
on an unmerged branch is *not* in the VM, and the test will fail on whatever
that fix repairs.
## Starting from a host you already have ## Starting from a host you already have
Both scripts take `--hote`. Creating a head VM to host a hypervisor you The three scripts take `--hote`. Creating a head VM to host a hypervisor you
already own costs five minutes *and* one level of nesting — that is, slowness, already own costs five minutes *and* one level of nesting — that is, slowness,
which is the very thing being measured. which is the very thing being measured.

9
long_test/deep_qemu.py Normal file → Executable file
View file

@ -109,9 +109,8 @@ PREPARE_APT_CMD = (
) )
# Le réseau « default » de libvirt sert 192.168.122.0/24, à TOUS les étages. # Le réseau « default » de libvirt sert 192.168.122.0/24, à TOUS les étages.
# Constaté au premier essai réel : l'étage 2, dont l'adresse était # Un étage dont l'adresse vient du « default » de son parent voit donc son
# 192.168.122.45 — servie par le « default » de son parent — a vu son propre # propre « net-start default » refusé net :
# « net-start default » refusé net :
# #
# error: internal error: Network is already in use by interface enp1s0 # error: internal error: Network is already in use by interface enp1s0
# #
@ -119,8 +118,8 @@ PREPARE_APT_CMD = (
# reçoit donc son propre sous-réseau, déduit de sa PROFONDEUR : deux étages ne # reçoit donc son propre sous-réseau, déduit de sa PROFONDEUR : deux étages ne
# peuvent pas tomber sur le même, et rien n'est à deviner. # peuvent pas tomber sur le même, et rien n'est à deviner.
# #
# 131 et au-delà : 122 est celui de libvirt et 123 celui de la machine où ce # 131 et au-delà : 122 est le sous-réseau de libvirt, et 123 celui qu'une
# test a été écrit. Les éviter tous les deux coûte un octet. # machine de développement porte souvent. Les éviter coûte un octet.
RESEAU_BASE = 131 RESEAU_BASE = 131

View file

@ -37,7 +37,7 @@ from script.proxmox import proxmox_deploy as pve # noqa: E402
# Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux # Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux
# descentes de piles différentes se disputeraient la RAM, le disque et # descentes de piles différentes se disputeraient la RAM, le disque et
# ~/.ssh/config aussi sûrement que deux de la même. # ~/.ssh/config aussi sûrement que deux de la même.
SCRIPTS = ("deep_proxmox.py", "deep_qemu.py") SCRIPTS = ("deep_proxmox.py", "deep_qemu.py", "install_nixos.py")
# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le # Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le
# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent # journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent
@ -750,22 +750,27 @@ def _lance_une_descente(pid):
"""`pid` exécute-t-il UN des scripts de descente — pas seulement le """`pid` exécute-t-il UN des scripts de descente — pas seulement le
nomme-t-il ? nomme-t-il ?
Par ARGUMENT, jamais par sous-chaîne. Constaté sur cette machine : un Par ARGUMENT, jamais par sous-chaîne. Un « pgrep -f deep_proxmox.py » posé
« pgrep -f deep_proxmox.py » posé dans une boucle de surveillance donne un dans une boucle de surveillance donne un shell dont la ligne de commande
shell dont la ligne de commande contient le motif, et le contrôle comptait contient le motif, et le contrôle comptait ce shell comme une descente —
ce shell comme une descente — deux faux positifs sur trois. Un argument deux faux positifs sur trois.
qui SE TERMINE par le nom du fichier, lui, ne peut venir que d'un
interpréteur qu'on a lancé dessus. Et par NOM DE FICHIER, pas par suffixe. « endswith » prenait
« test_longtest_install_nixos.py » pour « install_nixos.py » : le fichier
de tests se déclarait descente en cours, et « --detruire » refusait de
travailler tant qu'il tournait. Le basename ne confond pas deux fichiers
dont l'un finit comme l'autre, et reconnaît toujours le chemin complet
qu'un interpréteur reçoit.
""" """
try: try:
with open(f"/proc/{int(pid)}/cmdline", "rb") as fh: with open(f"/proc/{int(pid)}/cmdline", "rb") as fh:
arguments = fh.read().split(b"\0") arguments = fh.read().split(b"\0")
except (OSError, ValueError): except (OSError, ValueError):
return False return False
# Les DEUX scripts : deux descentes de piles différentes se disputent la # LES scripts, tous : deux tests longs se disputent la RAM, le disque et
# RAM, le disque et ~/.ssh/config aussi sûrement que deux de la même. # ~/.ssh/config aussi sûrement que deux descentes de la même pile.
attendus = tuple(nom.encode() for nom in SCRIPTS) attendus = {nom.encode() for nom in SCRIPTS}
return any(a.endswith(attendus) for a in arguments) return any(os.path.basename(a) in attendus for a in arguments)
def descente_vivante(pid): def descente_vivante(pid):

524
long_test/install_nixos.py Executable file
View file

@ -0,0 +1,524 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""ERPLibre s'installe-t-il sur NixOS, de bout en bout ?
Ni `deep_qemu.py` ni `deep_proxmox.py` : ceux-là mesurent une profondeur
d'imbrication, celui-ci n'a qu'UNE machine et une question binaire — le
chemin que le menu emprunte aboutit-il sur un système déclaratif.
Il est ici et non dans `test/` pour la raison qui vaut pour ses deux voisins :
il crée une VM, y installe un système entier et prend des heures.
CE QU'IL ENVOIE, ET POURQUOI CE N'EST PAS UN SCRIPT À LUI
L'installation est la commande distante que le menu compose,
`_qemu_erplibre_remote_cmd`, prise telle quelle. Un test qui installerait par
ses propres soins prouverait SON chemin, pas celui du produit — et c'est
justement là que se cachaient les pannes : l'amorçage sans branche nix, le
Makefile qui présumait /bin/bash, les chemins de compilation lus d'une
session plus vieille que le module qu'elle venait d'appliquer.
UNE SEULE SESSION SSH, ET C'EST LA CONDITION QUI COMPTE
Le bloc part en UN appel, comme le déploiement le fait. C'est ce qui expose
la panne du PREMIER passage : la session est ouverte avant que
« make install_os » n'applique le module, donc avant que pam_env ne pose
CPATH ; les paquets qui n'ont pas de roue amont s'y arrêtaient. Rejouer
l'installation dans une session neuve réussit et donne raison à tort.
LE VERDICT EST LA VÉRIFICATION, PAS UN CODE DE RETOUR
« nixos-rebuild switch » rend 4 quand une unité n'a pas redémarré alors que
le système EST activé, et chaque bloc d'outil du menu rend 0 par
construction. On juge donc sur l'état de la machine : les chemins que envfs
fabrique, le venv, les modules qui n'ont pas de roue et doivent se compiler,
l'absence des manuels HTML — leur retour ramènerait une construction Sphinx
de trois mille pages à chaque installation —, puis Odoo qui répond.
CE QU'IL INSTALLE : LE DÉPÔT PUBLIÉ, ENVOYÉ PAR LE CODE LOCAL
Deux moitiés, et il faut les distinguer. La COMMANDE vient du checkout où
l'on est — c'est celle du menu, importée. Le CLONE qu'elle fait dans la VM
vient du dépôt PUBLIC, sur la branche demandée, « develop » par défaut.
C'est voulu : le test mesure ce qu'un utilisateur reçoit. Mais la
conséquence se dit avant de lancer plutôt que de se découvrir sur un rouge —
un correctif encore sur une branche non fusionnée voyage dans la commande et
PAS dans le clone. Éprouvé : l'amorçage passe, le clone se fait, et le make
s'arrête sur le « /bin/bash » que le dépôt publié exige encore.
./long_test/install_nixos.py # crée la VM, installe, juge
./long_test/install_nixos.py --dry-run # le plan et les commandes
./long_test/install_nixos.py --hote nixos-1 # sur une machine qu'on a déjà
./long_test/install_nixos.py --detruire # défaire ce qui a été posé
"""
import argparse
import json
import os
import shlex
import subprocess
import sys
import time
RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, RACINE)
sys.path.insert(0, os.path.join(RACINE, "long_test"))
from descente import ( # noqa: E402
Famille,
capacite_hote,
cle_publique,
detruire,
dire,
)
DISTRO = "nixos"
NOM_BASE = "long-nixos"
OUTIL = "install_nixos"
# La RAM : le catalogue annonce 2048 Mo pour NixOS et le « nixos-rebuild » du
# module y tient — mesuré, une fois les manuels HTML écartés. La compilation
# de ce qui n'a pas de roue amont, elle, n'a été éprouvée qu'au-dessus : le
# défaut est donc plus large que le minimum, et « --memory » le règle pour qui
# veut mesurer la limite basse.
MEMOIRE_MO = 4096
VCPUS = 4
# Le disque VIRTUEL de la VM : 40 Go, le minimum que le catalogue annonce. Il
# garde la marge des générations du store Nix, que rien ne purge tout seul.
DISQUE_GO = 40
# Ce que l'installation ÉCRIT vraiment, mesuré à la fin d'une installation
# complète : 12 Go, dont 5,5 pour le store. C'est ce chiffre-là qu'il faut
# comparer à l'espace libre de l'hôte, et non les 40 Go ci-dessus : un qcow2
# n'est pas préalloué, il ne prend que ce qu'on y écrit. Les confondre faisait
# refuser le test sur une machine qui pouvait parfaitement le mener.
DISQUE_ECRIT_GO = 12
# Ce qu'il faut à la machine AVANT de commencer, en plus de la VM elle-même.
# Annoncer un plan qui ne tient pas coûte une heure pour rien.
MARGE_RAM_MO = 1024
MARGE_DISQUE_GO = 8
# Les modules qui n'ont PAS de roue amont et doivent donc se compiler dans la
# VM. Ce sont eux qui tombent quand les en-têtes manquent, et eux seuls : les
# roues manylinux s'installent sans compilateur.
MODULES_COMPILES = ("psycopg2", "ldap", "cups", "MySQLdb")
# Odoo sans base répond 303 vers son sélecteur. 200, 404 et 500 prouvent aussi
# qu'un serveur écoute et a chargé son registre ; 000 est l'absence de réponse.
CODES_VIVANTS = ("200", "303", "404", "500")
DELAI_SSH = 600
DELAI_INSTALL = 10800
DELAI_HTTP = 300
def journal_du_jour():
chemin = os.path.expanduser(
f"~/.erplibre/longtest/{NOM_BASE}-{time.strftime('%Y%m%d-%H%M%S')}.log"
)
os.makedirs(os.path.dirname(chemin), exist_ok=True)
return chemin
def commande_installation(branche):
"""Le bloc que le MENU envoie, pris tel quel.
Importé plutôt que recopié : deux copies d'une chaîne d'installation
divergent, et c'est la copie du test qui reste verte pendant que le
produit casse.
"""
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)._qemu_erplibre_remote_cmd(branche)
def ssh_base(cible, jump=""):
opts = [
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"BatchMode=yes",
"-o",
"ConnectTimeout=10",
]
if jump:
opts += ["-J", jump]
return ["ssh"] + opts + [cible]
def attendre_ssh(cible, journal, jump="", delai=DELAI_SSH):
"""Vrai dès que la machine répond. Le premier boot d'une image NixOS pose
le compte par cloud-init : tant qu'il n'a pas fini, le port répond mais
l'authentification échoue."""
fin = time.time() + delai
while time.time() < fin:
try:
fini = subprocess.run(
ssh_base(cible, jump) + ["true"],
capture_output=True,
timeout=30,
)
except (OSError, subprocess.SubprocessError):
# Un ssh pendu est un tour d'attente de plus, pas la fin de la
# course : sans ce rattrapage, une trace d'exception remplaçait
# le verdict d'un test lancé pour des heures sans surveillance.
time.sleep(5)
continue
if fini.returncode == 0:
return True
time.sleep(5)
dire(f" ✗ {cible} jamais joignable en {delai} s", journal)
return False
def creer_vm(nom, journal, dry_run, memoire=MEMOIRE_MO):
"""La VM, par la CLI du dépôt — celle que le menu appelle aussi.
Rend (nom, uuid) ou (None, ""). L'UUID est ce qui identifie la machine
pour la destruction : un nom se réutilise, un UUID non.
"""
argv = [
os.path.join(RACINE, ".venv.erplibre/bin/python"),
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
"--distro",
DISTRO,
"--name",
nom,
"--vcpus",
str(VCPUS),
"--memory",
str(memoire),
"--disk-size",
f"{DISQUE_GO}G",
]
pub = cle_publique()
if pub:
argv += ["--ssh-key", pub]
if dry_run:
dire(" " + " ".join(shlex.quote(a) for a in argv), journal)
return nom, ""
try:
fini = subprocess.run(argv, timeout=DELAI_SSH * 3)
except (OSError, subprocess.SubprocessError) as souci:
dire(f" ✗ la création de la VM s'est arrêtée : {souci}", journal)
return None, ""
if fini.returncode:
dire(" ✗ la création de la VM a échoué", journal)
return None, ""
# L'UUID manquant n'annule PAS la création : la VM existe, et le défaire
# saura la reprendre par son nom, en le disant.
try:
uuid = subprocess.run(
["sudo", "-n", "virsh", "domuuid", nom],
capture_output=True,
text=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
dire(f" ⚠ {nom} : UUID illisible, le nom devra suffire", journal)
return nom, ""
return nom, ("" if uuid.returncode else uuid.stdout.strip())
def adresse_de(nom, journal):
"""L'adresse de la VM, et l'entrée ~/.ssh/config qui va avec.
La CLI ne l'écrit pas : sans elle, « ssh long-nixos » rend « Name or
service not known » et l'attente irait jusqu'à son plein délai sur une
machine qui répond parfaitement à son adresse.
"""
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
ip = todo._qemu_vm_ip_now(nom)
if not ip:
dire(f" ✗ {nom} créée mais sans adresse", journal)
return ""
prive = cle_publique()[:-4] if cle_publique() else None
todo._write_ssh_config_entry([nom], "erplibre", ip, identity_file=prive)
dire(f" {nom} : {ip}", journal)
return ip
def installer(cible, branche, journal, jump="", dry_run=False):
"""Le bloc du menu, en UNE session — la condition qui expose la panne du
premier passage. Rend (ok, secondes)."""
bloc = commande_installation(branche)
if dry_run:
dire(f" {len(bloc)} caractères de commande distante", journal)
dire(" " + bloc[:200] + " …", journal)
return True, 0
debut = time.time()
try:
with open(journal, "a", encoding="utf-8") as fh:
fini = subprocess.run(
ssh_base(cible, jump) + ["bash -s"],
input=bloc,
text=True,
stdout=fh,
stderr=subprocess.STDOUT,
timeout=DELAI_INSTALL,
)
except (OSError, subprocess.SubprocessError) as souci:
# Une installation qui expire est un FAIT à rapporter, pas une trace
# d'exception : la vérification qui suit dira ce que la machine a
# tout de même reçu, et c'est elle qui tranche.
secondes = int(time.time() - debut)
dire(
f" ✗ installation interrompue après {secondes} s : {souci}",
journal,
)
return False, secondes
secondes = int(time.time() - debut)
# Le code de retour est NOTÉ, pas cru : la vérification tranche.
dire(f" installation : code={fini.returncode}, {secondes} s", journal)
return fini.returncode == 0, secondes
def sonder(cible, commande, jump=""):
"""La sortie d'une commande distante, ou "" si elle échoue."""
try:
fini = subprocess.run(
ssh_base(cible, jump) + [commande],
capture_output=True,
text=True,
timeout=180,
)
except (OSError, subprocess.SubprocessError):
return ""
return fini.stdout.strip() if fini.returncode == 0 else ""
def verifier(cible, journal, jump=""):
"""L'état de la machine, contrôle par contrôle. Rend {nom: bool}.
Ce sont des FAITS, pas des codes de retour : ce que envfs fabrique, le
venv, les modules qui doivent se compiler, et un serveur qui répond.
"""
resultats = {}
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",
jump,
)
resultats["envfs"] = chemins == "ok"
venv = sonder(
cible,
"cd git/erplibre 2>/dev/null && ls -d .venv.odoo*/bin/python"
" 2>/dev/null | head -1",
jump,
)
resultats["venv"] = bool(venv)
if venv:
# Les quatre en un seul import : ce qui manque est nommé par Python
# lui-même dans le journal, et un seul aller-retour suffit.
code = "import " + ", ".join(MODULES_COMPILES) + "; print('ok')"
rendu = sonder(
cible,
f"cd git/erplibre && ./{venv} -c {shlex.quote(code)}"
" 2>&1 | tail -1",
jump,
)
resultats["modules_compiles"] = rendu == "ok"
else:
resultats["modules_compiles"] = False
# Les manuels HTML : le module les écarte, et leur retour ferait revenir
# une construction Sphinx de trois mille pages à chaque installation.
doc = sonder(
cible, "ls /run/current-system/sw/share/doc 2>/dev/null | wc -l", jump
)
resultats["sans_manuels_html"] = doc == "0"
# Sans venv, il n'y a rien à démarrer : sonder cinq minutes une machine
# dont on sait déjà qu'elle n'a pas d'installation ne dit rien de plus.
if resultats["venv"]:
resultats["odoo_repond"] = odoo_repond(cible, journal, jump)
else:
dire(" — Odoo non sondé : pas de venv", journal)
resultats["odoo_repond"] = False
for nom, ok in resultats.items():
dire(f" {'✓' if ok else '✗'} {nom}", journal)
return resultats
def odoo_repond(cible, journal, jump=""):
"""Odoo démarré par le dépôt, et un code HTTP qui prouve un registre
chargé. Le serveur est lancé ICI plutôt que supposé en service : une VM de
développement n'en installe pas forcément un."""
try:
subprocess.run(
ssh_base(cible, jump)
+ [
"cd git/erplibre &&"
" nohup ./run.sh >/tmp/odoo-longtest.log 2>&1 &"
],
capture_output=True,
timeout=60,
)
except (OSError, subprocess.SubprocessError):
dire(" ✗ le lancement d'Odoo n'a pas abouti", journal)
return False
fin = time.time() + DELAI_HTTP
while time.time() < fin:
code = sonder(
cible,
"curl -s -o /dev/null -w '%{http_code}' -m 10"
" http://127.0.0.1:8069/web/login",
jump,
)
if code in CODES_VIVANTS:
dire(f" Odoo rend {code}", journal)
return True
time.sleep(10)
dire(" ✗ Odoo n'a rien rendu", journal)
return False
def plan_tient(journal, memoire=MEMOIRE_MO):
"""La machine peut-elle héberger la VM ? Le dire AVANT de la créer."""
coeurs, ram, disque = capacite_hote()
dire(
f" machine locale : {coeurs} cœurs, {ram} Mo disponibles,"
f" {disque} Go de disque",
journal,
)
manque = []
if ram < memoire + MARGE_RAM_MO:
manque.append(f"RAM (il en faut {memoire + MARGE_RAM_MO} Mo)")
if disque < DISQUE_ECRIT_GO + MARGE_DISQUE_GO:
manque.append(
f"disque (il en faut {DISQUE_ECRIT_GO + MARGE_DISQUE_GO} Go"
f" libres ; le disque virtuel fait {DISQUE_GO} Go, creux)"
)
if manque:
dire(f" ✗ pas assez de {' ni de '.join(manque)}", journal)
return False
return True
def mener(argv):
parseur = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parseur.add_argument("--dry-run", action="store_true")
parseur.add_argument("--detruire", action="store_true")
parseur.add_argument(
"--hote",
default="",
help="installer sur une machine NixOS existante (alias ssh ou"
" user@adresse) au lieu de créer une VM",
)
parseur.add_argument("--jump", default="", help="rebond ssh pour --hote")
parseur.add_argument("--branche", default="develop")
parseur.add_argument("--memory", type=int, default=MEMOIRE_MO)
args = parseur.parse_args(argv)
journal = journal_du_jour()
# `detruire_une` défait une VM IMBRIQUÉE chez son parent. Il n'y en a
# aucune ici — une seule machine, au premier étage — donc le défaire
# partagé ne l'appelle jamais. On le dit quand même plutôt que de poser
# None : le jour où il serait appelé, le journal nommerait la raison au
# lieu de s'arrêter sur « NoneType is not callable ».
def pas_d_imbrique(parent_alias, identite, nom, log=None):
dire(f" ✗ {nom} : ce test ne crée pas de VM imbriquée", log)
return False
famille = Famille(OUTIL, NOM_BASE, pas_d_imbrique)
if args.detruire:
return detruire(famille, journal, dry_run=args.dry_run)
dire(f" journal : {journal}", journal)
if args.dry_run:
dire(" --dry-run : rien ne sera créé.", journal)
# La forme du rapport n'est pas libre : c'est le CONTRAT du défaire
# partagé (descente.py). « etages » vide fait écarter le rapport par
# dernier_rapport — « rien créé » — et la VM survivrait au --detruire.
# « pid » est ce qui empêche de détruire la machine d'une installation
# EN COURS : sans lui, le rapport de la course en cours est le plus
# récent, donc celui qu'on choisit.
rapport = {
"outil": OUTIL,
"distro": DISTRO,
"branche": args.branche,
"dry_run": args.dry_run,
"pid": os.getpid(),
"etapes": {},
"etages": [],
}
chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json")
if args.hote:
cible, uuid = args.hote, ""
dire(f" hôte fourni : {args.hote}", journal)
else:
if not args.dry_run and not plan_tient(journal, args.memory):
return 1
nom = f"{NOM_BASE}-{time.strftime('%H%M%S')}"
dire(
f" création de {nom} ({args.memory} Mo, {DISQUE_GO} Go)", journal
)
nom, uuid = creer_vm(nom, journal, args.dry_run, args.memory)
if not nom:
return 1
# Écrit AVANT la suite : une course qui meurt en cours
# d'installation laisse quand même de quoi la défaire.
rapport["etages"] = [
{
"niveau": 1,
"nom": nom,
"uuid": uuid,
"alias": nom,
"cree": True,
}
]
cible = nom
if not args.dry_run:
_ecrire(chemin, rapport, journal)
if not adresse_de(nom, journal):
return 1
if args.dry_run:
installer(cible, args.branche, journal, args.jump, dry_run=True)
_ecrire(chemin, rapport, journal)
return 0
if not attendre_ssh(cible, journal, args.jump):
_ecrire(chemin, rapport, journal)
return 1
rapport["etapes"]["joignable"] = True
ok, secondes = installer(cible, args.branche, journal, args.jump)
rapport["etapes"]["installation_code_zero"] = ok
rapport["secondes_installation"] = secondes
controles = verifier(cible, journal, args.jump)
rapport["controles"] = controles
_ecrire(chemin, rapport, journal)
# Le verdict porte sur l'ÉTAT, pas sur le code de retour de l'installation.
tout = all(controles.values())
dire(f" {'✅' if tout else '❌'} ERPLibre sur NixOS", journal)
return 0 if tout else 1
def _ecrire(chemin, rapport, journal):
with open(chemin, "w", encoding="utf-8") as fh:
json.dump(rapport, fh, indent=2)
dire(f" rapport : {chemin}", journal)
if __name__ == "__main__":
sys.exit(mener(sys.argv[1:]))

View file

@ -20,6 +20,19 @@ import click
from script.todo.todo_i18n import t 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. # Le répertoire des tests longs, à la racine du dépôt.
LONGTEST_DIR = "long_test" LONGTEST_DIR = "long_test"
@ -108,6 +121,12 @@ class LongTestMenuMixin:
"Download cache: measure, then cut the upstream" "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")}, {"prompt_description": t("Undo what the descent created")},
] ]
# Le cache n'est pas une descente : ni profondeur, ni hôte de départ. # Le cache n'est pas une descente : ni profondeur, ni hôte de départ.
@ -124,6 +143,11 @@ class LongTestMenuMixin:
"2": ("deep_proxmox.py", True), "2": ("deep_proxmox.py", True),
"3": ("deep_qemu.py", True), "3": ("deep_qemu.py", True),
"4": ("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) help_info = self.fill_help_info(choices)
while True: while True:
@ -136,16 +160,21 @@ class LongTestMenuMixin:
continue continue
if status in scripts: if status in scripts:
script, demander = scripts[status] script, demander = scripts[status]
# La profondeur est DEMANDÉE : c'est le réglage qui décide de if script == "install_nixos.py":
# la durée — au-delà de trois étages, tout est 15 à 30 fois # Pas de profondeur : une seule machine, et la question
# plus lent, et cinq se comptent en heures. # 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()}" args = f"--depth {self._longtest_depth()}"
if demander: if demander:
args += self._longtest_depart(script) args += self._longtest_depart(script)
if status in ("1", "3"): if status in ("1", "3", "8"):
args += " --dry-run" args += " --dry-run"
self._longtest_run(script, args) self._longtest_run(script, args)
elif status == "8": elif status == "10":
self._longtest_defaire() self._longtest_defaire()
else: else:
print(t("Command not found !")) print(t("Command not found !"))
@ -161,11 +190,30 @@ class LongTestMenuMixin:
on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne
mène pas directement à un « qm destroy --purge ». mène pas directement à un « qm destroy --purge ».
""" """
for script in ("deep_proxmox.py", "deep_qemu.py", "qemu_cache.py"): for script in SCRIPTS_DEFAISABLES:
self._longtest_run(script, "--detruire --dry-run", demander=False) self._longtest_run(script, "--detruire --dry-run", demander=False)
if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")):
self._longtest_run(script, "--detruire", demander=False) 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): def _longtest_depart(self, script):
"""D'où part la descente : une VM neuve, ou un hôte qu'on a déjà. """D'où part la descente : une VM neuve, ou un hôte qu'on a déjà.

View file

@ -616,6 +616,9 @@ class QemuAccessMixin:
cmd = todo_install.install_command(["virt-viewer"]) cmd = todo_install.install_command(["virt-viewer"])
if not cmd: if not cmd:
print(f" ⚠ {t('no known package manager here.')}") print(f" ⚠ {t('no known package manager here.')}")
conseil = todo_install.conseil_sans_gestionnaire()
if conseil:
print(f" {conseil}")
return False return False
lisible = shlex.join(cmd) lisible = shlex.join(cmd)
print(f" {t('Will execute:')} {lisible}") print(f" {t('Will execute:')} {lisible}")

View file

@ -3283,6 +3283,18 @@ TRANSLATIONS = {
"fr": "virt-viewer manque ici ; installation.", "fr": "virt-viewer manque ici ; installation.",
"en": "virt-viewer is missing here; installing it.", "en": "virt-viewer is missing here; installing it.",
}, },
# Là où « aucun gestionnaire » est vrai ET sans issue.
"NixOS: nix-shell -p <package> for one shell, or declare it in"
" /etc/nixos/configuration.nix": {
"fr": (
"NixOS : nix-shell -p <paquet> le temps d'un shell, ou le"
" déclarer dans /etc/nixos/configuration.nix"
),
"en": (
"NixOS: nix-shell -p <package> for one shell, or declare it in"
" /etc/nixos/configuration.nix"
),
},
"no known package manager here.": { "no known package manager here.": {
"fr": "aucun gestionnaire de paquets connu ici.", "fr": "aucun gestionnaire de paquets connu ici.",
"en": "no known package manager here.", "en": "no known package manager here.",
@ -4497,6 +4509,31 @@ TRANSLATIONS = {
"fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)", "fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)",
"en": "Nested QEMU depth: plan only (dry-run)", "en": "Nested QEMU depth: plan only (dry-run)",
}, },
# ERPLibre installé sur NixOS, de bout en bout.
"ERPLibre on NixOS: plan only (dry-run)": {
"fr": "ERPLibre sur NixOS : le plan seulement (à blanc)",
"en": "ERPLibre on NixOS: plan only (dry-run)",
},
"ERPLibre on NixOS: run it": {
"fr": "ERPLibre sur NixOS : le lancer",
"en": "ERPLibre on NixOS: run it",
},
"Where does the install run?": {
"fr": "Où l'installation se fait-elle ?",
"en": "Where does the install run?",
},
"Create a fresh NixOS VM": {
"fr": "Créer une VM NixOS neuve",
"en": "Create a fresh NixOS VM",
},
"Use a NixOS machine you already have": {
"fr": "Partir d'une machine NixOS qu'on possède déjà",
"en": "Use a NixOS machine you already have",
},
"Choice (1-2, default 1): ": {
"fr": "Choix (1-2, défaut 1) : ",
"en": "Choice (1-2, default 1): ",
},
"Nested QEMU depth: run it": { "Nested QEMU depth: run it": {
"fr": "Profondeur QEMU imbriqué : le lancer", "fr": "Profondeur QEMU imbriqué : le lancer",
"en": "Nested QEMU depth: run it", "en": "Nested QEMU depth: run it",

View file

@ -87,6 +87,25 @@ def family() -> str | None:
return None return None
def conseil_sans_gestionnaire() -> str:
"""Ce qu'on peut faire là où aucune famille impérative ne gouverne, ou "".
« Aucun gestionnaire connu » est VRAI sur NixOS et n'y mène nulle part :
rien ne s'y installe par une commande, tout s'y déclare. Le dire seul
ferme la porte ; nommer les deux gestes qui marchent l'ouvre — le shell
jetable pour essayer, la déclaration pour ce qui reste.
Vide ailleurs : sur une machine dont on ne sait rien, inventer un conseil
vaut moins que de se taire.
"""
if os_id() != "nixos":
return ""
return t(
"NixOS: nix-shell -p <package> for one shell,"
" or declare it in /etc/nixos/configuration.nix"
)
def install_command(paquets, famille=None) -> list | None: def install_command(paquets, famille=None) -> list | None:
"""La commande d'installation, en liste d'arguments. None si personne. """La commande d'installation, en liste d'arguments. None si personne.
@ -149,6 +168,9 @@ def ask_and_install(execute, cmd, question, is_yes, prefix=" "):
""" """
if not cmd: if not cmd:
print(f"{prefix}⚠ {t('no known package manager here.')}") print(f"{prefix}⚠ {t('no known package manager here.')}")
conseil = conseil_sans_gestionnaire()
if conseil:
print(f"{prefix} {conseil}")
return None return None
if not show_and_ask(cmd, question, is_yes, prefix=prefix): if not show_and_ask(cmd, question, is_yes, prefix=prefix):
print(t("Nothing to do.")) print(t("Nothing to do."))

View file

@ -235,5 +235,47 @@ class LeModule(unittest.TestCase):
self.assertIn('"/include"', self.src) self.assertIn('"/include"', self.src)
class LHoteSansGestionnaire(unittest.TestCase):
"""Ce que le menu répond sur un système où rien ne s'installe.
« Aucun gestionnaire connu » est vrai sur NixOS et n'y mène nulle part :
la phrase seule ferme la porte, alors que deux gestes l'ouvrent — le shell
jetable pour essayer, la déclaration pour ce qui reste.
"""
def setUp(self):
from script.todo import todo_install
self.ti = todo_install
self._vrai = todo_install.os_id
self.addCleanup(setattr, todo_install, "os_id", self._vrai)
def test_a_nixos_host_is_told_what_works_instead(self):
self.ti.os_id = lambda: "nixos"
conseil = self.ti.conseil_sans_gestionnaire()
self.assertIn("nix-shell -p", conseil)
self.assertIn("configuration.nix", conseil)
def test_nothing_is_invented_elsewhere(self):
"""Sur une machine dont on ne sait rien, inventer un conseil vaut
moins que de se taire."""
for ident in ("debian", "fedora", "arch", ""):
with self.subTest(ident=ident):
self.ti.os_id = lambda ident=ident: ident
self.assertEqual("", self.ti.conseil_sans_gestionnaire())
def test_both_dead_ends_carry_it(self):
"""Deux endroits impriment « aucun gestionnaire » : celui que tout le
menu partage, et celui de virt-viewer."""
for chemin in (
"script/todo/todo_install.py",
"script/todo/qemu_access.py",
):
with self.subTest(chemin=chemin):
src = (RACINE / chemin).read_text(encoding="utf-8")
i = src.index("no known package manager here.")
self.assertIn("conseil_sans_gestionnaire", src[i : i + 400])
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View file

@ -0,0 +1,316 @@
#!/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"))
if __name__ == "__main__":
unittest.main()

View file

@ -575,6 +575,52 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase):
self.assertFalse(self.dp._lance_une_descente(faux.pid)) self.assertFalse(self.dp._lance_une_descente(faux.pid))
self.assertNotIn(faux.pid, self.dp.autre_descente()) self.assertNotIn(faux.pid, self.dp.autre_descente())
def _argv0(self, nom):
"""Un processus vivant dont argv[0] est `nom`, sans rien exécuter de
vrai.
`executable` sépare le binaire RÉELLEMENT lancé de ce que la ligne de
commande annonce : c'est elle que /proc publie, et donc elle que le
contrôle lit. Le faire par « sh -c 'exec -a …' » n'éprouvait rien — la
ligne de commande restait celle du shell, et le test passait à vide.
"""
import subprocess
faux = subprocess.Popen([nom, "30"], executable=shutil.which("sleep"))
self.addCleanup(faux.kill)
# Le noyau publie la nouvelle ligne de commande à l'exec, pas au fork.
for _ in range(100):
try:
with open(f"/proc/{faux.pid}/cmdline", "rb") as fh:
if fh.read().startswith(nom.encode()):
break
except OSError:
pass
time.sleep(0.02)
return faux
def test_a_file_whose_name_merely_ends_like_one_is_not_a_descent(self):
"""« endswith » prenait « test_longtest_install_nixos.py » pour
« install_nixos.py » : le fichier de tests se déclarait descente en
cours, et « --detruire » refusait de travailler tant qu'il tournait.
Le piège n'est pas propre à ce nom-là : tout script dont le nom
termine celui d'un test long y tombait."""
faux = self._argv0("/tmp/test_longtest_install_nixos.py")
self.assertFalse(self.dp._lance_une_descente(faux.pid))
def test_the_real_path_of_a_script_is_still_recognised(self):
"""Le basename ne doit pas rendre le contrôle aveugle : un
interpréteur reçoit le CHEMIN du script, pas son nom nu."""
for chemin in (
"long_test/install_nixos.py",
"/home/x/long_test/deep_qemu.py",
"./deep_proxmox.py",
):
with self.subTest(chemin=chemin):
faux = self._argv0(chemin)
self.assertTrue(self.dp._lance_une_descente(faux.pid))
def _fausse_descente(self): def _fausse_descente(self):
"""Un processus qui exécute VRAIMENT un « deep_proxmox.py ». """Un processus qui exécute VRAIMENT un « deep_proxmox.py ».
@ -1113,19 +1159,39 @@ class TestLeMenuDesDeuxTests(unittest.TestCase):
self.assertIn("deep_qemu.py", src) self.assertIn("deep_qemu.py", src)
def test_undoing_asks_each_stack_separately(self): def test_undoing_asks_each_stack_separately(self):
"""Chacun ne connaît que ses rapports : lancer les deux ne peut pas """Chacun ne connaît que ses rapports : lancer les trois ne peut pas
faire détruire à l'un ce que l'autre a créé.""" faire détruire à l'un ce que l'autre a créé."""
import inspect import inspect
from script.todo.longtest_menu import SCRIPTS_DEFAISABLES
src = inspect.getsource(self.todo._longtest_defaire) src = inspect.getsource(self.todo._longtest_defaire)
self.assertIn("deep_proxmox.py", src) self.assertIn("SCRIPTS_DEFAISABLES", src)
self.assertIn("deep_qemu.py", src) for script in ("deep_proxmox.py", "deep_qemu.py", "install_nixos.py"):
with self.subTest(script=script):
self.assertIn(script, SCRIPTS_DEFAISABLES)
# À BLANC d'abord, toujours : un choix d'une touche ne doit pas mener # À BLANC d'abord, toujours : un choix d'une touche ne doit pas mener
# droit à « qm destroy --purge ». # droit à « qm destroy --purge ».
self.assertLess( self.assertLess(
src.index("--detruire --dry-run"), src.index('"--detruire"') src.index("--detruire --dry-run"), src.index('"--detruire"')
) )
def test_everything_the_lock_knows_can_be_undone(self):
"""Le verrou (long_test/descente.py) et le défaire (le menu) portent
chacun la liste des tests longs, faute de pouvoir la partager : le
menu lance ces scripts en sous-processus et n'importe jamais
long_test/, qui traîne avec lui le module Proxmox.
L'inclusion, et non l'égalité : un test long que le verrou connaît
crée des machines, donc il DOIT être défaisable. L'inverse n'est pas
vrai — le menu défait aussi le test du cache, que le verrou ne
surveille pas. Exiger l'égalité ferait échouer ce test sur un écart
qui est un choix, pas une dérive."""
from script.todo.longtest_menu import SCRIPTS_DEFAISABLES
self.assertTrue(set(moteur.SCRIPTS) <= set(SCRIPTS_DEFAISABLES))
self.assertIn("install_nixos.py", moteur.SCRIPTS)
def test_the_host_options_are_built_from_the_host_dict(self): def test_the_host_options_are_built_from_the_host_dict(self):
self.assertEqual( self.assertEqual(
self.todo._longtest_args_hote({"target": "pve9", "jump": ""}), self.todo._longtest_args_hote({"target": "pve9", "jump": ""}),