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:
commit
86e2ef0ac5
69 changed files with 8216 additions and 236 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
23
CHANGELOG.md
23
CHANGELOG.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -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),) \
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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] -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
363
conf/nixos/erplibre.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
33
env_var.sh
33
env_var.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
9
long_test/deep_qemu.py
Normal file → Executable 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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
543
long_test/install_nixos.py
Executable 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:]))
|
||||
|
|
@ -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
7
run.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ----"
|
||||
|
|
|
|||
129
script/install/install_nixos_dependency.sh
Executable file
129
script/install/install_nixos_dependency.sh
Executable 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."
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'])}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
167
script/qemu_cache/oublie.go
Normal 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()
|
||||
}
|
||||
188
script/qemu_cache/oublie_test.go
Normal file
188
script/qemu_cache/oublie_test.go
Normal 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()) })
|
||||
}
|
||||
|
|
@ -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à.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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.')}")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
|
|
|||
|
|
@ -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
777
test/test_install_nixos.py
Normal 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()
|
||||
398
test/test_longtest_install_nixos.py
Normal file
398
test/test_longtest_install_nixos.py
Normal 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()
|
||||
183
test/test_proxmox_cloudinit.py
Normal file
183
test/test_proxmox_cloudinit.py
Normal 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()
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
160
test/test_proxmox_uefi.py
Normal 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()
|
||||
|
|
@ -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
|
||||
|
|
|
|||
127
test/test_qemu_bootstrap_nix.py
Normal file
127
test/test_qemu_bootstrap_nix.py
Normal 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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
198
test/test_qemu_nixanywhere.py
Normal file
198
test/test_qemu_nixanywhere.py
Normal 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
394
test/test_qemu_nixos.py
Normal 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()
|
||||
245
test/test_qemu_seed_locale_keyboard.py
Normal file
245
test/test_qemu_seed_locale_keyboard.py
Normal 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
164
test/test_qemu_sommes.py
Normal 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
222
test/test_qemu_timezone.py
Normal 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()
|
||||
173
test/test_qemu_vm_joignable.py
Normal file
173
test/test_qemu_vm_joignable.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue