[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
2026-09-16 00:42:30 -04:00
|
|
|
#!/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.
|
|
|
|
|
"""
|
[FIX] long_test et cache git : sudo pour créer, seuil nommé au refus
Les tests longs lançaient la CLI sans privilège, alors que le dossier des
images appartient à root en 755 : arrêt à l'étape 1 sur « Permission
refusée », avant d'avoir rien créé. Ils ne pouvaient donc pas tourner du
tout — pas même échouer utilement. Le menu passe sudo depuis toujours ;
c'est cette forme qui est reprise, et jamais à blanc.
Le refus de place d'un miroir reprenait un champ que tout appelant laisse
à zéro : « moins de 0 o libres » n'annonce aucun seuil et ne dit pas ce
qui a été mesuré. Treize tests héritaient du plancher de production,
10 Gio, et rougissaient sur un /tmp en tmpfs.
--- EN ---
The long tests ran the CLI unprivileged, while the image directory is
owned by root, mode 755: they stopped at step 1 on "permission denied",
before creating anything. They therefore could not run at all — not even
fail usefully. The menu has always passed sudo; that is the form taken
here, and never on a dry run.
A mirror's refusal for lack of space echoed a field every caller leaves at
zero: "less than 0 B free" announces no threshold and does not say what
was measured. Thirteen tests inherited the production floor, 10 GiB, and
went red on a tmpfs /tmp.
Assisted-by: Claude Opus 5
2026-09-16 00:43:06 -04:00
|
|
|
# SOUS SUDO, comme le menu le fait (« parts = [] if dry_run else
|
|
|
|
|
# ["sudo"] ») : le dossier des images appartient à root en 755 sur une
|
|
|
|
|
# installation ordinaire de libvirt, et la CLI s'arrête à l'étape 1 sur
|
|
|
|
|
# « Permission refusée » avant d'avoir rien créé. Sans cela le test ne
|
|
|
|
|
# peut pas tourner du tout — pas même échouer utilement.
|
|
|
|
|
#
|
|
|
|
|
# Jamais à blanc : un essai qui n'écrit rien n'a aucune raison de
|
|
|
|
|
# demander un mot de passe.
|
|
|
|
|
argv = ([] if dry_run else ["sudo"]) + [
|
[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
2026-09-16 00:42:30 -04:00
|
|
|
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 = {}
|
|
|
|
|
|
[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
2026-09-16 21:31:50 -04:00
|
|
|
# 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é.
|
[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
2026-09-16 00:42:30 -04:00
|
|
|
chemins = sonder(
|
|
|
|
|
cible,
|
|
|
|
|
"for f in /bin/bash /usr/bin/env; do [ -e $f ] || exit 1; done;"
|
[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
2026-09-16 21:31:50 -04:00
|
|
|
" 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',
|
[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
2026-09-16 00:42:30 -04:00
|
|
|
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:]))
|