Merge branch 'stage/support_nixos'

[ADD] qemu, proxmox et cache : NixOS devient déployable

23 commits. NixOS est le neuvième système déployable, et le seul dont aucune
distribution ne publie d'image cloud : celle d'un tiers est épinglée, sa somme
vérifiée, l'UEFI imposé là où elle n'a pas de secteur BIOS. Les dépendances
d'ERPLibre y sont DÉCLARÉES au lieu d'être posées commande par commande.
Presque rien ne s'y devine — /etc est en lecture seule, /bin un montage FUSE
que systemd ne voit pas, le pare-feu est actif et sshd ne montre aucun motd.
Le cache de téléchargement a dû apprendre à lui servir l'autorité, perdue à
trois frontières, sans quoi NixOS en restait soustrait et le hors ligne lui
était fermé. Vérifié sur machines réelles : installation complète, 0 refus de
certificat, Odoo en 303. 6697 tests.

--- EN ---

23 commits. NixOS is the ninth deployable system, and the only one no
distribution publishes a cloud image for: a third party's is pinned, its sum
verified, UEFI forced where it has no BIOS sector. ERPLibre's dependencies are
DECLARED there instead of installed one command at a time.
Almost nothing there is guessable — /etc is read-only, /bin a FUSE mount
systemd does not see, the firewall is on and sshd shows no motd. The download
cache had to learn to serve it the authority, lost at three boundaries,
without which NixOS stayed exempt from it and offline deployment was closed
to it. Checked on real machines: a complete install, 0 certificate refusals,
Odoo in 303. 6697 tests.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-17 04:07:13 -04:00
commit 86e2ef0ac5
69 changed files with 8216 additions and 236 deletions

View file

@ -72,6 +72,14 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
- NixOS 25.11 as a deployable system, alongside the eight others. It is the only one no distribution publishes a cloud image for: the image is rebuilt by a third party, so its release is pinned, its sha256 verified at every download, and its origin printed before anything is created. cloud-init receives no network configuration there — its networkd renderer takes the key of a block as an interface NAME, where netplan honours a `match:` — and the account is created with `/bin/sh`, sshd refusing an account whose shell does not exist
- ERPLibre installed on NixOS, its dependencies DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time. `services.envfs` answers `/usr/bin/env` and `programs.nix-ld` gives manylinux wheels the dynamic loader they ask for; the headers of what has no wheel are linked into the system profile, and its libraries reach the loader too, a module compiled on the machine carrying no RPATH. The install reaches it from the menu like any other distribution: the bootstrap poses git, make and python3 — absent from that image — in the user profile for the clone, and « make install_os » redeclares them for the system. Checked on a VM: the 362-package lock installs and Odoo answers over HTTP
- A `nix + nixos-anywhere` option on the OTHER distributions, the reverse of picking NixOS: it leaves an ordinary VM able to install NixOS onto any machine reachable over SSH. Nix is called by absolute path, the remote shell's PATH being frozen before the installer drops the binary, and the flake features are repeated on the command line, writing `nix.conf` needing a sudo that may fail
- NixOS deploys on a **Proxmox** host as it does on QEMU/KVM, which took three fixes no code reading could have found. Its image has no BIOS boot sector: a VM created in SeaBIOS reports « running » with a silent console, so the catalogue now marks which images need UEFI — one marker per distribution, since Debian 13 boots in SeaBIOS on that same host. `--ciuser` and `--sshkeys` defer to the image's DEFAULT account, which NixOS declares as another name: the repository's own cloud-config now travels as a snippet for every distribution, carrying an explicit `users:` block. And the cloud-init drive moves to the SCSI bus — an image built for virtio alone has no ATA driver and never sees an IDE drive, so cloud-init hunted network datasources and the VM came up with no account at all. Checked end to end: NixOS answers with `/bin/sh`, Debian 13 with `/bin/bash`, sudo on both
- The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see
- One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL
- The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause
- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified
<!-- [fr] -->
@ -116,6 +124,14 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
- NixOS 25.11 parmi les systèmes déployables, à côté des huit autres. C'est le seul dont aucune distribution ne publie d'image cloud : l'image est rebâtie par un tiers, donc sa version est épinglée, sa somme sha256 vérifiée à chaque téléchargement, et son origine dite avant que rien ne soit créé. cloud-init n'y reçoit aucune configuration réseau — son moteur networkd prend la clé d'un bloc pour un NOM d'interface, là où netplan honore un `match:` — et le compte y est créé avec `/bin/sh`, sshd refusant un compte dont le shell n'existe pas
- ERPLibre installé sur NixOS, ses dépendances DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande. `services.envfs` répond à `/usr/bin/env` et `programs.nix-ld` donne aux roues manylinux le chargeur dynamique qu'elles réclament ; les en-têtes de ce qui n'a pas de roue sont liés au profil du système, et leurs bibliothèques vont aussi au chargeur, un module compilé sur la machine ne portant pas de RPATH. L'installation l'atteint depuis le menu comme toute autre distribution : l'amorçage pose git, make et python3 — absents de cette image — dans le profil de l'utilisateur pour la durée du clone, et « make install_os » les redéclare pour le système. Vérifié sur une VM : le verrou de 362 paquets s'installe et Odoo répond en HTTP
- Une option `nix + nixos-anywhere` sur les AUTRES distributions, l'inverse de choisir NixOS : elle laisse une VM ordinaire capable d'installer NixOS sur toute machine joignable en SSH. Nix est appelé par son chemin absolu, le PATH du shell distant étant figé avant que l'installateur ne pose le binaire, et les fonctions des flakes sont redonnées sur la ligne de commande, l'écriture de `nix.conf` réclamant un sudo qui peut manquer
- NixOS se déploie sur un hôte **Proxmox** comme sur QEMU/KVM, ce qui a demandé trois correctifs qu'aucune lecture de code n'aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console muette, donc le catalogue marque désormais les images qui exigent l'UEFI — un marqueur par distribution, Debian 13 démarrant en SeaBIOS sur ce même hôte. `--ciuser` et `--sshkeys` s'en remettent au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part maintenant comme extrait pour toutes les distributions, avec son bloc `users:` explicite. Et le lecteur cloud-init passe sur le bus SCSI — une image bâtie pour virtio seul n'a pas de pilote ATA et ne voit jamais un lecteur IDE, si bien que cloud-init cherchait des sources réseau et que la VM arrivait sans aucun compte. Vérifié de bout en bout : NixOS rend `/bin/sh`, Debian 13 `/bin/bash`, sudo aux deux
- L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas
- Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte
- Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause
- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier
<!-- [en] -->
## Changed
@ -134,6 +150,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated`
- A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone
- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit
- Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked
- `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere
<!-- [fr] -->
@ -148,6 +166,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated`
- Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL
- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit
- Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot
- `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs
<!-- [en] -->
## Fixed
@ -193,6 +213,19 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes
- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones
- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them
- The timezone a deployed VM inherits from its host is translated to its canonical name. Ubuntu 24.04 cloud images no longer carry the legacy aliases — `Canada/*`, `US/*`, `Asia/Calcutta` — moved to a `tzdata-legacy` package they do not install: cloud-init refused the zone, the VM stayed on UTC, and the only sign was cloud-init reporting an error, the offset showing up in timestamps long afterwards. The alias table is the host's own `tzdata.zi`, not a copy kept in the code
- `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before
- The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing
- A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason
- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. The authority is therefore POINTED AT, consumer by consumer, through the environment — and the environment is lost at three boundaries the four imperative families never meet. nix-daemon is socket-activated and sees no session: a drop-in under /run/systemd/system, a tmpfs writable where /etc is not, reaches it. sudo wipes it, and root's nix talks straight to the local store rather than the daemon, downloading on its own: `Defaults env_keep` carries the variables across, visudo being reached through the system profile since cloud-init's PATH there holds no sudo. And the ssh session opens one second before cloud-init writes the bundle, so the exports live inside the wait for it rather than at the head of the command. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked on a fresh VM with upstream CUT: nix-shell realises from the store; and with the cache intercepting, a complete install ends with no certificate refusal
- Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms
- ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo
- What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line
- The connection guide is displayed on NixOS. Deployment writes `/etc/motd` everywhere and relies on pam_motd to show it — true of the four cloud images, false here, where sshd reports « printmotd no » and the PAM stack holds no pam_motd: the guide was written, complete, and nobody read it. It also gains a NixOS block naming the trap it exists for — `/etc/nixos/erplibre.nix` is rewritten by `make install_os`, and declarations added there vanish without a word
- « make db_drop_all » no longer announces databases as dropped that were not. It built a parallel command, discarded its exit status and printed the list; the case is reachable as soon as parallel is missing from the PATH, and the operator moves on believing their databases are gone
- A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
<!-- [fr] -->
@ -234,6 +267,19 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes
- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais
- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit
- Le fuseau horaire qu'une VM déployée hérite de son hôte est traduit en son nom canonique. Les images cloud d'Ubuntu 24.04 ne portent plus les alias historiques — `Canada/*`, `US/*`, `Asia/Calcutta` — déplacés dans un paquet `tzdata-legacy` qu'elles n'installent pas : cloud-init refusait le fuseau, la VM restait en UTC, et le seul signe était un cloud-init en erreur, le décalage n'apparaissant qu'aux horodatages longtemps après. La table des alias est le `tzdata.zi` de l'hôte, et non une copie figée dans le code
- `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant
- Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien
- Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison
- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. L'autorité y est donc POINTÉE, consommateur par consommateur, par l'environnement — et l'environnement se perd à trois frontières que les quatre familles impératives ne rencontrent jamais. nix-daemon est activé par socket et ne voit aucune session : un fragment sous /run/systemd/system, un tmpfs inscriptible là où /etc ne l'est pas, l'atteint. sudo l'efface, et le nix de root parle droit au magasin local plutôt qu'au démon, téléchargeant lui-même : « Defaults env_keep » fait traverser les variables, visudo étant atteint par le profil du système, le PATH qu'y donne cloud-init ne portant aucun sudo. Et la session ssh s'ouvre une seconde avant que cloud-init n'écrive le faisceau, si bien que les exports vivent DANS l'attente de celui-ci plutôt qu'en tête de commande. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin ; et cache interceptant, une installation complète se termine sans un refus de certificat
- Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms
- ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo
- Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne
- Le guide de connexion s'affiche sur NixOS. Le déploiement écrit `/etc/motd` partout et compte sur pam_motd pour le montrer — vrai des quatre images cloud, faux ici, où sshd rend « printmotd no » et où la pile PAM ne contient aucun pam_motd : le guide était écrit, complet, et personne ne le lisait. Il gagne aussi un bloc propre à NixOS, qui nomme le piège pour lequel il existe — `/etc/nixos/erplibre.nix` est réécrit par `make install_os`, et les déclarations qu'on y ajoute disparaissent sans un mot
- « make db_drop_all » n'annonce plus détruites des bases qui ne le sont pas. Il composait une commande parallel, jetait son code de retour et imprimait la liste ; le cas s'atteint dès que parallel manque du PATH, et l'opérateur passe à la suite en croyant ses bases parties
- Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
<!-- [en] -->
## Removed

View file

@ -52,6 +52,14 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
- NixOS 25.11 parmi les systèmes déployables, à côté des huit autres. C'est le seul dont aucune distribution ne publie d'image cloud : l'image est rebâtie par un tiers, donc sa version est épinglée, sa somme sha256 vérifiée à chaque téléchargement, et son origine dite avant que rien ne soit créé. cloud-init n'y reçoit aucune configuration réseau — son moteur networkd prend la clé d'un bloc pour un NOM d'interface, là où netplan honore un `match:` — et le compte y est créé avec `/bin/sh`, sshd refusant un compte dont le shell n'existe pas
- ERPLibre installé sur NixOS, ses dépendances DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande. `services.envfs` répond à `/usr/bin/env` et `programs.nix-ld` donne aux roues manylinux le chargeur dynamique qu'elles réclament ; les en-têtes de ce qui n'a pas de roue sont liés au profil du système, et leurs bibliothèques vont aussi au chargeur, un module compilé sur la machine ne portant pas de RPATH. L'installation l'atteint depuis le menu comme toute autre distribution : l'amorçage pose git, make et python3 — absents de cette image — dans le profil de l'utilisateur pour la durée du clone, et « make install_os » les redéclare pour le système. Vérifié sur une VM : le verrou de 362 paquets s'installe et Odoo répond en HTTP
- Une option `nix + nixos-anywhere` sur les AUTRES distributions, l'inverse de choisir NixOS : elle laisse une VM ordinaire capable d'installer NixOS sur toute machine joignable en SSH. Nix est appelé par son chemin absolu, le PATH du shell distant étant figé avant que l'installateur ne pose le binaire, et les fonctions des flakes sont redonnées sur la ligne de commande, l'écriture de `nix.conf` réclamant un sudo qui peut manquer
- NixOS se déploie sur un hôte **Proxmox** comme sur QEMU/KVM, ce qui a demandé trois correctifs qu'aucune lecture de code n'aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console muette, donc le catalogue marque désormais les images qui exigent l'UEFI — un marqueur par distribution, Debian 13 démarrant en SeaBIOS sur ce même hôte. `--ciuser` et `--sshkeys` s'en remettent au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part maintenant comme extrait pour toutes les distributions, avec son bloc `users:` explicite. Et le lecteur cloud-init passe sur le bus SCSI — une image bâtie pour virtio seul n'a pas de pilote ATA et ne voit jamais un lecteur IDE, si bien que cloud-init cherchait des sources réseau et que la VM arrivait sans aucun compte. Vérifié de bout en bout : NixOS rend `/bin/sh`, Debian 13 `/bin/bash`, sudo aux deux
- L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas
- Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte
- Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause
- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier
## Modifié
@ -66,6 +74,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated`
- Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL
- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit
- Vérifier une image téléchargée ne demande plus `--verify` : c'est le défaut pour toute distribution qui publie une somme, et `--no-verify` est ce qui la saute — à réserver aux essais hors ligne, où une image substituée passerait autrement sans un mot
- `--bios` est refusé sur une image sans secteur d'amorçage BIOS, et dit pourquoi. Forcé là, il donnait une VM « running » à console muette — la panne même que ce drapeau évite ailleurs
## Corrigé
@ -107,6 +117,19 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes
- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais
- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit
- Le fuseau horaire qu'une VM déployée hérite de son hôte est traduit en son nom canonique. Les images cloud d'Ubuntu 24.04 ne portent plus les alias historiques — `Canada/*`, `US/*`, `Asia/Calcutta` — déplacés dans un paquet `tzdata-legacy` qu'elles n'installent pas : cloud-init refusait le fuseau, la VM restait en UTC, et le seul signe était un cloud-init en erreur, le décalage n'apparaissant qu'aux horodatages longtemps après. La table des alias est le `tzdata.zi` de l'hôte, et non une copie figée dans le code
- `make` cherche bash au lieu de présumer `/bin/bash`. Ce chemin n'existe pas sur NixOS, où le shell vit dans le store, et make s'arrêtait avant d'exécuter la moindre recette — y compris celle qui installe de quoi créer ce chemin. Ailleurs, le shell résolu est celui d'avant
- Le locale et le clavier qu'une VM déployée reçoit s'appliquent désormais sur Debian, où les deux échouaient en silence. Un locale se génère à partir de `/etc/locale.gen` et de nulle part ailleurs : `update-locale` refusait celui qui n'y était pas et la VM restait en C.UTF-8 ; le module clavier finit par un `console-setup` que l'image genericcloud ne porte pas, alors `/etc/default/keyboard` — le fichier que localed et X relisent — est écrit directement. Chaque déploiement Debian imprimait `cloud-init: status: error`, et un mot qui s'affiche toujours n'avertit plus de rien
- Un invité sans ancre de confiance par fichier est soustrait au cache de téléchargement plutôt qu'intercepté sans elle. Le détournement est transparent et vaut pour tout le pont : une VM à qui l'on ne donne pas l'autorité échoue quand même sur « self-signed certificate in certificate chain » — et sur un système déclaratif, poser l'autorité arrive trop tard, la première reconstruction étant le premier téléchargement. Sur un hôte Proxmox, c'est l'HÔTE qui est excepté : un invité imbriqué sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire, dont les sources échouaient pour la même raison
- NixOS apprend l'autorité du cache de téléchargement SANS reconstruire, et n'en est donc plus soustrait — ce qui lui fermait le déploiement hors ligne, le magasin étant alors la seule source et une VM exceptée n'ayant plus rien. Il n'a pas d'ancre de confiance par fichier et /etc est généré en lecture seule, quand une déclaration arriverait trop tard, la première reconstruction étant le premier téléchargement. L'autorité y est donc POINTÉE, consommateur par consommateur, par l'environnement — et l'environnement se perd à trois frontières que les quatre familles impératives ne rencontrent jamais. nix-daemon est activé par socket et ne voit aucune session : un fragment sous /run/systemd/system, un tmpfs inscriptible là où /etc ne l'est pas, l'atteint. sudo l'efface, et le nix de root parle droit au magasin local plutôt qu'au démon, téléchargeant lui-même : « Defaults env_keep » fait traverser les variables, visudo étant atteint par le profil du système, le PATH qu'y donne cloud-init ne portant aucun sudo. Et la session ssh s'ouvre une seconde avant que cloud-init n'écrive le faisceau, si bien que les exports vivent DANS l'attente de celui-ci plutôt qu'en tête de commande. Le faisceau concatène les autorités du système et celle du cache, donner la seconde seule ferait cesser d'approuver tout le reste. Vérifié amont COUPÉ sur une VM neuve : nix-shell réalise depuis le magasin ; et cache interceptant, une installation complète se termine sans un refus de certificat
- Odoo répond depuis l'extérieur d'une VM NixOS. Il écoutait sur 0.0.0.0:8069 et répondait en local, mais NixOS active un pare-feu par défaut là où aucune des quatre autres images cloud n'en active : l'hôte ne recevait rien — pas un refus, un silence jusqu'au délai — et le suivi déclarait Odoo absent sur une machine où il tournait. Mesuré depuis l'hôte : 000 après 12 s, puis 303 en 9 ms
- ERPLibre tourne comme service sur NixOS. L'installation finissait par écrire une unité dans `/etc/systemd/system`, généré depuis le store et monté en lecture seule : elle rendait 1 à sa dernière étape, après que le clone, le venv et un démarrage d'Odoo avaient tous réussi. L'unité est désormais déclarée par le module ; son interpréteur vient du store, `/bin` étant un montage FUSE d'envfs que systemd ne voit pas quand il résout l'exécutable ; et son PATH porte bash, dont l'absence arrêtait `run.sh` avant Odoo
- Ce qu'un système déclaratif doit déclarer, et que les quatre autres reçoivent gratuitement de leur image cloud : xmlsec1, sans lequel Odoo refuse d'installer auth_saml, module du chemin des addons ; parallel et shfmt, appelés par leur nom nu ; growpart, absent de tout le système alors que l'agrandissement du disque s'écrit « … || true » et rendait 0 sans rien agrandir ; et l'agent invité, qui venait de l'image et non du dépôt, le PATH de son unité manquant findmnt si bien que guest-exec mourait en 127 dès sa première ligne
- Le guide de connexion s'affiche sur NixOS. Le déploiement écrit `/etc/motd` partout et compte sur pam_motd pour le montrer — vrai des quatre images cloud, faux ici, où sshd rend « printmotd no » et où la pile PAM ne contient aucun pam_motd : le guide était écrit, complet, et personne ne le lisait. Il gagne aussi un bloc propre à NixOS, qui nomme le piège pour lequel il existe — `/etc/nixos/erplibre.nix` est réécrit par `make install_os`, et les déclarations qu'on y ajoute disparaissent sans un mot
- « make db_drop_all » n'annonce plus détruites des bases qui ne le sont pas. Il composait une commande parallel, jetait son code de retour et imprimait la liste ; le cas s'atteint dès que parallel manque du PATH, et l'opérateur passe à la suite en croyant ses bases parties
- Un miroir du cache de téléchargement refusé faute de place nomme son seuil et sa mesure. Il reprenait un champ que tout appelant ordinaire laisse à zéro — « moins de 0 o libres sur le disque » n'annonce aucun seuil et ne dit pas ce qui a été mesuré
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
## Retiré

View file

@ -52,6 +52,14 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
- NixOS 25.11 as a deployable system, alongside the eight others. It is the only one no distribution publishes a cloud image for: the image is rebuilt by a third party, so its release is pinned, its sha256 verified at every download, and its origin printed before anything is created. cloud-init receives no network configuration there — its networkd renderer takes the key of a block as an interface NAME, where netplan honours a `match:` — and the account is created with `/bin/sh`, sshd refusing an account whose shell does not exist
- ERPLibre installed on NixOS, its dependencies DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time. `services.envfs` answers `/usr/bin/env` and `programs.nix-ld` gives manylinux wheels the dynamic loader they ask for; the headers of what has no wheel are linked into the system profile, and its libraries reach the loader too, a module compiled on the machine carrying no RPATH. The install reaches it from the menu like any other distribution: the bootstrap poses git, make and python3 — absent from that image — in the user profile for the clone, and « make install_os » redeclares them for the system. Checked on a VM: the 362-package lock installs and Odoo answers over HTTP
- A `nix + nixos-anywhere` option on the OTHER distributions, the reverse of picking NixOS: it leaves an ordinary VM able to install NixOS onto any machine reachable over SSH. Nix is called by absolute path, the remote shell's PATH being frozen before the installer drops the binary, and the flake features are repeated on the command line, writing `nix.conf` needing a sudo that may fail
- NixOS deploys on a **Proxmox** host as it does on QEMU/KVM, which took three fixes no code reading could have found. Its image has no BIOS boot sector: a VM created in SeaBIOS reports « running » with a silent console, so the catalogue now marks which images need UEFI — one marker per distribution, since Debian 13 boots in SeaBIOS on that same host. `--ciuser` and `--sshkeys` defer to the image's DEFAULT account, which NixOS declares as another name: the repository's own cloud-config now travels as a snippet for every distribution, carrying an explicit `users:` block. And the cloud-init drive moves to the SCSI bus — an image built for virtio alone has no ATA driver and never sees an IDE drive, so cloud-init hunted network datasources and the VM came up with no account at all. Checked end to end: NixOS answers with `/bin/sh`, Debian 13 with `/bin/bash`, sudo on both
- The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see
- One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL
- The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause
- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified
## Changed
@ -66,6 +74,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated`
- A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone
- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit
- Verifying a downloaded image no longer needs `--verify`: it runs by default for every distribution that publishes a sum, and `--no-verify` is what skips it — to be kept for offline runs, where a substituted image would otherwise pass unremarked
- `--bios` is refused on an image with no BIOS boot sector, and says why. Forced there, it gave a VM reported « running » with a silent console — the very failure that flag exists to avoid elsewhere
## Fixed
@ -107,6 +117,19 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes
- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones
- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them
- The timezone a deployed VM inherits from its host is translated to its canonical name. Ubuntu 24.04 cloud images no longer carry the legacy aliases — `Canada/*`, `US/*`, `Asia/Calcutta` — moved to a `tzdata-legacy` package they do not install: cloud-init refused the zone, the VM stayed on UTC, and the only sign was cloud-init reporting an error, the offset showing up in timestamps long afterwards. The alias table is the host's own `tzdata.zi`, not a copy kept in the code
- `make` looks for bash instead of assuming `/bin/bash`. That path does not exist on NixOS, where the shell lives in the store, and make stopped before running any recipe — including the one that installs what creates that path. Elsewhere the resolved shell is the same one as before
- The locale and the keyboard a deployed VM is given now apply on Debian, where both silently failed. A locale is generated from `/etc/locale.gen` and nowhere else, so `update-locale` refused one that was not there and the VM stayed on C.UTF-8; the keyboard module ends on a `console-setup` the genericcloud image does not carry, so `/etc/default/keyboard` — the file localed and X read — is written directly instead. Every Debian deployment used to print `cloud-init: status: error`, and a word that always shows warns of nothing
- A guest with no per-file trust anchor is taken out of the download cache instead of being intercepted without one. Interception is transparent and covers the whole bridge, so a VM given no authority still fails every HTTPS download on « self-signed certificate in certificate chain » — and on a declarative system placing the authority comes too late, the first rebuild being the first download. On a Proxmox host it is the HOST that is exempted: a nested guest leaves masqueraded behind it and the bridge never sees its own address. Measured from inside the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations, whose sources failed for the same reason
- NixOS learns the download cache's authority WITHOUT a rebuild, so it is no longer taken out of the cache — which used to close offline deployment to it, the store being the only source there and an exempted VM having none. It has no per-file trust anchor and /etc is generated read-only, while a declaration would come too late, the first rebuild being the first download. The authority is therefore POINTED AT, consumer by consumer, through the environment — and the environment is lost at three boundaries the four imperative families never meet. nix-daemon is socket-activated and sees no session: a drop-in under /run/systemd/system, a tmpfs writable where /etc is not, reaches it. sudo wipes it, and root's nix talks straight to the local store rather than the daemon, downloading on its own: `Defaults env_keep` carries the variables across, visudo being reached through the system profile since cloud-init's PATH there holds no sudo. And the ssh session opens one second before cloud-init writes the bundle, so the exports live inside the wait for it rather than at the head of the command. The bundle concatenates the system authorities with the cache's, giving the cache's alone would stop trusting everything else. Checked on a fresh VM with upstream CUT: nix-shell realises from the store; and with the cache intercepting, a complete install ends with no certificate refusal
- Odoo answers from outside a NixOS VM. It listened on 0.0.0.0:8069 and replied locally, but NixOS enables a firewall by default where none of the four other cloud images does: the host received nothing — not a refusal, silence until the timeout — and the monitor declared Odoo absent on a machine where it was running. Measured from the host: 000 after 12 s, then 303 in 9 ms
- ERPLibre runs as a service on NixOS. The install ended by writing a unit into `/etc/systemd/system`, generated from the store and mounted read-only: it returned 1 at its last step, after the clone, the venv and an Odoo start had all succeeded. The unit is now declared by the module; its interpreter comes from the store, `/bin` being an envfs FUSE mount that systemd does not see when it resolves the executable; and its PATH carries bash, whose absence stopped `run.sh` before Odoo
- What a declarative system must declare, and the four others receive free from their cloud image: xmlsec1, without which Odoo refuses to install auth_saml, a module of the addons path; parallel and shfmt, called by bare name; growpart, absent from the whole system while the disk grow is written « … || true » and returned 0 without growing anything; and the guest agent, which came from the image rather than the repository, its unit PATH lacking findmnt so that guest-exec died with 127 on its first line
- The connection guide is displayed on NixOS. Deployment writes `/etc/motd` everywhere and relies on pam_motd to show it — true of the four cloud images, false here, where sshd reports « printmotd no » and the PAM stack holds no pam_motd: the guide was written, complete, and nobody read it. It also gains a NixOS block naming the trap it exists for — `/etc/nixos/erplibre.nix` is rewritten by `make install_os`, and declarations added there vanish without a word
- « make db_drop_all » no longer announces databases as dropped that were not. It built a parallel command, discarded its exit status and printed the list; the case is reachable as soon as parallel is missing from the PATH, and the operator moves on believing their databases are gone
- A download cache mirror refused for lack of space names its threshold and its measurement. It echoed a field every ordinary caller leaves at zero — « less than 0 B free on disk » announces no threshold and does not say what was measured
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
## Removed

View file

@ -1,4 +1,9 @@
SHELL := /bin/bash
# bash, où qu'il soit. « /bin/bash » n'existe pas sur NixOS, où le shell
# vit dans le store : make s'y arrêtait sur « /bin/bash: No such file or
# directory » avant d'exécuter la moindre recette, y compris celle qui
# installe de quoi le créer. Le repli garde le chemin des plateformes qui
# l'ont, et l'affectation reste immédiate — SHELL est lu à chaque recette.
SHELL := $(shell command -v bash 2>/dev/null || echo /bin/bash)
ERPLIBRE_VERSION := $(strip \
$(if $(wildcard .erplibre-version),$(shell cat .erplibre-version),) \
)

View file

@ -101,6 +101,7 @@ Changez de version avec `make switch_odoo_18`, `make switch_odoo_16`, etc.
- **AlmaLinux, Rocky Linux** : 9 and 10 — RHEL and CentOS Stream take the same path
- **openSUSE** : Leap 16.0 and Tumbleweed
- **Arch Linux** : rolling release
- **NixOS** : 25.11 — the dependencies are DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time
- **macOS** : through mise or pyenv
- **Windows** : through WSL or Docker
- **Architectures** : amd64, arm64 and s390x (IBM Z mainframe)
@ -116,6 +117,7 @@ Changez de version avec `make switch_odoo_18`, `make switch_odoo_16`, etc.
- **AlmaLinux, Rocky Linux** : 9 et 10 — RHEL et CentOS Stream empruntent le même chemin
- **openSUSE** : Leap 16.0 et Tumbleweed
- **Arch Linux** : rolling release
- **NixOS** : 25.11 — les dépendances y sont DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande
- **macOS** : par mise ou pyenv
- **Windows** : par WSL ou Docker
- **Architectures** : amd64, arm64 et s390x (mainframe IBM Z)
@ -288,11 +290,11 @@ cd ERPLibre
<!-- [en] -->
`make install_os` detects the distribution and picks the right dependency script: apt for Ubuntu, Linux Mint and Debian, dnf for Fedora and the RHEL family, zypper for openSUSE, pacman for Arch. See the supported platforms above.
`make install_os` detects the distribution and picks the right dependency script: apt for Ubuntu, Linux Mint and Debian, dnf for Fedora and the RHEL family, zypper for openSUSE, pacman for Arch, and a declarative module for NixOS. See the supported platforms above.
<!-- [fr] -->
`make install_os` détecte la distribution et choisit le bon script de dépendances : apt pour Ubuntu, Linux Mint et Debian, dnf pour Fedora et la famille RHEL, zypper pour openSUSE, pacman pour Arch. Voir les plateformes supportées ci-dessus.
`make install_os` détecte la distribution et choisit le bon script de dépendances : apt pour Ubuntu, Linux Mint et Debian, dnf pour Fedora et la famille RHEL, zypper pour openSUSE, pacman pour Arch, et un module déclaratif pour NixOS. Voir les plateformes supportées ci-dessus.
<!-- [common] -->

View file

@ -54,6 +54,7 @@ Changez de version avec `make switch_odoo_18`, `make switch_odoo_16`, etc.
- **AlmaLinux, Rocky Linux** : 9 et 10 — RHEL et CentOS Stream empruntent le même chemin
- **openSUSE** : Leap 16.0 et Tumbleweed
- **Arch Linux** : rolling release
- **NixOS** : 25.11 — les dépendances y sont DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande
- **macOS** : par mise ou pyenv
- **Windows** : par WSL ou Docker
- **Architectures** : amd64, arm64 et s390x (mainframe IBM Z)
@ -146,7 +147,7 @@ cd ERPLibre
```
`make install_os` détecte la distribution et choisit le bon script de dépendances : apt pour Ubuntu, Linux Mint et Debian, dnf pour Fedora et la famille RHEL, zypper pour openSUSE, pacman pour Arch. Voir les plateformes supportées ci-dessus.
`make install_os` détecte la distribution et choisit le bon script de dépendances : apt pour Ubuntu, Linux Mint et Debian, dnf pour Fedora et la famille RHEL, zypper pour openSUSE, pacman pour Arch, et un module déclaratif pour NixOS. Voir les plateformes supportées ci-dessus.
```bash

View file

@ -53,6 +53,7 @@ Switch between versions with `make switch_odoo_18`, `make switch_odoo_16`, etc.
- **AlmaLinux, Rocky Linux** : 9 and 10 — RHEL and CentOS Stream take the same path
- **openSUSE** : Leap 16.0 and Tumbleweed
- **Arch Linux** : rolling release
- **NixOS** : 25.11 — the dependencies are DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time
- **macOS** : through mise or pyenv
- **Windows** : through WSL or Docker
- **Architectures** : amd64, arm64 and s390x (IBM Z mainframe)
@ -145,7 +146,7 @@ cd ERPLibre
```
`make install_os` detects the distribution and picks the right dependency script: apt for Ubuntu, Linux Mint and Debian, dnf for Fedora and the RHEL family, zypper for openSUSE, pacman for Arch. See the supported platforms above.
`make install_os` detects the distribution and picks the right dependency script: apt for Ubuntu, Linux Mint and Debian, dnf for Fedora and the RHEL family, zypper for openSUSE, pacman for Arch, and a declarative module for NixOS. See the supported platforms above.
```bash

363
conf/nixos/erplibre.nix Normal file
View file

@ -0,0 +1,363 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Dépendances système d'ERPLibre, pour NixOS.
#
# Le pendant DÉCLARATIF des quatre install_<distro>_dependency.sh. Ceux-là
# posent des paquets par apt, dnf, pacman ou zypper ; ici rien ne s'installe
# par une commande — ce qui doit rester se déclare, et ce fichier est cette
# déclaration. Un « nixos-rebuild switch » l'applique ; ce qui aurait été posé
# à la main disparaîtrait à la reconstruction suivante.
#
# Déposé en /etc/nixos/erplibre.nix par script/install/install_nixos_dependency.sh,
# qui l'ajoute aussi aux « imports » de la configuration de la machine.
#
# DEUX options portent tout le reste, et sans elles rien de ce dépôt ne
# fonctionne sur NixOS :
#
# services.envfs.enable — NixOS ne peuple pas /bin ni /usr/bin. Or le
# Makefile force « SHELL := /bin/bash » (toute cible make échouerait avant
# sa première ligne) et lib_python_provider.sh ne cherche l'interpréteur du
# système qu'en /usr/bin/pythonX.Y, jamais dans le PATH. envfs fabrique ces
# deux répertoires à la volée depuis le PATH : les scripts du dépôt marchent
# alors sans être réécrits.
#
# programs.nix-ld.enable — les binaires téléchargés (roues manylinux de pip,
# CPython précompilé de mise, archives amont) sont liés à
# /lib64/ld-linux-x86-64.so.2, qui n'existe pas ici. nix-ld le fournit et
# leur donne les bibliothèques listées plus bas.
{ config, lib, pkgs, ... }:
{
# ── Ce que /bin, /usr/bin et l'éditeur de liens doivent porter ──────────
services.envfs.enable = true;
programs.nix-ld.enable = true;
# Les bibliothèques que réclament les roues manylinux d'Odoo : psycopg2 veut
# libpq, lxml veut libxml2/libxslt, cryptography veut openssl, pillow veut
# zlib et freetype. Une roue qui n'en trouve pas une échoue à l'IMPORT, pas
# à l'installation — donc bien après, et sans rapport apparent.
programs.nix-ld.libraries = with pkgs; [
stdenv.cc.cc.lib
zlib
openssl
libxml2
libxslt
libffi
libpq
freetype
fontconfig
libjpeg
glib
expat
bzip2
xz
ncurses
readline
sqlite
util-linux
openldap
cyrus_sasl
cups
libmysqlclient
];
# ── La base de données ───────────────────────────────────────────────────
# Le compte de service est SUPERUSER comme sur les autres distributions :
# ERPLibre crée et détruit des bases (migrations, copies neutralisées), ce
# qu'un rôle ordinaire ne peut pas faire.
services.postgresql = {
enable = true;
# PostGIS comme les quatre autres scripts de distribution le posent. Ici
# c'est une extension DU SERVEUR, pas un paquet du système : déclarée
# ailleurs, elle ne serait pas chargeable par « CREATE EXTENSION ».
extensions = ps: with ps; [ postgis ];
ensureUsers = [
{
name = "@EL_USER@";
ensureClauses.superuser = true;
ensureClauses.createdb = true;
ensureClauses.login = true;
}
];
};
# ── Les outils ───────────────────────────────────────────────────────────
# python3.12 : la version qu'attend ERPLibre (.python-odoo-version). envfs
# la rend visible en /usr/bin/python3.12, où lib_python_provider.sh la
# cherche — ni mise ni pyenv n'ont alors à télécharger quoi que ce soit.
#
# Les sorties « .dev » portent les en-têtes : sans elles, une roue absente
# du dépôt amont devrait se compiler et ne trouverait ni libpq-fe.h ni
# openssl/ssl.h. Elles ne servent qu'à ce cas, et ne coûtent que du disque.
environment.systemPackages = with pkgs; [
python312
python312Packages.pip
python312Packages.virtualenv
uv
nodejs_22
postgresql
postgresql.dev
git
gnumake
gcc
pkg-config
openssl
openssl.dev
zlib
zlib.dev
libxml2
libxml2.dev
libxslt
libxslt.dev
libffi
libffi.dev
# python-ldap n'a PAS de roue amont : il compile, et réclame lber.h
# (openldap) plus sasl.h. Sans ces sorties « .dev », « poetry install »
# s'arrête sur « fatal error: lber.h: No such file or directory ».
openldap
openldap.dev
cyrus_sasl
cyrus_sasl.dev
# « pg_config » est une dérivation à PART dans nixpkgs : il n'est ni dans
# postgresql ni dans sa sortie « .dev », et psycopg2 s'arrête sur
# « Error: pg_config executable not found ».
postgresql.pg_config
# pycups veut cups/http.h, mysqlclient veut mysql.h. Les quatre autres
# scripts posent les mêmes (libcups2-dev, cups-devel,
# mariadb-connector-c-devel, mariadb-libs).
cups
# « cups.lib » porte libcups.so, que « out » n'a pas : sans elle la
# compilation de pycups PASSE et l'édition de liens échoue sur « -lcups ».
cups.lib
cups.dev
libmysqlclient
libmysqlclient.dev
# « less » est le PAGINATEUR ; « nodePackages.less » est lessc, le
# compilateur LESS des assets Odoo. Les quatre autres scripts les posent
# tous deux, l'un par le gestionnaire du système et l'autre par « npm
# install -g » — geste impossible ici, le préfixe npm étant le store, en
# lecture seule. nixpkgs les porte : ils se déclarent comme le reste.
less
nodePackages.less
nodePackages.rtlcss
sshpass
curl
wget
unzip
wkhtmltopdf
# « xmlsec » pose bin/xmlsec1, que les CINQ autres plateformes posent
# aussi. Le manifeste d'auth_saml (OCA server-auth, présent dans
# l'addons_path) déclare « bin: ["xmlsec1"] », et Odoo REFUSE d'installer
# ou de mettre à niveau le module tant que le binaire n'est pas dans le
# PATH : « Unable to find 'xmlsec1' in path », en boîte de dialogue.
xmlsec
# « parallel » et « shfmt » sont appelés par leur NOM NU, l'un par
# script/database/db_drop_all.py, l'autre par script/maintenance/
# format_bash.sh. Sans parallel, « make db_drop_all » annonce des bases
# détruites qui ne l'ont pas été — une opération destructrice qui rend un
# succès qu'elle n'a pas obtenu.
parallel
shfmt
# « cloud-utils » porte growpart, qu'aucune autre voie ne fournit ici.
# L'agrandissement du disque s'écrit « sudo growpart … || true » : sans le
# binaire, il rend 0 sans rien agrandir, et la VM garde la taille de son
# image pendant que le déploiement annonce la taille demandée.
cloud-utils
];
# Le profil du système ne porte PAS « /include » : la liste par défaut de
# ce qui y est lié ne contient ni les en-têtes ni les fichiers pkg-config.
# Sans cette ligne, déclarer une sortie « .dev » ne met rien nulle part, et
# « fatal error: lber.h: No such file or directory » reste entier.
environment.pathsToLink = [ "/include" "/lib/pkgconfig" ];
# Les manuels HTML, et EUX SEULS, sont écartés.
#
# NixOS installe la sortie « doc » de CHAQUE paquet du système
# (environment.extraOutputsToInstall vaut « man info doc »). Celle de
# CPython n'est pas dans le cache binaire : le premier « nixos-rebuild »
# la BÂTIT — un Sphinx qui lit puis écrit 3 000 pages. Sur une VM de 4 Go
# et 4 cœurs, cela domine le temps d'installation ; sur une de 2 Go, la
# machine cesse de répondre pendant la construction.
#
# « documentation.doc » et non « documentation » : les pages de manuel et
# info restent, elles se lisent depuis un terminal et ne coûtent rien.
documentation.doc.enable = false;
# « sessionVariables » et NON « variables » : la seconde n'écrit que dans
# /etc/set-environment, que seul un shell de CONNEXION lit. Or le
# déploiement installe par « ssh hôte 'commande' », qui n'en est pas un —
# la variable y serait vide. sessionVariables passe par pam_env, que toute
# session traverse, y compris celle-là.
environment.sessionVariables = {
CPATH = "/run/current-system/sw/include";
LIBRARY_PATH = "/run/current-system/sw/lib";
PKG_CONFIG_PATH = "/run/current-system/sw/lib/pkgconfig";
} // lib.optionalAttrs ("@EL_CA_BUNDLE@" != "") {
# L'autorité du cache de téléchargement, pour TOUTE session d'après
# l'installation — un « git pull » échouerait sinon sur un certificat
# qu'il ne reconnaît pas, comme le clone avant elle.
#
# La commande d'installation porte ces mêmes variables elle-même : elle
# tourne AVANT la première reconstruction, et aucun chemin PAM n'est
# inscriptible d'ici là. Les deux moitiés se relaient.
#
# « optionalAttrs » et non une valeur de repli : sur une machine sans
# cache le faisceau n'existe pas, et y pointer SSL_CERT_FILE couperait
# TLS partout.
SSL_CERT_FILE = "@EL_CA_BUNDLE@";
CURL_CA_BUNDLE = "@EL_CA_BUNDLE@";
GIT_SSL_CAINFO = "@EL_CA_BUNDLE@";
REQUESTS_CA_BUNDLE = "@EL_CA_BUNDLE@";
NODE_EXTRA_CA_CERTS = "@EL_CA_BUNDLE@";
PIP_CERT = "@EL_CA_BUNDLE@";
};
# Les réglages régionaux demandés au déploiement.
#
# Le FUSEAU marche déjà sans cela : cloud-init pose /etc/localtime, que
# NixOS laisse mutable tant que « time.timeZone » n'est pas déclaré.
# Mesuré sur une VM installée — le lien pointe bien la zone demandée. Le
# déclarer ne répare donc rien ; il fait passer la garantie du côté du
# module, comme pour l'agent invité.
#
# La LOCALE, elle, ne marchait pas : cloud-init l'applique par locale-gen
# et update-locale, qui n'existent pas ici, et la VM gardait le défaut de
# NixOS. Mesuré : « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu.
#
# « mkIf » plutôt qu'une valeur de repli : sur une machine où rien n'a été
# demandé — une NixOS que l'on avait déjà, installée par « --hote » —
# l'option n'est PAS définie, et le réglage de son propriétaire reste.
# Écrire un défaut ici l'écraserait sans le dire.
#
# CE QUE CELA COÛTE, mesuré : une locale autre que celle du défaut change
# l'ensemble des locales prises en charge, donc la dérivation de
# glibc-locales, qui n'est alors pas dans le cache binaire et se BÂTIT. La
# première reconstruction est longue, et silencieuse — il vaut mieux le
# savoir que de la prendre pour un blocage.
i18n.defaultLocale = lib.mkIf ("@EL_LOCALE@" != "") "@EL_LOCALE@";
time.timeZone = lib.mkIf ("@EL_TZ@" != "") "@EL_TZ@";
# Le guide de connexion, AFFICHÉ.
#
# Le déploiement écrit /etc/motd dans toutes les distributions, et compte
# sur pam_motd pour le montrer — c'est vrai des quatre images cloud, où
# sshd est en « PrintMotd no » et où ajouter l'inverse afficherait le guide
# DEUX FOIS. Ici, ni l'un ni l'autre : mesuré sur une VM installée, sshd
# rend « printmotd no » et /etc/pam.d/sshd ne contient AUCUN pam_motd. Le
# fichier est donc écrit, complet, et personne ne le lit.
#
# sshd et non pam_motd : le double affichage qu'on redoute ailleurs ne peut
# pas se produire tant que le PAM d'ici n'en contient pas, et c'est le seul
# des deux qui ne demande rien de plus que cette ligne.
services.openssh.settings.PrintMotd = true;
# L'agent invité, DÉCLARÉ ici plutôt que reçu de l'image.
#
# L'image épinglée l'active déjà (son configuration.nix porte la ligne), et
# c'est précisément le problème : la garantie appartient alors au tiers qui
# rebâtit l'image, pas au dépôt. Les quatre autres distributions reçoivent
# l'agent par le runcmd du déploiement, qui appelle leur gestionnaire de
# paquets — geste impossible sur un système déclaratif. Ici, c'est cette
# ligne ou rien.
#
# Sans agent, la voie Proxmox perd sa source PRIMAIRE d'adresse et retombe
# sur « ip neigh ». Déclarer l'option deux fois est sans effet : une option
# booléenne ne rompt l'évaluation que sur des valeurs DIFFÉRENTES. Hors
# QEMU, l'unité ne démarre pas — elle n'a pas de section [Install] et
# attend une règle udev sur le port virtio.
services.qemuGuest.enable = true;
# Ce que l'agent doit trouver dans son PATH.
#
# Une unité systemd n'hérite pas du profil du système : celle-ci ne porte
# que coreutils, findutils, grep, sed et systemd. Or « guest-exec » exécute
# les commandes du produit DANS ce PATH, et l'agrandissement du disque
# commence par « findmnt -no SOURCE / » — absent, donc code 127 dès la
# première ligne.
systemd.services.qemu-guest-agent.path = with pkgs; [
util-linux
e2fsprogs
cloud-utils
];
# Le port d'Odoo, OUVERT.
#
# NixOS active un pare-feu par défaut ; aucune des images cloud des quatre
# autres distributions n'en active un. Le service écoute bien sur
# 0.0.0.0:8069 et répond en local, mais l'extérieur ne reçoit RIEN — pas un
# refus, un silence, donc une attente jusqu'au délai. Ce qui sonde depuis
# l'hôte conclut « Odoo absent » sur une machine où il tourne, et le journal
# de l'installation ne porte aucune trace de la cause : elle est dans le
# pare-feu, pas dans l'application.
#
# 8069 SEUL. Le port websocket est configuré à 8072, mais Odoo ne le lie
# qu'en mode multi-processus, que cette configuration n'emploie pas : rien
# n'y écoute, et l'ouvrir donnerait un port béant sans service derrière.
# PostgreSQL n'écoute déjà que sur la boucle locale et n'a rien à ouvrir.
networking.firewall.allowedTCPPorts = [ 8069 ];
# Le service ERPLibre, DÉCLARÉ et non écrit.
#
# Sur toute autre distribution l'installation dépose l'unité par
# « tee /etc/systemd/system/erplibre.service ». Ici /etc est généré depuis
# le store et monté en lecture seule : le tee échoue sur « Read-only file
# system », et l'installation entière rend 1 à sa dernière étape, après que
# tout le reste a réussi.
#
# « wantedBy » est l'équivalent déclaratif de « systemctl enable » :
# l'activation par lien symbolique écrirait elle aussi dans /etc.
#
# L'interpréteur vient du STORE et non de /bin. /bin et /usr/bin sont ici
# un montage FUSE d'envfs, et systemd résout l'exécutable d'ExecStart
# lui-même, hors de portée de ce montage : « /bin/bash » y rend
# « 203/EXEC, Unable to locate executable ». Avec Restart=always, l'unité
# boucle alors indéfiniment. La même raison vaut pour /usr/bin/env, donc
# le shebang de run.sh ne suffirait pas davantage.
#
# La première reconstruction déclare le service AVANT qu'ERPLibre ne soit
# installé, et son démarrage échoue alors — c'est attendu, et c'est
# exactement le cas que « nixos-rebuild rend 4 » recouvre. L'installation
# le relance une fois le dépôt en place.
systemd.services.erplibre = {
description = "ERPLibre";
requires = [ "postgresql.service" ];
after = [ "network.target" "network-online.target" "postgresql.service" ];
wantedBy = [ "multi-user.target" ];
# Une unité systemd ne reçoit PAS le PATH d'une session : le sien ne
# porte que coreutils, findutils, grep, sed et systemd.
#
# bash — run.sh lance odoo_bin.sh et lib_db_select.sh, dont le shebang est
# « #!/usr/bin/env bash ». env est là, bash non : « env: 'bash': No such
# file or directory », et run.sh s'arrête avant Odoo.
#
# python3 — la sonde de réveil tourne AVANT que odoo_bin.sh n'active le
# venv, donc avec le python du système. Son échec est silencieux
# (« 2>/dev/null ») : sans elle, la première page ouverte attendrait le
# chargement du registre sans que rien ne le dise.
path = with pkgs; [ bash python312 ];
# L'unité est déclarée par le module, donc démarrée par la
# reconstruction — qui a lieu PENDANT « make install_os », alors que la
# source d'Odoo n'arrive qu'à « make install_odoo_18 ». Sans condition,
# run.sh échoue sur un odoo-bin absent et « Restart = always » le rejoue
# toutes les cinq secondes jusqu'à ce que l'installation le pose — vingt
# et un échecs mesurés sur une pose ordinaire, et un « nixos-rebuild »
# qui rend 4 parce qu'une unité n'a pas démarré.
#
# Une CONDITION, et non une dépendance : systemd saute l'unité en le
# disant une fois, sans la marquer en échec, et la démarre d'elle-même
# au prochain déclenchement une fois le fichier là. Le motif évite de
# figer la version d'Odoo ici, où elle vieillirait en silence.
unitConfig.ConditionPathExistsGlob = "@EL_DIR@/odoo*/odoo/odoo-bin";
serviceConfig = {
Type = "simple";
User = "@EL_USER@";
WorkingDirectory = "@EL_DIR@";
ExecStart = "${pkgs.bash}/bin/bash @EL_DIR@/run.sh";
Restart = "always";
RestartSec = 5;
StandardOutput = "journal+console";
};
};
}

