diff --git a/long_test/README.base.md b/long_test/README.base.md index c1370b1..554df8d 100644 --- a/long_test/README.base.md +++ b/long_test/README.base.md @@ -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 them, so the cache returns its named 504. pacman treats them as optional and 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 -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, 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 rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. 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à -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 d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. diff --git a/long_test/README.fr.md b/long_test/README.fr.md index 280c580..c9f6c53 100644 --- a/long_test/README.fr.md +++ b/long_test/README.fr.md @@ -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 rend donc son 504 nommé. pacman les traite comme optionnelles et poursuit. 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à -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 d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. diff --git a/long_test/README.md b/long_test/README.md index 5828754..3bc74b1 100644 --- a/long_test/README.md +++ b/long_test/README.md @@ -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 them, so the cache returns its named 504. pacman treats them as optional and 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 -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, which is the very thing being measured. diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py old mode 100644 new mode 100755 index 12e83e6..bb5eaf8 --- a/long_test/deep_qemu.py +++ b/long_test/deep_qemu.py @@ -109,9 +109,8 @@ PREPARE_APT_CMD = ( ) # 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 -# 192.168.122.45 — servie par le « default » de son parent — a vu son propre -# « net-start default » refusé net : +# Un étage dont l'adresse vient du « default » de son parent voit donc son +# propre « net-start default » refusé net : # # 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 # 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 -# test a été écrit. Les éviter tous les deux coûte un octet. +# 131 et au-delà : 122 est le sous-réseau de libvirt, et 123 celui qu'une +# machine de développement porte souvent. Les éviter coûte un octet. RESEAU_BASE = 131 diff --git a/long_test/descente.py b/long_test/descente.py index 1e67be2..eb59a90 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -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 # descentes de piles différentes se disputeraient la RAM, le disque et # ~/.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 # 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 nomme-t-il ? - Par ARGUMENT, jamais par sous-chaîne. Constaté sur cette machine : un - « pgrep -f deep_proxmox.py » posé dans une boucle de surveillance donne un - shell dont la ligne de commande contient le motif, et le contrôle comptait - ce shell comme une descente — deux faux positifs sur trois. Un argument - qui SE TERMINE par le nom du fichier, lui, ne peut venir que d'un - interpréteur qu'on a lancé dessus. + Par ARGUMENT, jamais par sous-chaîne. Un « pgrep -f deep_proxmox.py » posé + dans une boucle de surveillance donne un shell dont la ligne de commande + contient le motif, et le contrôle comptait ce shell comme une descente — + deux faux positifs sur trois. + + 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: with open(f"/proc/{int(pid)}/cmdline", "rb") as fh: arguments = fh.read().split(b"\0") except (OSError, ValueError): return False - # Les DEUX scripts : deux descentes de piles différentes se disputent la - # RAM, le disque et ~/.ssh/config aussi sûrement que deux de la même. - attendus = tuple(nom.encode() for nom in SCRIPTS) - return any(a.endswith(attendus) for a in arguments) + # LES scripts, tous : deux tests longs se disputent la RAM, le disque et + # ~/.ssh/config aussi sûrement que deux descentes de la même pile. + attendus = {nom.encode() for nom in SCRIPTS} + return any(os.path.basename(a) in attendus for a in arguments) def descente_vivante(pid): diff --git a/long_test/install_nixos.py b/long_test/install_nixos.py new file mode 100755 index 0000000..76ffa21 --- /dev/null +++ b/long_test/install_nixos.py @@ -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:])) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 04bcf91..21228dc 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -20,6 +20,19 @@ import click from script.todo.todo_i18n import t +# Les tests longs qui CRÉENT quelque chose, donc qui savent le défaire. +# +# Seconde copie de `long_test/descente.py::SCRIPTS`, et elle l'est faute de +# mieux : ce module-ci vit dans script/todo/ et lance les tests longs en +# sous-processus, sans jamais importer long_test/ — qui traîne avec lui le +# module Proxmox. Là-bas la liste sert au VERROU, ici au défaire. +SCRIPTS_DEFAISABLES = ( + "deep_proxmox.py", + "deep_qemu.py", + "qemu_cache.py", + "install_nixos.py", +) + # Le répertoire des tests longs, à la racine du dépôt. LONGTEST_DIR = "long_test" @@ -108,6 +121,12 @@ class LongTestMenuMixin: "Download cache: measure, then cut the upstream" ) }, + { + "prompt_description": t( + "ERPLibre on NixOS: plan only (dry-run)" + ) + }, + {"prompt_description": t("ERPLibre on NixOS: run it")}, {"prompt_description": t("Undo what the descent created")}, ] # Le cache n'est pas une descente : ni profondeur, ni hôte de départ. @@ -124,6 +143,11 @@ class LongTestMenuMixin: "2": ("deep_proxmox.py", True), "3": ("deep_qemu.py", True), "4": ("deep_qemu.py", True), + # 8 et 9, et non 5 et 6 : le cache occupe 5 à 7, et sa table est + # interrogée AVANT celle-ci. Les y laisser aurait rendu les + # entrées NixOS inatteignables — le menu aurait lancé le cache. + "8": ("install_nixos.py", True), + "9": ("install_nixos.py", True), } help_info = self.fill_help_info(choices) while True: @@ -136,16 +160,21 @@ class LongTestMenuMixin: continue if status in scripts: script, demander = scripts[status] - # La profondeur est DEMANDÉE : c'est le réglage qui décide de - # la durée — au-delà de trois étages, tout est 15 à 30 fois - # plus lent, et cinq se comptent en heures. - args = f"--depth {self._longtest_depth()}" - if demander: - args += self._longtest_depart(script) - if status in ("1", "3"): + if script == "install_nixos.py": + # Pas de profondeur : une seule machine, et la question + # est binaire — l'installation aboutit ou non. + args = self._longtest_depart_nixos() if demander else "" + else: + # La profondeur est DEMANDÉE : c'est le réglage qui décide + # de la durée — au-delà de trois étages, tout est 15 à 30 + # fois plus lent, et cinq se comptent en heures. + args = f"--depth {self._longtest_depth()}" + if demander: + args += self._longtest_depart(script) + if status in ("1", "3", "8"): args += " --dry-run" self._longtest_run(script, args) - elif status == "8": + elif status == "10": self._longtest_defaire() else: 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 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) if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): self._longtest_run(script, "--detruire", demander=False) + def _longtest_depart_nixos(self): + """D'où part l'installation : une VM neuve, ou une machine NixOS + qu'on possède déjà. + + La question n'est pas celle des descentes — il n'y a pas d'étage ici, + et un hôte fourni doit DÉJÀ porter NixOS : le script y installe + ERPLibre, il n'y installe pas le système. + """ + print(f"\n{t('Where does the install run?')}") + print(f" [1] {t('Create a fresh NixOS VM')} *") + print(f" [2] {t('Use a NixOS machine you already have')}") + if input(t("Choice (1-2, default 1): ")).strip() != "2": + return "" + hote = self._longtest_hote_manuel() + if hote: + return self._longtest_args_hote(hote) + print(t("Cancelled.")) + return "" + def _longtest_depart(self, script): """D'où part la descente : une VM neuve, ou un hôte qu'on a déjà. diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py index 08f21d4..5b637d1 100644 --- a/script/todo/qemu_access.py +++ b/script/todo/qemu_access.py @@ -616,6 +616,9 @@ class QemuAccessMixin: cmd = todo_install.install_command(["virt-viewer"]) if not cmd: print(f" ⚠ {t('no known package manager here.')}") + conseil = todo_install.conseil_sans_gestionnaire() + if conseil: + print(f" {conseil}") return False lisible = shlex.join(cmd) print(f" {t('Will execute:')} {lisible}") diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 216ce26..0816e96 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3283,6 +3283,18 @@ TRANSLATIONS = { "fr": "virt-viewer manque ici ; installation.", "en": "virt-viewer is missing here; installing it.", }, + # Là où « aucun gestionnaire » est vrai ET sans issue. + "NixOS: nix-shell -p for one shell, or declare it in" + " /etc/nixos/configuration.nix": { + "fr": ( + "NixOS : nix-shell -p le temps d'un shell, ou le" + " déclarer dans /etc/nixos/configuration.nix" + ), + "en": ( + "NixOS: nix-shell -p for one shell, or declare it in" + " /etc/nixos/configuration.nix" + ), + }, "no known package manager here.": { "fr": "aucun gestionnaire de paquets connu ici.", "en": "no known package manager here.", @@ -4497,6 +4509,31 @@ TRANSLATIONS = { "fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)", "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": { "fr": "Profondeur QEMU imbriqué : le lancer", "en": "Nested QEMU depth: run it", diff --git a/script/todo/todo_install.py b/script/todo/todo_install.py index 1023132..3914e3c 100644 --- a/script/todo/todo_install.py +++ b/script/todo/todo_install.py @@ -87,6 +87,25 @@ def family() -> str | 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 for one shell," + " or declare it in /etc/nixos/configuration.nix" + ) + + def install_command(paquets, famille=None) -> list | None: """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: print(f"{prefix}⚠ {t('no known package manager here.')}") + conseil = conseil_sans_gestionnaire() + if conseil: + print(f"{prefix} {conseil}") return None if not show_and_ask(cmd, question, is_yes, prefix=prefix): print(t("Nothing to do.")) diff --git a/test/test_install_nixos.py b/test/test_install_nixos.py index cb78c72..348c667 100644 --- a/test/test_install_nixos.py +++ b/test/test_install_nixos.py @@ -235,5 +235,47 @@ class LeModule(unittest.TestCase): 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__": unittest.main() diff --git a/test/test_longtest_install_nixos.py b/test/test_longtest_install_nixos.py new file mode 100644 index 0000000..c09fb27 --- /dev/null +++ b/test/test_longtest_install_nixos.py @@ -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() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 2a7231b..b80adc4 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -575,6 +575,52 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): self.assertFalse(self.dp._lance_une_descente(faux.pid)) 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): """Un processus qui exécute VRAIMENT un « deep_proxmox.py ». @@ -1113,19 +1159,39 @@ class TestLeMenuDesDeuxTests(unittest.TestCase): self.assertIn("deep_qemu.py", src) 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éé.""" import inspect + from script.todo.longtest_menu import SCRIPTS_DEFAISABLES + src = inspect.getsource(self.todo._longtest_defaire) - self.assertIn("deep_proxmox.py", src) - self.assertIn("deep_qemu.py", src) + self.assertIn("SCRIPTS_DEFAISABLES", 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 # droit à « qm destroy --purge ». self.assertLess( 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): self.assertEqual( self.todo._longtest_args_hote({"target": "pve9", "jump": ""}),