[ADD] qemu : NixOS déployable, de son image à ses dépendances
Neuvième système du catalogue, et le seul dont aucune distribution ne publie d'image cloud : celle d'un tiers est épinglée et sa somme vérifiée à chaque téléchargement. Son seed diffère aussi — networkd prend la clé d'un bloc pour un NOM d'interface là où netplan honore un « match: », et sshd refuse un compte dont le shell n'existe pas, d'où /bin/sh. Ses dépendances se DÉCLARENT dans conf/nixos/erplibre.nix : envfs répond à /usr/bin/env, nix-ld donne aux roues manylinux leur chargeur. Vérifié sur une VM : le verrou de 362 paquets s'installe et Odoo répond. --- EN --- Ninth system of the catalogue, and the only one no distribution publishes a cloud image for: a third party's is pinned and its sum verified at every download. Its seed differs too — networkd reads a block's key as an interface NAME where netplan honours a "match:", and sshd refuses an account whose shell does not exist, hence /bin/sh. Its dependencies are DECLARED in conf/nixos/erplibre.nix: envfs answers /usr/bin/env, nix-ld gives manylinux wheels their loader. Checked on a VM: the 362-package lock installs and Odoo answers. Assisted-by: Claude Opus 5
This commit is contained in:
parent
f317401881
commit
fce1d5b00b
29 changed files with 1887 additions and 48 deletions
|
|
@ -72,6 +72,9 @@ 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. Checked on a VM: the 362-package lock installs, and Odoo initialises a database and 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
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
|
|
@ -116,6 +119,9 @@ 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. Vérifié sur une VM : le verrou de 362 paquets s'installe, et Odoo initialise une base et 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
|
||||
|
||||
<!-- [en] -->
|
||||
## Changed
|
||||
|
|
@ -193,6 +199,7 @@ 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
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
|
|
@ -234,6 +241,7 @@ 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
|
||||
|
||||
<!-- [en] -->
|
||||
## Removed
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ 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. Vérifié sur une VM : le verrou de 362 paquets s'installe, et Odoo initialise une base et 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
|
||||
|
||||
## Modifié
|
||||
|
||||
|
|
@ -107,6 +110,7 @@ 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
|
||||
|
||||
## Retiré
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,9 @@ 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. Checked on a VM: the 362-package lock installs, and Odoo initialises a database and 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
|
||||
|
||||
## Changed
|
||||
|
||||
|
|
@ -107,6 +110,7 @@ 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
|
||||
|
||||
## Removed
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
167
conf/nixos/erplibre.nix
Normal file
167
conf/nixos/erplibre.nix
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
# © 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
|
||||
];
|
||||
|
||||
# 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" ];
|
||||
|
||||
# « 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";
|
||||
};
|
||||
}
|
||||
15
env_var.sh
15
env_var.sh
|
|
@ -48,3 +48,18 @@ 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
|
||||
|
|
|
|||
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
|
||||
|
|
|
|||
|
|
@ -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 ----"
|
||||
|
|
|
|||
103
script/install/install_nixos_dependency.sh
Executable file
103
script/install/install_nixos_dependency.sh
Executable file
|
|
@ -0,0 +1,103 @@
|
|||
#!/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}
|
||||
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 "s/@EL_USER@/${EL_USER}/g" "${MODULE_SRC}" | sudo tee "${MODULE_DST}" > /dev/null
|
||||
echo " posé : ${MODULE_DST} (compte ${EL_USER})"
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
|
|
|||
|
|
@ -171,6 +171,17 @@ PROXMOX_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|||
PROXMOX_DEBIAN_BASE: dict[str, str] = {"9": "13"}
|
||||
|
||||
# distro -> (table des versions, version par défaut).
|
||||
# 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 — d'où le TAG épinglé et la somme figée plus bas,
|
||||
# et d'où image_source_note(), qui le dit à l'écran avant de déployer.
|
||||
#
|
||||
# 40 Go et 2 Go de RAM là où Arch demande 20 et 1 : le store Nix garde chaque
|
||||
# génération du système et ne se purge qu'à la main (nix-collect-garbage).
|
||||
NIXOS_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
||||
"25.11": ("25.11", "nixos-25.11", 2048, "40G"),
|
||||
}
|
||||
|
||||
DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
|
||||
"ubuntu": (UBUNTU_VERSIONS, "24.04"),
|
||||
"debian": (DEBIAN_VERSIONS, "12"),
|
||||
|
|
@ -179,6 +190,7 @@ DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
|
|||
"rocky": (ROCKY_VERSIONS, "10"),
|
||||
"opensuse": (OPENSUSE_VERSIONS, "16.0"),
|
||||
"arch": (ARCH_VERSIONS, "latest"),
|
||||
"nixos": (NIXOS_VERSIONS, "25.11"),
|
||||
"proxmox": (PROXMOX_VERSIONS, "9"),
|
||||
}
|
||||
|
||||
|
|
@ -414,6 +426,18 @@ def gpu_install_args(node: str) -> list[str]:
|
|||
|
||||
ARCH_CLOUD_BASE = "https://geo.mirror.pkgbuild.com/images/latest"
|
||||
|
||||
# Image NixOS : release ÉPINGLÉE d'un tiers, jamais « latest ». Une release
|
||||
# qui bouge changerait le système de base d'un déploiement à l'autre, et
|
||||
# celle-ci n'est signée par personne — la somme ci-dessous est donc la nôtre,
|
||||
# relevée une fois à la revue, et vérifiée à chaque téléchargement.
|
||||
NIXOS_IMAGE_TAG = "2026.01.18-0057"
|
||||
NIXOS_IMAGE_BASE = (
|
||||
"https://github.com/cloudnull/nixos-openstack/releases/download"
|
||||
)
|
||||
NIXOS_IMAGE_SHA256 = (
|
||||
"9c7df1786106b3bcbed514d5c16e05b735a42642d3de0c51a690e7d8aba4ebc4"
|
||||
)
|
||||
|
||||
CLOUD_IMG_BASE = "https://cloud-images.ubuntu.com"
|
||||
# Debian : cloud.debian.org est un redirecteur qui, selon le réseau, peut
|
||||
# renvoyer vers un miroir injoignable. On essaie donc plusieurs bases dans
|
||||
|
|
@ -538,6 +562,10 @@ def image_candidates(
|
|||
if distro == "arch":
|
||||
# Rolling release : image « latest » officielle (cloud-init inclus).
|
||||
return [f"{ARCH_CLOUD_BASE}/Arch-Linux-{a}-cloudimg.qcow2"]
|
||||
if distro == "nixos":
|
||||
# Un seul asset, sans architecture dans son nom : le tiers ne publie
|
||||
# que x86_64, ce que ARCH_DISTRO_SUPPORT borne déjà.
|
||||
return [f"{NIXOS_IMAGE_BASE}/{NIXOS_IMAGE_TAG}/nixos.qcow2"]
|
||||
raise ValueError(f"URL indisponible pour la distro {distro!r}")
|
||||
|
||||
|
||||
|
|
@ -585,6 +613,25 @@ def resolve_fedora_url(version: str, arch: str, dry_run: bool) -> str:
|
|||
)
|
||||
|
||||
|
||||
def image_source_note(distro: str) -> tuple[str, str] | None:
|
||||
"""(url, tag) quand l'image ne vient PAS de la distribution elle-même.
|
||||
|
||||
None pour tout le reste : on ne commente que ce qui sort de l'ordinaire,
|
||||
et commenter chaque image noierait la seule qui le mérite.
|
||||
|
||||
NixOS ne publie aucune image cloud — sa page de téléchargement n'offre que
|
||||
des ISO, des AMI Amazon et Docker. Celle-ci est donc reconstruite par un
|
||||
tiers, à partir de nixpkgs. Ça se dit AVANT de déployer, pas après : le
|
||||
système de base d'une VM n'est pas un détail d'implémentation.
|
||||
"""
|
||||
if distro != "nixos":
|
||||
return None
|
||||
return (
|
||||
f"{NIXOS_IMAGE_BASE}/{NIXOS_IMAGE_TAG}/nixos.qcow2",
|
||||
NIXOS_IMAGE_TAG,
|
||||
)
|
||||
|
||||
|
||||
def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
|
||||
"""Nom de fichier local pour le cache d'image."""
|
||||
a = distro_arch(distro, arch)
|
||||
|
|
@ -600,6 +647,10 @@ def default_image_name(distro: str, code: str, arch: str, version: str) -> str:
|
|||
return f"debian-{PROXMOX_DEBIAN_BASE[version]}-genericcloud-{a}.qcow2"
|
||||
if distro == "arch":
|
||||
return f"arch-linux-{a}-cloudimg.qcow2"
|
||||
if distro == "nixos":
|
||||
# Le TAG est dans le nom : changer NIXOS_IMAGE_TAG retélécharge, au
|
||||
# lieu de réutiliser une image qui ne répond plus à sa somme.
|
||||
return f"nixos-{NIXOS_IMAGE_TAG}-{a}.qcow2"
|
||||
if distro == "opensuse":
|
||||
if version == "tumbleweed":
|
||||
return f"opensuse-tumbleweed-minimal-vm-{a}.qcow2"
|
||||
|
|
@ -1595,6 +1646,39 @@ def download_image(
|
|||
)
|
||||
|
||||
|
||||
def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
|
||||
"""Vérifie une image contre la somme que le DÉPÔT porte pour elle.
|
||||
|
||||
Sans rapport avec --verify, qui lit un SHA256SUMS publié par la
|
||||
distribution : ici l'amont n'en publie aucun, la somme 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
|
||||
TOUJOURS, sans drapeau à passer.
|
||||
|
||||
Une somme qui ne correspond pas ARRÊTE le déploiement : continuer
|
||||
reviendrait à installer un système que personne n'a regardé.
|
||||
"""
|
||||
if distro != "nixos" or dry_run:
|
||||
return
|
||||
attendu = NIXOS_IMAGE_SHA256
|
||||
digest = hashlib.sha256()
|
||||
with open(image, "rb") as fh:
|
||||
for morceau in iter(lambda: fh.read(1 << 20), b""):
|
||||
digest.update(morceau)
|
||||
obtenu = digest.hexdigest()
|
||||
if obtenu != attendu:
|
||||
sys.exit(
|
||||
f"Erreur : l'image {image} ne correspond pas à la somme que le "
|
||||
f"dépôt porte pour elle.\n"
|
||||
f" attendu : {attendu}\n"
|
||||
f" obtenu : {obtenu}\n"
|
||||
" Supprimez le fichier pour le retélécharger. S'il revient "
|
||||
"différent, l'amont a republié sous le même tag : le relire "
|
||||
"avant de figer la nouvelle somme."
|
||||
)
|
||||
print(f" Somme sha256 conforme à celle du dépôt ({attendu[:12]}…).")
|
||||
|
||||
|
||||
def verify_sha256(url: str, image: Path, dry_run: bool) -> None:
|
||||
"""Vérifie l'empreinte via le SHA256SUMS publié dans le même répertoire."""
|
||||
if dry_run:
|
||||
|
|
@ -1807,17 +1891,29 @@ def canonical_timezone(tz: str, table: str = TZ_ALIASES) -> str:
|
|||
La table des alias est « /usr/share/zoneinfo/tzdata.zi », dont chaque ligne
|
||||
de lien s'écrit « L <canonique> <alias> ». Absente ou illisible, le nom est
|
||||
rendu tel quel : un fuseau non traduit vaut mieux qu'un déploiement refusé.
|
||||
Elle est lue de l'HÔTE plutôt que recopiée ici, et suit donc les mises à
|
||||
jour de tzdata sans qu'on s'en occupe ; « table » n'existe que pour qu'un
|
||||
test en fournisse une autre sans dépendre du tzdata de sa machine.
|
||||
|
||||
DEUX tours, et non un seul : un lien peut désigner un autre lien —
|
||||
« Universal » mène à « UTC », qui mène à « Etc/UTC ». S'arrêter au premier
|
||||
rendrait un nom qui reste un alias, donc le défaut qu'on répare. Au-delà
|
||||
de deux, la table est incohérente et le nom d'origine vaut mieux qu'une
|
||||
boucle.
|
||||
"""
|
||||
if not tz:
|
||||
return tz
|
||||
alias = {}
|
||||
try:
|
||||
with open(table, encoding="utf-8") as fh:
|
||||
for ligne in fh:
|
||||
champs = ligne.split()
|
||||
if len(champs) >= 3 and champs[0] == "L" and champs[2] == tz:
|
||||
return champs[1]
|
||||
if len(champs) >= 3 and champs[0] == "L":
|
||||
alias[champs[2]] = champs[1]
|
||||
except OSError:
|
||||
pass
|
||||
return tz
|
||||
for _ in range(2):
|
||||
tz = alias.get(tz, tz)
|
||||
return tz
|
||||
|
||||
|
||||
|
|
@ -1916,6 +2012,21 @@ def user_groups(distro: str, gpu: bool = False) -> str:
|
|||
return ", ".join(noms)
|
||||
|
||||
|
||||
def user_shell(distro: str) -> str:
|
||||
"""Shell de connexion du compte créé par cloud-init.
|
||||
|
||||
sshd REFUSE un compte dont le shell n'existe pas — « User <x> not allowed
|
||||
because shell /bin/bash does not exist », avant même l'authentification,
|
||||
et la VM est alors inaccessible bien qu'elle démarre et porte la clé.
|
||||
|
||||
NixOS ne peuple pas /bin : il n'y met que « sh », lui-même lien vers le
|
||||
bash du store. C'est donc bash qu'on obtient, en mode POSIX, et non dash.
|
||||
Ailleurs /bin/bash existe et reste préférable : sur Debian et Ubuntu,
|
||||
/bin/sh EST dash, sans historique ni complétion.
|
||||
"""
|
||||
return "/bin/sh" if distro == "nixos" else "/bin/bash"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Guide de connexion (/etc/motd) et identité git de la VM
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
@ -1951,6 +2062,7 @@ DISTRO_LABELS: dict[str, str] = {
|
|||
"rocky": "Rocky Linux",
|
||||
"opensuse": "openSUSE",
|
||||
"arch": "Arch Linux",
|
||||
"nixos": "NixOS",
|
||||
"proxmox": "Proxmox VE",
|
||||
}
|
||||
|
||||
|
|
@ -1963,6 +2075,7 @@ DISTRO_PKG: dict[str, str] = {
|
|||
"rocky": "dnf",
|
||||
"opensuse": "zypper",
|
||||
"arch": "pacman",
|
||||
"nixos": "nix",
|
||||
# Debian dessous : c'est apt qui sert, et le guide de connexion le dit.
|
||||
"proxmox": "apt",
|
||||
}
|
||||
|
|
@ -1991,6 +2104,30 @@ def distro_label(distro: str, version: str) -> str:
|
|||
# « list installed » sans tirets y échouent tous. Les formes longues passent
|
||||
# partout, et ne coûtent rien.
|
||||
PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
|
||||
# Rien ne s'y installe « pour de bon » par une commande : ce qui doit
|
||||
# rester se DÉCLARE, et le guide le dit dans cet ordre.
|
||||
"nix": (
|
||||
(
|
||||
"nix-shell -p <paquet>",
|
||||
"essayer, le temps d'un shell",
|
||||
"try it, for one shell",
|
||||
),
|
||||
(
|
||||
"/etc/nixos/configuration.nix",
|
||||
"y déclarer ce qui reste",
|
||||
"declare there what stays",
|
||||
),
|
||||
(
|
||||
"sudo nixos-rebuild switch",
|
||||
"appliquer la déclaration",
|
||||
"apply the declaration",
|
||||
),
|
||||
(
|
||||
"sudo nix-collect-garbage -d",
|
||||
"libérer les générations",
|
||||
"free the generations kept",
|
||||
),
|
||||
),
|
||||
"apt": (
|
||||
("sudo apt update", "rafraîchir l'index", "refresh the index"),
|
||||
(
|
||||
|
|
@ -2950,7 +3087,7 @@ def build_cloud_config(
|
|||
# Le privilège lui-même vient de la ligne « sudo: » ci-dessus, pas du
|
||||
# groupe : celui-ci n'est qu'une commodité.
|
||||
f" groups: {user_groups(args.distro, gpu)}",
|
||||
" shell: /bin/bash",
|
||||
f" shell: {user_shell(args.distro)}",
|
||||
" lock_passwd: false" if pw_hash else " lock_passwd: true",
|
||||
]
|
||||
if pw_hash:
|
||||
|
|
@ -2962,7 +3099,10 @@ def build_cloud_config(
|
|||
|
||||
lines.append(f"ssh_pwauth: {'true' if pw_hash else 'false'}")
|
||||
lines.append(f"locale: {args.locale}")
|
||||
lines.append(f"timezone: {args.timezone}")
|
||||
# Canonicalisé ICI, au plus près de l'écriture : un fuseau passé
|
||||
# explicitement en ligne de commande mérite la même traduction que celui
|
||||
# de l'hôte.
|
||||
lines.append(f"timezone: {canonical_timezone(args.timezone)}")
|
||||
if getattr(args, "distro", "ubuntu") == "ubuntu":
|
||||
lines += apt_mirror_lines(
|
||||
getattr(args, "arch", "amd64"),
|
||||
|
|
@ -3082,9 +3222,20 @@ def build_cloud_config(
|
|||
# network-config (cloud-init v2) : DHCP sur toute interface « e* ». Les images
|
||||
# Debian genericcloud ne configurent pas toujours le réseau sans ça (le NIC
|
||||
# reste down -> pas d'IP), contrairement à Ubuntu. Inoffensif pour Ubuntu.
|
||||
# La clé est « eth0 » car le renderer cloud-init d'Arch utilise la CLÉ comme
|
||||
# nom d'interface (en ignorant « match ») et l'image Arch nomme son NIC eth0 ;
|
||||
# Debian/Fedora/Ubuntu utilisent bien « match: name: e* » (leur en*).
|
||||
# La clé n'est pas partout un nom d'interface. Les renderers netplan et ENI
|
||||
# (Ubuntu, Debian) la tiennent pour une étiquette et désignent l'interface par
|
||||
# le « match » ; le renderer networkd (Arch, NixOS) IGNORE le « match » et
|
||||
# écrit la clé telle quelle en « Name= ». L'image Arch nomme son NIC eth0, ce
|
||||
# que la clé couvre ; une image qui le nomme enp0s2 reçoit un « Name=eth0 »
|
||||
# qui ne correspond à rien, systemd-networkd-wait-online attend alors 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. La VM démarre,
|
||||
# applique la clé SSH, et n'est jamais joignable.
|
||||
#
|
||||
# Un glob en clé réglerait networkd et CASSERAIT netplan, qui refuse net :
|
||||
# « Definition ID 'e*' must not use globbing », cloud-init.service en échec,
|
||||
# aucun compte créé. Aucune valeur ne contente les deux renderers, d'où
|
||||
# network_config_for().
|
||||
NETWORK_CONFIG = (
|
||||
"version: 2\n"
|
||||
"ethernets:\n"
|
||||
|
|
@ -3096,10 +3247,36 @@ NETWORK_CONFIG = (
|
|||
)
|
||||
|
||||
|
||||
def network_config_for(distro: str) -> str | None:
|
||||
"""Le network-config à mettre dans le seed, ou None pour n'en pas mettre.
|
||||
|
||||
None n'est pas « pas de réseau » : privée de configuration réseau par sa
|
||||
source de données, cloud-init produit son repli — DHCP sur la première
|
||||
interface trouvée, désignée par son VRAI nom. C'est ce qu'il faut là où
|
||||
le renderer prend la clé pour un nom d'interface et où ce nom n'est pas
|
||||
connu d'avance.
|
||||
|
||||
NixOS est dans ce cas : son image nomme le NIC selon la machine émulée
|
||||
(enp0s2 en q35, ens3 en i440fx). Arch a le même renderer mais nomme le
|
||||
sien eth0, ce que la clé du document couvre : il garde donc le document,
|
||||
et son réseau ne change pas.
|
||||
"""
|
||||
return None if distro == "nixos" else NETWORK_CONFIG
|
||||
|
||||
|
||||
def build_seed(
|
||||
cloud_cfg: str, hostname: str, seed_dest: Path, runner: Runner
|
||||
cloud_cfg: str,
|
||||
hostname: str,
|
||||
seed_dest: Path,
|
||||
runner: Runner,
|
||||
network_config: str | None = NETWORK_CONFIG,
|
||||
) -> None:
|
||||
"""Génère le seed.iso (cidata) et le copie vers seed_dest."""
|
||||
"""Génère le seed.iso (cidata) et le copie vers seed_dest.
|
||||
|
||||
`network_config` à None n'écrit PAS de document réseau dans le seed, et
|
||||
c'est un réglage à part entière : cloud-init produit alors son repli,
|
||||
DHCP sur la première interface trouvée. Voir network_config_for().
|
||||
"""
|
||||
meta_data = f"instance-id: {hostname}\nlocal-hostname: {hostname}\n"
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
|
@ -3113,18 +3290,23 @@ def build_seed(
|
|||
print(" [dry-run] user-data qui serait généré :")
|
||||
print(textwrap.indent(cloud_cfg, " "))
|
||||
print(" [dry-run] network-config :")
|
||||
print(textwrap.indent(NETWORK_CONFIG, " "))
|
||||
print(
|
||||
textwrap.indent(network_config, " ")
|
||||
if network_config
|
||||
else " (aucun : repli DHCP de cloud-init)"
|
||||
)
|
||||
else:
|
||||
ud.write_text(cloud_cfg)
|
||||
md.write_text(meta_data)
|
||||
nc.write_text(NETWORK_CONFIG)
|
||||
if network_config:
|
||||
nc.write_text(network_config)
|
||||
|
||||
if runner.dry_run or shutil.which("cloud-localds"):
|
||||
reseau = ["--network-config", str(nc)] if network_config else []
|
||||
runner.run(
|
||||
[
|
||||
"cloud-localds",
|
||||
"--network-config",
|
||||
str(nc),
|
||||
*reseau,
|
||||
str(local_iso),
|
||||
str(ud),
|
||||
str(md),
|
||||
|
|
@ -3350,7 +3532,7 @@ def build_preseed(
|
|||
"d-i network-console/password password erplibre",
|
||||
"d-i network-console/password-again password erplibre",
|
||||
"d-i clock-setup/utc boolean true",
|
||||
f"d-i time/zone string {args.timezone}",
|
||||
f"d-i time/zone string {canonical_timezone(args.timezone)}",
|
||||
"d-i clock-setup/ntp boolean true",
|
||||
# Le disque est nommé : sur s390x virtio-ccw il n'y en a qu'un, mais
|
||||
# d-i pose quand même la question quand rien ne le désigne.
|
||||
|
|
@ -4804,6 +4986,7 @@ def main() -> None:
|
|||
)
|
||||
print(f" Destination : {args.image_path}")
|
||||
download_image(urls, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
|
||||
if do_verify:
|
||||
verify_sha256(url, args.image_path, args.dry_run)
|
||||
print("\nTerminé (téléchargement seul).")
|
||||
|
|
@ -4911,6 +5094,7 @@ def main() -> None:
|
|||
f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) =="
|
||||
)
|
||||
download_image(urls, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
|
||||
if do_verify:
|
||||
verify_sha256(url, args.image_path, args.dry_run)
|
||||
|
||||
|
|
@ -4919,7 +5103,13 @@ def main() -> None:
|
|||
|
||||
print(f"\n== 4/5 Seed cloud-init {seed} ==")
|
||||
cloud_cfg = build_cloud_config(args, pw_hash, ssh_keys)
|
||||
build_seed(cloud_cfg, args.hostname, seed, runner)
|
||||
build_seed(
|
||||
cloud_cfg,
|
||||
args.hostname,
|
||||
seed,
|
||||
runner,
|
||||
network_config_for(args.distro),
|
||||
)
|
||||
|
||||
resolved_osinfo = osinfo_arg(osinfo, args.distro)
|
||||
print(f"\n== 5/5 virt-install (--osinfo {resolved_osinfo}) ==")
|
||||
|
|
|
|||
|
|
@ -1044,12 +1044,58 @@ 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
|
||||
)
|
||||
if spec.get("install") and "nixos" in distros:
|
||||
lignes.append(
|
||||
"⚠ "
|
||||
+ t(
|
||||
"ERPLibre does not install on NixOS yet: the VM is"
|
||||
" created, the install fails"
|
||||
)
|
||||
)
|
||||
return lignes
|
||||
|
||||
def _qemu_sudo_lines(self):
|
||||
"""Pourquoi le déploiement va demander le mot de passe. Vide s'il ne
|
||||
le demandera pas.
|
||||
|
|
|
|||
|
|
@ -824,7 +824,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 +841,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 +857,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 +914,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 +953,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 +1028,7 @@ class QemuInstallMixin:
|
|||
"rocky": "dnf",
|
||||
"opensuse": "zypper",
|
||||
"arch": "pacman",
|
||||
"nixos": "nix",
|
||||
}
|
||||
|
||||
def _qemu_guest_context(self):
|
||||
|
|
@ -2438,6 +2483,92 @@ 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"
|
||||
# L'installateur écrit l'un OU l'autre selon sa version ; les deux
|
||||
# portent le PATH et les variables du démon.
|
||||
profils = "/etc/profile.d/nix.sh /etc/profile.d/nix-daemon.sh"
|
||||
source_line = f'for f in {profils}; do [ -e "$f" ] && . "$f"; done'
|
||||
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
|
||||
+ (
|
||||
f"grep -qF {shlex.quote(source_line)} ~/.bashrc 2>/dev/null"
|
||||
f" || echo {shlex.quote(source_line)} >> ~/.bashrc; "
|
||||
)
|
||||
# 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 +2589,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,
|
||||
|
|
|
|||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -5763,6 +5763,32 @@ 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",
|
||||
},
|
||||
"ERPLibre does not install on NixOS yet: the VM is created, the install"
|
||||
" fails": {
|
||||
"fr": (
|
||||
"ERPLibre ne s'installe pas encore sur NixOS : la VM est créée,"
|
||||
" l'installation échoue"
|
||||
),
|
||||
"en": (
|
||||
"ERPLibre does not install on NixOS yet: the VM is created, the"
|
||||
" install fails"
|
||||
),
|
||||
},
|
||||
# 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 +5831,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",
|
||||
|
|
|
|||
202
test/test_install_nixos.py
Normal file
202
test/test_install_nixos.py
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
#!/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"
|
||||
|
||||
|
||||
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_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)
|
||||
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
197
test/test_qemu_nixanywhere.py
Normal file
197
test/test_qemu_nixanywhere.py
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
#!/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_the_rc_line_is_written_once(self):
|
||||
"""Même invariant sur ~/.bashrc."""
|
||||
self.assertIn("grep -qF 'for f in /etc/profile.d/nix.sh", self.cmd)
|
||||
|
||||
def test_both_profile_scripts_are_sourced(self):
|
||||
"""L'installateur écrit l'un OU l'autre selon sa version."""
|
||||
self.assertIn("/etc/profile.d/nix.sh", self.cmd)
|
||||
self.assertIn("/etc/profile.d/nix-daemon.sh", 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
|
||||
et n'écrit rien hors de son répertoire temporaire."""
|
||||
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)
|
||||
rc = Path(maison, ".bashrc")
|
||||
self.assertIn("/etc/profile.d/nix.sh", rc.read_text())
|
||||
|
||||
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()
|
||||
230
test/test_qemu_nixos.py
Normal file
230
test/test_qemu_nixos.py
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
#!/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)
|
||||
|
||||
|
||||
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_the_link_and_the_warning(self):
|
||||
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)
|
||||
self.assertIn("ERPLibre", texte)
|
||||
|
||||
def test_without_an_install_the_warning_is_not_shown(self):
|
||||
"""Une VM NixOS sans installation ERPLibre n'a rien qui échoue : le
|
||||
dire serait du bruit."""
|
||||
todo = TODO.__new__(TODO)
|
||||
spec = {"vms": [{"name": "vm", "distro": "nixos"}], "install": None}
|
||||
with mock.patch.object(TODO, "_qemu_import_module", return_value=DQ):
|
||||
lignes = todo._qemu_image_lines(spec)
|
||||
self.assertNotIn("ERPLibre", " ".join(lignes))
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
177
test/test_qemu_timezone.py
Normal file
177
test/test_qemu_timezone.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
#!/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 :
|
||||
un lien peut désigner un AUTRE lien — « Universal » mène à « UTC », qui mène
|
||||
à « Etc/UTC ». S'arrêter au premier rendrait un nom qui reste un alias, donc
|
||||
le défaut même qu'on répare.
|
||||
|
||||
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. La
|
||||
# dernière paire enchaîne deux liens, ce qu'aucune autre n'éprouve.
|
||||
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):
|
||||
"""« Universal » pointe « UTC », qui pointe « Etc/UTC ».
|
||||
|
||||
C'est ce que la lecture ligne à ligne, qui rend au premier lien
|
||||
trouvé, ne sait pas faire : elle rendrait « UTC », un alias que
|
||||
l'image cloud peut très bien ne pas porter non plus.
|
||||
"""
|
||||
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), "")
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def test_two_passes_and_not_one(self):
|
||||
"""Le tour unique rendrait un alias d'alias inchangé au deuxième
|
||||
niveau : c'est précisément ce que ce fichier garde."""
|
||||
i = self.SRC.index("def canonical_timezone(")
|
||||
corps = self.SRC[i : i + 1800]
|
||||
self.assertIn("for _ in range(2):", corps)
|
||||
|
||||
|
||||
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 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()
|
||||
Loading…
Reference in a new issue