View file

@ -48,3 +48,36 @@ EL_LANG="fr"
# 0 = silencieux (défaut), 1 = logs détaillés. On respecte une valeur déjà
# passée en environnement : EL_VERBOSE=1 make install_odoo_18
EL_VERBOSE="${EL_VERBOSE:-0}"
# NixOS : donner aux roues manylinux les bibliotheques que nix-ld declare.
#
# nix-ld fournit l'editeur de liens (/lib64/ld-linux-x86-64.so.2) aux binaires
# etrangers qu'il LANCE. Mais un « .so » de roue manylinux n'est pas lance :
# il est charge par dlopen depuis le CPython du systeme, qui est un binaire de
# Nix et ne lit pas NIX_LD_LIBRARY_PATH. Sans cette ligne, l'installation
# reussit et « import psycopg2 » echoue APRES, sur « libz.so.1: cannot open
# shared object file » -- une erreur qui ne parle ni de pip ni de Nix.
#
# Rien ailleurs : la variable n'est posee que la ou nix-ld existe, et ne
# masque donc aucune bibliotheque sur les autres systemes.
if [ -n "${NIX_LD_LIBRARY_PATH:-}" ]; then
export LD_LIBRARY_PATH="${NIX_LD_LIBRARY_PATH}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
# NixOS : les chemins de compilation, relus du SYSTEME DE FICHIERS.
#
# Le module declare CPATH, LIBRARY_PATH et PKG_CONFIG_PATH, mais une session
# les recoit de pam_env a son OUVERTURE. Or l'installation applique le module
# (« make install_os ») puis compile (« make install_odoo_18 ») dans la MEME
# session : la sienne est plus vieille que ce qu'elle vient de declarer. Les
# trois paquets du verrou qui n'ont pas de roue amont s'arretaient alors sur
# « lber.h », « cups/http.h » et « mysql.h » -- au PREMIER passage seulement,
# ce qui est la pire des pannes : le second reussit et donne raison a tort.
#
# « /run/current-system/sw » est le profil courant du systeme, un lien que le
# rebuild vient de reposer : le lire ne depend d'aucune variable heritee.
if [ -d /run/current-system/sw ]; then
export CPATH="/run/current-system/sw/include${CPATH:+:${CPATH}}"
export LIBRARY_PATH="/run/current-system/sw/lib${LIBRARY_PATH:+:${LIBRARY_PATH}}"
export PKG_CONFIG_PATH="/run/current-system/sw/lib/pkgconfig${PKG_CONFIG_PATH:+:${PKG_CONFIG_PATH}}"
fi

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
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.

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
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.

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
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.

View file

@ -258,6 +258,10 @@ class Descente(descente.Descente):
"sshkey_path": "/root/.ssh/longtest.pub",
"nameservers": dns,
"start": True,
# Du CATALOGUE, jamais en dur : create_cmds lit cette clé, et un
# spec qui l'omet vaut SeaBIOS en silence — sur une image sans
# secteur d'amorçage BIOS, une VM « running » à la console muette.
"uefi": mod.requiert_uefi(DISTRO),
}
# La clé publique doit être un FICHIER sur le parent : « --sshkeys »
# n'accepte pas la clé en ligne.

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.
# 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

View file

@ -37,7 +37,39 @@ 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",
"qemu_cache.py",
)
# L'autorité du cache de téléchargement, telle que le service l'écrit sur
# l'hôte. Une seule définition pour tout long_test/ : une seconde dériverait
# en silence, et un test qui pose la mauvaise autorité échoue comme s'il n'en
# posait aucune.
CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt"
def drapeaux_cache():
"""Les drapeaux de cache à passer à la CLI QEMU pour une VM locale.
Le détournement est TRANSPARENT et vaut pour tout le pont libvirt : une
VM créée ici est interceptée qu'elle le demande ou non. Ne rien passer ne
la laisse donc pas en direct — elle reçoit un certificat qu'elle ne
reconnaît pas, et chaque téléchargement HTTPS échoue sur « self-signed
certificate in certificate chain » : l'image, puis le gestionnaire de
paquets, puis tout le reste de l'étage.
Deux issues, et elles se valent pour le test : approuver l'autorité quand
l'hôte en porte une, ou demander une exception par adresse MAC sinon. Sur
un hôte sans cache, l'exception ne fait rien et le dit — la CLI n'exige
pas d'installer un cache pour pouvoir s'en passer.
"""
if os.path.isfile(CACHE_CA):
return ["--cache-ca", CACHE_CA]
return ["--cache-bypass"]
# 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
@ -441,7 +473,12 @@ class Descente:
def creer_etage1(self, res):
"""Une VM locale, par la CLI QEMU/KVM. Le seul étage sur du métal."""
nom = self.nom_etage(1)
argv = [
# SOUS SUDO, comme le menu le fait : 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éé. Jamais à blanc — un essai qui n'écrit rien n'a
# aucune raison de demander un mot de passe.
argv = ([] if self.dry_run else ["sudo"]) + [
os.path.join(RACINE, ".venv.erplibre/bin/python"),
os.path.join(RACINE, "script/qemu/deploy_qemu.py"),
"--distro",
@ -458,6 +495,7 @@ class Descente:
pub = cle_publique()
if pub:
argv += ["--ssh-key", pub]
argv += drapeaux_cache()
if self.dry_run:
print(" " + " ".join(shlex.quote(a) for a in argv))
return nom
@ -750,22 +788,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):

543
long_test/install_nixos.py Executable file
View file

@ -0,0 +1,543 @@
#!/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.
"""
# 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"]) + [
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 = {}
# 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é.
chemins = sonder(
cible,
"for f in /bin/bash /usr/bin/env; do [ -e $f ] || exit 1; done;"
" 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',
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

@ -53,6 +53,7 @@ sys.path.insert(0, os.path.join(RACINE, "long_test"))
sys.path.insert(0, RACINE)
from descente import ( # noqa: E402
CACHE_CA,
Descente,
cle_publique,
detruire_etage1,
@ -95,7 +96,7 @@ DISTRO = "arch"
VERSION = "latest"
CLI = os.path.join(RACINE, "script/qemu/deploy_qemu.py")
CA = "/var/lib/erplibre_go_qemu_cache/ca.crt"
CA = CACHE_CA
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
SERVICE = "erplibre-go-qemu-cache.service"
CONF = "/etc/erplibre_go_qemu_cache/env"

7
run.sh
View file

@ -3,6 +3,13 @@ Red='\033[0;31m' # Red
Color_Off='\033[0m' # Text Reset
# Search by default local configuration
# NixOS : meme geste qu'en tete d'env_var.sh, et pour la meme raison -- ce
# fichier est lance par l'unite systemd, qui ne source pas env_var.sh, et
# Odoo importe psycopg2 au demarrage.
if [ -n "${NIX_LD_LIBRARY_PATH:-}" ]; then
export LD_LIBRARY_PATH="${NIX_LD_LIBRARY_PATH}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"
fi
CONFIG_PATH="./config.conf"
ORIGIN_CONFIG_PATH=CONFIG_PATH
if [ ! -f "${CONFIG_PATH}" ]; then

View file

@ -72,7 +72,17 @@ def main():
cmd_end += f' "./odoo_bin.sh db --drop --database {db_name}"'
lst_db_name.append(db_name)
if cmd_end:
execute_shell(cmd_all + cmd_end)
code, sortie = execute_shell(cmd_all + cmd_end)
if code:
# Une destruction qui annonce un succès qu'elle n'a pas obtenu
# est pire que celle qui échoue : l'opérateur passe à la suite en
# croyant ses bases parties. Le cas s'atteint dès que « parallel »
# manque du PATH — le shell rend 127 et pas une base n'est
# touchée, pendant que la liste s'affiche.
print("Database NOT deleted :", file=sys.stderr)
if sortie:
print(sortie, file=sys.stderr)
return code
print("Database deleted :")
for db_name in lst_db_name:
print(db_name)

View file

@ -39,6 +39,13 @@ if [[ "${OS_KERNEL}" == "Linux" ]]; then
./script/install/install_debian_dependency.sh
elif [[ "${ID}" == "arch" ]]; then
./script/install/install_arch_linux.sh
elif [[ "${ID}" == "nixos" ]]; then
# NixOS ne pose pas de paquets : il applique une declaration. Sans cette
# branche, l'aiguillage tombait dans le « else » et appelait le script
# Debian, qui sortait en erreur des la detection du systeme -- avant meme
# d'essayer apt-get, absent de toute facon.
echo "\n---- NixOS installation process started ----"
./script/install/install_nixos_dependency.sh
elif [[ "${ID}" == opensuse* || "${ID_LIKE}" == *"suse"* ]]; then
# openSUSE Tumbleweed / Leap, SLES : gestionnaire zypper.
echo "\n---- SUSE installation process started ----"
@ -50,7 +57,7 @@ if [[ "${OS_KERNEL}" == "Linux" ]]; then
./script/install/install_fedora_dependency.sh
else
./script/install/install_debian_dependency.sh
echo "Your Linux system is not supported, only support Ubuntu 24.04, 25.10, 26.04, Linux Mint 22.3, Debian, Fedora, AlmaLinux, Rocky Linux, openSUSE, Arch."
echo "Your Linux system is not supported, only support Ubuntu 24.04, 25.10, 26.04, Linux Mint 22.3, Debian, Fedora, AlmaLinux, Rocky Linux, openSUSE, Arch, NixOS."
fi
elif [[ "${OS_KERNEL}" == "Darwin" ]]; then
echo "\n---- Darwin installation process started ----"

View file

@ -0,0 +1,129 @@
#!/usr/bin/env bash
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
#
# Dépendances système ERPLibre pour NixOS.
#
# Les quatre autres scripts de distribution POSENT des paquets. Celui-ci n'en
# pose aucun : il dépose une déclaration et demande au système de s'y
# conformer. C'est la seule façon dont une dépendance dure sur NixOS — ce qui
# est installé à la main vit hors de la configuration et disparaît à la
# reconstruction suivante.
#
# Idempotent : relancé, il réécrit le module, n'ajoute pas deux fois l'import,
# et « nixos-rebuild switch » ne fait rien s'il n'y a rien à changer.
set -e
. ./env_var.sh
EL_USER=${USER}
# Le répertoire du service, pris du checkout QUI POSE le module : « make
# install_os » tourne à la racine du dépôt, et c'est ce dépôt-là que le
# service doit lancer. Deviner « /home/$USER/git/erplibre » se tromperait sur
# une installation de production, qui vit sous /opt.
EL_DIR=${EL_DIR:-${PWD}}
# Les réglages régionaux que le DÉPLOIEMENT a demandés, lus là où cloud-init
# garde ce qu'il a reçu. Vides quand rien ne les a demandés — une NixOS que
# l'on avait déjà —, et le module laisse alors les réglages en place.
EL_CLOUD_CFG=/var/lib/cloud/instance/cloud-config.txt
lire_seed() {
sudo grep -m1 -E "^${1}: " "${EL_CLOUD_CFG}" 2>/dev/null |
cut -d" " -f2- | tr -d "\r" || true
}
# Le faisceau que cloud-init a bâti, s'il l'a fait : vide sur une machine
# sans cache, et le module n'y déclare alors aucune variable — y pointer
# couperait TLS partout.
EL_CA_BUNDLE=/var/lib/erplibre/ca-bundle.crt
[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE=""
EL_LOCALE=${EL_LOCALE:-$(lire_seed locale)}
EL_TZ=${EL_TZ:-$(lire_seed timezone)}
MODULE_SRC="conf/nixos/erplibre.nix"
MODULE_DST="/etc/nixos/erplibre.nix"
CONFIG="/etc/nixos/configuration.nix"
if [ ! -f /etc/os-release ] || ! grep -q '^ID=nixos' /etc/os-release; then
echo "Ce script ne vaut que pour NixOS."
exit 1
fi
if [ ! -f "${MODULE_SRC}" ]; then
echo "Module absent : ${MODULE_SRC} (lancer depuis la racine du dépôt)."
exit 1
fi
echo -e "\n---- Module ERPLibre pour NixOS ----"
# Le nom du compte est substitué comme le user-data cloud-init l'est : le
# module déclare un rôle PostgreSQL, et un rôle porte un nom.
sed -e "s/@EL_USER@/${EL_USER}/g" -e "s#@EL_DIR@#${EL_DIR}#g" \
-e "s/@EL_LOCALE@/${EL_LOCALE}/g" -e "s#@EL_TZ@#${EL_TZ}#g" \
-e "s#@EL_CA_BUNDLE@#${EL_CA_BUNDLE}#g" \
"${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null
echo " posé : ${MODULE_DST} (compte ${EL_USER}, dépôt ${EL_DIR})"
echo " autorité du cache : ${EL_CA_BUNDLE:-aucune}"
echo " régional : locale « ${EL_LOCALE:-non demandée} »," \
"fuseau « ${EL_TZ:-non demandé} »"
if [ ! -f "${CONFIG}" ]; then
echo "Configuration introuvable : ${CONFIG}"
exit 1
fi
# L'import, une fois. Sans lui le module est un fichier mort : NixOS ne lit
# que ce que la configuration importe.
if grep -q "erplibre.nix" "${CONFIG}"; then
echo " import déjà présent dans ${CONFIG}"
else
sudo sed -i '0,/imports *= *\[/s//imports = [\n .\/erplibre.nix/' "${CONFIG}"
if grep -q "erplibre.nix" "${CONFIG}"; then
echo " import ajouté à ${CONFIG}"
else
echo "Aucun bloc « imports = [ » dans ${CONFIG} : ajoutez-y"
echo " ./erplibre.nix"
exit 1
fi
fi
echo -e "\n---- nixos-rebuild switch ----"
# La reconstruction télécharge et bâtit : elle est LONGUE au premier passage,
# et c'est elle qui pose PostgreSQL, node, le compilateur, nix-ld et envfs.
#
# Son code de retour n'est PAS le verdict. « switch-to-configuration » rend 4
# quand une unité n'a pas pu redémarrer, alors que le système est bel et bien
# activé : sur une VM cloud-init, cloud-config est un service à un coup, et
# toute reconstruction ultérieure le trouve mort. Prendre ce 4 pour un échec
# ferait échouer chaque redéploiement d'une machine déjà installée. C'est la
# vérification ci-dessous qui tranche, sur l'état du système.
if sudo nixos-rebuild switch; then
echo " reconstruction appliquée"
else
echo " ⚠ nixos-rebuild rend $? : une unité n'a pas redémarré."
echo " Le système peut être activé quand même — vérification ci-dessous."
fi
echo -e "\n---- Vérification ----"
manque=0
# La version voulue vient du dépôt, pas d'une valeur écrite ici : le module
# déclare python312 parce que .python-odoo-version dit 3.12.x, et les deux
# doivent se répondre. « 3.12.10 » -> « 3.12 », qui est ce que
# lib_python_provider.sh cherche en /usr/bin.
PY_WANT="$(cat .python-odoo-version 2> /dev/null | xargs)"
PY_MM="${PY_WANT%.*}"
for chemin in /bin/bash /usr/bin/env "/usr/bin/python${PY_MM}"; do
if [ -e "${chemin}" ]; then
echo " ${chemin}"
else
echo " MANQUE : ${chemin}"
manque=1
fi
done
if ! command -v psql > /dev/null 2>&1; then
echo " MANQUE : psql"
manque=1
fi
if [ "${manque}" -ne 0 ]; then
# Sans /bin/bash, la moindre cible make échoue avant sa première ligne :
# continuer ferait échouer l'installation une heure plus tard, ailleurs.
echo "Le système n'est pas conforme au module : voir services.envfs.enable."
exit 1
fi
echo " système conforme au module ERPLibre."

View file

@ -85,7 +85,15 @@ el_distro_python_exec() {
# Des chemins SYSTEME, jamais « command -v » : dans un venv activé celui-ci
# rend le python DU VENV, et l'on bâtirait un venv depuis un venv. Le PATH
# d'une session interactive n'a rien à faire dans cette décision.
for exe in "/usr/bin/python${want%.*}" "/usr/local/bin/python${want%.*}"; do
# Le profil du systeme AVANT /usr/bin, et c'est NixOS qui l'impose : la-bas
# /usr/bin est servi par envfs, qui repond a open() et a execve() mais PAS a
# stat(). Le test « -x » y reussit donc, l'interpreteur s'execute, et
# « python -m venv » echoue quand meme -- il stat son propre interpreteur
# pour le recopier : « Error: [Errno 2] No such file or directory ». Le
# chemin du profil, lui, est un vrai lien symbolique. Ailleurs il n'existe
# pas, et la boucle passe a la suite sans rien couter.
for exe in "/run/current-system/sw/bin/python${want%.*}" \
"/usr/bin/python${want%.*}" "/usr/local/bin/python${want%.*}"; do
if el_python_is_compatible "${exe}" "${want}"; then
echo "${exe}"
return 0

View file

@ -49,9 +49,9 @@ DISK_CONTENT = ("images", "rootdir")
def ssh_argv(host: dict, remote: str, tty: bool = False) -> list:
"""Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox.
`host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} —
« target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà
l'utilisateur, le port et le ProxyJump.
`host` : {"target": "root@hyperviseur", "jump": "rebond", "port": "22"}
— « target » suffit quand l'alias vient de ~/.ssh/config, qui porte
déjà l'utilisateur, le port et le ProxyJump.
"""
argv = ["ssh"]
if not tty:
@ -384,8 +384,8 @@ def ssh_server_ip(text: str) -> str:
« client_ip client_port SERVER_ip server_port » : le troisième champ. C'est
la seule adresse dont on SAIT qu'elle mène à la machine, rebond compris.
Les candidats habituels se trompent ici. Mesuré sur une Proxmox imbriquée :
« hostname -I » rend « 10.10.10.150 10.10.20.1 », et la seconde est le pont
Les candidats habituels se trompent ici. Sur une Proxmox imbriquée,
« hostname -I » rend PLUSIEURS adresses, et l'une d'elles est le pont
interne que notre propre code vient de créer. La poser dans /etc/hosts
ferait s'identifier le nœud par une adresse que personne ne joint.
"""
@ -740,8 +740,8 @@ INTERNAL_BRIDGE = "vmbr0"
INTERNAL_CIDR = "10.10.10.1/24"
# Le réseau interne ne peut PAS être une constante : un Proxmox dans un
# Proxmox hérite du réseau interne de son parent, et 10.10.10.1 y est
# l'adresse de sa propre PASSERELLE. La poser sur son pont rend tout le /24
# Proxmox hérite du réseau interne de son parent, et l'adresse de
# INTERNAL_CIDR y est celle de sa propre PASSERELLE. La poser sur son pont rend tout le /24
# local — la passerelle devient injoignable et la machine s'isole
# instantanément, au milieu de la commande qui la configure. Vécu : « ifup »
# n'a jamais rendu la main et la VM ne répondait plus, ni en ssh ni en ping.
@ -760,8 +760,8 @@ INTERNAL_CANDIDATES = (
)
# Tout ce que l'hôte sait déjà d'IPv4 : ses adresses ET ses routes. Les deux,
# parce qu'une route sans adresse locale suffit à créer le conflit — la route
# par défaut « via 10.10.10.1 » en est l'exemple exact.
# parce qu'une route sans adresse locale suffit à créer le conflit — une
# route par défaut « via » la passerelle d'un parent en est l'exemple exact.
USED_NETS_CMD = "ip -o -4 addr show; ip -4 route show"
@ -926,7 +926,7 @@ def ipconfig_for(pont_info: dict, vmid: int) -> str:
def ip_from_ipconfig(ipconfig: str) -> str:
"""Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP.
"""Adresse fixe d'un « ip=192.0.2.50/24,gw=… », ou '' si c'est du DHCP.
Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est
absurde : elle est connue avant que la VM ne démarre. La découverte (agent
@ -936,21 +936,110 @@ def ip_from_ipconfig(ipconfig: str) -> str:
return m.group(1) if m else ""
def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str:
def image_fetch_cmd(
url: str, nom: str, repertoire: str = IMAGE_DIR, sha256: str = ""
) -> str:
"""Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois.
C'est là que le disque de la VM sera écrit : faire descendre l'image chez
soi pour la renvoyer ensuite doublerait le transfert. Le test de présence
évite de retélécharger 325 Mio à chaque VM.
`sha256` : la somme que le dépôt porte pour cette image, quand il en a
une. Elle ne concerne que ce qu'aucune distribution ne publie — une image
rebâtie par un tiers —, et c'est la seule chose qui distingue celle qui a
été revue de n'importe quel fichier servi sous la même URL.
Vérifiée AUSSI quand l'image était déjà là : le cas qu'on veut prendre
est précisément celui d'un fichier substitué ou tronqué entre deux
déploiements, et le test de présence seul ne regarde que la taille.
Une somme qui ne correspond pas fait ÉCHOUER la commande, donc la suite :
continuer reviendrait à installer un système que personne n'a regardé.
"""
cible = f"{repertoire}/{nom}"
return (
partiel = f"{cible}.partiel"
somme = lambda f: ( # noqa: E731 - une expression, pas une fonction
f"echo {shlex.quote(f'{sha256} {f}')} | sha256sum -c -"
)
# Le téléchargement va dans un nom PROVISOIRE, et n'est renommé qu'une
# fois complet. « wget -O » écrivait dans la cible : une coupure —
# réseau, disque plein, Ctrl-C — y figeait une image tronquée que le
# « [ -s ] » ci-dessous acceptait à chaque déploiement suivant. Avec une
# somme, elle échouait pour toujours sans dire quoi effacer ; sans somme,
# elle servait à créer une VM.
recuperation = f"wget -nv -O {shlex.quote(partiel)} {shlex.quote(url)}"
if sha256:
# Vérifiée AVANT d'être mise en place : une image fausse ne devient
# jamais celle que le prochain déploiement trouvera « déjà présente ».
recuperation += f" && {somme(partiel)}"
recuperation += f" && mv {shlex.quote(partiel)} {shlex.quote(cible)}"
cmd = (
f"mkdir -p {shlex.quote(repertoire)} && "
f"if [ -s {shlex.quote(cible)} ]; then "
f'echo "image déjà présente : {cible}"; else '
f"wget -nv -O {shlex.quote(cible)} {shlex.quote(url)}; "
f"{recuperation}; "
f"fi"
)
if sha256:
# Et la cible elle-même, fraîche ou déjà en cache : le cas visé est un
# fichier substitué entre deux déploiements, qu'aucun test de présence
# ne voit.
#
# L'échec nomme le remède, parce que le remède ORDINAIRE ne suffit
# pas : cet hôte peut être une VM derrière le cache de
# téléchargement, et effacer l'image la fera resservir à l'identique
# depuis le magasin. L'entrée s'en retire d'abord — « --purge »
# efface tout, et « --purge-older-than » n'atteint jamais un objet
# que chaque service rajeunit.
# L'URL est DANS la commande proposée. Sans elle, « printf %s » n'a
# pas d'opérande, n'écrit rien, et « --oublie » lit un flux vide puis
# sort à 0 : l'opérateur croit avoir purgé, efface l'image, relance,
# et le magasin ressert les mêmes octets. Un remède qui réussit sans
# rien faire est pire que pas de remède.
aide = (
f"rm -f {shlex.quote(cible)} et relancer ;"
" derrière un cache de téléchargement, en retirer l'entrée"
" d'abord : printf '%s\\n' "
+ shlex.quote(f"GET {url}")
+ " | sudo erplibre_go_qemu_cache --oublie"
)
cmd += (
f" && {{ {somme(cible)} || {{ "
f"echo {shlex.quote(aide)} >&2; false; }}; }}"
)
return cmd
def snippet_name(vmid: int) -> str:
"""Le nom de l'extrait cloud-init d'une VM.
Porté par le VMID et non par le nom de la VM : deux VM peuvent porter le
même nom sur deux nœuds, jamais le même VMID sur un cluster. Un extrait
écrasé par un homonyme donnerait à une VM le compte d'une autre.
"""
return f"erplibre-{int(vmid)}.yml"
def snippet_write_cmd(storage: str, nom: str, contenu: str) -> str:
"""Écrit un extrait cloud-init SUR l'hôte Proxmox, à l'endroit qu'il dit.
Le chemin n'est pas déduit : « pvesm path » le demande à Proxmox. Un
stockage « dir » range ses extraits sous son propre répertoire, et le
supposer en /var/lib/vz marcherait pour « local » et pour lui seul.
« printf '%s' » et non « echo » : le contenu est un YAML de plusieurs
dizaines de lignes, et echo interprète les séquences d'échappement sur
certains shells — un « \n » dans un mot de passe haché suffirait à le
corrompre en silence.
"""
cible = f"{storage}:snippets/{nom}"
return (
f"f=$(pvesm path {shlex.quote(cible)}) && "
'mkdir -p "$(dirname "$f")" && '
f"printf '%s' {shlex.quote(contenu)} > \"$f\""
)
def create_cmds(vmid: int, spec: dict) -> list:
@ -970,18 +1059,29 @@ def create_cmds(vmid: int, spec: dict) -> list:
# posé dans les deux cas, donc la console série ne se perd jamais ; seule
# la nature de l'écran change.
vga = "virtio-gl" if spec.get("gpu3d") else "serial0"
# UEFI seulement pour les images qui n'ont pas de secteur d'amorçage BIOS,
# jamais par défaut : mesuré sur un Proxmox 9, une VM NixOS créée en
# SeaBIOS se déclare « running » avec une console MUETTE, quand la même en
# OVMF démarre. Debian 13, sur le même hôte, démarre en SeaBIOS — d'où un
# marqueur par distribution plutôt qu'un défaut renversé pour tous.
#
# Secure Boot désactivé (« pre-enrolled-keys=0 ») pour la raison qui vaut
# déjà côté qemu : un chargeur non signé par les clés Microsoft est refusé,
# et l'image ne démarre pas du tout.
uefi = bool(spec.get("uefi"))
cmds = [
# 1. La coquille : processeur, mémoire, réseau, contrôleur, agent.
"qm create {id} --name {nom} --memory {mem} --cores {cpu}"
" --cpu host --ostype l26 --scsihw virtio-scsi-single"
" --net0 virtio,bridge={pont} --agent enabled=1"
" --serial0 socket --vga {vga}".format(
" --serial0 socket --vga {vga}{bios}".format(
id=vmid,
nom=shlex.quote(nom),
mem=int(spec["memory"]),
cpu=int(spec["vcpus"]),
pont=spec["bridge"],
vga=vga,
bios=" --bios ovmf" if uefi else "",
),
# 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+)
# remplace l'ancien « qm importdisk » en une seule étape et attache
@ -991,13 +1091,53 @@ def create_cmds(vmid: int, spec: dict) -> list:
# 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order »,
# Proxmox laisse le disque importé hors de la liste et la VM démarre
# sur le réseau.
f"qm set {vmid} --ide2 {stockage}:cloudinit"
#
# Sur le bus SCSI et non en IDE, contrairement à ce que la
# documentation de Proxmox montre : une image cloud bâtie pour
# virtio seul n'a pas de pilote ATA, et le lecteur IDE lui est
# INVISIBLE. Mesuré sur une image NixOS — /sys/block ne portait pas
# de « sr0 », /dev/disk/by-label pas de « cidata », et cloud-init
# passait aux sources RÉSEAU faute de trouver la locale : la VM
# démarrait sans compte ni clé. Le même lecteur en scsi1 apparaît,
# et le contrôleur virtio-scsi est déjà là pour le disque.
f"qm set {vmid} --scsi1 {stockage}:cloudinit"
f" --boot order=scsi0 --bootdisk scsi0",
]
# 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur
# l'hôte — « --sshkeys » n'accepte pas la clé en ligne.
if uefi:
# Le disque EFI porte les variables du firmware. Sans lui, « --bios
# ovmf » démarre quand même mais ne retient RIEN : l'entrée d'amorçage
# que l'invité écrit à sa première installation est perdue au
# redémarrage suivant.
cmds.append(
f"qm set {vmid} --efidisk0"
f" {stockage}:0,efitype=4m,pre-enrolled-keys=0"
)
# 4. cloud-init. Deux formes, et la première est celle qu'on veut.
#
# « --cicustom user= » quand un user-data est fourni : c'est CELUI du
# dépôt, le même que le chemin qemu envoie, avec son bloc « users: »
# explicite. « --ciuser » et « --sshkeys » s'en remettent au compte par
# DÉFAUT de l'image, et une image qui en déclare un autre les ignore —
# mesuré sur NixOS, dont le cloud.cfg nomme « nixos » : la VM démarrait
# avec ce compte-là, sans la clé, donc injoignable.
#
# Le réseau reste à Proxmox (« --ipconfig0 ») : « --cicustom user= » ne
# remplace que la moitié utilisateur du cloud-init.
if spec.get("user_data"):
cmds.append(
snippet_write_cmd(stockage, snippet_name(vmid), spec["user_data"])
)
ci = (
f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}"
f"qm set {vmid} --cicustom"
f" user={stockage}:snippets/{snippet_name(vmid)}"
)
else:
# La forme d'avant, gardée pour qui n'a pas de user-data à donner.
# La clé est un FICHIER sur l'hôte — « --sshkeys » n'accepte pas la
# clé en ligne.
ci = (
f"qm set {vmid} --ciuser"
f" {shlex.quote(spec.get('user') or 'erplibre')}"
)
if spec.get("sshkey_path"):
ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}"

View file

@ -4,11 +4,12 @@
<!---------------------------->
<!-- [en] -->
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora)
# QEMU/KVM — Linux VM deployment
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
distribution with `--distro` (`ubuntu` default, plus `debian`, `fedora`,
`almalinux`, `rocky`, `opensuse`, `arch`, `nixos` and `proxmox`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
@ -19,11 +20,12 @@ minimum specs. It:
5. Waits for the DHCP lease and prints the SSH command.
<!-- [fr] -->
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora)
# QEMU/KVM — Déploiement de VM Linux
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la distribution avec `--distro` (`ubuntu` par défaut, puis `debian`,
`fedora`, `almalinux`, `rocky`, `opensuse`, `arch`, `nixos` et `proxmox`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
@ -417,12 +419,29 @@ version, never overwrites an existing `app.ini`, and does not recreate the
administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
a few others tune it; `--help` lists them.
A seventh, **AI coding tools**, pre-configures the shell one works in: tig,
htop and vim, rtk with its global hook, starship, one agent — Claude Code
or opencode — the git settings, the checkout hooks and the Claude commands.
Nothing there fails the VM: each pose is time-bounded and given no standard
input, because a `curl | sh` that asks a question would hang a deployment
that has no terminal to answer it.
An eighth, **nix + nixos-anywhere**, is the reverse of picking NixOS in the
catalogue: it leaves an ordinary VM able to INSTALL NixOS onto another
machine reachable over SSH. The official multi-user installer, flakes
enabled, and `nixos-anywhere` in the user profile. It greys out on NixOS,
where nix already is the system, and outside amd64/arm64.
Each tool is filtered per VM — by architecture, desktop flavour and package
family — and its disk cost is added to the plan before anything is created.
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
- `--distro` — `ubuntu` (default), `debian`, `fedora`, `almalinux`,
`rocky`, `opensuse`, `arch`, `nixos` or `proxmox`. NixOS is the one
image no distribution publishes: it is rebuilt by a third party, so the
release is pinned and its sha256 verified at every download, and the
origin is printed before anything is created.
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).
@ -602,12 +621,30 @@ bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
et quelques autres le règlent ; `--help` les énumère.
Un septième, **Outils d'assistance IA**, pré-configure le shell où l'on
travaille : tig, htop et vim, rtk et son hook global, starship, un agent —
Claude Code ou opencode —, les réglages git, les hooks du dépôt et les
commandes Claude. Rien là-dedans ne fait échouer la VM : chaque pose est
bornée dans le temps et privée d'entrée standard, car un « curl | sh » qui
pose une question pendrait un déploiement sans terminal pour lui répondre.
Un huitième, **nix + nixos-anywhere**, est l'inverse de NixOS choisi dans le
catalogue : il laisse une VM ordinaire capable d'INSTALLER NixOS sur une
autre machine joignable en SSH. L'installateur officiel multi-utilisateur,
les flakes activés, et `nixos-anywhere` dans le profil de l'utilisateur.
L'option est grisée sur NixOS, où nix est déjà le système, et hors
amd64/arm64.
Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
- `--distro` — `ubuntu` (défaut), `debian`, `fedora`, `almalinux`,
`rocky`, `opensuse`, `arch`, `nixos` ou `proxmox`. NixOS est la seule
image qu'aucune distribution ne publie : elle est rebâtie par un tiers,
donc la version est épinglée, sa somme sha256 vérifiée à chaque
téléchargement, et l'origine dite avant que rien ne soit créé.
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut

View file

@ -1,9 +1,10 @@
# QEMU/KVM — Déploiement de VM Linux (Ubuntu / Debian / Fedora)
# QEMU/KVM — Déploiement de VM Linux
`deploy_qemu.py` déploie une VM Linux (libvirt/KVM) à partir d'une image
cloud officielle, via `qemu-img` + `cloud-init` + `virt-install`. Choisissez
la distribution avec `--distro` (`ubuntu` par défaut, `debian`, `fedora`) et
la distribution avec `--distro` (`ubuntu` par défaut, puis `debian`,
`fedora`, `almalinux`, `rocky`, `opensuse`, `arch`, `nixos` et `proxmox`) et
la version avec `--version` ; `--list-images` affiche tout le catalogue avec
les specs minimales. Il :
@ -297,12 +298,30 @@ bonne version, ne réécrit jamais un `app.ini` existant et ne recrée pas
l'administrateur. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER`
et quelques autres le règlent ; `--help` les énumère.
Un septième, **Outils d'assistance IA**, pré-configure le shell où l'on
travaille : tig, htop et vim, rtk et son hook global, starship, un agent —
Claude Code ou opencode —, les réglages git, les hooks du dépôt et les
commandes Claude. Rien là-dedans ne fait échouer la VM : chaque pose est
bornée dans le temps et privée d'entrée standard, car un « curl | sh » qui
pose une question pendrait un déploiement sans terminal pour lui répondre.
Un huitième, **nix + nixos-anywhere**, est l'inverse de NixOS choisi dans le
catalogue : il laisse une VM ordinaire capable d'INSTALLER NixOS sur une
autre machine joignable en SSH. L'installateur officiel multi-utilisateur,
les flakes activés, et `nixos-anywhere` dans le profil de l'utilisateur.
L'option est grisée sur NixOS, où nix est déjà le système, et hors
amd64/arm64.
Chaque outil est filtré VM par VM — architecture, saveur de bureau et famille
de paquets — et sa place disque s'ajoute au plan avant que rien ne soit créé.
## Principales options
- `--distro` — `ubuntu` (défaut), `debian` ou `fedora`.
- `--distro` — `ubuntu` (défaut), `debian`, `fedora`, `almalinux`,
`rocky`, `opensuse`, `arch`, `nixos` ou `proxmox`. NixOS est la seule
image qu'aucune distribution ne publie : elle est rebâtie par un tiers,
donc la version est épinglée, sa somme sha256 vérifiée à chaque
téléchargement, et l'origine dite avant que rien ne soit créé.
- `--version` — version de la distro (défaut : celle par défaut de la distro).
- `--list-images` — affiche toutes les distros/versions et leurs specs.
- `--image-dir` — répertoire de cache des images (défaut

View file

@ -1,9 +1,10 @@
# QEMU/KVM — Linux VM deployment (Ubuntu / Debian / Fedora)
# QEMU/KVM — Linux VM deployment
`deploy_qemu.py` deploys a Linux VM (libvirt/KVM) from an official cloud
image, using `qemu-img` + `cloud-init` + `virt-install`. Pick the
distribution with `--distro` (`ubuntu` default, `debian`, `fedora`) and the
distribution with `--distro` (`ubuntu` default, plus `debian`, `fedora`,
`almalinux`, `rocky`, `opensuse`, `arch`, `nixos` and `proxmox`) and the
release with `--version`; run `--list-images` to see the full catalogue with
minimum specs. It:
@ -258,12 +259,29 @@ version, never overwrites an existing `app.ini`, and does not recreate the
administrator. `FORGEJO_VERSION`, `FORGEJO_HTTP_PORT`, `FORGEJO_ADMIN_USER` and
a few others tune it; `--help` lists them.
A seventh, **AI coding tools**, pre-configures the shell one works in: tig,
htop and vim, rtk with its global hook, starship, one agent — Claude Code
or opencode — the git settings, the checkout hooks and the Claude commands.
Nothing there fails the VM: each pose is time-bounded and given no standard
input, because a `curl | sh` that asks a question would hang a deployment
that has no terminal to answer it.
An eighth, **nix + nixos-anywhere**, is the reverse of picking NixOS in the
catalogue: it leaves an ordinary VM able to INSTALL NixOS onto another
machine reachable over SSH. The official multi-user installer, flakes
enabled, and `nixos-anywhere` in the user profile. It greys out on NixOS,
where nix already is the system, and outside amd64/arm64.
Each tool is filtered per VM — by architecture, desktop flavour and package
family — and its disk cost is added to the plan before anything is created.
## Main options
- `--distro` — `ubuntu` (default), `debian` or `fedora`.
- `--distro` — `ubuntu` (default), `debian`, `fedora`, `almalinux`,
`rocky`, `opensuse`, `arch`, `nixos` or `proxmox`. NixOS is the one
image no distribution publishes: it is rebuilt by a third party, so the
release is pinned and its sha256 verified at every download, and the
origin is printed before anything is created.
- `--version` — release for the distro (default: the distro's default).
- `--list-images` — print all distros/versions and their specs, then exit.
- `--image-dir` — image cache directory (default `/var/lib/libvirt/images/iso`).

File diff suppressed because it is too large Load diff

View file

@ -38,7 +38,7 @@ var anglais = map[string]string{
"dépôt illisible %q": "unreadable repository %q",
"dépôt sans chemin %q": "repository without a path %q",
"chemin de dépôt refusé %q": "repository path refused %q",
"miroir refusé pour %s : moins de %s libres sur le disque": "mirror refused for %s: less than %s free on disk",
"miroir refusé pour %s : %s libres sous %s, plancher de %s": "mirror refused for %s: %s free under %s, floor of %s",
"miroir git : sonde de %s en échec (%v) ; ses miroirs sont servis sans rafraîchissement jusqu'à ce qu'il réponde": "git mirror: probe of %s failed (%v); its mirrors are served without refresh until it answers",
"git %s : %v : %s": "git %s: %v: %s",
"miroir éteint": "mirror disabled",
@ -75,6 +75,7 @@ var anglais = map[string]string{
"dire les exceptions en place, une « MAC nom » par ligne": "list the exceptions in place, one « MAC name » per line",
"dire la version, puis sortir": "print the version, then exit",
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched",
"lire des lignes « MÉTHODE URL » sur l'entrée standard et EFFACER du magasin ce que chacune désigne : le corps ET le statut seul. Dit, par ligne et séparé par des tabulations, « verdict octets classe méthode url », verdict oublié, absent, non-cachable ou refus. Le seul geste qui retire UNE entrée : --purge efface tout, et --purge-older-than n'atteint jamais un objet qui sert, chaque service remettant sa date à maintenant. Écrit : demande le droit du service. Pas d'essai à blanc — --detient EST l'aperçu : il répond aux mêmes lignes, par les mêmes clés, sans rien modifier": "read « METHOD URL » lines on standard input and ERASE from the store what each one designates: the body AND the status-only entry. Says, per line and tab-separated, « verdict bytes class method url », verdict oublié, absent, non-cachable or refus. The only gesture that removes ONE entry: --purge erases everything, and --purge-older-than never reaches an object that serves, every service resetting its date to now. Writes: needs the service's rights. No dry run — --detient IS the preview: it answers the same lines, through the same keys, without modifying anything",
"entrée illisible : %v\n": "unreadable input: %v\n",
"exceptions illisibles : %v\n": "unreadable exceptions: %v\n",
"exception refusée : %v\n": "exception refused: %v\n",

View file

@ -60,6 +60,14 @@ var immutableSuffixes = []string{
// d'empreinte. Sans ces suffixes, « .metadata » ne correspond à rien et le
// fichier tombe dans le volatil par défaut, repris à chaque installation.
".whl.metadata", ".tar.gz.metadata", ".zip.metadata",
// nix : le NOM porte l'empreinte du chemin de store, chez cache.nixos.org
// comme sur tout miroir. Un « <empreinte>.narinfo » décrit ce chemin-là et
// rien d'autre ; une dérivation modifiée porte une autre empreinte, donc
// un autre nom. Sans ces suffixes ils tombaient dans le volatil par
// défaut, revalidés un par un contre l'amont : une installation NixOS en
// demande des centaines, et c'est autant d'allers-retours pour des
// fichiers qui ne changeront jamais.
".narinfo", ".nar", ".nar.xz", ".nar.zst", ".nar.bz2",
// images et supports d'installation
".qcow2", ".iso", ".img", ".raw", ".vmdk",
// archives amont

View file

@ -298,3 +298,36 @@ func TestLesMetadonneesPythonSontPortables(t *testing.T) {
}
}
}
// Les objets de nix sont adressés par l'empreinte du chemin de store : leur
// NOM est cette empreinte. Ils tombaient dans le volatil par défaut, donc
// revalidés un par un contre l'amont — une installation NixOS en demande des
// centaines, et aucun ne changera jamais.
func TestLesObjetsDeNixSontImmuables(t *testing.T) {
for _, chemin := range []string{
"/kdb6ag6vg2vfkxy3k47zqy7jm0266dhn.narinfo",
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.zst",
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.xz",
} {
u, err := url.Parse("https://cache.nixos.org" + chemin)
if err != nil {
t.Fatal(err)
}
if c := Classify(u); c != ClassImmutable {
t.Errorf("%s : classe %v, attendu immuable", chemin, c)
}
// Le nom identifie le fichier sur TOUT miroir : la clé écarte l'hôte.
if !PortableParChemin(u) {
t.Errorf("%s : devrait se ranger sans son hôte", chemin)
}
}
}
// « nix-cache-info » n'est PAS adressé par une empreinte : c'est la
// description du magasin amont, et elle peut changer. Elle reste volatile.
func TestLaDescriptionDuMagasinNixResteVolatile(t *testing.T) {
u, _ := url.Parse("https://cache.nixos.org/nix-cache-info")
if c := Classify(u); c == ClassImmutable {
t.Errorf("nix-cache-info tenu pour immuable : %v", c)
}
}

View file

@ -216,10 +216,11 @@ func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) {
return chemin, true
}
if !existe {
if !g.placeSuffisante() {
if libres, plancher, assez := g.placeSuffisante(); !assez {
log.Printf(
T("miroir refusé pour %s : moins de %s libres sur le disque"),
depot, HumanBytes(g.PlancherLibre))
T("miroir refusé pour %s : %s libres sous %s,"+
" plancher de %s"),
depot, HumanBytes(libres), g.Dir, HumanBytes(plancher))
return "", false
}
if err := os.MkdirAll(filepath.Dir(chemin), 0o755); err != nil {
@ -611,12 +612,18 @@ func DepotsDuFichier(chemin string) ([]string, error) {
// qu'une VM en cours de déploiement finisse, et pour que le système respire.
const PlancherParDefaut int64 = 10 << 30
// placeSuffisante dit s'il reste de quoi créer un miroir de plus.
// placeSuffisante dit s'il reste de quoi créer un miroir de plus, et rend de
// quoi le DIRE : (octets libres, plancher effectif, assez).
//
// Les trois valeurs et non le seul booléen : le refus est la seule trace que
// l'opérateur verra, et un refus qui ne nomme ni le seuil ni la mesure envoie
// chercher au mauvais endroit. Le plancher effectif n'est pas le champ —
// laissé à zéro, il vaut PlancherParDefaut — et c'est celui-là qui a refusé.
//
// La place est relue à CHAQUE appel : le disque se remplit pendant qu'on le
// remplit, et une valeur retenue au démarrage ne dirait rien de l'état où l'on
// est rendu.
func (g *GitMirror) placeSuffisante() bool {
func (g *GitMirror) placeSuffisante() (int64, int64, bool) {
plancher := g.PlancherLibre
if plancher <= 0 {
plancher = PlancherParDefaut
@ -624,10 +631,12 @@ func (g *GitMirror) placeSuffisante() bool {
var st syscall.Statfs_t
if err := syscall.Statfs(g.Dir, &st); err != nil {
// Illisible : on laisse passer plutôt que de bloquer sur une mesure
// qu'on ne sait pas faire.
return true
// qu'on ne sait pas faire. Le -1 dit « non mesuré », qu'aucun compte
// d'octets ne peut valoir.
return -1, plancher, true
}
return int64(st.Bavail)*int64(st.Bsize) > plancher
libres := int64(st.Bavail) * int64(st.Bsize)
return libres, plancher, libres > plancher
}
// Depot décrit un miroir tenu sur le disque.

View file

@ -153,6 +153,53 @@ func depotDEssai(t *testing.T) string {
return nu
}
// Le refus de place est la SEULE trace qu'un opérateur verra, et il nommait
// le champ PlancherLibre — laissé à zéro par tout appelant ordinaire — au
// lieu du plancher qui avait refusé. « moins de 0 o libres sur le disque »
// n'est ni vrai ni exploitable : il n'annonce aucun seuil et ne dit pas ce
// qu'il a mesuré.
func TestLeRefusConnaitLePlancherQuiARefuse(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
libres, plancher, assez := g.placeSuffisante()
if plancher != PlancherParDefaut {
t.Fatalf("plancher rendu %d, le défaut est %d",
plancher, PlancherParDefaut)
}
if libres < 0 {
t.Skip("place non mesurable sur ce répertoire")
}
if assez != (libres > plancher) {
t.Fatalf("verdict %v pour %d libres sous un plancher de %d",
assez, libres, plancher)
}
}
// Une mesure impossible ne bloque pas — c'est le choix déjà écrit — mais elle
// doit se DISTINGUER d'un disque vide : zéro octet libre est une mesure, pas
// une absence de mesure.
func TestUnePlaceNonMesurableNeBloquePasEtSeDistingue(t *testing.T) {
g := &GitMirror{Dir: filepath.Join(t.TempDir(), "nexiste", "pas")}
libres, _, assez := g.placeSuffisante()
if !assez {
t.Fatal("une mesure impossible ne doit pas refuser le miroir")
}
if libres != -1 {
t.Fatalf("libres %d : une mesure absente doit se distinguer", libres)
}
}
// Le plancher des tests qui ne portent PAS sur le plancher.
//
// Le défaut de production — 10 Gio laissés au disque de l'orchestrateur — n'a
// rien à voir avec un miroir de trois commits dans un répertoire temporaire,
// et il fait échouer treize tests sur toute machine dont le répertoire
// temporaire offre moins que cela : un /tmp en tmpfs y suffit. Le refus était
// alors pris pour une panne du miroir.
//
// Le plancher lui-même est éprouvé plus bas, avec une valeur qu'aucun disque
// n'atteint.
const plancherDEssai int64 = 1
// L'épreuve qui compte : un client clone à travers le miroir, sans que rien
// dans l'invité soit configuré, et le dépôt arrive complet.
func TestClonerAuTraversDuMiroir(t *testing.T) {
@ -160,7 +207,8 @@ func TestClonerAuTraversDuMiroir(t *testing.T) {
t.Skip("git-http-backend absent de cette machine")
}
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 2 * time.Minute}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 2 * time.Minute}
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
@ -200,7 +248,8 @@ func TestClonerAuTraversDuMiroir(t *testing.T) {
// peut faire de ce protocole.
func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) {
amont, srvAmont := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: time.Minute}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: time.Minute}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
@ -221,7 +270,8 @@ func TestUnMiroirExistantSertQuandLAmontEstMuet(t *testing.T) {
// relais, qui donnera au client la vraie erreur du réseau plutôt qu'une erreur
// inventée ici.
func TestAucunMiroirEtAucunAmont(t *testing.T) {
g := &GitMirror{Dir: t.TempDir(), Delai: 20 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 20 * time.Second}
chemin, pret := g.Assurer(
context.Background(), "https://127.0.0.1:1/nexiste/pas.git")
if pret {
@ -270,7 +320,8 @@ func TestDepotsDuFichier(t *testing.T) {
// arrêter pour lui perdrait le travail déjà fait.
func TestUnDepotEnEchecNEmportePasLesAutres(t *testing.T) {
bon, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
depots := []string{
bon,
"https://127.0.0.1:1/absent.git",
@ -293,7 +344,8 @@ func TestUnDepotEnEchecNEmportePasLesAutres(t *testing.T) {
// être servie, ce qui est tout l'objet de l'avance.
func TestApresPrefetchLeMiroirEstPret(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
if r, e := g.Prefetch(
context.Background(), []string{amont}, 2, nil,
); r != 1 || e != 0 {
@ -331,7 +383,8 @@ func TestLePlancherRefuseUnMiroirDeplus(t *testing.T) {
// qui a changé, et le refuser priverait de tout ce qui est déjà là.
func TestLePlancherNEmpechePasDeServirLexistant(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
if _, pret := g.Assurer(context.Background(), amont); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
@ -350,7 +403,8 @@ func TestLePlancherNEmpechePasDeServirLexistant(t *testing.T) {
// mesure ne peut pas avoir un chemin muet.
func TestCeQueLeMiroirSertEstCompte(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
chemin, pret := g.Assurer(context.Background(), amont)
if !pret {
t.Fatal("le miroir n'a pas pu être créé")
@ -383,7 +437,8 @@ func TestCeQueLeMiroirSertEstCompte(t *testing.T) {
// La liste est triée par TAILLE : c'est ce qu'on cherche quand on surveille
// la place à la main, et trois dépôts font les trois quarts du total.
func TestLesDepotsSontTriesParTaille(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir()}
for nom, poids := range map[string]int{
"h/petit.git": 10, "h/gros.git": 5000, "h/moyen.git": 500,
} {
@ -415,7 +470,8 @@ func TestLesDepotsSontTriesParTaille(t *testing.T) {
// l'effacement doit rester DANS les miroirs — un appel mal formé ne doit pas
// pouvoir emporter autre chose.
func TestRetirerRefuseCeQuiEstDehors(t *testing.T) {
g := &GitMirror{Dir: t.TempDir()}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir()}
dehors := filepath.Join(t.TempDir(), "ailleurs.git")
if err := os.MkdirAll(dehors, 0o755); err != nil {
t.Fatal(err)
@ -432,7 +488,8 @@ func TestRetirerRefuseCeQuiEstDehors(t *testing.T) {
func TestRetirerEffaceLeMiroir(t *testing.T) {
amont, _ := amontGit(t)
g := &GitMirror{Dir: t.TempDir(), Delai: 30 * time.Second}
g := &GitMirror{
PlancherLibre: plancherDEssai, Dir: t.TempDir(), Delai: 30 * time.Second}
chemin, pret := g.Assurer(context.Background(), amont)
if !pret {
t.Fatal("le miroir n'a pas pu être créé")
@ -458,6 +515,7 @@ func TestRetirerEffaceLeMiroir(t *testing.T) {
func TestUnRafraichissementNattendPasCommeUnClonage(t *testing.T) {
amont, srv := amontGit(t)
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: t.TempDir(),
Delai: 30 * time.Minute,
DelaiMaj: 2 * time.Second,

View file

@ -355,6 +355,7 @@ func amontGitEnPanne(t *testing.T) (string, string, *int64, *atomic.Bool) {
func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
}
if _, pret := g.Assurer(context.Background(), depot); !pret {
@ -377,6 +378,7 @@ func TestUnAmontConnuMuetNeRafraichitPasLeMiroir(t *testing.T) {
func TestUneSondeRefuseeRetientLHoteEtSertLeMiroir(t *testing.T) {
depot, appels, _ := amontGitCompte(t)
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: t.TempDir(), Delai: 30 * time.Second, Muets: NouvelleJoignabilite(),
}
if _, pret := g.Assurer(context.Background(), depot); !pret {
@ -414,7 +416,8 @@ func sansMandataire(*http.Request) (*url.URL, error) { return nil, nil }
func miroirCree(t *testing.T, depot string) string {
t.Helper()
dir := t.TempDir()
creation := &GitMirror{Dir: dir, Delai: 30 * time.Second}
creation := &GitMirror{
PlancherLibre: plancherDEssai, Dir: dir, Delai: 30 * time.Second}
if _, pret := creation.Assurer(context.Background(), depot); !pret {
t.Fatal("le miroir n'a pas pu être créé")
}
@ -427,6 +430,7 @@ func miroirCree(t *testing.T, depot string) string {
func TestUnRafraichissementManqueSurUnAmontJoignableEstRetente(t *testing.T) {
depot, appels, panne := amontGitCompte(t)
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(),
Sonder: func(context.Context, string) error { return nil },
@ -452,6 +456,7 @@ func TestUnRafraichissementManqueSurUnAmontQuiRefuseEstNote(t *testing.T) {
depot, appels, panne := amontGitCompte(t)
var sondes int64
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(),
// La sonde d'avant la mise à jour passe ; celle d'après trouve
@ -519,6 +524,7 @@ func pousserUnCommit(t *testing.T, nu string) string {
func TestUnRafraichissementEn500NeFigePasLeMiroir(t *testing.T) {
depot, nu, _, panne := amontGitEnPanne(t)
g := &GitMirror{
PlancherLibre: plancherDEssai,
Dir: miroirCree(t, depot), Delai: 30 * time.Second, Frais: time.Minute,
Muets: NouvelleJoignabilite(), Mandataire: sansMandataire,
}

View file

@ -95,6 +95,17 @@ func main() {
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
" --cache-dir suffit, sans privilège, et l'âge des objets"+
" n'est pas touché"))
oublie = flag.Bool("oublie", false,
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+
" EFFACER du magasin ce que chacune désigne : le corps ET le"+
" statut seul. Dit, par ligne et séparé par des tabulations,"+
" « verdict octets classe méthode url », verdict oublié,"+
" absent, non-cachable ou refus. Le seul geste qui retire UNE"+
" entrée : --purge efface tout, et --purge-older-than"+
" n'atteint jamais un objet qui sert, chaque service remettant"+
" sa date à maintenant. Écrit : demande le droit du service."+
" Pas d'essai à blanc — --detient EST l'aperçu : il répond aux"+
" mêmes lignes, par les mêmes clés, sans rien modifier"))
)
flag.Parse()
// Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi
@ -117,6 +128,15 @@ func main() {
}
return
}
// Au même endroit et pour la même raison : la question ne porte que sur
// le magasin. Écrire plutôt que lire ne change pas de quoi elle dépend.
if *oublie {
store := &Store{Dir: *cacheDir}
if err := EcrireOublis(store, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err)
}
return
}
bypass := BypassFile{Path: *bypassFile}
// Les exceptions entrent dans les règles dès leur RENDU : le service les

167
script/qemu_cache/oublie.go Normal file
View file

@ -0,0 +1,167 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bufio"
"fmt"
"io"
"net/url"
"os"
"strings"
)
// « --oublie » efface du magasin ce qu'une méthode et une URL désignent.
//
// LE MANQUE QU'IL COMBLE. Rien n'invalide une entrée quand la somme d'un
// fichier servi ne correspond pas : le magasin continue de le rendre, et
// retélécharger ne change rien puisque c'est lui qui répond. Les deux purges
// existantes ne l'atteignent pas — « --purge » efface TOUT, et
// « --purge-older-than » saute ce dont la date est récente, or Store.Get remet
// cette date à maintenant à CHAQUE service. Un objet empoisonné qui continue
// d'être servi rajeunit donc à chaque VM et ne vieillit jamais : aucun délai
// ne le rattrape.
//
// SYMÉTRIQUE DE « --detient », et c'est ce qui le rend sûr : mêmes lignes en
// entrée, mêmes fonctions de classement et de clé, mêmes verdicts de refus.
// Ce que l'un dit « garde », l'autre l'efface ; ce que l'un dit
// « non-cachable », l'autre n'a rien à effacer. Deux calculs de clé finiraient
// par diverger, et l'on croirait avoir effacé ce que le service sert encore.
//
// ÉCRITURE : le service écrit ses objets en root, et les effacer demande le
// même droit. Un refus est DIT plutôt que compté comme un succès — croire un
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
// Verdicts de « --oublie ».
const (
// OubliEfface : le magasin tenait quelque chose, il ne l'a plus.
OubliEfface = "oublié"
// OubliAbsent : la requête est cachable, le magasin ne tenait rien.
OubliAbsent = "absent"
// OubliNonCachable : le magasin ne tiendra jamais cette requête, il n'y
// a donc rien à y effacer.
OubliNonCachable = "non-cachable"
// OubliRefus : un objet était là et n'a pas pu être retiré — droits,
// système de fichiers en lecture seule. Surtout pas « oublié ».
OubliRefus = "refus"
)
// Oubli dit ce que le magasin a cessé de tenir pour une méthode et une URL.
type Oubli struct {
Verdict string
// Octets rendus au disque. Nul quand rien n'a été effacé.
Octets int64
Classe string
Methode string
URL string
}
// Ligne rend l'oubli en une ligne séparée par des tabulations : verdict,
// octets, classe, méthode, URL. Un champ sans valeur vaut « - », pour que
// chaque ligne ait cinq champs.
func (o Oubli) Ligne() string {
octets, classe := "-", "-"
if o.Octets != 0 {
octets = fmt.Sprintf("%d", o.Octets)
}
if o.Classe != "" {
classe = o.Classe
}
return strings.Join(
[]string{o.Verdict, octets, classe, o.Methode, o.URL}, "\t")
}
// Oublier efface du magasin ce que la requête désigne, et dit ce qu'il en est.
//
// LES DEUX CLÉS, toujours : le corps (CleDe) et le statut seul (CleStatut)
// vivent dans des espaces séparés, et n'en effacer qu'une laisserait le rejeu
// resservir une redirection vers l'octet qu'on vient de retirer.
//
// Le méta part AVEC le corps, comme dans Purger : un méta orphelin ferait
// croire à une copie présente, et la lecture échouerait au moment de servir.
func (s *Store) Oublier(methode, brut string) Oubli {
methode = strings.ToUpper(methode)
o := Oubli{Verdict: OubliNonCachable, Methode: methode, URL: brut}
u, err := url.Parse(brut)
if err != nil || !u.IsAbs() || u.Host == "" {
return o
}
class := Classify(u)
o.Classe = class.String()
if !CacheableMethod(methode) || class == ClassNoStore {
return o
}
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
return o
}
o.Verdict = OubliAbsent
for _, key := range []string{
CleDe(methode, u), CleStatut(methode, u),
} {
octets, etat := s.effacer(key)
o.Octets += octets
switch etat {
case OubliRefus:
o.Verdict = OubliRefus
case OubliEfface:
if o.Verdict != OubliRefus {
o.Verdict = OubliEfface
}
}
}
return o
}
// effacer retire le corps et le méta d'une clé. Rend (octets rendus, verdict
// de cette clé seule).
//
// Un fichier absent n'est pas une erreur : une clé sans objet est le cas
// ordinaire, et deux clés sont essayées pour chaque requête. Un fichier
// PRÉSENT qui résiste en est une, et elle remonte.
func (s *Store) effacer(key string) (int64, string) {
metaPath, bodyPath := s.paths(key)
octets := int64(0)
etat := OubliAbsent
if fi, err := os.Stat(bodyPath); err == nil {
if e := os.Remove(bodyPath); e != nil {
return 0, OubliRefus
}
octets = fi.Size()
etat = OubliEfface
}
if _, err := os.Stat(metaPath); err == nil {
if e := os.Remove(metaPath); e != nil {
return octets, OubliRefus
}
etat = OubliEfface
}
return octets, etat
}
// EcrireOublis lit des lignes « MÉTHODE URL » et écrit une ligne d'oubli pour
// chacune, dans l'ordre. Une ligne vide ou commençant par « # » est sautée ;
// une ligne d'un seul champ vaut « GET <champ> ». Chaque réponse part aussitôt
// écrite, comme pour « --detient ».
func EcrireOublis(s *Store, entree io.Reader, sortie io.Writer) error {
sc := bufio.NewScanner(entree)
// Une URL signée dépasse volontiers les 64 Kio de la ligne par défaut.
sc.Buffer(make([]byte, 64*1024), 1<<20)
w := bufio.NewWriter(sortie)
for sc.Scan() {
ligne := strings.TrimSpace(sc.Text())
if ligne == "" || strings.HasPrefix(ligne, "#") {
continue
}
champs := strings.Fields(ligne)
methode, brut := "GET", champs[0]
if len(champs) >= 2 {
methode, brut = champs[0], champs[1]
}
fmt.Fprintln(w, s.Oublier(methode, brut).Ligne())
if err := w.Flush(); err != nil {
return err
}
}
return sc.Err()
}

View file

@ -0,0 +1,188 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// Le magasin est rempli PAR le service, comme pour « --detient » : c'est la
// seule façon de voir une divergence entre le calcul de clé du service et
// celui de la commande. Effacer sous une clé que le service n'écrit pas
// laisserait l'objet empoisonné en place tout en disant l'avoir retiré.
func TestOublieRetireCeQueLeMagasinSert(t *testing.T) {
paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst"
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/installer.sh":
rediriger(http.StatusFound,
"https://ailleurs.example/bootstrap.sh")(w, r)
default:
servir("contenu du paquet")(w, r)
}
})
p := proxyDeTest(t)
for _, c := range []string{"/installer.sh", "/depot/etat", paquet} {
joue(t, p, "GET", a.hote(), c)
}
base := "http://" + a.hote()
questions := []string{
"GET " + base + paquet,
"GET " + base + "/installer.sh",
"GET " + base + "/jamais-vu.sh",
"POST " + base + "/depot/etat",
"",
"# un commentaire",
"GET pas-une-url",
}
var sortie bytes.Buffer
if err := EcrireOublis(
p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie,
); err != nil {
t.Fatal(err)
}
attendus := []struct{ verdict, classe, methode, url string }{
{"oublié", "immutable", "GET", base + paquet},
// Une redirection est un STATUT SEUL : sans la seconde clé, le rejeu
// la resservirait vers l'octet qu'on vient de retirer.
{"oublié", "volatile", "GET", base + "/installer.sh"},
{"absent", "volatile", "GET", base + "/jamais-vu.sh"},
{"non-cachable", "volatile", "POST", base + "/depot/etat"},
{"non-cachable", "-", "GET", "pas-une-url"},
}
lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n")
if len(lignes) != len(attendus) {
t.Fatalf("%d lignes, attendu %d :\n%s",
len(lignes), len(attendus), sortie.String())
}
for i, at := range attendus {
champs := strings.Split(lignes[i], "\t")
if len(champs) != 5 {
t.Errorf("ligne %d : %d champs, attendu 5 : %q",
i+1, len(champs), lignes[i])
continue
}
if champs[0] != at.verdict || champs[2] != at.classe ||
champs[3] != at.methode || champs[4] != at.url {
t.Errorf("ligne %d : %q, attendu %s … %s %s %s",
i+1, lignes[i], at.verdict, at.classe, at.methode, at.url)
}
}
// Les octets ne sont comptés que pour ce qui est parti.
if champs := strings.Split(lignes[0], "\t"); champs[1] == "-" {
t.Error("un objet effacé doit dire les octets rendus")
}
if champs := strings.Split(lignes[2], "\t"); champs[1] != "-" {
t.Errorf("rien d'effacé, octets %q", champs[1])
}
}
// L'INVARIANT qui rend la commande sûre : ce que « --detient » dit tenir,
// « --oublie » l'efface, et « --detient » ne le tient plus. Deux calculs de
// clé qui divergeraient se verraient ici, et nulle part ailleurs.
func TestCeQueDetientVoitOublieLeRetire(t *testing.T) {
paquet := "/core/os/x86_64/outil-2.0-1-x86_64.pkg.tar.zst"
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
joue(t, p, "GET", a.hote(), paquet)
question := "GET http://" + a.hote() + paquet
avant := ligneUnique(t, p, question, EcrireDetentions)
if verdict := strings.Split(avant, "\t")[0]; verdict != "garde" {
t.Fatalf("le magasin devrait garder : %q", avant)
}
efface := ligneUnique(t, p, question, EcrireOublis)
if verdict := strings.Split(efface, "\t")[0]; verdict != "oublié" {
t.Fatalf("l'oubli devrait effacer : %q", efface)
}
apres := ligneUnique(t, p, question, EcrireDetentions)
if verdict := strings.Split(apres, "\t")[0]; verdict != "absent" {
t.Fatalf("le magasin tient encore : %q", apres)
}
}
// Ce que ni « --purge » ni « --purge-older-than » ne savent faire : retirer
// UNE entrée. La voisine reste, et c'est tout l'intérêt — un objet empoisonné
// ne doit pas coûter le cache entier.
func TestOublieNeTouchePasLaVoisine(t *testing.T) {
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
garde := "/core/os/x86_64/garde-1.0-1-x86_64.pkg.tar.zst"
part := "/core/os/x86_64/part-1.0-1-x86_64.pkg.tar.zst"
joue(t, p, "GET", a.hote(), garde)
joue(t, p, "GET", a.hote(), part)
ligneUnique(t, p, "GET http://"+a.hote()+part, EcrireOublis)
reste := ligneUnique(t, p, "GET http://"+a.hote()+garde, EcrireDetentions)
if verdict := strings.Split(reste, "\t")[0]; verdict != "garde" {
t.Fatalf("la voisine a disparu : %q", reste)
}
}
// Un objet présent qui résiste est un REFUS, jamais un oubli : croire un
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
func TestUnObjetQuiResisteEstUnRefus(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root efface dans un répertoire en lecture seule")
}
a := nouvelAmontScripte(t, servir("contenu du paquet"))
p := proxyDeTest(t)
paquet := "/core/os/x86_64/dur-1.0-1-x86_64.pkg.tar.zst"
joue(t, p, "GET", a.hote(), paquet)
u := "http://" + a.hote() + paquet
casier := casierDe(t, p, u)
fige(t, casier, 0o500)
ligne := ligneUnique(t, p, "GET "+u, EcrireOublis)
if verdict := strings.Split(ligne, "\t")[0]; verdict != "refus" {
t.Fatalf("un effacement impossible doit se dire : %q", ligne)
}
}
// ligneUnique pose UNE question et rend la ligne rendue, sans son saut.
func ligneUnique(
t *testing.T, p *Proxy, question string,
ecrire func(*Store, io.Reader, io.Writer) error,
) string {
t.Helper()
var sortie bytes.Buffer
if err := ecrire(p.Store, strings.NewReader(question), &sortie); err != nil {
t.Fatal(err)
}
return strings.TrimRight(sortie.String(), "\n")
}
// casierDe rend le répertoire où le magasin range le corps d'une URL.
func casierDe(t *testing.T, p *Proxy, brut string) string {
t.Helper()
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
_, corps := p.Store.paths(CleDe("GET", u))
return filepath.Dir(corps)
}
// fige retire le droit d'écrire dans un répertoire, et le rend au nettoyage :
// sans cela le répertoire temporaire du test ne pourrait plus être effacé.
func fige(t *testing.T, dir string, mode os.FileMode) {
t.Helper()
avant, err := os.Stat(dir)
if err != nil {
t.Fatal(err)
}
if err := os.Chmod(dir, mode); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.Chmod(dir, avant.Mode()) })
}

View file

@ -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.
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"):
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à.

View file

@ -15,12 +15,14 @@ qu'elles réutilisent volontairement plutôt que de les redire."""
import os
import re
import shlex
import shutil
import subprocess
import time
import click
from script.todo import todo_prefs
from script.todo.qemu_privilege import virsh_argv
from script.todo.todo_i18n import t
@ -67,7 +69,8 @@ class ProxmoxMenuMixin:
@staticmethod
def _pve_label(host):
"""« root@10.0.0.5 (par rebond) », pour l'afficher en tête de menu."""
"""« root@hyperviseur (par rebond) », pour l'afficher en tête de
menu."""
if not host:
return ""
lab = host.get("target", "?")
@ -676,6 +679,33 @@ class ProxmoxMenuMixin:
return ""
time.sleep(5)
def _pve_user_data(self, mod, distro, nom, cle_locale):
"""Le user-data du DÉPÔT pour une VM Proxmox, ou "".
Le même que le chemin qemu envoie : un bloc « users: » explicite, avec
le nom du compte, son shell, son sudo et ses clés. « --ciuser » et
« --sshkeys » de Proxmox s'en remettent au compte par DÉFAUT de
l'image, et une image qui en déclare un autre les ignore — mesuré sur
NixOS, dont le cloud.cfg nomme « nixos » : la VM démarrait avec ce
compte-là, sans la clé, donc injoignable.
Rend "" quand la clé publique est illisible : mieux vaut retomber sur
la forme d'avant, qui pose au moins un compte, que d'écrire un
user-data sans aucun moyen d'entrer.
"""
try:
with open(os.path.expanduser(cle_locale), encoding="utf-8") as fh:
cle = fh.read().strip()
except OSError as exc:
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
return ""
if not cle:
return ""
args = mod.build_parser().parse_args(
["--distro", distro, "--hostname", nom, "--user", "erplibre"]
)
return mod.build_cloud_config(args, None, [cle])
def _pve_push_key(self, chemin_local):
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
FICHIER là-bas, pas une clé en ligne."""
@ -936,8 +966,8 @@ class ProxmoxMenuMixin:
"""Réseau du futur pont interne, CHOISI d'après l'hôte.
Pas une constante : un Proxmox dans un Proxmox hérite du réseau
interne de son parent, et 10.10.10.1 y est l'adresse de sa propre
PASSERELLE. La poser sur son pont rend tout le /24 local, la
interne de son parent, et l'adresse de INTERNAL_CIDR y est celle
de sa propre PASSERELLE. La poser sur son pont rend tout le /24 local, la
passerelle devient injoignable, et la machine s'isole au milieu de la
commande qui la configure. Vécu : « ifup » n'a jamais rendu la main et
la VM ne répondait plus, ni en ssh ni en ping."""
@ -1043,9 +1073,9 @@ class ProxmoxMenuMixin:
from script.proxmox import proxmox_deploy as pve
host = self._pve_host(ask=False)
# Le réseau est LU sur l'hôte avant d'être proposé : l'annoncer
# 10.10.10.1/24 pour en poser un autre serait mentir sur l'écran même
# où l'on demande l'accord.
# Le réseau est LU sur l'hôte avant d'être proposé : annoncer le
# réseau par défaut pour en poser un autre serait mentir sur
# l'écran même où l'on demande l'accord.
cidr = self._pve_internal_cidr(host) if host else pve.INTERNAL_CIDR
print(f"\n ⚠ {t('No network bridge on this host.')}")
print(f" {t('qm create needs one. Two ways:')}")
@ -1406,6 +1436,9 @@ class ProxmoxMenuMixin:
"storage": spec["storage"],
"bridge": spec["bridge"],
"image": image,
# L'image qui n'a pas de secteur d'amorçage BIOS : le catalogue le
# sait, ce menu le transmet, et « qm create » en tire l'OVMF.
"uefi": mod.requiert_uefi(vm.get("distro") or ""),
"user": spec.get("user") or "erplibre",
"start": spec.get("start", True),
"ipconfig": vm.get("ipconfig") or "ip=dhcp",
@ -1419,9 +1452,21 @@ class ProxmoxMenuMixin:
}
if spec.get("sshkey_path"):
detail["sshkey_path"] = spec["sshkey_path"]
return [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vm["vmid"], detail
# Le user-data du dépôt, pour TOUTES les distributions. La clé locale
# vient de la spec de l'écran ; sans elle, on retombe sur la forme
# d'avant, qui pose au moins un compte.
cle_locale = spec.get("ssh_key_local") or self._qemu_default_ssh_key()
if cle_locale:
detail["user_data"] = self._pve_user_data(
mod, vm.get("distro") or "", vm.get("name") or "", cle_locale
)
# La somme que le dépôt porte pour cette image, quand il en a une :
# c'est la seule chose qui distingue une image tierce revue de
# n'importe quel fichier servi sous la même URL.
somme = mod.pinned_sha256(vm.get("distro") or "")
return [
pve.image_fetch_cmd(url, image, sha256=somme)
] + pve.create_cmds(vm["vmid"], detail)
def _pve_deploy_spec(self, host, spec, mod, dry_run=False, coupee=False):
"""Exécute la spec rendue par l'écran.
@ -1690,6 +1735,121 @@ class ProxmoxMenuMixin:
return ""
return self._qemu_cache_ca_path()
@staticmethod
def _pve_note(vm, ligne):
"""Dit la ligne à l'écran ET la garde pour le journal de CETTE VM.
La console défile et se perd ; le journal est ce qu'on rouvre quand
l'installation a échoué, parfois le lendemain. Une décision prise ici
— l'autorité du cache posée ou non, l'exception — n'explique la panne
que si elle atteint le second. Sans cela le journal ne porte que le
symptôme : des centaines de lignes de construction et un certificat
refusé, sans un mot sur ce qui l'a voulu.
"""
print(ligne)
vm.setdefault("notes", []).append(ligne.strip())
def _pve_cache_bypass_hote(self, host, vm, hors_ligne=False):
"""Soustrait au cache l'hôte Proxmox qui porte un invité sans magasin.
Rend True quand l'exception est en place, False quand il n'y a rien à
faire ou qu'elle a échoué.
POURQUOI LA MAC DE L'HÔTE, ET NON CELLE DE L'INVITÉ. Un invité
imbriqué sort MASQUÉ derrière son hôte : sur le pont d'ici, le cache
ne voit jamais que la MAC de l'hôte Proxmox, et c'est donc elle
qu'il faut excepter. Mesuré des deux côtés — sans l'exception, une
requête de l'invité vers cache.nixos.org rend code 000 et
vérification SSL 19 ; avec, code 200 et vérification 0.
CE QUE COÛTE L'ABSENCE DE REMÈDE. Une distribution dont le magasin de
confiance n'a pas de forme par fichier ne peut pas recevoir
l'autorité, et poser celle-ci par déclaration arriverait trop tard :
sur un système déclaratif, la première reconstruction EST le premier
téléchargement. Le gestionnaire de paquets ne lit alors plus son cache
binaire, se rabat sur la construction depuis les sources — des
centaines de dérivations — et ces sources échouent pour la même
raison. L'installation part pour une heure avant de rendre 1.
CE QU'ELLE COÛTE. L'exception vaut pour TOUT ce que l'hôte relaie, y
compris ses propres téléchargements : il cesse de profiter du cache.
C'est le prix, et il est dit plutôt que subi.
JAMAIS HORS LIGNE. L'amont du cache est alors coupé et le magasin est
la SEULE source : excepter l'hôte ne le ferait pas télécharger en
direct, cela le priverait de tout — ses propres paquets compris. Les
deux issues échouent pour l'invité, mais celle-ci emporte l'hôte avec
lui. On garde donc le cache, et l'avertissement qui suit dit à
l'invité ce qui l'attend.
"""
if hors_ligne:
return False
try:
mod = self._qemu_import_module()
except Exception: # pragma: no cover - dépend du module
mod = None
if not mod:
# Un module qui ne se charge pas ne doit pas emporter la suite de
# la création : sans lui on ne sait pas si l'invité a un magasin
# de confiance, et l'autorité reste la voie par défaut.
return False
distro = vm.get("distro") or ""
if not mod.cache_sans_autorite(distro):
return False
nom = (host.get("target") or "").split("@")[-1]
if not nom or nom not in set(self._qemu_list_domains()):
# Un hôte Proxmox qui ne vit pas ici ne traverse pas ce pont.
return False
mac = self._qemu_domain_mac(nom)
if not mac:
self._pve_note(
vm, f" ⚠ {t('download cache: host MAC not found')} : {nom}"
)
return False
geste = (
f"{shlex.quote(mod.CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
f" --bypass-name {shlex.quote(nom)}"
)
# Le binaire écrit le fichier d'exceptions et rend sur sa sortie le
# geste à chaud ; sans nft, seul le redémarrage du service repose la
# chaîne entière.
if shutil.which("nft"):
cmd = ["sudo", "sh", "-c", f"{geste} | nft -f -"]
else:
cmd = ["sudo", "sh", "-c", geste]
try:
fini = subprocess.run(cmd, capture_output=True, text=True)
except (OSError, subprocess.SubprocessError) as souci:
print(f" ⚠ {t('download cache bypass not set')} ({souci})")
return False
if fini.returncode:
print(
f" ⚠ {t('download cache bypass not set')}"
f" ({fini.returncode})"
)
return False
self._pve_note(
vm, f" ✓ {t('host taken out of the download cache')} : {nom}"
)
self._pve_note(
vm, f" {t('its own downloads stop being cached too.')}"
)
return True
def _qemu_domain_mac(self, nom):
"""Première MAC du domaine libvirt `nom`, ou ''."""
try:
res = subprocess.run(
virsh_argv("domiflist", nom),
capture_output=True,
text=True,
timeout=15,
)
except (OSError, subprocess.SubprocessError):
return ""
trouve = re.findall(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout)
return trouve[0].lower() if trouve else ""
def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False):
"""Pose l'autorité du cache DANS la VM, par ssh.
@ -1717,10 +1877,22 @@ class ProxmoxMenuMixin:
)
fichiers = mod.cache_files(args)
if not fichiers:
# Distribution hors table, ou autorité illisible : la VM
# télécharge en direct, ce qui marche tant qu'aucune règle ne la
# vise. Poser le fichier au mauvais endroit ne marcherait pas et
# ne dirait rien.
# Une distribution dont le magasin de confiance n'a pas de forme
# par fichier — un système déclaratif n'a pas d'ancre où écrire —
# ou une autorité illisible. Poser le fichier au mauvais endroit
# ne marcherait pas.
#
# Mais le silence était faux : on n'arrive ici que lorsque l'hôte
# Proxmox est lui-même une VM de CE pont, donc ses invités sont
# détournés. Celui-ci n'aura rien pour reconnaître le cache, et
# chaque téléchargement HTTPS échouera sur « self-signed
# certificate in certificate chain » — plus tard, dans la VM,
# loin d'ici. C'est pourquoi il est nommé.
cle = (
"no trust store for this distribution, its downloads "
"will fail"
)
self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}")
return False
morceaux = []
for chemin, mode, contenu, _proprio in fichiers:
@ -1738,7 +1910,7 @@ class ProxmoxMenuMixin:
f" ⚠ {t('download cache authority not installed')} ({code})"
)
return False
print(f" ✓ {t('download cache authority installed')}")
self._pve_note(vm, f" ✓ {t('download cache authority installed')}")
return True
def _pve_attendre_ssh(self, cible, delai=300, pas=10):
@ -1808,10 +1980,13 @@ class ProxmoxMenuMixin:
" /etc/apt/sources.list.d/*.list 2>/dev/null; true"
)
code, _o = self._pve_ssh(cible, geste, timeout=60)
# Au JOURNAL, pas seulement à la console : c'est en rouvrant le
# journal qu'on cherche quel miroir a été posé, le jour où la suite
# échoue sur des dépendances introuvables.
if code:
print(f" ⚠ {t('apt mirror not pinned')} ({code})")
self._pve_note(vm, f" ⚠ {t('apt mirror not pinned')} ({code})")
return False
print(f" ✓ {t('apt mirror pinned')} : {miroir}")
self._pve_note(vm, f" ✓ {t('apt mirror pinned')} : {miroir}")
return True
def _pve_set_gpu_groups(self, cible, utilisateur, mod=None):
@ -1877,6 +2052,18 @@ class ProxmoxMenuMixin:
),
erplibre_make=self._qemu_make_target(cmd_install),
desktop=bool(vm.get("desktop")),
# Les outils que le guide annoncera, FILTRÉS par cette machine
# comme la voie libvirt le fait : en annoncer un que l'
# architecture ou l'absence de bureau écarte enverrait chercher
# une commande qui ne sera jamais posée.
vm_tools=",".join(
self._qemu_tools_for(
spec.get("vm_tools") or (),
vm.get("arch") or "amd64",
bool(vm.get("desktop")),
vm.get("distro") or "",
)
),
no_git_identity=False,
user=spec.get("user") or "erplibre",
)
@ -2128,8 +2315,18 @@ class ProxmoxMenuMixin:
# range ses index sous l'hôte demandé, et une VM qui en
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
# Un invité dont le magasin de confiance n'a pas de forme
# par fichier ne peut RIEN recevoir : on soustrait son
# hôte au cache à la place. L'autorité n'est posée que
# lorsqu'il y a quelqu'un pour la recevoir.
if not self._pve_cache_bypass_hote(
host, vm, hors_ligne=bool(coupee)
):
self._pve_set_cache_ca(
vm["alias"], vm, ca_cache, hors_ligne=bool(coupee)
vm["alias"],
vm,
ca_cache,
hors_ligne=bool(coupee),
)
# Après la création, qui a posé l'écran accéléré : l'accès au
# nœud de rendu est une affaire de COMPTE, et il se donne
@ -2230,6 +2427,14 @@ class ProxmoxMenuMixin:
)
for vm in joignables
},
# Ce que l'hôte a DÉCIDÉ pour chaque VM avant de lancer
# l'installation. Sans cela, le journal ne porte que le
# symptôme, et la cause reste sur une console qui défile.
notes={
vm["name"]: vm.get("notes") or []
for vm in joignables
if vm.get("notes")
},
)
return resultat
# Sans suivi mais avec quelque chose à installer : en série, sortie à
@ -2343,17 +2548,22 @@ class ProxmoxMenuMixin:
"storage": stockage,
"bridge": pont,
"image": image,
"uefi": mod.requiert_uefi(distro),
"user": "erplibre",
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
# Le user-data du dépôt, pour TOUTES les distributions : un seul
# cloud-init à comprendre, et celui-là est déjà éprouvé.
"user_data": self._pve_user_data(mod, distro, nom, cle_locale),
"start": True,
# DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont
# interne : là, aucun serveur DHCP ne répondrait et la VM
# resterait muette.
"ipconfig": ipconfig,
}
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
)
somme = mod.pinned_sha256(distro)
etapes = [
pve.image_fetch_cmd(url, image, sha256=somme)
] + pve.create_cmds(vmid, spec)
if dry_run:
print(f"\n── {t('Would run on')} {host['target']} ──")
print(f" # {t('SSH key ->')} {spec['sshkey_path']}")
@ -2368,9 +2578,9 @@ class ProxmoxMenuMixin:
if cle_locale and not self._pve_push_key(cle_locale):
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
spec.pop("sshkey_path", None)
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
)
etapes = [
pve.image_fetch_cmd(url, image, sha256=somme)
] + pve.create_cmds(vmid, spec)
for cmd in etapes:
code, _out = self._pve_show(cmd, timeout=1800)
if code:
@ -2683,4 +2893,10 @@ class ProxmoxMenuMixin:
url = mod.image_url(distro, code, "amd64", version)
nom = mod.default_image_name(distro, code, "amd64", version)
print(f"\n {nom}\n {url}")
self._pve_show(pve.image_fetch_cmd(url, nom), timeout=1800)
# La somme, ici comme au déploiement : une image téléchargée d'avance
# est celle qu'un déploiement futur trouvera « déjà présente », et il
# ne la regardera pas mieux que celui-ci.
self._pve_show(
pve.image_fetch_cmd(url, nom, sha256=mod.pinned_sha256(distro)),
timeout=1800,
)

View file

@ -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}")

View file

@ -1449,6 +1449,7 @@ class QemuCacheMenuMixin:
)
},
{"prompt_description": t("Clean - Everything")},
{"prompt_description": t("Clean - Forget one URL")},
]
grains = {"1": "jour", "2": "semaine", "3": "mois"}
help_info = self.fill_help_info(choices)
@ -1465,6 +1466,8 @@ class QemuCacheMenuMixin:
self._cache_nettoyer_age()
elif status == "5":
self._cache_nettoyer_tout()
elif status == "6":
self._cache_oublier_url()
else:
print(t("Command not found !"))
@ -1498,6 +1501,30 @@ class QemuCacheMenuMixin:
return
self._cache_lancer(f"--purge-older-than {shlex.quote(delai)}")
def _cache_oublier_url(self):
"""Retire UNE entrée, par son URL. Montrée d'abord, effacée ensuite.
Le seul geste qui vise une entrée : « effacer ce qui n'a plus servi »
n'atteint jamais un objet que le service rajeunit à chaque fois qu'il
le rend, et « tout effacer » coûte le cache entier pour un fichier.
Le cas qui l'appelle est une somme qui ne correspond pas — le magasin
continue alors de servir les mêmes octets, et retélécharger ne change
rien puisque c'est lui qui répond.
« --detient » AVANT, et c'est l'aperçu : il répond à la même ligne,
par la même clé, sans rien modifier. Un « absent » dit que l'URL n'est
pas celle qu'on croit, avant d'avoir effacé quoi que ce soit.
"""
url = click.prompt(t("URL to forget (or METHOD URL)"), default="")
url = url.strip()
if not url:
return
print(f"\n {t('What the store holds for it:')}\n")
self._cache_lancer(f"--detient <<< {shlex.quote(url)}", sudo=False)
if not click.confirm(t("Remove this entry from the store?")):
return
self._cache_lancer(f"--oublie <<< {shlex.quote(url)}")
def _cache_nettoyer_tout(self):
"""Tout, objets ET dépôts. Montré à blanc d'abord, comme le reste."""
print(f" ⚠ {t('This empties the objects AND the git mirrors.')}")

View file

@ -250,8 +250,42 @@ class QemuDeployMixin:
"--auto-agree-with-licenses $PKGS; "
"elif command -v yum >/dev/null 2>&1; then "
"sudo yum makecache -q || true; sudo yum install -y $PKGS; "
"else echo 'Aucun gestionnaire de paquets "
"(apt/dnf/pacman/zypper/yum)'; exit 1; fi; "
# NixOS n'a aucun des cinq, et c'est là que l'installation
# s'arrêtait : « Aucun gestionnaire de paquets », avant même le
# clone. Le système est DÉCLARATIF, mais l'amorçage est le seul
# moment où on ne peut pas l'être — le module qui déclare git et
# make vit DANS le dépôt qu'il faut git pour cloner.
#
# Le profil de l'utilisateur tranche ce nœud : les outils y sont
# posés pour la durée du clone, et « make install_os » les
# redéclare ensuite pour le système entier. Rien n'est écrit dans
# /etc/nixos avant que le dépôt ne soit là pour le faire.
#
# « <nixpkgs> » et non un canal : l'utilisateur n'en a aucun sur
# cette image, là où NIX_PATH est posé pour tout le monde et
# pointe les canaux de root.
#
# Les noms sont ceux de nixpkgs, où make s'appelle gnumake — $PKGS
# porte les noms des quatre autres familles et ne vaut pas ici.
#
# python3 s'y ajoute, et seulement ici : les images des quatre
# autres familles l'embarquent — cloud-init est écrit en Python et
# le pose sur le PATH. Sur NixOS il vit dans le store, hors PATH,
# et « make install_os » s'arrêtait sur « env: python3: No such
# file or directory » à sa première recette. Sa version importe
# peu : l'installation choisit ensuite le Python d'Odoo, que le
# module déclare et que le profil du système porte, cherché avant
# celui de l'utilisateur.
+ "elif command -v nix-env >/dev/null 2>&1; then "
"nix-env -f '<nixpkgs>' -iA git gnumake curl python3"
f"{self._qemu_editor_suffix()}; "
# Le PATH de cette commande distante a été figé à l'ouverture du
# shell SSH, avant que nix-env ne pose quoi que ce soit : sans
# cette ligne, le contrôle juste en dessous déclare git manquant
# sur une machine où il vient d'être installé.
'export PATH="$HOME/.nix-profile/bin:$PATH"; '
+ "else echo 'Aucun gestionnaire de paquets "
"(apt/dnf/pacman/zypper/yum/nix)'; exit 1; fi; "
# Vérifie explicitement que tout est là : erreur nette plutôt
# qu'un « command not found » cryptique plus loin.
"for t in curl git make; do command -v $t >/dev/null 2>&1 || "
@ -332,6 +366,7 @@ class QemuDeployMixin:
vm_tools=(),
pve=None,
meta=None,
notes=None,
ai_agent="",
guet_hors_ligne=False,
deploy_started=None,
@ -352,6 +387,8 @@ class QemuDeployMixin:
`vm_tools` : outils cochés pour tout le parc, filtrés machine par
machine (Android Studio n'existe qu'en x86_64, les extensions GNOME
n'ont pas de sens sous Cinnamon).
`notes` : {nom: [lignes]} — ce que l'hôte a décidé pour cette VM
avant l'installation, recopié en tête de son journal.
`meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que
sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux
pour une VM qui vit sur un Proxmox distant."""
@ -415,6 +452,11 @@ class QemuDeployMixin:
# connaît pas.
if (pve or {}).get(name):
entry["pve"] = pve[name]
# Ce que l'hôte a décidé AVANT de lancer l'installation :
# écrit en tête du journal, là où on le cherche quand ça a
# échoué. La console qui l'a dit a défilé depuis.
if (notes or {}).get(name):
entry["notes"] = list(notes[name])
# Les outils imposent une commande PAR VM même quand tout le
# reste est commun : ils dépendent de l'architecture de la
# machine et de sa saveur de bureau, que seule cette boucle
@ -1044,12 +1086,50 @@ class QemuDeployMixin:
)
print(f" {t('~/.ssh/config:')} {cfg}")
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
# Avant la ligne du mot de passe : le système de base d'une VM se dit
# AVANT de la créer, pas dans un journal d'installation.
for rang, ligne in enumerate(self._qemu_image_lines(spec)):
print(f" {ligne}" if rang == 0 else f" {ligne}")
# DERNIÈRE ligne de la page, parce que l'invite de sudo tombe juste
# après : elle n'explique rien d'elle-même, et un mot de passe tapé
# sans savoir ce qu'il autorise est donné à l'aveugle.
for rang, ligne in enumerate(self._qemu_sudo_lines()):
print(f" {ligne}" if rang == 0 else f" {ligne}")
def _qemu_image_lines(self, spec):
"""Ce qu'il faut savoir de l'image des VM retenues. Vide s'il n'y a
rien de particulier à en dire.
Les FAITS viennent de deploy_qemu, seule autorité sur l'image qu'il
télécharge ; leur mise en phrase revient au menu, qui parle deux
langues. Une distribution dont l'image vient d'un tiers se dit ici, et
une distribution où l'installation ERPLibre échouera encore aussi :
les deux se découvrent sinon après le déploiement.
"""
distros = {vm.get("distro") for vm in spec.get("vms") or ()}
try:
mod = self._qemu_import_module()
except Exception:
return []
lignes = []
for distro in sorted(d for d in distros if d):
note = mod.image_source_note(distro)
if not note:
continue
url, tag = note
lignes.append(
t(
"%s: image rebuilt by a third party, not published by the"
" distribution"
)
% distro
)
lignes.append(url)
lignes.append(
t("release pinned to %s, sha256 fixed in the repository") % tag
)
return lignes
def _qemu_sudo_lines(self):
"""Pourquoi le déploiement va demander le mot de passe. Vide s'il ne
le demandera pas.
@ -1198,6 +1278,15 @@ class QemuDeployMixin:
# ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un
# disque plein après une heure.
extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d)
# Ce que le guide de connexion annoncera. FILTRÉ par la machine, et
# non la liste cochée pour le parc : Android Studio n'existe qu'en
# x86_64 et les extensions GNOME n'ont pas de sens sans bureau —
# annoncer un outil qui ne sera pas posé enverrait chercher une
# commande absente. Rien n'est installé par deploy_qemu.py, qui ne
# s'en sert que pour écrire /etc/motd.
retenus = self._qemu_tools_for(vm_tools, arch, desktop, d)
if retenus:
parts += ["--vm-tools", ",".join(retenus)]
# TOUJOURS, même sans supplément : sans le drapeau, deploy_qemu.py
# reprend la taille par défaut du catalogue. Une VM réglée à 60 G mais
# sans rien à installer repartait donc à 20 G, en silence.

View file

@ -205,8 +205,28 @@ class QemuInstallMixin:
"sudo sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' "
"/etc/selinux/config 2>/dev/null || true; fi; "
)
# NixOS est reconnu DANS la VM, et non d'après ce que l'hôte croit
# savoir : la même commande sert au déploiement, au test long et à un
# « --hote » qu'on n'a pas créé. /etc y est généré depuis le store et
# monté en lecture seule — le tee plus bas échoue sur « Read-only file
# system », et l'installation rend 1 à sa dernière étape après que
# tout le reste a réussi. L'unité vient du module déclaratif posé par
# « make install_os » ; il ne reste qu'à la relancer, une fois le
# dépôt en place.
nixos = (
"if [ -f /etc/os-release ] && grep -q '^ID=nixos' "
"/etc/os-release; then "
"if systemctl cat erplibre.service >/dev/null 2>&1; then "
"sudo systemctl restart erplibre.service; "
"else "
'echo "erplibre.service non déclaré : '
'« make install_os » pose le module NixOS qui le porte." >&2; '
"exit 1; fi; "
"else "
)
return (
f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
nixos
+ f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; '
+ pre
+ selinux_shell
+ "sudo tee /etc/systemd/system/erplibre.service >/dev/null <<UNIT\n"
@ -230,7 +250,8 @@ class QemuInstallMixin:
"WantedBy=multi-user.target\n"
"UNIT\n"
"sudo systemctl daemon-reload; "
"sudo systemctl enable --now erplibre.service"
"sudo systemctl enable --now erplibre.service; "
"fi"
)
# Bureaux disponibles, par gestionnaire de paquets. Une seule source pour
@ -407,6 +428,39 @@ class QemuInstallMixin:
# cloud de 40 G, l'oublier remplit le disque en pleine installation.
QEMU_DESKTOP_EXTRA_DISK_GB = 6
@staticmethod
def _qemu_ca_exports():
"""Donne l'autorité du cache à TOUT ce que la commande distante lance.
Les quatre familles impératives posent l'autorité dans le magasin du
système, et curl, git et le reste la trouvent seuls. NixOS n'a pas
d'ancre par fichier : l'autorité y est un faisceau sous un chemin
inscriptible, que rien ne consulte sans qu'on le dise — nix mis à
part, qui a son fragment de service.
PAS par /etc/environment : mesuré sur une VM, le pam_env de sshd porte
« readenv=0 » et son fichier de configuration est un lien vers le
store. Aucun chemin PAM n'est inscriptible avant la première
reconstruction, et c'est précisément avant elle que le clone a lieu.
La commande porte donc ses variables elle-même.
GARDÉ par l'existence du fichier : ailleurs il n'y en a pas, et
pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout. Un
« if » et non un « && » : sous « set -e », une garde fausse en fin de
liste ET-OU est un cas limite qui dépend du shell.
"""
faisceau = "/var/lib/erplibre/ca-bundle.crt"
variables = (
"SSL_CERT_FILE",
"CURL_CA_BUNDLE",
"GIT_SSL_CAINFO",
"REQUESTS_CA_BUNDLE",
"NODE_EXTRA_CA_CERTS",
"PIP_CERT",
)
export = " ".join(f"{v}={faisceau}" for v in variables)
return f"if [ -r {faisceau} ]; then export {export}; fi; "
@staticmethod
def _qemu_cloud_init_wait():
"""Attend la fin de cloud-init, qui tient le verrou apt/dnf/pacman
@ -456,6 +510,14 @@ class QemuInstallMixin:
# Les variables du cache sont écrites par cloud-init PENDANT
# l'attente : cette session, ouverte avant, ne les a pas reçues.
+ cache_env_reload() + "; "
# ICI, et nulle part avant. Le faisceau que ces exports désignent
# est écrit par cloud-init lui aussi : mesuré sur une VM, la
# session ssh est acceptée une seconde avant qu'il existe, donc
# une garde évaluée en tête de commande est fausse et n'exporte
# rien. Tout ce qui suit perd alors l'autorité — le clone du dépôt
# échoue sur « self-signed certificate », quand nix, qui lit son
# fragment au moment de s'en servir, télécharge très bien.
+ QemuInstallMixin._qemu_ca_exports()
)
@staticmethod
@ -824,7 +886,10 @@ class QemuInstallMixin:
"arches": ("amd64", "arm64"),
"desktops": (),
"needs_desktop": True,
"families": (),
# L'archive JetBrains est liée dynamiquement : elle ne s'exécute pas
# sur un système sans /lib64/ld-linux. Les quatre familles
# impératives, donc, et pas « toutes ».
"families": ("apt", "dnf", "pacman", "zypper"),
"phase": "before",
},
"android": {
@ -838,7 +903,8 @@ class QemuInstallMixin:
"arches": ("amd64",),
"desktops": (),
"needs_desktop": True,
"families": (),
# Même raison que PyCharm : archive amont liée dynamiquement.
"families": ("apt", "dnf", "pacman", "zypper"),
"phase": "before",
},
"gnome_ext": {
@ -853,7 +919,9 @@ class QemuInstallMixin:
"arches": (),
"desktops": ("gnome",),
"needs_desktop": True,
"families": (),
# Les paquets viennent des dépôts de la distribution, et le
# gestionnaire d'extensions n'existe qu'à travers eux.
"families": ("apt", "dnf", "pacman", "zypper"),
"phase": "before",
},
# Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche
@ -908,7 +976,10 @@ class QemuInstallMixin:
"arches": ("amd64", "arm64"),
"desktops": (),
"needs_desktop": False,
"families": (),
# Binaire statique, mais son installateur écrit dans /usr/local,
# crée un compte système et pose une unité systemd à la main :
# rien de tout cela n'a de sens sur un système déclaratif.
"families": ("apt", "dnf", "pacman", "zypper"),
# APRÈS l'installation : le script vit dans le dépôt, donc après le
# clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv
# ni d'Odoo.
@ -944,12 +1015,47 @@ class QemuInstallMixin:
"arches": (),
"desktops": (),
"needs_desktop": False,
"families": (),
# Trois installateurs « curl | sh » qui posent des binaires liés
# dynamiquement, et des paquets par le gestionnaire du
# système : aucun des deux gestes n'existe sur NixOS.
"families": ("apt", "dnf", "pacman", "zypper"),
# AVANT le clone : chaque outil s'y garde lui-même, et aucun ne
# doit faire échouer l'installation d'ERPLibre pour un curl qui
# ne répond pas.
"phase": "before",
},
# Nix sur une distribution qui n'est pas NixOS : le gestionnaire de
# paquets seul, posé à côté de celui du système, et l'installateur
# qui s'en sert pour porter NixOS sur une AUTRE machine, jointe par
# SSH — un serveur loué, une VM du parc, une carte ARM.
#
# « families » exclut « nix » et ce n'est pas un oubli : sur NixOS,
# nix EST le système. L'option n'y aurait rien à poser.
#
# Disque : le store porte la fermeture d'un système NixOS complet et
# le noyau kexec qui le démarre à distance.
"nixanywhere": {
"label": "nix + nixos-anywhere",
"help": (
"the official multi-user installer, nix as a daemon",
"experimental-features: nix-command and flakes",
"nixos-anywhere, by nix profile install",
"it installs NixOS on any machine reachable over SSH",
),
"hint": (
"nix, flakes, and nixos-anywhere to install NixOS elsewhere"
),
"disk_gb": 8,
# L'amont bâtit ce qu'on lui demande plutôt que de servir un
# binaire : les deux architectures que nix supporte pleinement.
"arches": ("amd64", "arm64"),
"desktops": (),
"needs_desktop": False,
"families": ("apt", "dnf", "pacman", "zypper"),
# AVANT le clone, comme les autres poses amont : l'outil se garde
# lui-même et ne fait échouer ni les autres ni ERPLibre.
"phase": "before",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
@ -984,6 +1090,7 @@ class QemuInstallMixin:
"rocky": "dnf",
"opensuse": "zypper",
"arch": "pacman",
"nixos": "nix",
}
def _qemu_guest_context(self):
@ -2438,6 +2545,91 @@ class QemuInstallMixin:
f"python3 -c {shlex.quote(code)} 2>/dev/null || true"
)
# L'installateur OFFICIEL, en mode multi-utilisateur : il pose /nix, le
# groupe nixbld et le démon. C'est le mode qui survit à plusieurs comptes
# sur la machine, là où le mode mono-utilisateur donne /nix à celui qui
# installe. « --yes » donne d'avance la confirmation que personne ne
# tapera : la pose se fait par SSH, sans terminal.
_QEMU_NIX_UPSTREAM = (
"curl -fsSL https://nixos.org/nix/install -o /tmp/nix-install.sh"
" && sh /tmp/nix-install.sh --daemon --yes"
)
# Ces deux fonctions restent « expérimentales » en amont, et
# nixos-anywhere ne se distribue QUE par un flake : sans elles,
# « nix profile install github:… » refuse la référence.
_QEMU_NIX_FEATURES = "nix-command flakes"
_QEMU_NIX_CONF_LINE = f"experimental-features = {_QEMU_NIX_FEATURES}"
# L'installateur de NixOS À DISTANCE, par nix-community : il démarre la
# machine cible sur un noyau kexec, partitionne, et y pose le système
# décrit par un flake. La cible n'a besoin que d'un SSH root.
_QEMU_NIXANYWHERE_FLAKE = "github:nix-community/nixos-anywhere"
def _qemu_nixanywhere_remote_cmd(self):
"""Nix et nixos-anywhere dans la VM, sur le moule d'aidev.
Trois gestes : l'installateur amont, les fonctions expérimentales que
les flakes réclament, puis nixos-anywhere dans le profil de
l'utilisateur. La VM devient alors une machine d'où l'on INSTALLE
NixOS ailleurs — elle-même reste sur sa distribution.
Chaque pose est bornée par « timeout » et privée d'entrée standard,
pour la raison qui vaut pour toute la phase « before » : « || true »
couvre l'échec, pas l'ATTENTE, et un installateur qui pose une
question resterait pendu sur un SSH sans terminal.
Nix est appelé par son CHEMIN ABSOLU. Le PATH de cette commande
distante a été figé à l'ouverture du shell SSH, avant que
l'installateur ne pose quoi que ce soit ; « nix » nu rendrait 127 sans
dire que rien n'a été installé. Les fonctions expérimentales sont
redonnées sur la ligne de commande en plus d'être écrites dans
/etc/nix/nix.conf : l'écriture demande sudo et peut échouer, l'appel
doit réussir quand même.
"""
nix = "/nix/var/nix/profiles/default/bin/nix"
outil = "$HOME/.nix-profile/bin/nixos-anywhere"
def pose(cmd, secondes):
return (
f"timeout {secondes} sh -c {shlex.quote(cmd)}"
" </dev/null || true; "
)
conf = (
"sudo mkdir -p /etc/nix 2>/dev/null || true; "
f"grep -qF {shlex.quote(self._QEMU_NIX_CONF_LINE)}"
" /etc/nix/nix.conf 2>/dev/null"
f" || echo {shlex.quote(self._QEMU_NIX_CONF_LINE)}"
" | sudo tee -a /etc/nix/nix.conf >/dev/null || true; "
"sudo systemctl restart nix-daemon 2>/dev/null || true; "
)
installe = (
f'[ -x "{nix}" ] && timeout 900 "{nix}"'
" --extra-experimental-features"
f" {shlex.quote(self._QEMU_NIX_FEATURES)}"
f" profile install {shlex.quote(self._QEMU_NIXANYWHERE_FLAKE)}"
" </dev/null >/dev/null 2>&1 || true; "
)
return (
f'echo "== {t("nix + nixos-anywhere")} =="; '
+ pose(self._QEMU_NIX_UPSTREAM, 900)
+ conf
+ installe
# Rien n'est ajouté à ~/.bashrc, et c'est délibéré : l'installateur
# écrit lui-même /etc/bash.bashrc, /etc/profile.d/nix.sh et les
# fichiers zsh et fish. Une ligne de plus dans le ~/.bashrc de
# l'utilisateur serait posée APRÈS le « return » que ce fichier
# exécute pour tout shell non interactif — donc jamais atteinte par
# « ssh hôte 'commande' », le seul cas qu'elle prétendait couvrir.
#
# Le verdict porte sur le BINAIRE, pas sur le code de retour des
# poses : toutes rendent 0 par construction.
+ f'[ -x "{outil}" ]'
f' && echo " {t("nixos-anywhere ready")}"'
f' || echo " {t("nixos-anywhere missing, see the log above")}"; '
)
def _qemu_tools_remote_cmd(
self, tools, prod=False, phase="before", ai_agent=""
):
@ -2458,6 +2650,7 @@ class QemuInstallMixin:
# En tête : quelques secondes de curl, contre des minutes pour un
# IDE. Ce qui échoue vite se voit tôt.
"aidev": lambda: self._qemu_aidev_remote_cmd(ai_agent),
"nixanywhere": self._qemu_nixanywhere_remote_cmd,
"gnome_ext": self._qemu_gnome_ext_remote_cmd,
"pycharm": lambda: self._qemu_pycharm_remote_cmd(prod),
"android": self._qemu_android_studio_remote_cmd,

View file

@ -352,6 +352,16 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
bar = "=" * 64
titre = t("installation") if branch else t("VM start-up")
ligne_branche = f" Branche : {branch}\n" if branch else ""
# Ce que l'hôte a décidé AVANT de lancer : l'autorité du cache posée ou
# non, l'exception, le miroir. Ces lignes sont dites à la console au
# moment où elles se prennent, et la console a défilé depuis. Le journal
# est ce qu'on rouvre quand l'installation a échoué — sans elles il ne
# porte que le symptôme.
notes = vm.get("notes") or []
bloc = ""
if notes:
corps = "\n".join(f" {ligne}" for ligne in notes)
bloc = f" {t('Prepared by the host:')}\n{corps}\n{bar}\n"
return (
f"{bar}\n"
f" ERPLibre — {titre}\n"
@ -361,7 +371,9 @@ def _log_header(vm: dict, branch: str, when: str) -> str:
f" Architecture : {arch}\n"
f"{ligne_branche}"
f" IP : {vm['ip']}\n"
f"{bar}\n\n"
f"{bar}\n"
f"{bloc}"
f"\n"
)

View file

@ -46,6 +46,7 @@ class QemuMenuMixin:
# deploy_qemu.py, qui fait autorité sur le catalogue.
"opensuse": (["16.0", "tumbleweed"], "16.0"),
"arch": (["latest"], "latest"),
"nixos": (["25.11"], "25.11"),
# Proxmox VE : le numéro est celui de PVE, pas de Debian (9 = trixie).
# Une seule version au catalogue, la seule qui couvre amd64 ET arm64 —
# voir PROXMOX_VERSIONS dans deploy_qemu.py, qui fait autorité.

View file

@ -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 <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.": {
"fr": "aucun gestionnaire de paquets connu ici.",
"en": "no known package manager here.",
@ -4497,6 +4509,27 @@ 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",
},
"Nested QEMU depth: run it": {
"fr": "Profondeur QEMU imbriqué : le lancer",
"en": "Nested QEMU depth: run it",
@ -5763,6 +5796,21 @@ TRANSLATIONS = {
"fr": "Outils d'assistance IA",
"en": "AI coding tools",
},
# Ce qu'il faut savoir de l'image d'une VM, dit avant de la créer.
"%s: image rebuilt by a third party, not published by the distribution": {
"fr": (
"%s : image reconstruite par un tiers, non publiée par la"
" distribution"
),
"en": (
"%s: image rebuilt by a third party, not published by the"
" distribution"
),
},
"release pinned to %s, sha256 fixed in the repository": {
"fr": "release épinglée sur %s, sha256 figée dans le dépôt",
"en": "release pinned to %s, sha256 fixed in the repository",
},
# Pourquoi le déploiement demande un mot de passe, dit AVANT l'invite.
"sudo password: asked when the deployment starts": {
"fr": "Mot de passe sudo : demandé au lancement du déploiement, pour",
@ -5805,6 +5853,40 @@ TRANSLATIONS = {
"fr": "rtk, starship, un agent, git et Claude prêts",
"en": "rtk, starship, one agent, git and Claude ready",
},
# Nix posé dans une VM qui n'est pas NixOS, et l'installateur qui porte
# NixOS sur une autre machine.
"nix + nixos-anywhere": {
"fr": "nix + nixos-anywhere",
"en": "nix + nixos-anywhere",
},
"nix, flakes, and nixos-anywhere to install NixOS elsewhere": {
"fr": "nix, flakes et nixos-anywhere pour installer NixOS ailleurs",
"en": "nix, flakes, and nixos-anywhere to install NixOS elsewhere",
},
"the official multi-user installer, nix as a daemon": {
"fr": "l'installateur officiel multi-utilisateur, nix en démon",
"en": "the official multi-user installer, nix as a daemon",
},
"experimental-features: nix-command and flakes": {
"fr": "experimental-features : nix-command et flakes",
"en": "experimental-features: nix-command and flakes",
},
"nixos-anywhere, by nix profile install": {
"fr": "nixos-anywhere, par nix profile install",
"en": "nixos-anywhere, by nix profile install",
},
"it installs NixOS on any machine reachable over SSH": {
"fr": "il installe NixOS sur toute machine joignable en SSH",
"en": "it installs NixOS on any machine reachable over SSH",
},
"nixos-anywhere ready": {
"fr": "nixos-anywhere prêt",
"en": "nixos-anywhere ready",
},
"nixos-anywhere missing, see the log above": {
"fr": "nixos-anywhere absent, voir le journal ci-dessus",
"en": "nixos-anywhere missing, see the log above",
},
"Help": {
"fr": "Aide",
"en": "Help",
@ -7175,6 +7257,50 @@ TRANSLATIONS = {
"fr": "autorité du cache de téléchargement non posée",
"en": "download cache authority not installed",
},
"Clean - Forget one URL": {
"fr": "Nettoyer - Oublier une URL",
"en": "Clean - Forget one URL",
},
"URL to forget (or METHOD URL)": {
"fr": "URL à oublier (ou MÉTHODE URL)",
"en": "URL to forget (or METHOD URL)",
},
"What the store holds for it:": {
"fr": "Ce que le magasin en tient :",
"en": "What the store holds for it:",
},
"Remove this entry from the store?": {
"fr": "Retirer cette entrée du magasin ?",
"en": "Remove this entry from the store?",
},
"Prepared by the host:": {
"fr": "Préparé par l'hôte :",
"en": "Prepared by the host:",
},
"host taken out of the download cache": {
"fr": "hôte soustrait au cache de téléchargement",
"en": "host taken out of the download cache",
},
"its own downloads stop being cached too.": {
"fr": "ses propres téléchargements cessent aussi d'être gardés.",
"en": "its own downloads stop being cached too.",
},
"download cache bypass not set": {
"fr": "exception au cache de téléchargement non posée",
"en": "download cache bypass not set",
},
"download cache: host MAC not found": {
"fr": "cache de téléchargement : MAC de l'hôte introuvable",
"en": "download cache: host MAC not found",
},
"no trust store for this distribution, its downloads "
"will fail": {
"fr": (
"pas de magasin de confiance pour cette distribution, ses "
"téléchargements échoueront"
),
"en": "no trust store for this distribution, its downloads will fail",
},
"Timezone": {
"fr": "Fuseau horaire",
"en": "Timezone",

View file

@ -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 <package> 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."))

View file

@ -146,5 +146,74 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase):
self.assertEqual(len(s2.difference(s1)), 0)
class UneDestructionNAnnonceQueCeQuElleAFait(unittest.TestCase):
"""« make db_drop_all » composait une commande « parallel », jetait son
code de retour et imprimait la liste des bases comme détruites.
Le cas s'atteint dès que « parallel » manque du PATH : le shell rend 127,
pas une base n'est touchée, et l'opérateur passe à la suite en croyant
ses bases parties. Une destruction qui annonce un succès qu'elle n'a pas
obtenu est pire que celle qui échoue.
"""
def _module(self):
import importlib.util
chemin = os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"script/database/db_drop_all.py",
)
spec = importlib.util.spec_from_file_location("db_drop_all", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def _courir(self, code_destruction):
import contextlib
mod = self._module()
def faux_shell(cmd):
if "--list" in cmd:
return 0, "test_alpha\ntest_beta"
return code_destruction, (
"" if not code_destruction else "parallel: command not found"
)
mod.execute_shell = faux_shell
class Config:
database = ""
test_only = True
mod.get_config = lambda: Config()
sortie, erreur = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(sortie):
with contextlib.redirect_stderr(erreur):
code = mod.main()
return code, sortie.getvalue(), erreur.getvalue()
def test_a_failed_drop_is_not_announced_as_done(self):
code, sortie, erreur = self._courir(127)
self.assertEqual(127, code)
self.assertNotIn("Database deleted", sortie)
self.assertNotIn("test_alpha", sortie)
def test_the_cause_reaches_the_operator(self):
"""Le code de retour seul laisserait chercher : la sortie du shell
nomme ce qui manque."""
_code, _sortie, erreur = self._courir(127)
self.assertIn("NOT deleted", erreur)
self.assertIn("parallel", erreur)
def test_a_real_drop_is_still_announced(self):
"""Le cas ordinaire ne change pas : les bases détruites se disent."""
code, sortie, _erreur = self._courir(0)
self.assertEqual(0, code)
self.assertIn("Database deleted", sortie)
self.assertIn("test_alpha", sortie)
self.assertIn("test_beta", sortie)
if __name__ == "__main__":
unittest.main()

777
test/test_install_nixos.py Normal file
View file

@ -0,0 +1,777 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'installation des dépendances ERPLibre sur NixOS.
Les quatre autres scripts de distribution POSENT des paquets ; celui-ci n'en
pose aucun. Il dépose une déclaration et demande au système de s'y conformer,
parce que c'est la seule façon dont une dépendance dure là-bas : ce qui est
installé à la main vit hors de la configuration et disparaît à la
reconstruction suivante.
Ce que ces tests gardent :
- l'aiguillage reconnaît « nixos ». Sans cette branche, install_dev.sh tombe
dans son « else », appelle le script Debian, et celui-ci sort en erreur dès
la détection du système — avant même d'essayer un apt-get qui n'existe pas ;
- les DEUX options qui portent tout le reste sont déclarées. Sans envfs, /bin
et /usr/bin restent vides : le Makefile force « SHELL := /bin/bash » et
toute cible échoue avant sa première ligne, tandis que
lib_python_provider.sh ne cherche l'interpréteur du système qu'en
/usr/bin/pythonX.Y. Sans nix-ld, aucune roue manylinux ni aucun binaire
téléchargé ne s'exécute ;
- la version de Python déclarée répond à celle que le dépôt demande ;
- le compte du rôle PostgreSQL est substitué, jamais écrit en dur.
"""
import re
import subprocess
import unittest
from pathlib import Path
RACINE = Path(__file__).resolve().parents[1]
MODULE = RACINE / "conf/nixos/erplibre.nix"
SCRIPT = RACINE / "script/install/install_nixos_dependency.sh"
AIGUILLAGE = RACINE / "script/install/install_dev.sh"
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
import importlib.util
import sys
sys.argv = ["todo.py"]
chemin = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class LAiguillage(unittest.TestCase):
def setUp(self):
self.src = AIGUILLAGE.read_text(encoding="utf-8")
def test_nixos_has_its_own_branch(self):
self.assertIn('"${ID}" == "nixos"', self.src)
self.assertIn("install_nixos_dependency.sh", self.src)
def test_it_is_decided_before_the_debian_fallback(self):
"""Le « else » de fin appelle le script Debian : une branche placée
après lui ne serait jamais atteinte."""
self.assertLess(
self.src.index('"${ID}" == "nixos"'),
self.src.rindex("install_debian_dependency.sh"),
)
def test_the_supported_list_names_it(self):
"""Le message de repli énumère les systèmes supportés : en omettre un
qu'on supporte fait douter de celui qu'on lit."""
ligne = [x for x in self.src.splitlines() if "not supported" in x][-1]
self.assertIn("NixOS", ligne)
class LeScript(unittest.TestCase):
def setUp(self):
self.src = SCRIPT.read_text(encoding="utf-8")
def test_it_is_valid_shell(self):
fini = subprocess.run(
["bash", "-n", str(SCRIPT)], capture_output=True, text=True
)
self.assertEqual(0, fini.returncode, fini.stderr)
def test_it_is_executable(self):
"""git saute silencieusement un script sans bit d'exécution, et
install_dev.sh l'appelle directement."""
import os
self.assertTrue(os.access(SCRIPT, os.X_OK))
def test_it_refuses_another_system(self):
"""Déposer un module NixOS ailleurs ne ferait rien de bon, et le
« nixos-rebuild » qui suit n'existerait pas."""
self.assertIn("ID=nixos", self.src)
def test_the_import_is_added_once(self):
"""Relancé, le script ne doit pas empiler les imports : la
configuration ne compilerait plus."""
self.assertIn('grep -q "erplibre.nix"', self.src)
def test_it_says_when_there_is_no_imports_block(self):
"""Une configuration sans « imports = [ » laisserait le module mort :
NixOS ne lit que ce que la configuration importe."""
self.assertIn("imports = [", self.src)
self.assertIn("exit 1", self.src)
def test_the_python_version_comes_from_the_repository(self):
"""Une version écrite ici dériverait de .python-odoo-version au
premier changement."""
self.assertIn(".python-odoo-version", self.src)
def test_it_verifies_what_the_module_promised(self):
"""Sans /bin/bash, la moindre cible make échoue avant sa première
ligne : le dire ici plutôt qu'une heure plus tard, ailleurs."""
for chemin in ("/bin/bash", "/usr/bin/env"):
self.assertIn(chemin, self.src)
class LeModule(unittest.TestCase):
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def test_the_two_hinges_are_declared(self):
"""envfs pour /bin et /usr/bin, nix-ld pour les binaires étrangers :
sans l'une des deux, rien de ce dépôt ne fonctionne."""
self.assertIn("services.envfs.enable = true;", self.src)
self.assertIn("programs.nix-ld.enable = true;", self.src)
def test_what_compiles_here_is_also_given_to_the_loader(self):
"""Un module compilé sur place n'a pas de RPATH : son « .so » ne
retrouve sa bibliothèque à l'IMPORT que par LD_LIBRARY_PATH, donc par
ce que nix-ld déclare. Déclarer l'en-tête sans la bibliothèque fait
réussir la compilation et échouer l'import."""
entetes = ("openldap", "cups", "libmysqlclient")
loader = self.src.split("programs.nix-ld.libraries")[1].split("];")[0]
for lib in entetes:
with self.subTest(lib=lib):
self.assertIn(lib, loader)
def test_pg_config_is_its_own_derivation(self):
"""Il n'est ni dans postgresql ni dans sa sortie « .dev », et
psycopg2 s'arrête sur « pg_config executable not found »."""
self.assertIn("postgresql.pg_config", self.src)
def test_the_dynamic_loader_gets_what_the_wheels_ask(self):
"""Une roue manylinux qui ne trouve pas sa bibliothèque échoue à
l'IMPORT, pas à l'installation : bien plus tard, et sans rapport
apparent avec pip."""
for lib in ("libpq", "libxml2", "libxslt", "openssl", "zlib"):
with self.subTest(lib=lib):
self.assertIn(lib, self.src)
def test_the_python_matches_what_the_repository_wants(self):
voulu = (RACINE / ".python-odoo-version").read_text().strip()
majeur, mineur = voulu.split(".")[:2]
self.assertIn(f"python{majeur}{mineur}", self.src)
def test_the_database_role_is_substituted(self):
"""Le nom du compte varie d'un déploiement à l'autre ; l'écrire en dur
donnerait un rôle qui ne correspond à personne."""
self.assertIn("@EL_USER@", self.src)
self.assertIn("ensureClauses.superuser = true;", self.src)
def test_the_placeholder_is_the_one_the_script_replaces(self):
script = SCRIPT.read_text(encoding="utf-8")
for marque in set(re.findall(r"@[A-Z_]+@", self.src)):
with self.subTest(marque=marque):
self.assertIn(marque, script)
def test_what_npm_cannot_install_is_declared_instead(self):
"""Les quatre autres scripts posent lessc et rtlcss par « npm install
-g ». Ici le préfixe npm EST le store, en lecture seule : le geste
échoue. nixpkgs porte les deux, et une déclaration ne s'installe
pas — elle tient."""
self.assertIn("nodePackages.less", self.src)
self.assertIn("nodePackages.rtlcss", self.src)
self.assertNotIn("npm install -g", self.src)
def test_the_pager_and_the_less_compiler_are_two_things(self):
"""« less » le paginateur et « less » le compilateur LESS portent le
même nom et ne se remplacent pas : les deux sont déclarés."""
lignes = [x.strip() for x in self.src.splitlines()]
self.assertIn("less", lignes)
self.assertIn("nodePackages.less", lignes)
def test_postgis_is_a_server_plugin_not_a_package(self):
"""Déclarée hors du serveur, l'extension ne serait pas chargeable par
« CREATE EXTENSION »."""
self.assertIn("extensions = ps:", self.src)
self.assertIn("postgis", self.src)
def test_a_compiler_outside_a_nix_shell_finds_the_headers(self):
"""Sans ces variables, ce qui n'a pas de roue amont ne se compile pas :
gcc ne cherche ni en-têtes ni bibliothèques dans le profil système."""
for var in ("CPATH", "LIBRARY_PATH", "PKG_CONFIG_PATH"):
with self.subTest(var=var):
self.assertIn(var, self.src)
def test_the_variables_reach_a_non_interactive_ssh(self):
"""« environment.variables » n'écrit que dans /etc/set-environment,
que seul un shell de CONNEXION lit. Le déploiement, lui, installe par
« ssh hôte 'commande' » : la variable y serait vide."""
self.assertIn("environment.sessionVariables", self.src)
self.assertNotIn("environment.variables", self.src)
def test_the_html_manuals_are_left_out(self):
"""NixOS installe la sortie « doc » de CHAQUE paquet du système
(extraOutputsToInstall vaut « man info doc »). Celle de CPython n'est
pas dans le cache binaire : le premier rebuild la BÂTIT, un Sphinx de
trois mille pages qui domine le temps d'installation et fait cesser de
répondre une machine étroite.
« documentation.doc » et non « documentation » : les pages de manuel
et info restent, elles se lisent depuis un terminal."""
self.assertIn("documentation.doc.enable = false;", self.src)
self.assertNotIn("documentation.enable", self.src)
def test_the_install_reads_the_paths_off_the_filesystem(self):
"""Une session reçoit les variables du module à son OUVERTURE, par
pam_env. L'installation applique le module (« make install_os ») puis
compile (« make install_odoo_18 ») dans la MÊME session : la sienne
est plus vieille que ce qu'elle vient de déclarer.
Les trois paquets du verrou qui n'ont pas de roue amont s'arrêtaient
alors sur « lber.h », « cups/http.h » et « mysql.h » — au PREMIER
passage seulement, ce qui est la pire des pannes : le second réussit
et donne raison à tort. env_var.sh relit donc le profil courant du
système, un lien sur le disque, plutôt qu'une variable héritée."""
env = (RACINE / "env_var.sh").read_text(encoding="utf-8")
self.assertIn("if [ -d /run/current-system/sw ]", env)
for var in ("CPATH", "LIBRARY_PATH", "PKG_CONFIG_PATH"):
with self.subTest(var=var):
self.assertIn(f"export {var}=", env)
self.assertIn("/run/current-system/sw", env)
def test_nothing_is_exported_off_nixos(self):
"""Le garde est le répertoire lui-même : ailleurs, aucune de ces
variables n'est touchée."""
env = (RACINE / "env_var.sh").read_text(encoding="utf-8")
bloc = env.split("if [ -d /run/current-system/sw ]")[1].split("fi")[0]
self.assertNotIn("\nexport", bloc.replace("\n export", ""))
def test_the_headers_are_linked_into_the_profile(self):
"""Le profil ne porte pas « /include » par défaut : sans cette ligne,
déclarer une sortie « .dev » ne met les en-têtes nulle part, et
« fatal error: lber.h » reste entier."""
self.assertIn("environment.pathsToLink", 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])
class LeServiceEstDeclare(unittest.TestCase):
"""Sur NixOS, /etc est généré depuis le store et monté en lecture seule.
L'installation dépose l'unité par « tee /etc/systemd/system/
erplibre.service » sur toute autre distribution ; ici le tee échoue sur
« Read-only file system », et l'installation entière rend 1 à sa dernière
étape, après que le dépôt, le venv, les modules compilés et un démarrage
d'Odoo ont tous réussi. L'unité vient donc de la configuration.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
self.script = SCRIPT.read_text(encoding="utf-8")
def test_the_unit_is_declared_in_the_module(self):
self.assertIn("systemd.services.erplibre", self.src)
self.assertIn('Type = "simple";', self.src)
self.assertIn('User = "@EL_USER@";', self.src)
def test_it_starts_at_boot_without_a_symlink(self):
"""« systemctl enable » activerait par un lien dans /etc, que le
système refuse d'écrire. « wantedBy » en est l'équivalent déclaratif,
et c'est la seule forme d'activation qui tienne ici.
L'absence de « systemctl enable » se vérifie sur la COMMANDE du menu,
plus bas : un module Nix n'exécute rien, et l'y chercher interdirait
au commentaire de nommer ce qu'il explique."""
self.assertIn('wantedBy = [ "multi-user.target" ];', self.src)
def test_the_interpreter_comes_from_the_store(self):
"""/bin et /usr/bin sont un montage FUSE d'envfs, et systemd résout
l'exécutable d'ExecStart lui-même, hors de portée de ce montage :
« /bin/bash » y rend « 203/EXEC ». Avec Restart=always, l'unité boucle
indéfiniment."""
self.assertIn('ExecStart = "${pkgs.bash}/bin/bash', self.src)
self.assertNotIn('ExecStart = "/bin/bash', self.src)
self.assertNotIn('ExecStart = "/usr/bin/env', self.src)
def test_the_unit_carries_the_path_its_scripts_need(self):
"""Une unité ne reçoit pas le PATH d'une session. run.sh lance des
scripts dont le shebang est « env bash » : env est dans le PATH par
défaut, bash non, et run.sh s'arrête avant Odoo."""
self.assertIn("path = with pkgs; [ bash python312 ];", self.src)
def test_the_repository_is_not_guessed(self):
"""Le service lance le dépôt QUI A POSÉ le module. Écrire
« /home/$USER/git/erplibre » se tromperait sur une installation de
production, qui vit sous /opt."""
self.assertIn('WorkingDirectory = "@EL_DIR@";', self.src)
self.assertIn("EL_DIR=${EL_DIR:-${PWD}}", self.script)
def test_both_placeholders_are_substituted(self):
"""Un marqueur non substitué partirait tel quel dans /etc/nixos et
ferait échouer l'évaluation du module."""
for marqueur in ("@EL_USER@", "@EL_DIR@"):
with self.subTest(marqueur=marqueur):
self.assertIn(marqueur, self.src)
self.assertIn(marqueur, self.script)
class CeQueLeDepotAPPELLE_ParSonNomNu(unittest.TestCase):
"""Un système déclaratif n'a que ce qui est écrit.
Les quatre autres distributions posent ces outils par leur gestionnaire
de paquets ; ici, l'absence ne se voit qu'à l'usage, et tard.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def test_xmlsec_is_declared(self):
"""Le manifeste d'auth_saml (OCA server-auth, dans l'addons_path)
déclare « bin: ["xmlsec1"] » : Odoo REFUSE d'installer et de mettre à
niveau le module tant que le binaire n'est pas dans le PATH."""
self.assertIn("\n xmlsec\n", self.src)
def test_the_tools_called_by_bare_name_are_declared(self):
"""« parallel » et « shfmt » sont invoqués sans chemin. Sans
parallel, « make db_drop_all » annonce des bases détruites qui ne
l'ont pas été."""
for outil in ("parallel", "shfmt"):
with self.subTest(outil=outil):
self.assertIn(f"\n {outil}\n", self.src)
def test_growpart_has_a_provider(self):
"""L'agrandissement s'écrit « sudo growpart … || true » : sans le
binaire il rend 0 sans rien agrandir, et la VM garde la taille de son
image pendant que le déploiement annonce celle demandée."""
self.assertIn("\n cloud-utils\n", self.src)
class LAgentInviteVientDuDepot(unittest.TestCase):
"""L'image épinglée active déjà l'agent — et c'est le problème : la
garantie appartient alors au tiers qui la rebâtit.
Les quatre autres distributions le reçoivent du runcmd, qui appelle leur
gestionnaire de paquets. Sur un système déclaratif, c'est cette ligne ou
rien.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def test_the_agent_is_declared(self):
self.assertIn("services.qemuGuest.enable = true;", self.src)
def test_its_path_carries_what_guest_exec_runs(self):
"""Une unité systemd n'hérite pas du profil du système. « guest-exec »
exécute les commandes du produit DANS ce PATH, et l'agrandissement
commence par « findmnt -no SOURCE / » : absent, code 127 dès la
première ligne."""
i = self.src.index("systemd.services.qemu-guest-agent.path")
bloc = self.src[i : self.src.index("];", i)]
for paquet in ("util-linux", "e2fsprogs", "cloud-utils"):
with self.subTest(paquet=paquet):
self.assertIn(paquet, bloc)
class LesReglagesRegionauxDemandes(unittest.TestCase):
"""cloud-init applique la locale par locale-gen et update-locale, qui
n'existent pas ici : la VM gardait le défaut de NixOS. Mesuré —
« fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu.
Le fuseau, lui, marchait déjà : cloud-init pose /etc/localtime, que NixOS
laisse mutable tant que l'option n'est pas déclarée. Le déclarer ne
répare rien, il fait passer la garantie du côté du module.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
self.script = SCRIPT.read_text(encoding="utf-8")
def test_both_are_declared(self):
self.assertIn("i18n.defaultLocale", self.src)
self.assertIn("time.timeZone", self.src)
def test_nothing_is_imposed_when_nothing_was_asked(self):
"""Sur une NixOS que l'on avait déjà — installée par « --hote » —
l'option ne doit pas être définie du tout : un défaut écrit ici
écraserait le réglage de son propriétaire sans le dire."""
for marqueur in ("@EL_LOCALE@", "@EL_TZ@"):
with self.subTest(marqueur=marqueur):
self.assertIn(f'lib.mkIf ("{marqueur}" != "")', self.src)
def test_the_values_come_from_what_the_deployment_asked(self):
"""Lues là où cloud-init garde ce qu'il a reçu, et non devinées."""
self.assertIn("cloud-config.txt", self.script)
self.assertIn("lire_seed locale", self.script)
self.assertIn("lire_seed timezone", self.script)
def test_both_markers_are_substituted(self):
"""Un marqueur non substitué partirait tel quel dans /etc/nixos et
ferait échouer l'évaluation du module."""
for marqueur in ("@EL_LOCALE@", "@EL_TZ@"):
with self.subTest(marqueur=marqueur):
self.assertIn(marqueur, self.script)
def test_the_cost_is_written_down(self):
"""Une reconstruction qui bâtit glibc-locales est longue et muette :
prise pour un blocage, elle se fait interrompre."""
self.assertIn("glibc-locales", self.src)
class LAutoriteAtteintToutCeQuiTelecharge(unittest.TestCase):
"""nix a son fragment de service ; curl, git et le reste n'ont rien.
Les quatre familles impératives posent l'autorité dans le magasin du
système et tout la trouve seul. Ici l'autorité est un faisceau sous un
chemin inscriptible, que rien ne consulte sans qu'on le dise — et le
clone du dépôt échouait donc sur « self-signed certificate in
certificate chain » alors que nix, lui, téléchargeait très bien.
Deux moitiés qui se relaient : la commande d'installation porte ses
variables elle-même, car elle tourne AVANT la première reconstruction et
aucun chemin PAM n'est inscriptible d'ici là — mesuré, le pam_env de
sshd porte « readenv=0 » et son fichier est un lien vers le store. Le
module prend le relais pour les sessions d'après.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
self.script = SCRIPT.read_text(encoding="utf-8")
def _exports(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)._qemu_ca_exports()
def test_the_command_carries_them_itself(self):
exports = self._exports()
for var in ("SSL_CERT_FILE", "GIT_SSL_CAINFO", "CURL_CA_BUNDLE"):
with self.subTest(var=var):
self.assertIn(var, exports)
def test_nothing_is_exported_where_there_is_no_bundle(self):
"""Pointer SSL_CERT_FILE sur un fichier absent couperait TLS partout,
et les quatre autres familles n'ont pas ce faisceau."""
self.assertIn("if [ -r ", self._exports())
# Un « if » et non un « && » : sous « set -e », une garde fausse en
# fin de liste ET-OU est un cas limite qui dépend du shell.
self.assertNotIn("] && export", self._exports())
def test_the_module_takes_over_afterwards(self):
"""Sans cela un « git pull » plus tard échouerait comme le clone."""
self.assertIn("GIT_SSL_CAINFO", self.src)
self.assertIn("@EL_CA_BUNDLE@", self.src)
def test_a_machine_without_a_cache_declares_nothing(self):
"""« optionalAttrs » et non un défaut : le faisceau n'existe pas
partout, et l'installeur laisse le marqueur vide dans ce cas."""
self.assertIn('lib.optionalAttrs ("@EL_CA_BUNDLE@" != "")', self.src)
self.assertIn(
'[ -r "${EL_CA_BUNDLE}" ] || EL_CA_BUNDLE=""', self.script
)
class SudoNEffacePasLAutorite(unittest.TestCase):
"""L'installation reconstruit le système par « sudo », et sudo remet
l'environnement à zéro.
Le fragment de service donné au démon nix ne couvre pas ce cas : mesuré
sur une VM, « sudo nix store info » rend « Store URL: local » et un
NIX_REMOTE vide. Le nix de root parle DIRECTEMENT au magasin local, sans
jamais passer par le démon — c'est lui qui télécharge, avec
l'environnement que sudo lui laisse, et il n'en laisse aucun.
Le mode de défaillance : chaque objet que la reconstruction doit chercher
est demandé sans l'autorité, et le seul journal est une répétition de
« unable to download ... narinfo: SSL peer certificate ... was not OK ».
Le système n'est pas activé, et rien ne nomme la cause.
Le remède est celui des autres familles, « Defaults env_keep », et non un
second : NixOS lit bien /etc/sudoers.d — son /etc/sudoers porte
« #includedir », le répertoire existe, et visudo est dans le PATH de root.
"""
def _commandes(self):
import tempfile
with tempfile.NamedTemporaryFile("w", suffix=".crt") as fh:
fh.write("-----BEGIN CERTIFICATE-----\n")
fh.flush()
args = DQ.build_parser().parse_args(
["--distro", "nixos", "--hostname", "x", "--cache-ca", fh.name]
)
return DQ.cache_commands(args)
def test_the_variables_cross_sudo(self):
joint = " ".join(self._commandes())
self.assertIn(DQ.CACHE_SUDOERS, joint)
self.assertIn("env_keep", joint)
def test_every_variable_the_session_exports_is_kept(self):
"""Les deux listes sont lues ensemble ou pas du tout : une variable
exportée que sudo efface est exactement le défaut qu'on répare, et
une variable gardée que rien n'exporte ne garde rien."""
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
exportees = set(
re.findall(
r"(\w+)=/var/lib/erplibre/ca-bundle\.crt",
TODO.__new__(TODO)._qemu_ca_exports(),
)
)
self.assertTrue(exportees, "la commande n'exporte rien")
self.assertEqual(exportees, set(DQ.NIX_CA_VARS))
def test_the_rebuild_is_the_one_that_needs_it(self):
"""Le garde-fou pointe la ligne qui justifie tout le reste : si la
reconstruction cessait de passer par sudo, ce mécanisme n'aurait plus
de raison d'être, et personne ne s'en apercevrait."""
self.assertIn(
"sudo nixos-rebuild switch",
SCRIPT.read_text(encoding="utf-8"),
)
class LePortDOdooTraverseLePareFeu(unittest.TestCase):
"""NixOS active un pare-feu par défaut ; aucune des images cloud des
quatre autres distributions n'en active un.
Le service écoute bien sur 0.0.0.0:8069 et répond en local, mais
l'extérieur ne reçoit RIEN — pas un refus, un silence, donc une attente
jusqu'au délai. Ce qui sonde depuis l'hôte conclut « Odoo absent » sur une
machine où il tourne, et le journal de l'installation ne porte aucune
trace de la cause.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def _port_sonde(self):
"""Le port que l'hôte sonde VRAIMENT, lu de sa signature : une
constante recopiée ici vieillirait sans que rien ne le dise."""
import inspect
import sys
sys.path.insert(0, str(RACINE))
from script.todo.qemu_install_monitor import _port_open
return inspect.signature(_port_open).parameters["port"].default
def test_the_port_probed_from_the_host_is_the_one_opened(self):
port = self._port_sonde()
self.assertIn(
f"networking.firewall.allowedTCPPorts = [ {port} ];", self.src
)
def test_the_firewall_is_not_simply_turned_off(self):
"""Le couper ouvrirait TOUT. PostgreSQL n'écoute aujourd'hui que sur
la boucle locale, mais un module qui désactive le pare-feu ne le
protégerait plus le jour où cela changerait."""
self.assertNotIn("networking.firewall.enable = false", self.src)
def test_only_what_serves_is_opened(self):
"""Odoo ne lie le port websocket qu'en mode multi-processus, que cette
configuration n'emploie pas : rien n'y écoute, et l'ouvrir donnerait
un port béant sans service derrière."""
i = self.src.index("allowedTCPPorts")
ligne = self.src[i : self.src.index("\n", i)]
self.assertNotIn("8072", ligne)
self.assertNotIn("5432", ligne)
class LeMenuNEcritPasDansEtcSurNixos(unittest.TestCase):
def _cmd(self, prod=False):
import sys
sys.argv = ["todo.py"]
sys.path.insert(0, str(RACINE))
from script.todo.todo import TODO
return TODO.__new__(TODO)._qemu_odoo_service_cmd(prod=prod)
def test_nixos_is_recognised_before_the_write(self):
"""Reconnu DANS la VM : la même commande sert au déploiement, au test
long et à un « --hote » qu'on n'a pas créé."""
for prod in (False, True):
with self.subTest(prod=prod):
cmd = self._cmd(prod)
self.assertLess(
cmd.index("ID=nixos"),
cmd.index("tee /etc/systemd/system"),
)
def test_the_declarative_branch_only_restarts(self):
"""L'unité existe déjà — le module l'a déclarée. « enable » écrirait
un lien dans /etc, que le système refuse."""
cmd = self._cmd()
nix = cmd[cmd.index("ID=nixos") : cmd.index("tee /etc/systemd/system")]
self.assertIn("systemctl restart erplibre.service", nix)
self.assertNotIn("systemctl enable", nix)
def test_a_missing_unit_is_named_and_fails(self):
"""Sans le module, « restart » rendrait une erreur de systemd sans
dire ce qui manque ni où le prendre."""
cmd = self._cmd()
self.assertIn("systemctl cat erplibre.service", cmd)
self.assertIn("make install_os", cmd)
class LAutoriteEstExporteeApresQueCloudInitLAEcrite(unittest.TestCase):
"""L'ordre entre l'attente et les exports, qui n'est pas un détail.
Le faisceau est écrit par cloud-init, et la session ssh est ouverte
AVANT lui : mesuré sur une VM, ssh est accepté à 13:18:27 et le fichier
apparaît à 13:18:28. Une garde « if [ -r … ] » évaluée en tête de
commande est donc fausse, n'exporte rien, et tout ce qui suit dans ce
shell perd l'autorité — le clone du dépôt échoue sur « self-signed
certificate » alors que nix, qui lit son fragment au moment de s'en
servir, télécharge très bien. Le symptôme accuse le réseau ; la cause est
une seconde d'écart.
Le remède est STRUCTUREL : les exports vivent dans l'attente elle-même,
à côté de la relecture des variables que les autres familles y font déjà.
Trois commandes distantes sont bâties à des endroits différents, et
aucune ne peut plus les oublier ni les mettre trop tôt.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
def test_the_exports_live_inside_the_wait(self):
attente = self._todo()._qemu_cloud_init_wait()
self.assertIn("ca-bundle.crt", attente)
def test_they_come_after_the_unit_that_writes_the_bundle(self):
"""« cloud-init status --wait » rend la main trop tôt : c'est
l'attente de l'unité finale qui garantit le fichier écrit."""
attente = self._todo()._qemu_cloud_init_wait()
self.assertLess(
attente.index("cloud-final"), attente.index("ca-bundle.crt")
)
def test_every_remote_command_carries_them_exactly_once(self):
"""Les trois points de sortie — bureau seul, bureau et outils,
installation complète — passent tous par l'attente."""
todo = self._todo()
for cas in (
{"branch": None, "desktop": False},
{"branch": None, "desktop": True},
{"branch": "master", "desktop": False},
):
with self.subTest(**cas):
cmd = todo._qemu_erplibre_remote_cmd(**cas)
self.assertEqual(
cmd.count("if [ -r /var/lib/erplibre/ca-bundle.crt ]"),
1,
"les exports doivent y être, et une seule fois",
)
def test_no_caller_puts_them_back_in_front(self):
"""Un appelant qui les rajouterait en tête ramènerait le défaut sans
que rien d'autre ne change."""
source = (RACINE / "script/todo/qemu_deploy.py").read_text(
encoding="utf-8"
)
self.assertNotIn("_qemu_ca_exports()", source)
class LeServiceAttendQuOdooSoitLa(unittest.TestCase):
"""L'unité est déclarée par le module, donc démarrée par la
reconstruction — laquelle a lieu PENDANT « make install_os ».
La source d'Odoo, elle, n'arrive qu'à « make install_odoo_18 ». Entre les
deux, run.sh échoue sur un odoo-bin absent et « Restart = always » le
rejoue toutes les cinq secondes : vingt et un échecs mesurés sur une pose
ordinaire, et un « nixos-rebuild » qui rend 4 au lieu de 0 parce qu'une
unité n'a pas démarré. Le journal d'une machine neuve s'ouvre alors sur
une avalanche qui n'accuse rien de réel.
Une CONDITION plutôt qu'une dépendance : mesuré sur une VM, systemd saute
l'unité en le disant une fois — « was skipped because of an unmet
condition check » — sans la marquer en échec, puis la démarre d'elle-même
une fois le fichier là.
"""
def setUp(self):
self.src = MODULE.read_text(encoding="utf-8")
def test_the_unit_waits_for_what_run_sh_executes(self):
self.assertIn(
'unitConfig.ConditionPathExistsGlob = "@EL_DIR@/odoo*/odoo/'
'odoo-bin";',
self.src,
)
def test_the_odoo_version_is_not_frozen_in_the_module(self):
"""Une version écrite ici vieillirait en silence : l'unité cesserait
de démarrer le jour où le dépôt passe à la suivante."""
condition = [l for l in self.src.splitlines() if "ConditionPath" in l]
self.assertEqual(len(condition), 1)
self.assertNotIn("odoo18", condition[0])
def test_it_is_a_condition_and_not_a_dependency(self):
"""« requires » sur un chemin n'existe pas, et « after » ne
garantirait rien : seule une condition SAUTE au lieu d'échouer."""
self.assertNotIn('odoo-bin" ]', self.src)
self.assertIn("ConditionPathExistsGlob", self.src)
def test_the_marker_is_substituted_everywhere(self):
"""Le chemin du dépôt apparaît désormais deux fois dans le module ;
un sed sans « g » n'en remplacerait qu'une, et la condition porterait
un « @EL_DIR@ » littéral que rien ne satisfait jamais."""
script = SCRIPT.read_text(encoding="utf-8")
self.assertIn("s#@EL_DIR@#${EL_DIR}#g", script)
self.assertGreater(self.src.count("@EL_DIR@"), 1)
if __name__ == "__main__":
unittest.main()

View file

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

View file

@ -0,0 +1,183 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le cloud-init des VM créées SUR un hôte Proxmox.
Deux corrections que seule une VM réelle pouvait dicter, mesurées sur un
Proxmox 9 :
**Le user-data est celui du DÉPÔT**, écrit comme extrait et donné par
« --cicustom user= ». « --ciuser » et « --sshkeys » s'en remettent au compte
par DÉFAUT de l'image : celle de NixOS en déclare un autre — « nixos » — et
les ignorait. La VM démarrait avec ce compte-là, sans la clé, donc
injoignable. Le user-data du dépôt porte un bloc « users: » explicite, avec le
nom, le shell et le sudo — le même que le chemin qemu envoie, et déjà éprouvé.
**Le lecteur cloud-init est sur le bus SCSI**, contrairement à ce que la
documentation de Proxmox montre. Une image bâtie pour virtio seul n'a pas de
pilote ATA et ne VOIT pas un lecteur IDE : /sys/block sans « sr0 »,
/dev/disk/by-label sans « cidata », et cloud-init qui passe aux sources
réseau faute de trouver la locale. Le même lecteur en scsi1 apparaît.
Éprouvé de bout en bout, séquence régénérée sans retouche : NixOS rend
« erplibre@… shell=/bin/sh sudo-ok », Debian 13 « shell=/bin/bash sudo-ok ».
"""
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
from script.proxmox import proxmox_deploy as pve # noqa: E402
SPEC = {
"name": "essai",
"storage": "local",
"bridge": "vmbr0",
"memory": 2048,
"vcpus": 2,
"disk": "20G",
"image": "image.qcow2",
"user": "erplibre",
"start": True,
"ipconfig": "ip=dhcp",
}
UD = "#cloud-config\nhostname: essai\nusers:\n - name: erplibre\n"
class LeLecteurCloudInit(unittest.TestCase):
def test_it_sits_on_the_scsi_bus(self):
"""Une image bâtie pour virtio seul ne voit pas un lecteur IDE, et
cloud-init passe alors aux sources réseau : la VM démarre sans compte
ni clé. Le contrôleur virtio-scsi est déjà là pour le disque."""
joint = " ".join(pve.create_cmds(100, SPEC))
self.assertIn("--scsi1 local:cloudinit", joint)
self.assertNotIn("--ide2", joint)
def test_the_boot_order_still_names_the_system_disk(self):
"""Le lecteur cloud-init n'est pas amorçable : sans « boot order »,
Proxmox laisse le disque importé hors de la liste."""
ligne = [c for c in pve.create_cmds(100, SPEC) if "--scsi1" in c][0]
self.assertIn("--boot order=scsi0", ligne)
self.assertIn("--bootdisk scsi0", ligne)
class LNomDeLExtrait(unittest.TestCase):
def test_it_is_carried_by_the_vmid(self):
"""Deux VM peuvent porter le même NOM sur deux nœuds, jamais le même
VMID sur un cluster. Un extrait écrasé par un homonyme donnerait à
une VM le compte d'une autre."""
self.assertEqual("erplibre-9030.yml", pve.snippet_name(9030))
self.assertNotEqual(pve.snippet_name(100), pve.snippet_name(101))
def test_it_ends_in_yml(self):
"""Proxmox lit l'extrait comme du YAML ; l'extension est ce qui le
distingue d'un hook ou d'un fichier de sauvegarde dans le dossier."""
self.assertTrue(pve.snippet_name(1).endswith(".yml"))
class LEcritureDeLExtrait(unittest.TestCase):
def test_the_path_is_asked_of_proxmox(self):
"""Un stockage « dir » range ses extraits sous son propre répertoire :
supposer /var/lib/vz marcherait pour « local » et pour lui seul."""
cmd = pve.snippet_write_cmd("autre", "x.yml", "a: b\n")
self.assertIn("pvesm path", cmd)
self.assertIn("autre:snippets/x.yml", cmd)
def test_the_directory_is_created(self):
"""Un stockage neuf n'a pas encore son dossier d'extraits."""
self.assertIn("mkdir -p", pve.snippet_write_cmd("local", "x", "y"))
def test_printf_and_not_echo(self):
"""echo interprète les séquences d'échappement sur certains shells :
un « \\n » dans un mot de passe haché serait corrompu en silence."""
cmd = pve.snippet_write_cmd("local", "x", "a: b\n")
self.assertIn("printf '%s'", cmd)
self.assertNotIn("echo ", cmd)
def test_the_content_is_quoted_whole(self):
"""Un YAML porte des deux-points, des espaces et des retours à la
ligne : non cité, il se disperse en arguments."""
cmd = pve.snippet_write_cmd("local", "x", "a: b\nc: 'd'\n")
self.assertIn("a: b", cmd)
self.assertIn("c: ", cmd)
class LaSequenceAvecUserData(unittest.TestCase):
def _cmds(self):
return pve.create_cmds(100, dict(SPEC, user_data=UD))
def test_the_repository_user_data_is_given_to_the_vm(self):
joint = " ".join(self._cmds())
self.assertIn("--cicustom user=local:snippets/erplibre-100.yml", joint)
def test_the_default_user_keys_are_dropped(self):
"""Les laisser ferait deux autorités sur le même compte."""
joint = " ".join(self._cmds())
self.assertNotIn("--ciuser", joint)
self.assertNotIn("--sshkeys", joint)
def test_the_network_stays_with_proxmox(self):
"""« --cicustom user= » ne remplace que la moitié utilisateur : le
réseau vient toujours d'ipconfig0, et sans lui la VM est muette."""
self.assertIn("--ipconfig0 ip=dhcp", " ".join(self._cmds()))
def test_the_snippet_is_written_before_it_is_referenced(self):
"""Proxmox lit l'extrait quand il fabrique l'ISO : référencer un
fichier absent laisse la VM sans user-data du tout."""
cmds = self._cmds()
i_ecrit = next(i for i, c in enumerate(cmds) if "pvesm path" in c)
i_ref = next(i for i, c in enumerate(cmds) if "--cicustom" in c)
self.assertLess(i_ecrit, i_ref)
def test_everything_comes_before_the_start(self):
cmds = self._cmds()
i_start = next(i for i, c in enumerate(cmds) if c == "qm start 100")
for marque in ("pvesm path", "--cicustom"):
with self.subTest(marque=marque):
i = next(i for i, c in enumerate(cmds) if marque in c)
self.assertLess(i, i_start)
class LaFormeDAvantEstGardee(unittest.TestCase):
"""Sans user-data, la séquence reste celle d'avant : `create_cmds` sert
aussi aux tests longs, qui n'en fabriquent pas."""
def test_the_old_keys_come_back(self):
cmds = pve.create_cmds(100, dict(SPEC, sshkey_path="/root/k.pub"))
ci = [c for c in cmds if "--ciuser" in c][0]
self.assertIn("--ciuser erplibre", ci)
self.assertIn("--sshkeys /root/k.pub", ci)
self.assertNotIn("--cicustom", " ".join(cmds))
def test_no_snippet_is_written(self):
joint = " ".join(pve.create_cmds(100, SPEC))
self.assertNotIn("pvesm path", joint)
class LeMenuFabriqueLeUserData(unittest.TestCase):
SRC = (RACINE / "script/todo/proxmox_menu.py").read_text(encoding="utf-8")
def test_it_comes_from_the_repository_builder(self):
"""Un user-data écrit à la main dans le menu divergerait de celui du
chemin qemu, et c'est celui-là qui est éprouvé."""
self.assertIn("mod.build_cloud_config(", self.SRC)
self.assertIn("def _pve_user_data", self.SRC)
def test_both_call_sites_use_it(self):
self.assertEqual(2, self.SRC.count('"user_data"'))
def test_an_unreadable_key_falls_back_instead_of_locking_out(self):
"""Un user-data sans clé donnerait une VM sans aucun moyen d'entrer :
mieux vaut la forme d'avant, qui pose au moins un compte."""
i = self.SRC.index("def _pve_user_data")
corps = self.SRC[i : i + 1600]
self.assertIn("SSH key unreadable:", corps)
self.assertIn('return ""', corps)
if __name__ == "__main__":
unittest.main()

View file

@ -403,6 +403,62 @@ class TestLesCommandes(unittest.TestCase):
self.assertIn("if [ -s", cmd)
self.assertIn("wget", cmd)
def test_a_substituted_image_names_what_to_do_first(self):
"""Cet hôte peut être une VM derrière le cache de téléchargement :
effacer l'image la fera resservir à l'identique depuis le magasin.
L'entrée s'en retire d'abord, et l'échec le dit — c'est là que
l'opérateur se tient quand la somme lâche."""
cmd = pve.image_fetch_cmd(
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
)
self.assertIn("--oublie", cmd)
self.assertIn("rm -f", cmd)
# Vers l'erreur standard : la sortie normale est lue par la machine.
self.assertIn(">&2", cmd)
def test_the_remedy_names_the_url_it_forgets(self):
"""Le message dit de lancer cette commande EN PREMIER, et une
commande qu'on ne peut pas recopier telle quelle vaut moins que pas
de commande.
« printf %s » sans opérande n'écrit rien ; « --oublie » lit alors un
flux vide, n'efface rien et sort à 0. L'opérateur croit avoir purgé,
efface l'image, relance — et le magasin ressert les mêmes octets,
indéfiniment. Le succès silencieux est le pire des deux."""
url = "https://exemple.invalide/img/deb.qcow2"
cmd = pve.image_fetch_cmd(url, "deb.qcow2", sha256="ab" * 32)
self.assertIn(url, cmd)
oubli = cmd[cmd.index("printf") : cmd.index("--oublie")]
self.assertIn(url, oubli)
self.assertIn("GET ", oubli)
def test_the_remedy_survives_a_real_shell(self):
"""Joué pour de vrai : c'est la LIGNE RENDUE qui compte, pas la
chaîne Python. Elle traverse deux quotages — celui de l'aide, puis
celui du « echo » qui la porte — et une interpolation tombée ne se
voit qu'ici."""
import subprocess
url = "https://exemple.invalide/img/deb.qcow2"
cmd = pve.image_fetch_cmd(url, "deb.qcow2", sha256="ab" * 32)
aide = cmd[cmd.index("echo 'rm -f") + len("echo ") :]
aide = aide[: aide.index(" >&2")]
rendu = subprocess.run(
["sh", "-c", f"echo {aide}"], capture_output=True, text=True
)
self.assertEqual(rendu.returncode, 0, rendu.stderr)
self.assertIn(f"'GET {url}'", rendu.stdout)
# Le tube est complet : un « printf » nu ne serait pas recopiable.
self.assertIn("| sudo erplibre_go_qemu_cache --oublie", rendu.stdout)
def test_the_failure_is_still_a_failure(self):
"""Un message n'absout pas : « false » garde le code de retour, sans
quoi le déploiement continuerait sur une image substituée."""
cmd = pve.image_fetch_cmd(
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
)
self.assertIn("false;", cmd)
def test_the_internal_bridge_never_touches_a_physical_nic(self):
"""Le point le plus important de ce module : ajouter l'interface au
pont déplace l'adresse de l'hôte et coupe la session SSH — à distance,

View file

@ -1216,6 +1216,33 @@ class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
self.assertIn("archive|security", vu["cmd"])
self.assertNotIn("second.invalid", vu["cmd"])
def test_the_mirror_decision_reaches_the_log(self):
"""La console défile ; le journal est ce qu'on rouvre le lendemain.
C'est en le rouvrant qu'on cherche quel miroir a été posé, le jour où
l'installation échoue plus bas sur des dépendances introuvables — un
message qui accuse le dépôt, jamais le miroir. Dite à l'écran seule,
la décision manquait à l'endroit exact où on la cherche.
"""
for code, marque, porte in (
(0, "✓", "miroir.invalid"),
(7, "⚠", "(7)"),
):
with self.subTest(code=code):
vm = {"distro": "ubuntu", "arch": "amd64"}
self._poser(vm, code=code)
notes = vm.get("notes") or []
self.assertEqual(len(notes), 1, notes)
self.assertTrue(notes[0].startswith(marque), notes[0])
self.assertIn(porte, notes[0])
def test_the_pinned_mirror_is_named_in_the_log(self):
"""« posé » sans dire lequel n'apprend rien : c'est le NOM qu'on
vient chercher."""
vm = {"distro": "ubuntu", "arch": "amd64"}
self._poser(vm)
self.assertIn("miroir.invalid/ubuntu", (vm["notes"] or [""])[0])
def test_les_deux_formats_de_sources_sont_couverts(self):
"""Le « .sources » deb822 des images récentes, et le
« sources.list » des anciennes : n'en réécrire qu'un laisse l'autre
@ -2552,5 +2579,232 @@ class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase):
self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False])
class TestUnInviteQueLAutoriteNAtteintPas(unittest.TestCase):
"""Une distribution dont le magasin de confiance n'a pas de forme par
fichier ne reçoit rien à poser.
On n'arrive ici que lorsque l'hôte Proxmox est lui-même une VM de ce pont
— c'est la condition de _pve_cache_ca. Son invité est donc détourné par
le cache, et sans autorité chaque téléchargement HTTPS échoue sur
« self-signed certificate in certificate chain ». Le taire déplace la
panne dans la VM, des minutes plus tard et sans sa cause.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
class _Sans:
@staticmethod
def cache_files(args):
return []
class _Avec:
@staticmethod
def cache_files(args):
return [
("/usr/local/share/ca-certificates/x.crt", "0644", "PEM", "")
]
@staticmethod
def cache_commands(args):
return ["update-ca-certificates"]
def _poser(self, mod, distro):
import contextlib
import io
todo = self._todo()
vu = {"ssh": []}
todo._qemu_import_module = lambda: mod
todo._pve_ssh = lambda cible, cmd, timeout=120: (
vu["ssh"].append(cmd) or (0, "")
)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
vu["rendu"] = todo._pve_set_cache_ca(
"pve+vm-a", {"distro": distro}, "/var/lib/cache/ca.crt"
)
vu["ecrit"] = sortie.getvalue()
return vu
def test_la_distribution_sans_magasin_est_nommee(self):
vu = self._poser(self._Sans, "nixos")
self.assertFalse(vu["rendu"])
self.assertEqual([], vu["ssh"], "ssh lancé pour rien")
self.assertIn("⚠", vu["ecrit"])
self.assertIn("nixos", vu["ecrit"])
def test_celle_qui_en_a_un_le_pose_sans_avertir(self):
"""L'avertissement ne doit pas se banaliser : le cas ordinaire sort
un « ✓ » et rien d'autre."""
vu = self._poser(self._Avec, "debian")
self.assertTrue(vu["rendu"])
self.assertEqual(1, len(vu["ssh"]))
self.assertIn("update-ca-certificates", vu["ssh"][0])
self.assertNotIn("⚠", vu["ecrit"])
class UnInviteImbriqueSortMasqueDerriereSonHote(unittest.TestCase):
"""Une distribution sans magasin de confiance par fichier ne peut RIEN
recevoir, et le poser par déclaration arriverait trop tard : sur un
système déclaratif, la première reconstruction EST le premier
téléchargement.
Le remède est de ne pas intercepter. Mais l'invité imbriqué sort MASQUÉ
derrière son hôte : sur le pont d'ici, le cache ne voit que la MAC de
l'hôte Proxmox, et c'est donc elle qu'il faut excepter.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
class _Mod:
CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache"
@staticmethod
def cache_sans_autorite(distro):
return distro == "nixos"
def _poser(self, distro, domaines=("pve-local",), mac="52:54:00:ab:cd:ef"):
import contextlib
import io
todo = self._todo()
vu = {"cmd": None}
todo._qemu_import_module = lambda: self._Mod
todo._qemu_list_domains = lambda: list(domaines)
todo._qemu_domain_mac = lambda nom: mac
class Fini:
returncode = 0
stdout = ""
stderr = ""
def faux_run(cmd, **kw):
vu["cmd"] = cmd
return Fini()
import subprocess as sp
vrai = sp.run
self.addCleanup(setattr, sp, "run", vrai)
sp.run = faux_run
with contextlib.redirect_stdout(io.StringIO()) as sortie:
vu["rendu"] = todo._pve_cache_bypass_hote(
{"target": "pve-local"}, {"distro": distro}
)
vu["ecrit"] = sortie.getvalue()
return vu
def test_a_guest_with_a_trust_store_is_left_alone(self):
"""L'autorité suffit pour lui, et excepter son hôte lui retirerait le
cache sans rien lui rendre."""
vu = self._poser("debian")
self.assertFalse(vu["rendu"])
self.assertIsNone(vu["cmd"], "une commande lancée pour rien")
def test_a_module_that_will_not_load_does_not_stop_the_creation(self):
"""Sans le module on ne sait pas si l'invité a un magasin de
confiance : l'autorité reste la voie par défaut, et la suite de la
création continue."""
import contextlib
import io
todo = self._todo()
todo._qemu_import_module = lambda: None
with contextlib.redirect_stdout(io.StringIO()):
self.assertFalse(
todo._pve_cache_bypass_hote(
{"target": "pve-local"}, {"distro": "nixos"}
)
)
def test_a_remote_host_never_crosses_this_bridge(self):
"""Un hôte Proxmox qui ne vit pas ici n'est pas intercepté : rien à
excepter, et sa MAC ne nous appartient pas."""
vu = self._poser("nixos", domaines=())
self.assertFalse(vu["rendu"])
self.assertIsNone(vu["cmd"])
def test_the_host_mac_is_the_one_excepted(self):
"""Celle de l'invité n'apparaît jamais sur ce pont : il est masqué."""
vu = self._poser("nixos")
self.assertTrue(vu["rendu"])
joint = " ".join(vu["cmd"])
self.assertIn("--bypass-add 52:54:00:ab:cd:ef", joint)
self.assertIn("--bypass-name pve-local", joint)
def test_the_decision_is_kept_for_the_log(self):
"""Dit à l'écran ET gardé : la console défile, le journal reste."""
import contextlib
import io
todo = self._todo()
vm = {"distro": "x"}
with contextlib.redirect_stdout(io.StringIO()) as sortie:
todo._pve_note(vm, " ✓ une décision")
self.assertIn("une décision", sortie.getvalue())
self.assertEqual(["✓ une décision"], vm["notes"])
def test_the_menu_hands_them_to_the_installer(self):
"""Gardées et non transmises, elles ne serviraient à personne."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
src = (racine / "script/todo/proxmox_menu.py").read_text(
encoding="utf-8"
)
self.assertIn("notes={", src)
dep = (racine / "script/todo/qemu_deploy.py").read_text(
encoding="utf-8"
)
self.assertIn('entry["notes"] = list(notes[name])', dep)
def test_offline_never_exempts(self):
"""L'amont du cache est alors coupé et le magasin est la SEULE
source : excepter l'hôte ne le ferait pas télécharger en direct, cela
le priverait de tout — ses propres paquets compris. Les deux issues
échouent pour l'invité, mais celle-ci emporte l'hôte avec lui."""
import contextlib
import io
import subprocess as sp
todo = self._todo()
todo._qemu_import_module = lambda: self._Mod
todo._qemu_list_domains = lambda: ["pve-local"]
todo._qemu_domain_mac = lambda nom: "52:54:00:ab:cd:ef"
lance = []
vrai = sp.run
self.addCleanup(setattr, sp, "run", vrai)
sp.run = lambda c, **k: lance.append(c)
with contextlib.redirect_stdout(io.StringIO()):
rendu = todo._pve_cache_bypass_hote(
{"target": "pve-local"}, {"distro": "nixos"}, hors_ligne=True
)
self.assertFalse(rendu)
self.assertEqual([], lance, "une exception posée hors ligne")
def test_the_price_is_said(self):
"""L'exception vaut pour TOUT ce que l'hôte relaie, ses propres
téléchargements compris. Le taire ferait chercher plus tard pourquoi
le cache ne sert plus cet hôte."""
vu = self._poser("nixos")
self.assertIn("pve-local", vu["ecrit"])
self.assertIn("✓", vu["ecrit"])
self.assertTrue(
len(vu["ecrit"].strip().splitlines()) >= 2,
f"le prix n'est pas dit : {vu['ecrit']!r}",
)
if __name__ == "__main__":
unittest.main(verbosity=2)

160
test/test_proxmox_uefi.py Normal file
View file

@ -0,0 +1,160 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'amorçage UEFI des VM créées SUR un hôte Proxmox.
Les deux chemins de déploiement ne partent pas du même firmware : celui de
qemu amorce en UEFI pour tout le monde, celui de Proxmox part en SeaBIOS.
Tant que toutes les images savaient faire les deux, la divergence ne se
voyait pas.
Mesuré sur un Proxmox 9 : une VM NixOS créée en SeaBIOS se déclare
« running » et sa console reste MUETTE ; la même en OVMF démarre — systemd,
cloud-init, réseau, invite de connexion. Debian 13, sur le même hôte, démarre
en SeaBIOS sans rien devoir à l'UEFI.
D'où ce que ces tests gardent : un marqueur PAR DISTRIBUTION, et non un
défaut renversé pour tous. Renverser le défaut aurait ajouté un disque EFI à
chaque VM du parc pour le besoin d'une seule image.
"""
import importlib.util
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
from script.proxmox import proxmox_deploy as pve # noqa: E402
def _deploy_qemu():
chemin = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
SPEC = {
"name": "essai",
"storage": "local",
"bridge": "vmbr0",
"memory": 2048,
"vcpus": 2,
"disk": "40G",
"image": "image.qcow2",
"user": "erplibre",
"start": False,
"ipconfig": "ip=dhcp",
}
class LeCatalogueSaitQuiEnABesoin(unittest.TestCase):
def test_the_image_with_no_bios_sector_is_named(self):
"""NixOS ne publie pas d'image cloud : celle du tiers qui la rebâtit
n'a pas de secteur d'amorçage BIOS."""
self.assertTrue(DQ.requiert_uefi("nixos"))
def test_the_others_are_left_alone(self):
"""Mesuré : Debian 13 démarre en SeaBIOS sur un Proxmox 9. Les
basculer coûterait un disque EFI par VM, pour rien."""
for distro in ("ubuntu", "debian", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertFalse(DQ.requiert_uefi(distro))
def test_an_unknown_distro_does_not_get_uefi(self):
"""Le doute ne vaut pas un firmware : c'est le catalogue qui sait."""
self.assertFalse(DQ.requiert_uefi(""))
self.assertFalse(DQ.requiert_uefi("inconnue"))
class LaSequenceDeCreation(unittest.TestCase):
def _cmds(self, **extra):
return pve.create_cmds(100, dict(SPEC, **extra))
def test_without_the_marker_nothing_changes(self):
"""La VM ordinaire garde exactement la séquence d'avant."""
joint = " ".join(self._cmds())
self.assertNotIn("--bios", joint)
self.assertNotIn("efidisk", joint)
def test_with_the_marker_the_firmware_is_ovmf(self):
creation = self._cmds(uefi=True)[0]
self.assertIn("qm create 100", creation)
self.assertIn("--bios ovmf", creation)
def test_the_efi_disk_is_added(self):
"""« --bios ovmf » sans disque EFI démarre, mais ne retient RIEN :
l'entrée d'amorçage écrite par l'invité est perdue au redémarrage."""
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c]
self.assertEqual(1, len(efi))
self.assertIn("efitype=4m", efi[0])
def test_the_efi_disk_lives_on_the_same_storage(self):
"""Un stockage inventé fait échouer « qm set » après que la VM
existe : elle reste alors à moitié créée."""
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c][0]
self.assertIn(f" {SPEC['storage']}:0,", efi)
def test_secure_boot_is_off(self):
"""Un chargeur non signé par les clés Microsoft est refusé, et
l'image ne démarre pas du tout — la même raison que côté qemu."""
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c][0]
self.assertIn("pre-enrolled-keys=0", efi)
def test_the_efi_disk_comes_before_the_start(self):
"""Ajouter un disque à une VM qui tourne déjà ne l'amorce pas
dessus."""
cmds = self._cmds(uefi=True, start=True)
i_efi = next(i for i, c in enumerate(cmds) if "efidisk0" in c)
i_start = next(
i for i, c in enumerate(cmds) if c.endswith("qm start 100")
)
self.assertLess(i_efi, i_start)
class TousLesConstructeursDeSpecLeDisent(unittest.TestCase):
"""create_cmds lit « uefi » dans le spec, et un spec qui l'omet vaut
SeaBIOS sans rien dire.
Compter les appels dans UN fichier laissait échapper tout constructeur
écrit ailleurs, et il y en avait un. Le garde-fou cherche donc les
appelants plutôt que de les supposer.
"""
def _appelants(self):
trouves = []
for dossier in ("script", "long_test"):
for chemin in sorted((RACINE / dossier).rglob("*.py")):
if chemin.name == "proxmox_deploy.py":
continue
texte = chemin.read_text(encoding="utf-8")
if "create_cmds(" in texte:
trouves.append((chemin, texte))
return trouves
def test_the_callers_are_found_at_all(self):
"""Un test qui ne trouve plus personne passerait en restant vert."""
self.assertTrue(self._appelants())
def test_every_one_of_them_declares_the_firmware(self):
for chemin, texte in self._appelants():
with self.subTest(fichier=chemin.name):
self.assertIn('"uefi"', texte)
def test_none_of_them_decides_it_itself(self):
"""Écrire « nixos » à côté du spec ferait deux autorités sur une même
question, et la seconde vieillirait sans qu'on le sache."""
for chemin, texte in self._appelants():
with self.subTest(fichier=chemin.name):
self.assertNotIn('"uefi": True', texte)
self.assertNotIn('"uefi": "nixos"', texte)
if __name__ == "__main__":
unittest.main()

View file

@ -73,11 +73,23 @@ class UneSeuleAutorite(unittest.TestCase):
class LeCatalogue(unittest.TestCase):
def test_the_tool_exists_and_needs_no_desktop(self):
"""On s'en sert en SSH : une VM serveur le prend aussi."""
"""On s'en sert en SSH : une VM serveur le prend aussi, et aucune
architecture n'est exclue."""
spec = TODO._QEMU_VM_TOOLS["aidev"]
self.assertFalse(spec["needs_desktop"])
self.assertEqual((), spec["arches"])
self.assertEqual((), spec["families"])
def test_it_is_bounded_to_the_imperative_families(self):
"""Trois installateurs « curl | sh » qui posent des binaires liés
dynamiquement, plus des paquets par le gestionnaire du système :
aucun des deux gestes n'existe sur un système déclaratif, où ce qui
est installé à la main ne survit pas à la reconstruction."""
spec = TODO._QEMU_VM_TOOLS["aidev"]
self.assertEqual(("apt", "dnf", "pacman", "zypper"), spec["families"])
self.assertEqual(
[],
TODO._qemu_tools_for(("aidev",), "amd64", "", "nixos"),
)
def test_it_is_posed_before_the_clone(self):
"""« before » est la phase où chaque outil se garde lui-même. En

View file

@ -0,0 +1,127 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'amorçage d'une installation ERPLibre sur NixOS.
Le catalogue offre NixOS et le dépôt sait y déclarer ses dépendances ; entre
les deux manquait le geste qui va du système nu au dépôt cloné. L'amorçage
posait curl, git et make par apt, dnf, pacman, zypper ou yum, et s'arrêtait
sur « Aucun gestionnaire de paquets » — avant le clone, donc avant tout.
Deux nœuds, et chacun se dénoue au même endroit : on ne peut pas être
déclaratif AVANT d'avoir le dépôt qui porte la déclaration.
- git et make sont ABSENTS de l'image NixOS (curl et sudo, eux, y sont). Ils
sont donc posés dans le profil de l'utilisateur pour la durée du clone, et
« make install_os » les redéclare ensuite pour le système entier ;
- « /bin/bash » n'existe pas non plus — le shell vit dans le store. Le
Makefile l'exigeait dès sa première ligne, et make s'arrêtait avant
d'exécuter la moindre recette, y compris celle qui installe de quoi créer
ce chemin.
"""
import subprocess
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
class LAmorcage(unittest.TestCase):
def setUp(self):
self.cmd = TODO.__new__(TODO)._qemu_erplibre_remote_cmd("develop")
def test_a_nixos_vm_is_no_longer_turned_away(self):
"""Le message d'arrêt nomme désormais nix : sans la branche, une VM
NixOS n'atteignait jamais son clone."""
self.assertIn("command -v nix-env", self.cmd)
self.assertIn("(apt/dnf/pacman/zypper/yum/nix)", self.cmd)
def test_the_tools_come_from_nixpkgs_not_from_a_channel(self):
"""L'utilisateur n'a aucun canal sur cette image, là où NIX_PATH est
posé pour tout le monde et pointe les canaux de root."""
self.assertIn("nix-env -f '<nixpkgs>' -iA", self.cmd)
def test_make_is_asked_for_by_its_nixpkgs_name(self):
"""Dans nixpkgs, make s'appelle gnumake : demander « make » ferait
échouer la pose sans que rien ne dise pourquoi."""
i = self.cmd.index("nix-env -f")
pose = self.cmd[i : self.cmd.index(";", i)]
self.assertIn("gnumake", pose)
self.assertNotIn(" make", pose)
def test_python3_is_posed_here_and_only_here(self):
"""Les images des quatre autres familles l'embarquent — cloud-init
est écrit en Python. Sur NixOS il vit dans le store, hors PATH, et la
première recette de « make install_os » s'arrêtait sur « env:
python3: No such file or directory »."""
i = self.cmd.index("nix-env -f")
pose = self.cmd[i : self.cmd.index(";", i)]
self.assertIn("python3", pose)
self.assertNotIn("python3", self.cmd[: self.cmd.index("PKGS=")])
def test_the_path_is_widened_after_the_pose(self):
"""Le PATH du shell distant est figé à son ouverture : sans cette
ligne, le contrôle qui suit déclare git manquant sur une machine où
il vient d'être installé."""
i = self.cmd.index("nix-env -f")
j = self.cmd.index("for t in curl git make")
self.assertIn('PATH="$HOME/.nix-profile/bin:$PATH"', self.cmd[i:j])
def test_the_check_that_follows_is_unchanged(self):
"""Le même contrôle vaut pour les six familles : une erreur nette
plutôt qu'un « command not found » cryptique plus loin."""
self.assertIn("for t in curl git make; do command -v $t", self.cmd)
def test_it_is_valid_shell(self):
fini = subprocess.run(
["bash", "-n"], input=self.cmd, text=True, capture_output=True
)
self.assertEqual(0, fini.returncode, fini.stderr)
class LeShellDuMakefile(unittest.TestCase):
PREMIERES = (RACINE / "Makefile").read_text(encoding="utf-8")[:600]
def test_bash_is_looked_for_rather_than_assumed(self):
"""« /bin/bash » n'existe pas sur NixOS."""
self.assertIn("command -v bash", self.PREMIERES)
self.assertNotIn("SHELL := /bin/bash", self.PREMIERES)
def test_a_host_without_bash_keeps_the_old_path(self):
"""Le repli ne rend pas la panne pire qu'avant sur une plateforme qui
n'a pas bash du tout."""
self.assertIn("|| echo /bin/bash", self.PREMIERES)
def test_the_assignment_stays_immediate(self):
"""« = » différé ferait relancer le sous-shell à chaque recette, et
SHELL est lu à chacune."""
self.assertIn("SHELL := ", self.PREMIERES)
def test_the_resolved_shell_exists_here(self):
"""L'épreuve sur la machine qui lit ce test : make doit pouvoir
exécuter une recette."""
ligne = next(
x
for x in (RACINE / "Makefile")
.read_text(encoding="utf-8")
.splitlines()
if x.startswith("SHELL")
)
fini = subprocess.run(
["make", "-s", "-f", "-", "essai"],
input=f"{ligne}\nessai:\n\techo ok\n",
text=True,
capture_output=True,
cwd=RACINE,
)
self.assertEqual(0, fini.returncode, fini.stderr)
self.assertIn("ok", fini.stdout)
if __name__ == "__main__":
unittest.main()

View file

@ -318,9 +318,9 @@ class TestLesVariablesTraversentSudo(unittest.TestCase):
commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"),
)
self.assertIn("chmod 0440", commande)
self.assertTrue(
commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai")
)
# Le temporaire part toujours, et l'échec se DIT : muet, il se paie
# plus tard sur un « self-signed certificate » que rien ne relie ici.
self.assertIn("|| (rm -f /etc/sudoers.d/.essai; echo ", commande)
def test_le_contenu_ecrit_est_une_ligne_par_variable(self):
"""Joué dans un vrai shell, visudo remplacé : c'est le fichier produit
@ -386,7 +386,16 @@ class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase):
self.assertTrue(ligne.rstrip().endswith("|| true"))
def test_une_vm_en_ligne_garde_sa_synchronisation(self):
self.assertNotIn("bootcmd", self.config())
"""La levée n'a lieu QUE hors ligne.
« Aucun bootcmd » l'a longtemps dit, parce que la levée était le seul.
Le locale en pose un autre depuis — il génère la locale demandée avant
que le module de cloud-init ne l'applique —, et l'absence GLOBALE ne
prouve donc plus rien. C'est la levée nommément qui doit manquer, et
c'est elle que cette classe garde."""
cmd = self.config().get("bootcmd") or []
aplati = " ".join(str(c) for c in cmd)
self.assertNotIn("systemd-time-wait-sync", aplati)
class TestLaLangueDesMessagesDuCache(unittest.TestCase):
@ -464,17 +473,77 @@ class TestAucunSystemeNestOublie(unittest.TestCase):
)
def test_chaque_famille_sait_poser_lautorite(self):
from script.qemu.deploy_qemu import CACHE_TRUST, DISTROS, cache_family
"""Chaque famille du catalogue sait poser l'autorité, OU est
explicitement soustraite au détournement.
manquantes = sorted(
{cache_family(d) for d in DISTROS} - set(CACHE_TRUST)
Le détournement porte sur tout le pont : une famille qui ne sait pas
apprendre l'autorité et qu'on laisse passer n'échoue pas à
l'installation du certificat — elle échoue sur CHAQUE téléchargement,
avec un message qui ne dit rien de la cause.
L'épreuve porte sur le COMPORTEMENT et non sur l'appartenance à une
table : « nix » ne pose pas l'autorité dans un répertoire d'ancres —
il n'en a pas — mais il la pose, par un fragment systemd. Compter les
entrées de CACHE_TRUST l'aurait déclaré manquant alors qu'il est
servi, et déclarerait manquante toute mécanique future.
"""
import tempfile
from script.qemu.deploy_qemu import (
CACHE_SANS_AUTORITE,
DISTROS,
build_parser,
cache_commands,
cache_family,
)
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n")
fh.write("-----END CERTIFICATE-----\n")
ca = fh.name
muettes = []
for distro in DISTROS:
if not cache_family(distro):
continue
if cache_family(distro) in CACHE_SANS_AUTORITE:
continue
args = build_parser().parse_args(
["--distro", distro, "--hostname", "x", "--cache-ca", ca]
)
if not cache_commands(args):
muettes.append(distro)
self.assertEqual(
manquantes,
muettes,
[],
f"familles sans commande de confiance : {manquantes}",
f"systèmes qui ne posent rien : {muettes}",
)
def test_une_famille_exemptee_nest_pas_aussi_dans_la_table(self):
"""Les deux listes s'excluent : une famille qui sait poser l'autorité
n'a rien à faire parmi les exemptées, et l'y laisser soustrairait au
cache une VM qui pouvait parfaitement en vivre."""
from script.qemu.deploy_qemu import CACHE_SANS_AUTORITE, CACHE_TRUST
self.assertEqual(set(), set(CACHE_TRUST) & CACHE_SANS_AUTORITE)
def test_plus_aucun_systeme_du_catalogue_nest_soustrait(self):
"""L'exemption est le dernier recours, et plus personne n'y tombe.
NixOS y était tant qu'on ne savait pas lui donner l'autorité : /etc
est en lecture seule, et une déclaration arriverait après le premier
téléchargement. Il l'a désormais par un fragment systemd, ce qui le
fait ENTRER dans le cache au lieu de l'en soustraire — et sans cela le
hors ligne lui était fermé, le magasin étant alors la seule source.
"""
from script.qemu.deploy_qemu import DISTROS, cache_sans_autorite
for distro in DISTROS:
with self.subTest(distro=distro):
self.assertFalse(cache_sans_autorite(distro))
self.assertFalse(cache_sans_autorite("inconnue"))
def test_la_famille_vient_du_catalogue_et_nest_pas_recopiee(self):
"""Deux tables qui disent la même chose dérivent : c'est ce qui a
laissé Proxmox de côté."""
@ -484,5 +553,208 @@ class TestAucunSystemeNestOublie(unittest.TestCase):
self.assertEqual(cache_family(d), attendue, d)
class LeHorsLigneNeSousTraitPasUneDistributionSansMagasin(unittest.TestCase):
"""Les deux ne se combinent pas, et c'est dit AVANT.
Hors ligne, l'amont est coupé et le magasin est la SEULE source.
Soustraire au cache une distribution sans magasin de certificats ne la
ferait pas télécharger en direct : cela ne lui laisserait RIEN. Et sans
l'exception, chaque téléchargement bute sur un certificat inconnu. Les
deux issues échouent — une heure plus tard, si personne ne le dit.
"""
SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8")
def _bloc(self):
i = self.SRC.index("sans_magasin = cache_sans_autorite(")
return self.SRC[i : i + 1400]
def test_offline_is_tested_before_the_fallback(self):
"""L'ordre EST la correction : l'ancien code posait l'exception sans
jamais regarder si l'amont était coupé."""
bloc = self._bloc()
self.assertLess(
bloc.index('getattr(args, "offline", False)'),
bloc.index("args.cache_bypass = True"),
)
def test_offline_never_exempts(self):
bloc = self._bloc()
avant = bloc[: bloc.index("args.cache_bypass = True")]
self.assertNotIn("args.cache_bypass = True", avant)
self.assertIn("elif sans_magasin", bloc)
def test_the_dead_end_is_named(self):
"""Un refus qui ne dit pas pourquoi renvoie chercher dans la VM."""
bloc = self._bloc()
self.assertIn("Aucune source", bloc)
class NixApprendLAutoriteSansReconstruire(unittest.TestCase):
"""NixOS n'a pas d'ancre de confiance par fichier, et une déclaration
arriverait après le premier téléchargement — la première reconstruction
EST ce téléchargement.
Mesuré sur une VM interceptée : une LECTURE passe par le client nix et se
contente d'une variable de session, mais RÉALISER une dérivation passe
par nix-daemon, qui ne la voit pas. Un fragment systemd l'atteint, et
/run/systemd/system est un tmpfs — inscriptible quand /etc ne l'est pas.
"""
def _args(self, distro="nixos"):
import tempfile
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZXNzYWk=\n")
fh.write("-----END CERTIFICATE-----\n")
return build_parser().parse_args(
["--distro", distro, "--hostname", "x", "--cache-ca", fh.name]
)
def test_the_authority_lands_where_it_can_be_written(self):
"""/etc est généré depuis le store : y déposer échouerait."""
from script.qemu.deploy_qemu import NIX_CA_DIR, cache_files
chemins = [f[0] for f in cache_files(self._args())]
self.assertTrue(chemins)
for c in chemins:
with self.subTest(chemin=c):
self.assertTrue(c.startswith(NIX_CA_DIR))
self.assertFalse(c.startswith("/etc/"))
def test_the_bundle_keeps_the_system_authorities(self):
"""Donner la seule autorité du cache ferait cesser d'approuver tout
le reste : le faisceau est une CONCATÉNATION."""
from script.qemu.deploy_qemu import NIX_CA_SYSTEME, cache_commands
joint = " ".join(cache_commands(self._args()))
self.assertIn(NIX_CA_SYSTEME, joint)
self.assertIn("cat ", joint)
def test_the_daemon_is_reached_not_the_session(self):
"""La session suffit pour LIRE, jamais pour réaliser : c'est le démon
qui télécharge alors, et seul un fragment l'atteint."""
from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands
joint = " ".join(cache_commands(self._args()))
self.assertIn(NIX_DROPIN, joint)
self.assertIn("NIX_SSL_CERT_FILE", joint)
self.assertIn("daemon-reload", joint)
def test_nothing_needs_a_rebuild(self):
"""Tout l'enjeu : la première reconstruction est elle-même le premier
téléchargement."""
joint = " ".join(cache_commands(self._args()))
self.assertNotIn("nixos-rebuild", joint)
def test_the_other_families_are_untouched(self):
"""Elles ont un répertoire d'ancres et une commande qui le relit."""
from script.qemu.deploy_qemu import NIX_DROPIN, cache_commands
for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
joint = " ".join(cache_commands(self._args(distro)))
self.assertNotIn(NIX_DROPIN, joint)
self.assertIn("/etc/environment", joint)
class LesGestesSurvivententAuTransport(unittest.TestCase):
"""Les commandes du cache voyagent par deux transports, et chacun a ses
caractères mortels.
Le premier est le « runcmd » de cloud-init, un scalaire simple de YAML :
un « : » suivi d'une espace, une accolade ou un crochet en tête y font
lire autre chose qu'une commande. Le second est « sh -c '…' », que la
moindre apostrophe referme — le shell distant meurt alors sur
« unexpected EOF », loin de la ligne fautive.
Aucun des deux ne se voit à la lecture du Python : la chaîne y est
correcte, et c'est sa TRAVERSÉE qui échoue. D'où ces épreuves, une par
transport, sur toutes les familles à la fois.
"""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def _familles(self):
"""Une distribution par famille, nix compris — il est le seul dont
les gestes ne viennent pas de CACHE_TRUST."""
return ("debian", "ubuntu", "fedora", "arch", "opensuse", "nixos")
def test_every_runcmd_line_survives_a_yaml_parse(self):
for distro in self._familles():
with self.subTest(distro=distro):
lignes = cache_runcmd(faux_args(Path(self.tmp.name), distro))
charge = yaml.safe_load("runcmd:\n" + "\n".join(lignes))
self.assertEqual(
charge["runcmd"],
[l.removeprefix(" - ") for l in lignes],
"le YAML ne rend pas la commande telle qu'elle est écrite",
)
def test_every_command_survives_a_real_shell(self):
"""« sh -n » lit la commande sans rien exécuter : une quote non
refermée s'y voit, un rm -rf ne s'y joue pas."""
import subprocess
for distro in self._familles():
for commande in cache_commands(
faux_args(Path(self.tmp.name), distro)
):
with self.subTest(distro=distro, debut=commande[:40]):
res = subprocess.run(
["sh", "-n", "-c", commande],
capture_output=True,
text=True,
)
self.assertEqual(res.returncode, 0, res.stderr)
class LeSudoersAtteintVisudoLaOuIlEst(unittest.TestCase):
"""Un système déclaratif ne met pas sudo dans le PATH de cloud-init.
Mesuré sur une VM : le « runcmd » hérite du PATH du service de
cloud-init, vingt-six chemins du magasin dont AUCUN ne porte sudo, et
/usr/bin/visudo n'existe pas non plus. La vérification échouait donc,
le garde-fou effaçait le fichier au lieu de le poser, et sudo continuait
de vider l'environnement — l'installation ne rendait plus qu'une
répétition de refus de certificat.
Le profil du système est le chemin que ces distributions garantissent ;
les quatre familles impératives ne l'ont pas, et l'ajout n'y change rien.
"""
PROFIL = "/run/current-system/sw/bin"
def test_the_system_profile_is_on_the_path(self):
self.assertIn(self.PROFIL, commande_sudoers(["A"]))
def test_the_path_is_extended_not_replaced(self):
"""Remplacer le PATH perdrait echo, chmod et mv, qui viennent du
magasin eux aussi et ne sont nulle part ailleurs."""
self.assertIn(f"PATH=$PATH:{self.PROFIL}", commande_sudoers(["A"]))
def test_a_failure_is_announced(self):
"""Muet, l'échec se paie plus tard sur un « self-signed certificate »
que rien ne relie à ce geste."""
self.assertIn("echo ", commande_sudoers(["A"]).split("||")[-1])
def test_the_message_carries_none_of_the_deadly_characters(self):
"""L'apostrophe referme la commande, et les trois autres font lire au
YAML autre chose qu'un scalaire simple."""
from script.qemu.deploy_qemu import t_sudoers_manque
message = t_sudoers_manque()
self.assertTrue(message)
for interdit in ("'", ": ", "{", "}", "[", "]"):
with self.subTest(interdit=interdit):
self.assertNotIn(interdit, message)
if __name__ == "__main__":
unittest.main()

View file

@ -256,7 +256,9 @@ class TestSousMenusDuCache(unittest.TestCase):
self.verifier("_cache_nettoyage_auto", "_cache_nettoyage_etat", 4)
def test_le_menu_de_lage(self):
self.verifier("_cache_age", "_cache_lancer", 5)
"""Six depuis qu'on peut oublier UNE entrée : les cinq autres
montrent l'âge ou effacent en gros, celle-là vise une URL."""
self.verifier("_cache_age", "_cache_lancer", 6)
def test_letat_du_service_est_la_troisieme(self):
"""Sous le diagnostic, comme demandé : le décalage du guide et des
@ -1124,5 +1126,51 @@ class TestLesReglagesDuNettoyage(unittest.TestCase):
faux.execute.exec_command_live.assert_not_called()
class OublierUneUrlDepuisLeMenu(unittest.TestCase):
"""Le seul geste qui vise UNE entrée.
« Effacer ce qui n'a plus servi » ne l'atteint jamais — le service
rajeunit un objet chaque fois qu'il le rend — et « tout effacer » coûte
le cache entier pour un fichier. Le cas qui l'appelle est une somme qui
ne correspond pas : le magasin sert alors les mêmes octets, et
retélécharger ne change rien puisque c'est lui qui répond.
"""
def _src(self):
import inspect
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return inspect.getsource(TODO._cache_oublier_url)
def test_the_preview_comes_first(self):
"""« --detient » répond à la même ligne, par la même clé, sans rien
modifier : un « absent » dit que l'URL n'est pas celle qu'on croit,
avant d'avoir effacé quoi que ce soit."""
src = self._src()
self.assertLess(src.index("--detient"), src.index("--oublie"))
def test_nothing_is_erased_without_a_yes(self):
src = self._src()
self.assertLess(src.index("confirm"), src.index("--oublie"))
def test_the_url_is_quoted(self):
"""Une URL signée porte des « & » et des « ? » : non échappée, le
shell y verrait des opérateurs."""
self.assertIn("shlex.quote(url)", self._src())
def test_the_entry_is_offered_in_the_cleanup_menu(self):
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
menu = (racine / "script/todo/qemu_cache_menu.py").read_text(
encoding="utf-8"
)
self.assertIn('t("Clean - Forget one URL")', menu)
self.assertIn("self._cache_oublier_url()", menu)
if __name__ == "__main__":
unittest.main()

View file

@ -28,9 +28,11 @@ RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu.deploy_qemu import ( # noqa: E402
FIRMWARE_IMPOSE,
amorcage_bios,
canonical_timezone,
hostname_valide,
requiert_uefi,
)
@ -145,6 +147,39 @@ class TestAmorcage(unittest.TestCase):
def test_une_distribution_inconnue_garde_le_defaut(self):
self.assertFalse(amorcage_bios("inconnue", False))
def test_la_demande_ne_peut_pas_inventer_un_secteur_damorcage(self):
"""La seule limite de « la demande l'emporte » : une image sans
secteur d'amorçage BIOS n'en gagne pas un parce qu'on l'a tapé. Forcée,
elle se déclare « running » avec une console muette — soit la panne
que ce drapeau est censé éviter ailleurs."""
self.assertFalse(amorcage_bios("nixos", True))
self.assertFalse(amorcage_bios("nixos", False))
class TestUneSeuleTableDeFirmware(unittest.TestCase):
"""Les deux voies de déploiement partent de défauts OPPOSÉS — libvirt en
UEFI, Proxmox en SeaBIOS — et lisent la MÊME table. Deux tables se
contrediraient sans que rien ne le dise, chacune n'étant lue que d'un
côté."""
def test_les_deux_lectures_viennent_de_la_table(self):
for distro, impose in FIRMWARE_IMPOSE.items():
with self.subTest(distro=distro):
self.assertEqual(impose == "uefi", requiert_uefi(distro))
self.assertEqual(
impose == "bios", amorcage_bios(distro, False)
)
def test_la_table_ne_connait_que_deux_valeurs(self):
"""Une troisième valeur serait lue comme « rien d'imposé » par les
deux lectures, en silence."""
self.assertLessEqual(set(FIRMWARE_IMPOSE.values()), {"bios", "uefi"})
def test_une_distribution_hors_table_nimpose_rien(self):
self.assertFalse(requiert_uefi(""))
self.assertFalse(requiert_uefi("inconnue"))
self.assertFalse(amorcage_bios("inconnue", False))
if __name__ == "__main__":
unittest.main()

View file

@ -215,6 +215,28 @@ class TestLeJournal(unittest.TestCase):
self.assertIn(mon.t("VM start-up"), head)
self.assertNotIn("Branche", head)
def test_what_the_host_decided_reaches_the_log(self):
"""La console qui a dit la décision a défilé ; le journal est ce
qu'on rouvre quand l'installation a échoué. Sans ces lignes il ne
porte que le symptôme — des centaines de lignes de construction et
un certificat refusé, sans un mot sur ce qui l'a voulu."""
vm = dict(
self._vm(),
notes=[
"⚠ pas de magasin de confiance : nixos",
"✓ hôte soustrait au cache : pve-local",
],
)
head = mon._log_header(vm, "develop", "2026-01-01 00:00:00")
self.assertIn(mon.t("Prepared by the host:"), head)
self.assertIn("pas de magasin de confiance", head)
self.assertIn("hôte soustrait au cache", head)
def test_a_log_without_notes_is_unchanged(self):
"""Le cas ordinaire ne gagne pas une section vide."""
head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00")
self.assertNotIn(mon.t("Prepared by the host:"), head)
def test_the_prologue_says_what_actually_follows(self):
"""« installation ERPLibre en cours » alors que rien ne s'installe."""
import tempfile

View file

@ -290,9 +290,13 @@ class TestMobileBuild(unittest.TestCase):
def test_it_runs_on_a_server_vm(self):
"""Elle compile, elle n'affiche rien : un bureau serait du gaspillage.
L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X, et
les outils d'assistance encore moins : on s'en sert en SSH."""
les outils d'assistance encore moins : on s'en sert en SSH. nix et
nixos-anywhere non plus : ils installent NixOS sur une AUTRE machine,
depuis une ligne de commande."""
got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
self.assertEqual(["mobile", "forgejo", "aidev", "avd"], got)
self.assertEqual(
["mobile", "forgejo", "aidev", "nixanywhere", "avd"], got
)
# Forgejo est là pour la même raison que la compilation : un
# service ne demande pas d'écran.

View file

@ -46,11 +46,18 @@ class TestTheCheckbox(unittest.TestCase):
got = self.todo._qemu_tools_for(("forgejo",), "amd64", "", "ubuntu")
self.assertIn("forgejo", got)
def test_every_package_family_gets_it(self):
"""Le binaire est statique : aucune famille n'est exclue, à la
différence de la compilation mobile que son installateur borne à apt.
"""
self.assertEqual(self.spec["families"], ())
def test_every_imperative_family_gets_it(self):
"""Le binaire est statique, donc aucune des quatre familles de
gestionnaire n'est exclue — à la différence de la compilation mobile,
que son installateur borne à apt.
Les quatre, et pas « toutes » : l'installateur crée un compte système,
écrit dans /usr/local et pose une unité systemd à la main. Sur un
système déclaratif, ces gestes vivent HORS de la configuration et ne
survivent pas à la reconstruction suivante."""
self.assertEqual(
("apt", "dnf", "pacman", "zypper"), self.spec["families"]
)
for distro in ("ubuntu", "debian", "almalinux", "opensuse", "arch"):
self.assertIn(
"forgejo",
@ -58,6 +65,11 @@ class TestTheCheckbox(unittest.TestCase):
distro,
)
def test_a_declarative_system_is_left_out(self):
self.assertEqual(
[], self.todo._qemu_tools_for(("forgejo",), "amd64", "", "nixos")
)
def test_arm64_yes_s390x_no(self):
"""Forgejo publie amd64, arm64 et arm-6. Sur s390x il faudrait le bâtir
en Go : la case se grise plutôt que de poser un binaire inexécutable.

View file

@ -38,6 +38,7 @@ COMBOS = (
("opensuse", "16.0", "amd64"),
("opensuse", "tumbleweed", "amd64"),
("arch", "latest", "amd64"),
("nixos", "25.11", "amd64"),
)
# Largeur d'un terminal standard. Au-delà, le guide se replie et devient
@ -55,6 +56,7 @@ class TestMotdContent(unittest.TestCase):
"rocky": "dnf",
"opensuse": "zypper",
"arch": "pacman",
"nixos": "nix",
}
for distro, version, arch in COMBOS:
motd = dq.build_motd(distro, version, arch)
@ -403,5 +405,266 @@ class TestDesktopBlock(unittest.TestCase):
self.assertNotIn("Bureau", motd)
class LeGuideNixosSeVoitEtDitVrai(unittest.TestCase):
"""Sur NixOS rien ne lisait /etc/motd — mesuré sur une VM installée :
sshd en « printmotd no » et aucun pam_motd dans son PAM. Le fichier était
écrit, complet, et personne ne le montrait. Le module le fait afficher ;
ces tests gardent ce qu'il montre.
"""
def _motd(self, distro="nixos", **kw):
return dq.build_motd(
distro, "25.11", "amd64", el_dir="~/git/erplibre", **kw
)
def test_the_file_that_gets_rewritten_is_named_as_such(self):
"""Le piège que ce bloc existe pour dire : « make install_os » repose
/etc/nixos/erplibre.nix depuis le dépôt, et ce qu'on y avait ajouté
disparaît sans un mot."""
motd = self._motd()
self.assertIn("/etc/nixos/erplibre.nix", motd)
self.assertIn("make install_os", motd)
def test_where_ones_own_declarations_survive(self):
"""Un guide qui nomme le piège sans nommer l'issue laisse l'opérateur
devant un fichier qu'il n'ose plus toucher."""
self.assertIn("/etc/nixos/configuration.nix", self._motd())
def test_the_source_of_truth_is_the_repository(self):
self.assertIn("conf/nixos/erplibre.nix", self._motd())
def test_the_block_is_translated(self):
for lang, attendu in (("fr", "déclaratif"), ("en", "declarative")):
with self.subTest(lang=lang):
self.assertIn(attendu, self._motd(lang=lang))
def test_the_other_distributions_are_left_alone(self):
"""Rien de ceci ne veut dire quoi que ce soit ailleurs."""
for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertNotIn("/etc/nixos/", self._motd(distro))
def test_a_vm_without_erplibre_says_nothing_of_it(self):
"""Même règle que le bloc AUR : c'est l'installation qui pose le
module dont ces lignes parlent."""
nu = dq.build_motd("nixos", "25.11", "amd64")
self.assertNotIn("conf/nixos/erplibre.nix", nu)
def test_the_module_turns_the_display_on(self):
"""Écrire le guide sans rien pour le lire ne sert personne."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
module = (racine / "conf/nixos/erplibre.nix").read_text(
encoding="utf-8"
)
self.assertIn("services.openssh.settings.PrintMotd = true;", module)
class LeCheminDuDepotEstEntierDansLeGuide(unittest.TestCase):
"""Le guide est lu par quelqu'un qui vient d'entrer en ssh, donc posé
dans son foyer, et qui va TAPER ce qu'il lit.
« conf/nixos/erplibre.nix » était la seule ligne du bloc à n'être ni un
chemin absolu ni une commande : depuis le foyer elle ne désigne rien, et
la racine change avec le profil — ~/git/erplibre en développement,
/opt/erplibre en production. Elle porte donc la racine, comme les autres
blocs le font déjà pour les leurs.
"""
def _lignes(self, el_dir="~/git/erplibre"):
return dq.nixos_rows(el_dir)
def test_the_checkout_path_is_complete(self):
chemins = [c for c, _, _ in self._lignes() if "conf/nixos" in c]
self.assertEqual(len(chemins), 1)
self.assertTrue(chemins[0].startswith("~/git/erplibre/"), chemins[0])
def test_the_root_follows_the_profile(self):
"""Écrire « ~/git/erplibre » en dur enverrait une machine de
production chercher un dépôt qui n'y est pas."""
chemins = [
c for c, _, _ in self._lignes("/opt/erplibre") if "conf/nixos" in c
]
self.assertEqual(chemins, ["/opt/erplibre/conf/nixos/erplibre.nix"])
def test_a_missing_root_falls_back_rather_than_breaking(self):
"""Un marqueur non substitué serait affiché tel quel."""
rendu = "\n".join(c for c, _, _ in self._lignes(""))
self.assertNotIn("{el_dir}", rendu)
self.assertIn("~/git/erplibre/conf/nixos/erplibre.nix", rendu)
def test_no_line_is_a_bare_relative_path(self):
"""La règle que la ligne fautive violait, sur TOUTES les lignes : ce
qui n'est pas une commande est un chemin qu'on peut ouvrir d'où l'on
est."""
for cmd, _, _ in self._lignes():
with self.subTest(cmd=cmd):
if "/" not in cmd or " " in cmd:
continue # une commande, pas un chemin
self.assertTrue(
cmd.startswith(("/", "~/")),
f"{cmd} ne s'ouvre pas depuis le foyer",
)
class LeGuideNixosDitCeQueNixosChange(unittest.TestCase):
"""Ce que le bloc ajoute, et qui ne se devine pas.
Chaque ligne a été confrontée à une VM NixOS 25.11 vivante avant d'être
écrite : « nixos-version » rend bien sa version, « --rollback » est dans
le synopsis de nixos-rebuild, et « ls /bin » rend VIDE sur une machine où
/bin/bash s'exécute.
Une recherche de paquet a été ESSAYÉE puis écartée : « nix-env -qaP »
ne rend rien sur cette image, dont le canal n'est pas peuplé, et
« nix search » se met à tirer un canal entier. Un guide qui envoie taper
une commande muette coûte plus qu'un guide qui se tait.
"""
def _motd(self, **kw):
return dq.build_motd(
"nixos", "25.11", "amd64", el_dir="~/git/erplibre", **kw
)
def test_the_safety_net_of_editing_is_named(self):
"""Une déclaration fautive se défait par une commande ; ailleurs elle
laisse un système à réparer à la main."""
self.assertIn("nixos-rebuild switch --rollback", self._motd())
def test_the_version_is_reachable(self):
self.assertIn("nixos-version", self._motd())
def test_the_trap_of_an_empty_bin_is_told(self):
"""envfs résout un nom sans jamais énumérer : tout ce qui cherche par
motif ne trouve rien, quand le nom exact marche."""
motd = self._motd()
self.assertIn("ls /bin", motd)
self.assertIn("envfs", motd)
def test_it_does_not_repeat_the_package_block(self):
"""nix-shell, « nixos-rebuild switch » nu et nix-collect-garbage sont
déjà dans le bloc du gestionnaire de paquets. Les redire userait la
seule chose que ce bloc a — dire ce qui n'est écrit nulle part."""
commandes = [c for c, _, _ in dq.nixos_rows("~/git/erplibre")]
for deja in (
"nix-shell -p <paquet>",
"sudo nixos-rebuild switch",
"sudo nix-collect-garbage -d",
):
with self.subTest(commande=deja):
self.assertNotIn(deja, commandes)
def test_both_languages_carry_every_line(self):
"""Un bloc à moitié traduit se voit tout de suite, et fait douter du
reste."""
for _, fr, en in dq.nixos_rows("~/git/erplibre"):
with self.subTest(fr=fr):
self.assertTrue(fr.strip())
self.assertTrue(en.strip())
self.assertNotEqual(fr, en)
class LesOutilsPosesSAnnoncent(unittest.TestCase):
"""Le guide ne disait rien des outils installés dans la VM : on entrait
en ssh sans savoir que nix, PyCharm ou la forge étaient là, ni par quelle
commande s'en servir.
Ce qui est écrit doit être VRAI sur la machine : le lecteur va taper ces
lignes, et une commande absente coûte plus qu'un guide muet.
"""
def _motd(self, tools, **kw):
return dq.build_motd(
"ubuntu",
"24.04",
"amd64",
el_dir="~/git/erplibre",
tools=tools,
**kw,
)
def test_only_what_was_installed_is_announced(self):
motd = self._motd(("pycharm",))
self.assertIn("PyCharm", motd)
self.assertNotIn("nixos-anywhere", motd)
self.assertNotIn("Forgejo", motd)
def test_a_vm_without_tools_gains_nothing(self):
"""Le cas ordinaire ne doit pas gagner de bloc vide."""
motd = self._motd(())
for libelle, _lignes in dq.TOOL_GUIDE.values():
with self.subTest(libelle=libelle):
self.assertNotIn(libelle, motd)
def test_the_checkout_root_is_substituted(self):
"""« {el_dir} » laissé tel quel ferait taper une accolade."""
motd = self._motd(("pycharm",), el_make="install_odoo_18")
self.assertIn("pycharm ~/git/erplibre", motd)
self.assertNotIn("{el_dir}", motd)
def test_nix_on_another_distribution_is_not_nixos(self):
"""L'outil pose nix en démon SUR une distribution ordinaire : il n'y a
ni /etc/nixos ni nixos-rebuild, et reprendre les lignes du bloc NixOS
enverrait chercher des commandes qui n'existent pas ici."""
motd = self._motd(("nixanywhere",))
self.assertIn("nix shell nixpkgs#", motd)
self.assertNotIn("nixos-rebuild", motd)
self.assertNotIn("/etc/nixos", motd)
def test_the_deprecated_profile_verb_is_not_taught(self):
"""« nix profile install » est un alias déprécié depuis nix 2.30, et
l'installateur amont sert une version postérieure : le proposer ferait
répondre un avertissement à qui le tape."""
motd = self._motd(("nixanywhere",))
self.assertIn("nix profile add", motd)
self.assertNotIn("nix profile install", motd)
def test_every_key_exists_in_the_catalogue(self):
"""Une clé d'ici que le menu ne sait pas poser annoncerait un outil
qui n'arrivera jamais."""
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
for cle in dq.TOOL_GUIDE:
with self.subTest(cle=cle):
self.assertIn(cle, TODO._QEMU_VM_TOOLS)
def test_the_menu_hands_over_the_filtered_list(self):
"""La liste cochée vaut pour le parc ; celle-ci est filtrée par la
machine. Android Studio n'existe qu'en x86_64 et PyCharm veut un
bureau — les annoncer ailleurs enverrait chercher une commande qui ne
sera jamais posée."""
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
choisis = ("nixanywhere", "pycharm", "android")
def parts(arch, desktop):
p = todo._qemu_build_deploy_parts(
"ubuntu",
"24.04",
arch,
"vm",
4096,
2,
"40G",
None,
"develop",
desktop=desktop,
vm_tools=choisis,
dry_run=True,
)
return p[p.index("--vm-tools") + 1] if "--vm-tools" in p else ""
self.assertEqual("nixanywhere,pycharm,android", parts("amd64", True))
self.assertEqual("nixanywhere", parts("arm64", False))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,198 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Nix et nixos-anywhere posés dans une VM qui n'est pas NixOS.
L'option donne à une VM ordinaire — Debian, Arch, Fedora, openSUSE — le
gestionnaire de paquets de NixOS à côté du sien, et l'installateur qui s'en
sert pour porter NixOS sur une AUTRE machine, jointe par SSH.
Ce que ces tests gardent :
- l'option n'est pas offerte sur NixOS, où nix EST le système : elle n'y
aurait rien à poser ;
- elle n'est offerte que là où l'amont bâtit, amd64 et arm64 ;
- aucune pose ne peut PENDRE : « || true » couvre l'échec, pas l'attente
d'une réponse que personne ne donnera sur un SSH sans terminal ;
- nix est appelé par son CHEMIN ABSOLU — le PATH du shell distant a été figé
avant que l'installateur ne pose quoi que ce soit ;
- les fonctions expérimentales sont redonnées sur la ligne de commande :
l'écriture dans /etc/nix/nix.conf demande sudo et peut échouer, l'appel
doit réussir quand même ;
- le disque annoncé grandit de ce que le store réclame, faute de quoi la VM
se remplit pendant l'installation.
"""
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
from script.todo.todo_i18n import TRANSLATIONS # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
SPEC = TODO._QEMU_VM_TOOLS["nixanywhere"]
class LOption(unittest.TestCase):
def test_it_needs_no_desktop(self):
"""C'est un outil de ligne de commande : une VM serveur le prend."""
self.assertFalse(SPEC["needs_desktop"])
self.assertEqual((), SPEC["desktops"])
def test_it_is_posed_before_the_clone(self):
"""Comme les autres poses amont : l'outil se garde lui-même et ne
fait échouer ni les autres ni l'installation d'ERPLibre."""
self.assertEqual("before", SPEC["phase"])
def test_it_is_not_offered_on_nixos(self):
"""Sur NixOS, nix est déjà là — l'option n'y a pas de sens."""
self.assertEqual(
[], TODO._qemu_tools_for(("nixanywhere",), "amd64", "", "nixos")
)
def test_it_is_offered_on_the_imperative_distributions(self):
for distro in ("debian", "ubuntu", "arch", "fedora", "opensuse"):
with self.subTest(distro=distro):
self.assertEqual(
["nixanywhere"],
TODO._qemu_tools_for(
("nixanywhere",), "amd64", "", distro
),
)
def test_it_is_bounded_to_the_architectures_nix_builds_for(self):
"""Ailleurs, cocher la case poserait un installateur qui s'arrête."""
self.assertEqual(("amd64", "arm64"), SPEC["arches"])
self.assertEqual(
[], TODO._qemu_tools_for(("nixanywhere",), "s390x", "", "debian")
)
def test_the_disk_grows_by_what_the_store_asks(self):
"""Le store porte la fermeture d'un système NixOS complet et le noyau
kexec : la VM se remplirait pendant l'installation."""
self.assertEqual(8, SPEC["disk_gb"])
sans = TODO._qemu_tools_disk_gb((), "amd64", "", "debian")
avec = TODO._qemu_tools_disk_gb(
("nixanywhere",), "amd64", "", "debian"
)
self.assertEqual(sans + 8, avec)
class LaCommandeDistante(unittest.TestCase):
def setUp(self):
self.cmd = TODO.__new__(TODO)._qemu_nixanywhere_remote_cmd()
def test_no_pose_can_hang(self):
"""« || true » couvre l'ÉCHEC, pas l'ATTENTE."""
bornees = [x for x in self.cmd.split("; ") if "timeout " in x]
self.assertGreaterEqual(len(bornees), 2)
for morceau in bornees:
self.assertIn("</dev/null", morceau)
self.assertIn("|| true", morceau)
def test_the_installer_is_told_not_to_ask(self):
"""Sans « --yes », il attend une confirmation ; sans « --daemon », il
installe en mono-utilisateur et /nix appartient à qui a installé."""
self.assertIn("--daemon", self.cmd)
self.assertIn("--yes", self.cmd)
def test_nix_is_called_by_absolute_path(self):
"""Le PATH du shell distant a été figé à son ouverture, avant que
l'installateur ne pose le binaire : « nix » nu rendrait 127 sans dire
que rien n'a été installé."""
self.assertIn("/nix/var/nix/profiles/default/bin/nix", self.cmd)
def test_the_flake_features_are_given_on_the_command_line_too(self):
"""L'écriture dans /etc/nix/nix.conf demande sudo et peut échouer ;
sans ces fonctions, « nix profile install github:… » refuse la
référence."""
self.assertIn("--extra-experimental-features", self.cmd)
self.assertIn("nix-command flakes", self.cmd)
def test_the_conf_line_is_written_once(self):
"""Sans le grep, chaque redéploiement d'une même VM rallonge
/etc/nix/nix.conf d'une ligne identique."""
self.assertIn("grep -qF 'experimental-features", self.cmd)
def test_nothing_is_added_to_the_user_rc_file(self):
"""L'installateur écrit lui-même /etc/bash.bashrc et
/etc/profile.d/nix.sh : c'est de là que « ssh hôte \'commande\' »
tient son PATH.
Une ligne de plus dans le ~/.bashrc de l'utilisateur serait posée
APRÈS le « return » que ce fichier exécute pour tout shell non
interactif — donc jamais atteinte dans le seul cas qu'elle
prétendait couvrir, et sans objet dans les autres."""
self.assertNotIn(".bashrc", self.cmd)
def test_the_verdict_looks_at_the_binary(self):
"""Toutes les poses rendent 0 par construction : leur code de retour
ne dit rien de ce qui a été installé."""
self.assertIn("$HOME/.nix-profile/bin/nixos-anywhere", self.cmd)
def test_it_returns_zero_when_everything_fails(self):
"""La phase « before » ne doit pas emporter l'installation : sous
« set -e », une seule commande non gardée y suffirait.
Le bloc est joué pour de vrai, avec les commandes qui SORTENT de la
machine remplacées par un échec — un test unitaire ne télécharge rien.
Le HOME temporaire reste VIDE : le bloc n'écrit chez personne."""
neutre = (
"curl(){ return 1; }; sudo(){ return 1; }; "
"timeout(){ return 1; }; systemctl(){ return 1; }; "
)
with tempfile.TemporaryDirectory() as maison:
fini = subprocess.run(
["bash", "-c", "set -e; " + neutre + self.cmd],
capture_output=True,
text=True,
env={"PATH": os.environ["PATH"], "HOME": maison},
)
self.assertEqual(0, fini.returncode, fini.stderr)
self.assertEqual([], list(Path(maison).iterdir()))
def test_it_is_valid_shell(self):
"""Une commande mal citée casse tout le bloc des outils, pas
seulement le sien."""
fini = subprocess.run(
["bash", "-n"],
input="{ " + self.cmd + " }",
text=True,
capture_output=True,
)
self.assertEqual(0, fini.returncode, fini.stderr)
def test_it_reaches_the_block_of_the_checked_tools(self):
"""Déclarer l'outil sans le brancher le laisserait cochable et sans
effet."""
todo = TODO.__new__(TODO)
bloc = todo._qemu_tools_remote_cmd(("nixanywhere",))
self.assertIn("nixos-anywhere", bloc)
self.assertNotIn("nixos-anywhere", todo._qemu_tools_remote_cmd(()))
class LesLibelles(unittest.TestCase):
def test_every_string_shown_is_translated(self):
"""Le libellé, l'indice et chaque ligne du « ? » : les deux langues
sont obligatoires."""
for cle in (SPEC["label"], SPEC["hint"], *SPEC["help"]):
with self.subTest(cle=cle):
self.assertIn(cle, TRANSLATIONS)
self.assertTrue(TRANSLATIONS[cle].get("fr"))
self.assertTrue(TRANSLATIONS[cle].get("en"))
def test_the_help_says_what_the_tool_is_for(self):
"""Le « ? » d'une option nomme ce qu'elle installe ET ce qu'elle
permet : nix seul ne dit pas qu'on installera NixOS ailleurs."""
aide = " ".join(SPEC["help"]).lower()
self.assertIn("nixos-anywhere", aide)
self.assertIn("ssh", aide)
if __name__ == "__main__":
unittest.main()

394
test/test_qemu_nixos.py Normal file
View file

@ -0,0 +1,394 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""NixOS au catalogue des systèmes déployables.
NixOS ne publie AUCUNE image cloud : sa page de téléchargement offre des ISO,
des AMI Amazon et Docker, rien d'autre. L'image vient donc d'un tiers qui la
reconstruit depuis nixpkgs — ce qui change deux choses par rapport aux sept
autres distributions du catalogue, et ces tests gardent les deux :
- la release est ÉPINGLÉE et sa somme sha256 FIGÉE dans le dépôt. L'amont
n'en publie pas ; celle-ci a été relevée une fois à la revue, et c'est la
seule chose qui distingue l'image revue de n'importe quel fichier servi
sous la même URL. Elle se vérifie donc sans drapeau à passer ;
- l'origine se DIT à l'écran avant de déployer : le système de base d'une VM
n'est pas un détail d'implémentation.
Le reste est le lot commun d'une distribution qu'on ajoute : deux catalogues
à tenir d'accord, un nom de fichier de cache qui ne doit pas retomber sur
celui de Fedora, une famille de paquets, un groupe d'administration.
S'y ajoute ce qu'un système DÉCLARATIF ne peut pas recevoir : les outils de
VM qui posent un binaire par « curl | sh » ou écrivent dans /usr/local n'y
survivraient pas à la reconstruction suivante.
"""
import importlib.util
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
from script.todo.todo import TODO # noqa: E402
RACINE = Path(__file__).resolve().parents[1]
try:
import yaml
YAML = True
except Exception: # pragma: no cover - dépend de l'environnement
YAML = False
def _deploy_qemu():
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class LeCatalogue(unittest.TestCase):
def test_it_is_in_both_catalogues_with_the_same_versions(self):
"""Le sélecteur en ligne tient sa propre copie : deux tables à la
main, dont une qu'on oublie."""
self.assertIn("nixos", DQ.DISTROS)
self.assertIn("nixos", TODO._QEMU_DISTROS)
self.assertEqual(
sorted(DQ.DISTROS["nixos"][0]),
sorted(TODO._QEMU_DISTROS["nixos"][0]),
)
self.assertEqual(
DQ.DISTROS["nixos"][1], TODO._QEMU_DISTROS["nixos"][1]
)
def test_the_version_is_the_one_inside_the_image(self):
"""Annoncer une version que l'image ne porte pas mentirait sur ce
qu'on livre : la release épinglée embarque 25.11."""
self.assertIn("25.11", DQ.DISTROS["nixos"][0])
code, osinfo, ram, disque = DQ.DISTROS["nixos"][0]["25.11"]
self.assertEqual("nixos-25.11", osinfo)
self.assertEqual(2048, ram)
self.assertEqual("40G", disque)
def test_the_disk_floor_is_above_the_rolling_neighbour(self):
"""Le store Nix garde chaque génération et ne se purge qu'à la main :
le plancher est plus haut que celui d'Arch, à dessein."""
_c, _o, ram_arch, _d = DQ.DISTROS["arch"][0]["latest"]
_c, _o, ram_nix, _d = DQ.DISTROS["nixos"][0]["25.11"]
self.assertGreater(ram_nix, ram_arch)
def test_it_is_x86_only(self):
"""Le tiers ne publie qu'un asset, sans architecture dans son nom."""
self.assertNotIn("nixos", DQ.ARM64_DISTROS)
self.assertNotIn("nixos", DQ.S390X_DISTROS)
class LImage(unittest.TestCase):
def test_the_release_is_pinned(self):
"""« latest » changerait le système de base d'un déploiement à
l'autre, sans que rien ne le dise."""
url = DQ.image_candidates("nixos", "25.11", "amd64", "25.11", True)[0]
self.assertIn(DQ.NIXOS_IMAGE_TAG, url)
self.assertNotIn("latest", url)
def test_the_cache_name_carries_the_tag(self):
"""Changer le tag doit RETÉLÉCHARGER, et non réutiliser une image qui
ne répond plus à la somme figée."""
nom = DQ.default_image_name("nixos", "25.11", "amd64", "25.11")
self.assertIn(DQ.NIXOS_IMAGE_TAG, nom)
def test_the_cache_name_never_falls_back_to_fedora(self):
"""Le repli de fin de default_image_name() nomme « fedora-cloud-* » :
une distribution oubliée là met son image en cache sous le nom d'une
autre, silencieusement."""
nom = DQ.default_image_name("nixos", "25.11", "amd64", "25.11")
self.assertNotIn("fedora", nom)
def test_the_pinned_sum_looks_like_a_sha256(self):
self.assertEqual(64, len(DQ.NIXOS_IMAGE_SHA256))
int(DQ.NIXOS_IMAGE_SHA256, 16)
def test_a_wrong_sum_stops_the_deployment(self):
"""Continuer reviendrait à installer un système que personne n'a
regardé."""
with mock.patch.object(DQ, "open", mock.mock_open(read_data=b"x")):
with self.assertRaises(SystemExit):
DQ.verify_pinned_sha256("nixos", Path("/x"), False)
def test_other_distros_are_not_checked_against_it(self):
"""Leur somme, quand elle existe, vient de leur propre SHA256SUMS."""
DQ.verify_pinned_sha256("ubuntu", Path("/inexistant"), False)
def _refus(self, urls=()):
with mock.patch.object(DQ, "open", mock.mock_open(read_data=b"x")):
with self.assertRaises(SystemExit) as pris:
DQ.verify_pinned_sha256("nixos", Path("/x"), False, urls)
return str(pris.exception)
def test_the_cache_entry_is_named_as_what_must_go_first(self):
"""Le remède ordinaire — effacer le fichier — ne suffit PAS derrière
le cache : c'est lui qui resservira les mêmes octets, indéfiniment.
Ni « --purge », qui efface tout, ni « --purge-older-than », qu'un
objet servi rajeunit à chaque fois, ne l'atteignent."""
msg = self._refus(("https://miroir.example/nixos.qcow2",))
self.assertIn("--oublie", msg)
self.assertIn("GET https://miroir.example/nixos.qcow2", msg)
def test_without_a_url_the_message_stays_short(self):
"""Une commande qu'on ne peut pas recopier telle quelle vaut moins
que pas de commande du tout."""
msg = self._refus()
self.assertNotIn("--oublie", msg)
self.assertIn("attendu", msg)
class CeQuiSeDitALEcran(unittest.TestCase):
def test_the_third_party_origin_is_named(self):
note = DQ.image_source_note("nixos")
self.assertIsNotNone(note)
url, tag = note
self.assertIn(tag, url)
self.assertTrue(url.startswith("https://"))
def test_an_official_image_says_nothing(self):
"""Commenter chaque image noierait la seule qui le mérite."""
for distro in ("ubuntu", "debian", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertIsNone(DQ.image_source_note(distro))
def test_the_recap_shows_where_the_image_comes_from(self):
"""L'origine d'une image rebâtie par un tiers se dit AVANT de créer
quoi que ce soit : c'est la seule des neuf dans ce cas."""
todo = TODO.__new__(TODO)
spec = {
"vms": [{"name": "vm", "distro": "nixos"}],
"install": {"branch": "develop"},
}
with mock.patch.object(TODO, "_qemu_import_module", return_value=DQ):
lignes = todo._qemu_image_lines(spec)
texte = " ".join(lignes)
self.assertIn(DQ.NIXOS_IMAGE_TAG, texte)
self.assertIn("https://", texte)
def test_the_plan_no_longer_promises_the_install_will_fail(self):
"""L'écran portait « ERPLibre ne s'installe pas encore sur NixOS ».
Il s'y installe, vérifié de bout en bout sur une VM — un avertissement
périmé sur le DERNIER écran avant de créer ne fait pas qu'induire en
erreur, il décourage d'essayer ce qui marche.
L'épreuve porte sur le texte RENDU et non sur la clé de traduction :
c'est ce que l'opérateur lit, et la clé pouvait partir en laissant la
phrase ailleurs."""
todo = TODO.__new__(TODO)
for install in ({"branch": "develop"}, None):
spec = {
"vms": [{"name": "vm", "distro": "nixos"}],
"install": install,
}
with self.subTest(install=bool(install)):
with mock.patch.object(
TODO, "_qemu_import_module", return_value=DQ
):
texte = " ".join(todo._qemu_image_lines(spec))
for interdit in ("ERPLibre", "échoue", "fails"):
self.assertNotIn(interdit, texte)
def test_no_orphan_translation_is_left_behind(self):
"""Une entrée que plus personne n'appelle vieillit sans qu'on le
sache, et réapparaît le jour où quelqu'un la recopie."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
i18n = (racine / "script/todo/todo_i18n.py").read_text(
encoding="utf-8"
)
self.assertNotIn("does not install on NixOS", i18n)
def test_a_deployment_without_nixos_says_nothing(self):
todo = TODO.__new__(TODO)
spec = {"vms": [{"name": "vm", "distro": "debian"}], "install": None}
with mock.patch.object(TODO, "_qemu_import_module", return_value=DQ):
self.assertEqual([], todo._qemu_image_lines(spec))
class LeSystemeInvite(unittest.TestCase):
def test_the_package_family_is_its_own(self):
"""Aucune des quatre familles impératives : ce qui doit rester se
déclare, et le guide de connexion le dit dans cet ordre."""
self.assertEqual("nix", DQ.DISTRO_PKG["nixos"])
self.assertEqual("nix", TODO._QEMU_DISTRO_FAMILY["nixos"])
self.assertIn("nix", DQ.PKG_GUIDE)
def test_the_guide_never_suggests_an_imperative_install(self):
commandes = [c for c, _fr, _en in DQ.PKG_GUIDE["nix"]]
self.assertTrue(any("nixos-rebuild" in c for c in commandes))
self.assertTrue(
any("configuration.nix" in c for c in commandes), commandes
)
for interdit in ("apt ", "dnf ", "pacman ", "zypper "):
self.assertFalse(any(interdit in c for c in commandes), interdit)
def test_the_admin_group_is_wheel(self):
self.assertIn("wheel", DQ.user_groups("nixos").split(", "))
def test_the_label_names_the_distribution(self):
self.assertEqual("NixOS 25.11", DQ.distro_label("nixos", "25.11"))
@unittest.skipUnless(YAML, "PyYAML absent")
def test_the_cloud_config_stays_parsable(self):
args = DQ.build_parser().parse_args(
["--distro", "fedora", "--hostname", "vm"]
)
args.distro = "nixos"
vu = yaml.safe_load(
DQ.build_cloud_config(args, None, ["ssh-ed25519 A"])
)
(compte,) = vu["users"]
self.assertEqual("/bin/sh", compte["shell"])
class CeQuUnSystemeDeclaratifNeRecoitPas(unittest.TestCase):
def test_no_vm_tool_is_offered(self):
"""Chacun pose un binaire par « curl | sh », écrit dans /usr/local ou
installe par le gestionnaire du système. Aucun de ces gestes ne
survit à la reconstruction suivante."""
tous = list(TODO._QEMU_VM_TOOLS)
self.assertEqual(
[], TODO._qemu_tools_for(tous, "amd64", "gnome", "nixos")
)
def test_the_other_distros_keep_theirs(self):
"""Le bornage ne doit rien retirer à qui sait s'en servir."""
tous = list(TODO._QEMU_VM_TOOLS)
for distro in ("ubuntu", "debian", "arch", "fedora"):
with self.subTest(distro=distro):
self.assertTrue(
TODO._qemu_tools_for(tous, "amd64", "gnome", distro)
)
class LesDeuxCheminsLisentLaMemeSomme(unittest.TestCase):
"""L'image tierce se vérifie sur les DEUX chemins de déploiement.
Aucune distribution ne publie cette image : la somme a été relevée une
fois à la revue, et c'est la seule chose qui distingue le fichier revu de
n'importe quel autre servi sous la même URL. Le chemin qemu vérifie ce
qu'il vient de télécharger ; celui de Proxmox fait vérifier sur l'hôte,
où l'image descend.
Deux copies de la somme dériveraient, et la copie oubliée serait celle
qui laisse passer une image que personne n'a regardée.
"""
def test_one_accessor_carries_it(self):
self.assertEqual(DQ.NIXOS_IMAGE_SHA256, DQ.pinned_sha256("nixos"))
self.assertEqual(64, len(DQ.pinned_sha256("nixos")))
def test_the_distros_that_publish_their_own_have_none(self):
"""--verify lit alors le SHA256SUMS de la distribution : porter une
somme figée en plus ferait deux autorités."""
for distro in ("ubuntu", "debian", "fedora", "arch", ""):
with self.subTest(distro=distro):
self.assertEqual("", DQ.pinned_sha256(distro))
def test_the_qemu_side_reads_the_accessor(self):
"""Un second test « distro == nixos » ailleurs se désaccorderait du
jour où une autre image tierce entre au catalogue."""
src = (RACINE / "script/qemu/deploy_qemu.py").read_text(
encoding="utf-8"
)
i = src.index("def verify_pinned_sha256")
self.assertIn("pinned_sha256(distro)", src[i : i + 700])
def test_the_proxmox_side_verifies_on_the_host(self):
from script.proxmox import proxmox_deploy as pve
cmd = pve.image_fetch_cmd(
"http://x/i.qcow2", "i.qcow2", sha256="a" * 64
)
self.assertIn("sha256sum -c -", cmd)
# Par « && » : une somme qui ne correspond pas doit ARRÊTER la suite,
# et non se contenter d'un avertissement dans le journal.
self.assertIn("&& echo", cmd)
def test_the_download_lands_on_its_final_name_only_when_complete(self):
"""« wget -O » écrivait dans la CIBLE : une coupure — réseau, disque
plein, Ctrl-C — y figeait une image tronquée que le test de présence
acceptait à chaque déploiement suivant. Avec une somme, elle échouait
pour toujours sans dire quoi effacer ; sans somme, elle servait à
créer une VM.
Mesuré : un « .partiel » laissé par une coupure est repris et la
cible finit identique à l'amont."""
from script.proxmox import proxmox_deploy as pve
cmd = pve.image_fetch_cmd("http://x/i.qcow2", "i.qcow2")
self.assertIn("-O", cmd)
self.assertIn(".partiel", cmd)
# Le « mv » suit le téléchargement, et par « && » : un wget en échec
# ne doit RIEN mettre en place.
self.assertLess(cmd.index("wget"), cmd.index("mv "))
self.assertIn("&& mv ", cmd)
def test_a_fresh_download_is_checked_before_it_is_installed(self):
"""Une image fausse ne doit jamais devenir celle que le prochain
déploiement trouvera « déjà présente »."""
from script.proxmox import proxmox_deploy as pve
cmd = pve.image_fetch_cmd(
"http://x/i.qcow2", "i.qcow2", sha256="c" * 64
)
i_somme = cmd.index("sha256sum")
self.assertLess(i_somme, cmd.index("mv "))
def test_a_cached_image_is_verified_too(self):
"""Le cas qu'on veut prendre est un fichier substitué ou tronqué
entre deux déploiements : le test de présence ne regarde que la
taille, et la vérification vient donc APRÈS le « fi »."""
from script.proxmox import proxmox_deploy as pve
cmd = pve.image_fetch_cmd(
"http://x/i.qcow2", "i.qcow2", sha256="b" * 64
)
# La DERNIÈRE vérification, celle qui porte sur le fichier en
# place : la première garde le téléchargement frais avant de
# l'installer, et se trouve donc AVANT le « fi ».
self.assertLess(cmd.index("fi"), cmd.rindex("sha256sum"))
self.assertEqual(2, cmd.count("sha256sum"))
def test_nothing_is_appended_without_a_sum(self):
from script.proxmox import proxmox_deploy as pve
self.assertNotIn(
"sha256sum", pve.image_fetch_cmd("http://x/i.qcow2", "i.qcow2")
)
def test_the_menu_passes_it_at_every_call_site(self):
"""Un appel qui l'oublie télécharge sans regarder."""
src = (RACINE / "script/todo/proxmox_menu.py").read_text(
encoding="utf-8"
)
# Les appels qui TÉLÉCHARGENT, l'exemple d'affichage excepté : les
# trois du déploiement, plus celui du téléchargement d'avance — une
# image posée par lui est celle qu'un déploiement futur trouvera
# « déjà présente », et il ne la regardera pas mieux.
vrais = [
l
for l in src.splitlines()
if "pve.image_fetch_cmd(" in l and "https://…" not in l
]
self.assertEqual(4, len(vrais))
for ligne in vrais:
with self.subTest(ligne=ligne.strip()[:50]):
self.assertIn("sha256=", ligne)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,245 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le seed ne demande à l'invité que ce que son image sait honorer.
Deux réglages régionaux échouaient sur toute VM Debian, et le seul signe en
était le mot « error » dans le compte-rendu de cloud-init que le déploiement
imprime — un mot qui, s'il s'affiche à chaque fois, n'avertit plus de rien.
- le LOCALE : « update-locale LANG=xx_YY.UTF-8 » refuse un locale qui n'est
pas généré, et un locale ne se génère qu'à partir de /etc/locale.gen. La VM
restait en C.UTF-8 ;
- le CLAVIER : le module de cloud-init finit par « systemctl restart
console-setup », service absent de l'image genericcloud. Le réglage est
écrit AVANT cet échec, dans /etc/default/keyboard — c'est le fichier que
localed et X relisent —, donc le poser nous-mêmes ne perd rien d'autre que
la disposition de la console texte, qui demande ce paquet.
Ces tests gardent aussi la contrainte qui domine toutes les autres ici : le
user-data reste un YAML valide. Une erreur de syntaxe fait rejeter la
configuration ENTIÈRE, sans message : la VM démarre sans compte ni clé.
"""
import importlib.util
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
try:
import yaml
except ImportError: # pragma: no cover
yaml = None
def _deploy_qemu():
chemin = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
def _cloud_config(distro, cache_ca=""):
argv = ["--distro", distro, "--hostname", "x"]
if cache_ca:
argv += ["--cache-ca", cache_ca]
args = DQ.build_parser().parse_args(argv)
return DQ.build_cloud_config(args, None, ["ssh-ed25519 AAAA essai"])
class LeLocale(unittest.TestCase):
def test_the_locale_is_generated_before_cloud_init_applies_it(self):
"""« bootcmd » et non « runcmd » : le premier tourne à l'étape init,
avant les modules de configuration dont « locale » fait partie. Le
second viendrait après, et n'aurait plus rien à rattraper."""
lignes = DQ.locale_bootcmd_lines("fr_CA.UTF-8")
self.assertEqual("bootcmd:", lignes[0])
self.assertIn("locale-gen", lignes[1])
def test_the_line_is_added_to_locale_gen_only_once(self):
"""bootcmd tourne à CHAQUE démarrage : sans le grep, /etc/locale.gen
s'allonge d'une ligne identique par boot."""
self.assertIn(
"grep -qxF 'fr_CA.UTF-8 UTF-8'",
" ".join(DQ.locale_bootcmd_lines("fr_CA.UTF-8")),
)
def test_a_host_without_locale_gen_is_left_alone(self):
"""Fedora, la famille RHEL et openSUSE embarquent leurs locales déjà
générées, et n'ont pas ce fichier."""
self.assertIn(
"[ -f /etc/locale.gen ] || exit 0",
" ".join(DQ.locale_bootcmd_lines("fr_CA.UTF-8")),
)
def test_a_builtin_locale_asks_for_nothing(self):
"""C.UTF-8 et POSIX sont dans la bibliothèque C, pas dans
locale.gen : les demander ferait échouer locale-gen, donc bootcmd,
donc cloud-init — l'erreur qu'on retire."""
for locale in ("C.UTF-8", "POSIX", ""):
with self.subTest(locale=locale):
self.assertEqual([], DQ.locale_bootcmd_lines(locale))
class LeClavier(unittest.TestCase):
def test_debian_gets_the_file_and_not_the_cloud_init_key(self):
"""Le module de cloud-init n'y aboutit pas : console-setup manque."""
self.assertEqual([], DQ.keyboard_lines("debian", "ca", "multix"))
fichiers = DQ.keyboard_files("debian", "ca", "multix")
self.assertEqual("/etc/default/keyboard", fichiers[0][0])
self.assertIn("XKBLAYOUT=ca", fichiers[0][2])
self.assertIn("XKBVARIANT=multix", fichiers[0][2])
def test_elsewhere_cloud_init_keeps_the_job(self):
"""Une VM Ubuntu applique le clavier sans erreur : lui retirer le
bloc perdrait la disposition de sa console, elle qui l'obtient."""
for distro in ("ubuntu", "fedora", "arch", "opensuse", "nixos"):
with self.subTest(distro=distro):
self.assertEqual(
["keyboard:", " layout: ca", " variant: multix"],
DQ.keyboard_lines(distro, "ca", "multix"),
)
self.assertEqual([], DQ.keyboard_files(distro, "ca", "multix"))
def test_the_file_carries_a_model(self):
"""localed lit les quatre champs ; un XKBMODEL vide laisse X deviner."""
self.assertIn("XKBMODEL=", DQ.keyboard_files("debian", "ca", "")[0][2])
class LeSeedEntier(unittest.TestCase):
"""Ce que la configuration produite doit rester, distro par distro."""
def setUp(self):
if yaml is None:
self.skipTest("PyYAML absent")
def test_the_user_data_stays_valid_yaml(self):
"""Une erreur de syntaxe fait rejeter le user-data en ENTIER, sans
message : la VM démarre nue, sans compte ni clé SSH."""
for distro in ("debian", "ubuntu", "fedora", "arch", "nixos"):
with self.subTest(distro=distro):
doc = yaml.safe_load(_cloud_config(distro))
self.assertIsInstance(doc, dict)
self.assertIn("users", doc)
def test_bootcmd_is_a_list_of_arguments_not_a_shell_string(self):
"""La forme [sh, -c, "…"] met le corps hors de portée de YAML :
accolades, apostrophes et redirections y passent sans échappement."""
doc = yaml.safe_load(_cloud_config("debian"))
self.assertIsInstance(doc["bootcmd"][0], list)
self.assertEqual(["sh", "-c"], doc["bootcmd"][0][:2])
def test_debian_asks_cloud_init_for_no_keyboard(self):
doc = yaml.safe_load(_cloud_config("debian"))
self.assertIsNone(doc.get("keyboard"))
chemins = [f["path"] for f in doc["write_files"]]
self.assertIn("/etc/default/keyboard", chemins)
def test_ubuntu_keeps_asking(self):
doc = yaml.safe_load(_cloud_config("ubuntu"))
self.assertEqual("ca", doc["keyboard"]["layout"])
chemins = [f["path"] for f in doc["write_files"]]
self.assertNotIn("/etc/default/keyboard", chemins)
class LAutoriteDuCacheDansLeSeedAssemble(unittest.TestCase):
"""Le clavier et l'autorité du cache arrivent par la MÊME clé.
Trois écritures se disputent « write_files: » — le guide, l'autorité du
cache, le clavier — et cloud-init n'en lit qu'UNE : un document qui porte
la clé deux fois garde la dernière, sans erreur ni message. Ce qui est
perdu ne se voit alors qu'à l'usage, chez l'invité, et seulement pour ce
qui en dépendait.
Les tests plus haut regardent les fonctions une à une ; ceux-ci regardent
le document ASSEMBLÉ, seul endroit où cette collision existe.
"""
def setUp(self):
if yaml is None:
self.skipTest("PyYAML absent")
dossier = tempfile.mkdtemp(prefix="cache-ca-")
self.addCleanup(shutil.rmtree, dossier, ignore_errors=True)
# Une autorité INVENTÉE : cache_files exige « BEGIN CERTIFICATE » et
# ne lit rien d'autre du fichier. Reprendre celle d'un hôte réel
# figerait dans le dépôt le certificat d'une machine.
self.ca = os.path.join(dossier, "ca.crt")
with open(self.ca, "w", encoding="utf-8") as fh:
fh.write(
"-----BEGIN CERTIFICATE-----\nZXNzYWk=\n"
"-----END CERTIFICATE-----\n"
)
def _chemins(self, distro):
doc = yaml.safe_load(_cloud_config(distro, self.ca))
return [f["path"] for f in doc.get("write_files", [])]
def test_the_key_appears_exactly_once(self):
"""Deux « write_files: » dans le même document : PyYAML garde le
second et jette le premier, sans rien dire."""
for distro in ("debian", "ubuntu", "fedora", "arch", "nixos"):
with self.subTest(distro=distro):
texte = _cloud_config(distro, self.ca)
lignes = texte.splitlines()
self.assertEqual(
1, lignes.count("write_files:"), "\n".join(lignes[:5])
)
def test_the_authority_reaches_the_assembled_document(self):
"""Ce qu'aucun test ne gardait : l'autorité peut disparaître du
document tout en restant correcte dans cache_files."""
for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
chemins = self._chemins(distro)
self.assertTrue(
any(c.endswith(DQ.CACHE_CERT_NAME) for c in chemins),
chemins,
)
def test_debian_keeps_both_the_keyboard_and_the_authority(self):
"""La seule distribution qui demande les deux par write_files : si
une écriture en écrase une autre, c'est ici que cela se voit."""
chemins = self._chemins("debian")
self.assertIn("/etc/default/keyboard", chemins)
self.assertTrue(any(c.endswith(DQ.CACHE_CERT_NAME) for c in chemins))
def test_the_trust_command_reaches_runcmd(self):
"""Le fichier posé sans la commande qui relit le magasin ne sert à
rien : les deux moitiés voyagent séparément."""
doc = yaml.safe_load(_cloud_config("debian", self.ca))
self.assertTrue(
any("update-ca-certificates" in str(c) for c in doc["runcmd"]),
doc["runcmd"],
)
def test_a_declarative_system_gets_it_where_it_can_be_written(self):
"""Un système déclaratif n'a pas d'ancre PAR FICHIER, mais il reçoit
l'autorité quand même : sous un chemin inscriptible, /etc étant
généré depuis le store et monté en lecture seule.
Il l'a longtemps reçue nulle part, et c'est ce qui lui fermait le
hors ligne — le magasin est alors la seule source, et une VM
soustraite n'a plus rien."""
doc = yaml.safe_load(_cloud_config("nixos", self.ca))
chemins = [f["path"] for f in doc.get("write_files", [])]
autorite = [c for c in chemins if DQ.CACHE_CERT_NAME in c]
self.assertEqual(1, len(autorite), chemins)
self.assertFalse(autorite[0].startswith("/etc/"))
# Et le reste du document lui parvient toujours.
self.assertIn("users", doc)
self.assertEqual("ca", doc["keyboard"]["layout"])
if __name__ == "__main__":
unittest.main()

164
test/test_qemu_sommes.py Normal file
View file

@ -0,0 +1,164 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'image téléchargée est vérifiée contre la somme que son éditeur publie.
La vérification existait, sous un drapeau, et pour Ubuntu SEULEMENT : les
huit autres images arrivaient sans que rien ne les regarde, alors que leurs
éditeurs publient tous une somme à côté. Le risque couvert est une image
substituée sur un miroir, et le coût une lecture du fichier qu'on vient de
télécharger.
Ni le nom du fichier de sommes ni l'algorithme ne se devinent, et les
extraits plus bas sont pris des dépôts eux-mêmes : Debian publie du sha512
quand tout le reste est en sha256, les familles RHEL nomment leur fichier
« CHECKSUM » sans dire l'algorithme, Rocky l'écrit en forme BSD, et Arch
comme openSUSE posent une somme PAR image.
"""
import importlib.util
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
chemin = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
# Un extrait de chaque format, relevé sur le dépôt de la distribution. Les
# empreintes sont RACCOURCIES et inventées : ce qu'on éprouve est
# l'appariement, pas la valeur, et une somme réelle figée ici vieillirait au
# premier renouvellement d'image.
FORMATS = {
"ubuntu": (
"aaaa *autre-image-amd64.img\n"
"bbbb *ubuntu-24.04-server-cloudimg-amd64.img\n",
"ubuntu-24.04-server-cloudimg-amd64.img",
"bbbb",
),
"debian": (
"cccc debian-12-genericcloud-arm64.qcow2\n"
"dddd debian-12-genericcloud-amd64.qcow2\n",
"debian-12-genericcloud-amd64.qcow2",
"dddd",
),
"rocky": (
"# Rocky-10-GenericCloud.latest.x86_64.qcow2: 544997376 bytes\n"
"SHA256 (Rocky-10-GenericCloud.latest.x86_64.qcow2) = eeee\n",
"Rocky-10-GenericCloud.latest.x86_64.qcow2",
"eeee",
),
"opensuse": (
"ffff Leap-16.0-Minimal-VM.x86_64-Cloud.qcow2\n",
"Leap-16.0-Minimal-VM.x86_64-Cloud.qcow2",
"ffff",
),
}
class LAppariement(unittest.TestCase):
def test_every_published_format_is_read(self):
for distro, (texte, nom, attendu) in FORMATS.items():
with self.subTest(distro=distro):
self.assertEqual(attendu, DQ.expected_sum(texte, nom))
def test_a_longer_name_ending_the_same_is_not_a_match(self):
"""Le nom est comparé EN ENTIER, pas par sa fin. Un fichier de sommes
porte toutes les images d'un répertoire, et un nom plus long qui se
termine par celui qu'on cherche rendrait la somme d'une AUTRE image —
la vérification échouerait alors en accusant l'image juste.
La forme d'origine comparait la fin de la LIGNE, ce qui revient au
même défaut."""
texte = "1111 prefixe-image.qcow2\n2222 image.qcow2\n"
self.assertEqual("2222", DQ.expected_sum(texte, "image.qcow2"))
def test_signature_and_comment_lines_fall_away(self):
texte = (
"-----BEGIN PGP SIGNED MESSAGE-----\n"
"Hash: SHA256\n"
"\n"
"# une ligne de commentaire\n"
"3333 image.qcow2\n"
)
self.assertEqual("3333", DQ.expected_sum(texte, "image.qcow2"))
def test_an_absent_name_yields_nothing(self):
self.assertEqual("", DQ.expected_sum("4444 autre.qcow2\n", "x.qcow2"))
class OuChaqueDistributionPublie(unittest.TestCase):
def test_the_six_that_publish_are_resolved(self):
"""Une somme par répertoire chez les uns, à côté de l'image chez les
autres : le nom ne se déduit pas de l'image seule."""
for distro in DQ.SUMS_SOURCE:
with self.subTest(distro=distro):
url = f"https://exemple.invalid/d/image-{distro}.qcow2"
su, algo = DQ.sums_url_for(url, distro)
self.assertTrue(su.startswith("https://exemple.invalid/d/"))
self.assertIn(algo, ("sha256", "sha512"))
def test_debian_is_the_one_in_sha512(self):
"""L'algorithme vient de la table, pas du nom du fichier."""
self.assertEqual("sha512", DQ.SUMS_SOURCE["debian"][1])
for distro, (_nom, algo) in DQ.SUMS_SOURCE.items():
if distro != "debian":
with self.subTest(distro=distro):
self.assertEqual("sha256", algo)
def test_the_per_image_forms_carry_the_image_name(self):
for distro in ("opensuse", "arch"):
with self.subTest(distro=distro):
url = "https://exemple.invalid/d/monimage.qcow2"
su, _a = DQ.sums_url_for(url, distro)
self.assertIn("monimage.qcow2", su)
def test_a_distribution_that_publishes_nothing_says_so(self):
"""NixOS a sa somme ÉPINGLÉE dans le dépôt, vérifiée toujours ; son
image tierce ne publie rien à côté."""
self.assertNotIn("nixos", DQ.SUMS_SOURCE)
self.assertEqual(
("", ""), DQ.sums_url_for("https://x/i.qcow2", "nixos")
)
class LaVerificationEstLeDefaut(unittest.TestCase):
SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8")
def test_no_flag_is_needed_any_more(self):
self.assertIn("do_verify = not args.no_verify", self.SRC)
def test_skipping_it_must_be_asked_for(self):
self.assertIn('"--no-verify"', self.SRC)
def test_unreachable_sums_do_not_stop_a_deployment(self):
"""Un fichier de sommes injoignable est une panne de DISPONIBILITÉ —
miroir en travaux, réseau coupé. Refuser de déployer pour cela
rendrait la vérification plus coûteuse que le risque qu'elle couvre.
"""
i = self.SRC.index("def verify_sha256(")
corps = self.SRC[i : self.SRC.index("def hash_password", i)]
self.assertIn("image NON vérifiée", corps)
self.assertNotIn('sys.exit(f"Impossible', corps)
def test_a_mismatch_still_stops_everything(self):
"""Elle, c'est une panne d'INTÉGRITÉ."""
i = self.SRC.index("def verify_sha256(")
corps = self.SRC[i : self.SRC.index("def hash_password", i)]
self.assertIn("NON conforme", corps)
self.assertIn("image.unlink", corps)
if __name__ == "__main__":
unittest.main()

222
test/test_qemu_timezone.py Normal file
View file

@ -0,0 +1,222 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le fuseau écrit dans le seed est un nom que l'invité sait lire.
Une VM hérite du fuseau de l'hôte qui la déploie. Le nom passait tel quel, et
les images cloud d'Ubuntu 24.04 ne portent plus les alias historiques —
Canada/*, US/*, Brazil/*, Asia/Calcutta… — déplacés dans un paquet
« tzdata-legacy » qu'elles n'installent pas. cloud-init refuse alors le
fuseau, la VM RESTE en UTC, et le module se solde par un échec qui ne se voit
qu'aux horodatages, longtemps après le déploiement.
Ce que ces tests gardent, et que la traduction ligne à ligne ne donne pas :
l'INVARIANT que le nom rendu n'est jamais lui-même un alias. Le format
autorise qu'un lien désigne un autre lien, et s'arrêter au premier rendrait
alors un nom qui reste un alias — le défaut même qu'on répare.
La chaîne qui l'éprouve est FABRIQUÉE, et la table plus bas contredit sur ce
point le tzdata publié, où « Universal » vise « Etc/UTC » directement : aucun
lien n'y désigne un autre lien. L'inventer est la seule façon d'exercer le
second tour, et la dernière classe vérifie l'invariant sur la VRAIE table,
qu'une chaîne y apparaisse un jour ou non.
La table est passée par le paramètre « table », qui existe exactement pour
cela : un test qui lirait le tzdata de la machine qui l'exécute passerait ou
non selon cette machine.
"""
import importlib.util
import os
import shutil
import sys
import tempfile
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
# Une tzdata.zi réduite : les lignes « L <canonique> <alias> » sont les seules
# qui portent un lien, le reste du fichier décrit les règles horaires. Les
# deux dernières lignes enchaînent deux liens — un cas que le format autorise
# et que le tzdata publié ne contient pas, d'où la fabrication.
TABLE = """# tzdata.zi, extrait
R d 1974 ma 1 - Ap Su>=1 2 1 D
Z America/Toronto -5:17:32 - LMT 1895
L America/Toronto Canada/Eastern
L America/Los_Angeles US/Pacific
L Asia/Kolkata Asia/Calcutta
L Etc/UTC UTC
L UTC Universal
"""
class LaTraduction(unittest.TestCase):
def setUp(self):
dossier = tempfile.mkdtemp(prefix="tzdata-")
self.addCleanup(shutil.rmtree, dossier, ignore_errors=True)
self.table = os.path.join(dossier, "tzdata.zi")
with open(self.table, "w", encoding="utf-8") as fh:
fh.write(TABLE)
def test_a_legacy_alias_becomes_its_canonical_name(self):
"""Le cas qui casse : l'alias que l'image cloud ne porte plus."""
self.assertEqual(
DQ.canonical_timezone("Canada/Eastern", self.table),
"America/Toronto",
)
self.assertEqual(
DQ.canonical_timezone("US/Pacific", self.table),
"America/Los_Angeles",
)
def test_a_renamed_zone_follows_the_rename(self):
"""Un fuseau renommé en amont est un lien comme un autre."""
self.assertEqual(
DQ.canonical_timezone("Asia/Calcutta", self.table), "Asia/Kolkata"
)
def test_a_canonical_name_passes_through_untouched(self):
"""Traduire ce qui n'a pas besoin de l'être serait une régression : le
fuseau demandé est celui qu'on veut, quand l'invité sait le lire."""
self.assertEqual(
DQ.canonical_timezone("America/Toronto", self.table),
"America/Toronto",
)
self.assertEqual(
DQ.canonical_timezone("Europe/Paris", self.table), "Europe/Paris"
)
def test_an_alias_of_an_alias_resolves(self):
"""Dans CETTE table, « Universal » pointe « UTC », qui pointe
« Etc/UTC ».
Un seul tour rendrait « UTC », un alias que l'image cloud peut très
bien ne pas porter non plus. Le tzdata publié n'enchaîne aucun lien :
la chaîne est fabriquée pour exercer le second tour, le format
l'autorisant.
"""
self.assertEqual(
DQ.canonical_timezone("Universal", self.table), "Etc/UTC"
)
def test_an_empty_zone_stays_empty(self):
"""Rien à traduire, et surtout rien à inventer."""
self.assertEqual(DQ.canonical_timezone("", self.table), "")
def test_a_third_link_is_left_where_two_passes_reach(self):
"""La BORNE, celle que la docstring annonce : deux tours, pas une
boucle. Une table qui enchaîne trois liens est incohérente, et s'y
arrêter vaut mieux que tourner sur une table qui se mord la queue."""
chemin = os.path.join(os.path.dirname(self.table), "trois.zi")
with open(chemin, "w", encoding="utf-8") as fh:
fh.write("L Etc/UTC A\nL A B\nL B C\n")
self.assertEqual(DQ.canonical_timezone("C", chemin), "A")
def test_a_byte_outside_utf8_does_not_stop_the_deployment(self):
"""Le fichier est lu en UTF-8 strict. Un octet qui n'en est pas
lèverait une erreur de DÉCODAGE, qui n'est pas une erreur de
fichier : non rattrapée, elle ferait échouer tout le déploiement
pour une traduction de confort."""
chemin = os.path.join(os.path.dirname(self.table), "binaire.zi")
with open(chemin, "wb") as fh:
fh.write(b"L America/Toronto Canada/Eastern\n\xff\xfe\n")
self.assertEqual(
DQ.canonical_timezone("Canada/Eastern", chemin), "Canada/Eastern"
)
class SansTable(unittest.TestCase):
def test_a_host_without_the_table_keeps_the_zone_asked_for(self):
"""Un hôte sans tzdata.zi — macOS, une image trop maigre — n'empêche
pas de déployer : mieux vaut le fuseau demandé qu'un fuseau deviné."""
self.assertEqual(
DQ.canonical_timezone("Canada/Eastern", "/nexiste/pas.zi"),
"Canada/Eastern",
)
def test_an_empty_zone_needs_no_table_at_all(self):
"""Le vide sort avant toute lecture : un fuseau absent n'est pas une
erreur de fichier."""
self.assertEqual(DQ.canonical_timezone("", "/nexiste/pas.zi"), "")
class LesDeuxPointsDEcriture(unittest.TestCase):
"""Le seed cloud-init et le preseed Debian écrivent tous deux un fuseau.
Traduire à un seul endroit laisserait l'autre défaillant."""
SRC = (RACINE / "script/qemu/deploy_qemu.py").read_text(encoding="utf-8")
def test_the_cloud_config_writes_a_canonical_zone(self):
self.assertIn(
"timezone: {canonical_timezone(args.timezone)}", self.SRC
)
def test_the_preseed_writes_a_canonical_zone(self):
self.assertIn(
"d-i time/zone string {canonical_timezone(args.timezone)}",
self.SRC,
)
def test_the_table_of_the_host_is_the_default(self):
"""Une table figée dans le code vieillit sans qu'on le sache : c'est
celle du système qui fait foi, et « table » n'est là que pour les
tests."""
self.assertIn('TZ_ALIASES = "/usr/share/zoneinfo/tzdata.zi"', self.SRC)
self.assertIn("table: str = TZ_ALIASES", self.SRC)
class LeVraiFichier(unittest.TestCase):
"""Ce que la vraie table garantit, sur un hôte qui la porte."""
def setUp(self):
if not Path(DQ.TZ_ALIASES).exists():
self.skipTest("hôte sans tzdata.zi")
def _liens(self):
alias = {}
with open(DQ.TZ_ALIASES, encoding="utf-8") as fh:
for ligne in fh:
champs = ligne.split()
if len(champs) >= 3 and champs[0] == "L":
alias[champs[2]] = champs[1]
return alias
def test_no_alias_survives_the_translation(self):
"""L'invariant, sur la vraie table et sur TOUS ses liens : le nom
rendu n'est pas lui-même un alias.
Ce test est celui qui compte le jour où le tzdata enchaînera deux
liens — il restera vert sans qu'on y touche, alors qu'une implémen-
tation à un seul tour deviendrait fausse ce jour-là."""
alias = self._liens()
self.assertTrue(alias, "table sans lien")
for nom in alias:
with self.subTest(alias=nom):
self.assertNotIn(DQ.canonical_timezone(nom), alias)
def test_the_zone_written_exists_in_the_guest_tzdata(self):
"""L'épreuve qui compte : le nom rendu est un fichier de zoneinfo, ce
que cloud-init vérifie chez l'invité avant de poser /etc/localtime."""
for zone in ("Canada/Eastern", "US/Pacific", "America/Toronto"):
with self.subTest(zone=zone):
rendu = DQ.canonical_timezone(zone)
self.assertTrue(Path("/usr/share/zoneinfo", rendu).exists())
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,173 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qui décide qu'une VM fraîchement déployée est JOIGNABLE.
Deux réglages du cloud-init, deux façons d'obtenir une machine qui démarre,
applique la clé SSH, et n'ouvre jamais de session. Aucune des deux ne se voit
en lisant un journal d'installation : la VM est « verte » et inutilisable.
- Le NETWORK-CONFIG du seed. Le renderer networkd de cloud-init — celui
d'Arch comme celui de NixOS — ignore le « match » et écrit la CLÉ telle
quelle en « Name= ». Une clé « eth0 » sur une image dont le NIC s'appelle
enp0s2 fait attendre systemd-networkd-wait-online sans fin, et TOUT ce qui
suit network-online.target reste en file : sshd-keygen, cloud-init.service,
cloud-config.service, sshd lui-même. Mettre un glob en clé règle networkd
et CASSE netplan, qui refuse « Definition ID 'e*' must not use globbing » —
les deux ont été mesurés. D'où un réglage par distribution, et le repli de
cloud-init là où le nom du NIC n'est pas connu d'avance.
- Le SHELL du compte. sshd refuse un compte dont le shell n'existe pas, avant
l'authentification : « User <x> not allowed because shell /bin/bash does
not exist ». NixOS ne peuple pas /bin — il n'y met que « sh ».
Chaque test dit ce que le code REFUSE désormais, et pourquoi le refus n'a pas
la même valeur d'une famille de renderer à l'autre.
"""
import importlib.util
import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
try:
import yaml
YAML = True
except Exception: # pragma: no cover - dépend de l'environnement
YAML = False
def _deploy_qemu():
path = RACINE / "script/qemu/deploy_qemu.py"
spec = importlib.util.spec_from_file_location("deploy_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
DQ = _deploy_qemu()
class LeDocumentReseau(unittest.TestCase):
def _cle(self):
"""La clé de l'unique interface déclarée par le network-config."""
cles = [
ligne.strip().rstrip(":").strip('"')
for ligne in DQ.NETWORK_CONFIG.splitlines()
if ligne.startswith(" ") and not ligne.startswith(" ")
]
self.assertEqual(1, len(cles), cles)
return cles[0]
def test_the_key_is_never_a_glob(self):
"""netplan REFUSE un identifiant qui globe — « Definition ID 'e*'
must not use globbing » — et cloud-init.service échoue alors en
entier : ni compte, ni clé SSH, sur Ubuntu et Debian."""
self.assertNotIn("*", self._cle())
def test_the_match_stays_for_the_renderers_that_honour_it(self):
"""netplan et ENI lisent le « match » : c'est lui qui désigne
l'interface là où la clé n'est qu'une étiquette."""
self.assertIn("match:", DQ.NETWORK_CONFIG)
self.assertIn('name: "e*"', DQ.NETWORK_CONFIG)
@unittest.skipUnless(YAML, "PyYAML absent")
def test_it_stays_parsable(self):
vu = yaml.safe_load(DQ.NETWORK_CONFIG)
self.assertEqual(2, vu["version"])
self.assertEqual(1, len(vu["ethernets"]))
(reglage,) = vu["ethernets"].values()
self.assertTrue(reglage["dhcp4"])
self.assertFalse(reglage["dhcp6"])
class LeChoixDuDocument(unittest.TestCase):
"""À qui l'on donne un document réseau, et à qui l'on n'en donne pas."""
def test_nixos_gets_none_so_cloud_init_finds_the_nic_itself(self):
"""Son image nomme le NIC selon la machine émulée (enp0s2 en q35,
ens3 en i440fx) : aucune clé écrite d'avance ne peut tomber juste.
Sans document, cloud-init produit son repli — DHCP sur la première
interface, désignée par son vrai nom."""
self.assertIsNone(DQ.network_config_for("nixos"))
def test_everyone_else_keeps_the_document_unchanged(self):
"""Le corriger pour NixOS ne doit rien changer aux autres : Arch a le
même renderer mais nomme son NIC eth0, que la clé couvre."""
for distro in ("ubuntu", "debian", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertEqual(
DQ.NETWORK_CONFIG, DQ.network_config_for(distro)
)
def test_the_seed_omits_the_option_when_there_is_no_document(self):
"""cloud-localds appelé avec « --network-config » et un fichier vide
écrirait un document VIDE, ce qui n'est pas la même chose que pas de
document : cloud-init ne replierait pas."""
lancees = []
runner = mock.MagicMock()
runner.dry_run = False
runner.run.side_effect = lambda cmd, **kw: lancees.append(cmd)
with tempfile.TemporaryDirectory() as tmp:
cible = Path(tmp) / "seed.iso"
with mock.patch.object(DQ.shutil, "which", return_value="/x"):
DQ.build_seed("#cloud-config\n", "vm", cible, runner, None)
DQ.build_seed(
"#cloud-config\n", "vm", cible, runner, DQ.NETWORK_CONFIG
)
sans, avec = lancees[0], lancees[2]
self.assertNotIn("--network-config", sans)
self.assertIn("--network-config", avec)
class LeShellDuCompte(unittest.TestCase):
def test_nixos_gets_the_only_shell_its_bin_holds(self):
"""/bin n'y contient que « sh », lien vers le bash du store : c'est
donc bash en mode POSIX, pas dash."""
self.assertEqual("/bin/sh", DQ.user_shell("nixos"))
def test_everyone_else_keeps_bash(self):
"""Sur Debian et Ubuntu, /bin/sh EST dash : y basculer tout le monde
pour réparer NixOS coûterait l'historique et la complétion à ceux
qui n'ont rien demandé."""
for distro in ("ubuntu", "debian", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertEqual("/bin/bash", DQ.user_shell(distro))
def test_an_unknown_distro_gets_the_common_shell(self):
self.assertEqual("/bin/bash", DQ.user_shell("cequonnaitpas"))
class LeCloudConfig(unittest.TestCase):
"""Le réglage doit ATTEINDRE le document, pas seulement exister."""
def _config(self, distro):
args = DQ.build_parser().parse_args(
["--distro", "fedora", "--hostname", "vm"]
)
args.distro = distro
return DQ.build_cloud_config(args, None, ["ssh-ed25519 AAAA essai"])
def test_the_shell_reaches_the_user_block(self):
self.assertIn("shell: /bin/sh", self._config("nixos"))
self.assertIn("shell: /bin/bash", self._config("debian"))
@unittest.skipUnless(YAML, "PyYAML absent")
def test_the_document_stays_parsable_for_every_distro(self):
for distro in ("ubuntu", "debian", "fedora", "arch", "nixos"):
with self.subTest(distro=distro):
vu = yaml.safe_load(self._config(distro))
(compte,) = vu["users"]
self.assertEqual(DQ.user_shell(distro), compte["shell"])
self.assertEqual("erplibre", compte["name"])
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.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,38 @@ 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_the_two_copies_of_the_list_agree(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'égalité, dans les deux sens, et chacun de ses sens couvre une
panne : un test long absent du VERROU laisse une autre descente
détruire ses machines pendant qu'il tourne ; absent du DÉFAIRE, il
laisse ses VM derrière lui, sans rien pour les reprendre."""
from script.todo.longtest_menu import SCRIPTS_DEFAISABLES
self.assertEqual(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": ""}),
@ -1701,5 +1766,50 @@ class TestLeMenu(unittest.TestCase):
self.assertIn("Long tests", src)
class LeCacheDeLEtage1(unittest.TestCase):
"""L'étage 1 est une VM du pont libvirt local, et le cache détourne tout
ce pont. Ne rien lui passer ne la laisse pas en direct : elle est
interceptée sans autorité, et chaque téléchargement HTTPS de l'étage —
l'image, puis le gestionnaire de paquets — échoue sur « self-signed
certificate in certificate chain »."""
def setUp(self):
self.dossier = tempfile.mkdtemp(prefix="descente-ca-")
self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True)
self.addCleanup(setattr, moteur, "CACHE_CA", moteur.CACHE_CA)
def test_the_authority_is_trusted_when_the_host_has_one(self):
ca = os.path.join(self.dossier, "ca.crt")
with open(ca, "w", encoding="utf-8") as fh:
fh.write("-----BEGIN CERTIFICATE-----\n")
moteur.CACHE_CA = ca
self.assertEqual(["--cache-ca", ca], moteur.drapeaux_cache())
def test_a_host_without_one_asks_for_an_exception(self):
"""L'exception par MAC, et non le silence : sur un hôte sans cache
elle ne fait rien et le dit, ce qui ne coûte qu'une ligne ; sur un
hôte qui en porte un, le silence coûte l'étage."""
moteur.CACHE_CA = os.path.join(self.dossier, "absent.crt")
self.assertEqual(["--cache-bypass"], moteur.drapeaux_cache())
def test_the_first_floor_passes_them(self):
"""Les deux piles héritent de creer_etage1 : le drapeau posé là les
couvre toutes les deux."""
import inspect
src = inspect.getsource(moteur.Descente.creer_etage1)
self.assertIn("argv += drapeaux_cache()", src)
def test_the_long_tests_share_one_authority(self):
"""Une seconde définition du chemin dériverait en silence, et poser
la mauvaise autorité échoue comme n'en poser aucune."""
import sys as _sys
_sys.path.insert(0, os.path.join(RACINE, "long_test"))
import qemu_cache
self.assertEqual(moteur.CACHE_CA, qemu_cache.CA)
if __name__ == "__main__":
unittest.main(verbosity=2)