[ADD] proxmox : NixOS s'y déploie, ce qui a pris trois correctifs
Aucune lecture de code ne les aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console MUETTE, d'où un marqueur par distribution — Debian 13 démarre en SeaBIOS sur le même hôte, et renverser le défaut coûterait un disque EFI à chaque VM du parc. « --ciuser » s'en remet au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part en extrait pour toutes les distributions. Et le lecteur cloud-init passe sur le bus SCSI, une image bâtie pour virtio seul ne voyant jamais un lecteur IDE. --- EN --- No code reading would have found them. Its image has no BIOS boot sector: a VM created in SeaBIOS reports "running" with a SILENT console, hence a per-distribution marker — Debian 13 boots in SeaBIOS on the same host, and flipping the default would cost an EFI disk on every VM. "--ciuser" defers to the image's DEFAULT account, which NixOS names otherwise: the repository's cloud-config now travels as a snippet for every distribution. And the cloud-init drive moves to the SCSI bus, an image built for virtio alone never seeing an IDE drive. Assisted-by: Claude Opus 5
This commit is contained in:
parent
5c87238dd5
commit
f3edd806bc
9 changed files with 635 additions and 41 deletions
|
|
@ -75,6 +75,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
- NixOS 25.11 as a deployable system, alongside the eight others. It is the only one no distribution publishes a cloud image for: the image is rebuilt by a third party, so its release is pinned, its sha256 verified at every download, and its origin printed before anything is created. cloud-init receives no network configuration there — its networkd renderer takes the key of a block as an interface NAME, where netplan honours a `match:` — and the account is created with `/bin/sh`, sshd refusing an account whose shell does not exist
|
||||
- ERPLibre installed on NixOS, its dependencies DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time. `services.envfs` answers `/usr/bin/env` and `programs.nix-ld` gives manylinux wheels the dynamic loader they ask for; the headers of what has no wheel are linked into the system profile, and its libraries reach the loader too, a module compiled on the machine carrying no RPATH. The install reaches it from the menu like any other distribution: the bootstrap poses git, make and python3 — absent from that image — in the user profile for the clone, and « make install_os » redeclares them for the system. Checked on a VM: the 362-package lock installs and Odoo answers over HTTP
|
||||
- A `nix + nixos-anywhere` option on the OTHER distributions, the reverse of picking NixOS: it leaves an ordinary VM able to install NixOS onto any machine reachable over SSH. Nix is called by absolute path, the remote shell's PATH being frozen before the installer drops the binary, and the flake features are repeated on the command line, writing `nix.conf` needing a sudo that may fail
|
||||
- NixOS deploys on a **Proxmox** host as it does on QEMU/KVM, which took three fixes no code reading could have found. Its image has no BIOS boot sector: a VM created in SeaBIOS reports « running » with a silent console, so the catalogue now marks which images need UEFI — one marker per distribution, since Debian 13 boots in SeaBIOS on that same host. `--ciuser` and `--sshkeys` defer to the image's DEFAULT account, which NixOS declares as another name: the repository's own cloud-config now travels as a snippet for every distribution, carrying an explicit `users:` block. And the cloud-init drive moves to the SCSI bus — an image built for virtio alone has no ATA driver and never sees an IDE drive, so cloud-init hunted network datasources and the VM came up with no account at all. Checked end to end: NixOS answers with `/bin/sh`, Debian 13 with `/bin/bash`, sudo on both
|
||||
- The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
|
|
@ -122,6 +124,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
- NixOS 25.11 parmi les systèmes déployables, à côté des huit autres. C'est le seul dont aucune distribution ne publie d'image cloud : l'image est rebâtie par un tiers, donc sa version est épinglée, sa somme sha256 vérifiée à chaque téléchargement, et son origine dite avant que rien ne soit créé. cloud-init n'y reçoit aucune configuration réseau — son moteur networkd prend la clé d'un bloc pour un NOM d'interface, là où netplan honore un `match:` — et le compte y est créé avec `/bin/sh`, sshd refusant un compte dont le shell n'existe pas
|
||||
- ERPLibre installé sur NixOS, ses dépendances DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande. `services.envfs` répond à `/usr/bin/env` et `programs.nix-ld` donne aux roues manylinux le chargeur dynamique qu'elles réclament ; les en-têtes de ce qui n'a pas de roue sont liés au profil du système, et leurs bibliothèques vont aussi au chargeur, un module compilé sur la machine ne portant pas de RPATH. L'installation l'atteint depuis le menu comme toute autre distribution : l'amorçage pose git, make et python3 — absents de cette image — dans le profil de l'utilisateur pour la durée du clone, et « make install_os » les redéclare pour le système. Vérifié sur une VM : le verrou de 362 paquets s'installe et Odoo répond en HTTP
|
||||
- Une option `nix + nixos-anywhere` sur les AUTRES distributions, l'inverse de choisir NixOS : elle laisse une VM ordinaire capable d'installer NixOS sur toute machine joignable en SSH. Nix est appelé par son chemin absolu, le PATH du shell distant étant figé avant que l'installateur ne pose le binaire, et les fonctions des flakes sont redonnées sur la ligne de commande, l'écriture de `nix.conf` réclamant un sudo qui peut manquer
|
||||
- NixOS se déploie sur un hôte **Proxmox** comme sur QEMU/KVM, ce qui a demandé trois correctifs qu'aucune lecture de code n'aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console muette, donc le catalogue marque désormais les images qui exigent l'UEFI — un marqueur par distribution, Debian 13 démarrant en SeaBIOS sur ce même hôte. `--ciuser` et `--sshkeys` s'en remettent au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part maintenant comme extrait pour toutes les distributions, avec son bloc `users:` explicite. Et le lecteur cloud-init passe sur le bus SCSI — une image bâtie pour virtio seul n'a pas de pilote ATA et ne voit jamais un lecteur IDE, si bien que cloud-init cherchait des sources réseau et que la VM arrivait sans aucun compte. Vérifié de bout en bout : NixOS rend `/bin/sh`, Debian 13 `/bin/bash`, sudo aux deux
|
||||
- L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas
|
||||
|
||||
<!-- [en] -->
|
||||
## Changed
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
- NixOS 25.11 parmi les systèmes déployables, à côté des huit autres. C'est le seul dont aucune distribution ne publie d'image cloud : l'image est rebâtie par un tiers, donc sa version est épinglée, sa somme sha256 vérifiée à chaque téléchargement, et son origine dite avant que rien ne soit créé. cloud-init n'y reçoit aucune configuration réseau — son moteur networkd prend la clé d'un bloc pour un NOM d'interface, là où netplan honore un `match:` — et le compte y est créé avec `/bin/sh`, sshd refusant un compte dont le shell n'existe pas
|
||||
- ERPLibre installé sur NixOS, ses dépendances DÉCLARÉES dans `conf/nixos/erplibre.nix` puis appliquées par `nixos-rebuild`, là où les quatre autres familles les installent commande par commande. `services.envfs` répond à `/usr/bin/env` et `programs.nix-ld` donne aux roues manylinux le chargeur dynamique qu'elles réclament ; les en-têtes de ce qui n'a pas de roue sont liés au profil du système, et leurs bibliothèques vont aussi au chargeur, un module compilé sur la machine ne portant pas de RPATH. L'installation l'atteint depuis le menu comme toute autre distribution : l'amorçage pose git, make et python3 — absents de cette image — dans le profil de l'utilisateur pour la durée du clone, et « make install_os » les redéclare pour le système. Vérifié sur une VM : le verrou de 362 paquets s'installe et Odoo répond en HTTP
|
||||
- Une option `nix + nixos-anywhere` sur les AUTRES distributions, l'inverse de choisir NixOS : elle laisse une VM ordinaire capable d'installer NixOS sur toute machine joignable en SSH. Nix est appelé par son chemin absolu, le PATH du shell distant étant figé avant que l'installateur ne pose le binaire, et les fonctions des flakes sont redonnées sur la ligne de commande, l'écriture de `nix.conf` réclamant un sudo qui peut manquer
|
||||
- NixOS se déploie sur un hôte **Proxmox** comme sur QEMU/KVM, ce qui a demandé trois correctifs qu'aucune lecture de code n'aurait trouvés. Son image n'a pas de secteur d'amorçage BIOS : une VM créée en SeaBIOS se déclare « running » avec une console muette, donc le catalogue marque désormais les images qui exigent l'UEFI — un marqueur par distribution, Debian 13 démarrant en SeaBIOS sur ce même hôte. `--ciuser` et `--sshkeys` s'en remettent au compte par DÉFAUT de l'image, que NixOS nomme autrement : le cloud-config du dépôt part maintenant comme extrait pour toutes les distributions, avec son bloc `users:` explicite. Et le lecteur cloud-init passe sur le bus SCSI — une image bâtie pour virtio seul n'a pas de pilote ATA et ne voit jamais un lecteur IDE, si bien que cloud-init cherchait des sources réseau et que la VM arrivait sans aucun compte. Vérifié de bout en bout : NixOS rend `/bin/sh`, Debian 13 `/bin/bash`, sudo aux deux
|
||||
- L'image tierce est vérifiée contre la somme que le dépôt épingle pour elle sur le chemin Proxmox aussi, où un `wget` nu suffisait. Un seul accesseur porte cette somme pour les deux chemins, et le contrôle vaut même pour une image déjà en cache — le cas visé est un fichier substitué ou tronqué entre deux déploiements, qu'un test de présence ne voit pas
|
||||
|
||||
## Modifié
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||
- NixOS 25.11 as a deployable system, alongside the eight others. It is the only one no distribution publishes a cloud image for: the image is rebuilt by a third party, so its release is pinned, its sha256 verified at every download, and its origin printed before anything is created. cloud-init receives no network configuration there — its networkd renderer takes the key of a block as an interface NAME, where netplan honours a `match:` — and the account is created with `/bin/sh`, sshd refusing an account whose shell does not exist
|
||||
- ERPLibre installed on NixOS, its dependencies DECLARED in `conf/nixos/erplibre.nix` and applied by `nixos-rebuild`, where the four other families install them one command at a time. `services.envfs` answers `/usr/bin/env` and `programs.nix-ld` gives manylinux wheels the dynamic loader they ask for; the headers of what has no wheel are linked into the system profile, and its libraries reach the loader too, a module compiled on the machine carrying no RPATH. The install reaches it from the menu like any other distribution: the bootstrap poses git, make and python3 — absent from that image — in the user profile for the clone, and « make install_os » redeclares them for the system. Checked on a VM: the 362-package lock installs and Odoo answers over HTTP
|
||||
- A `nix + nixos-anywhere` option on the OTHER distributions, the reverse of picking NixOS: it leaves an ordinary VM able to install NixOS onto any machine reachable over SSH. Nix is called by absolute path, the remote shell's PATH being frozen before the installer drops the binary, and the flake features are repeated on the command line, writing `nix.conf` needing a sudo that may fail
|
||||
- NixOS deploys on a **Proxmox** host as it does on QEMU/KVM, which took three fixes no code reading could have found. Its image has no BIOS boot sector: a VM created in SeaBIOS reports « running » with a silent console, so the catalogue now marks which images need UEFI — one marker per distribution, since Debian 13 boots in SeaBIOS on that same host. `--ciuser` and `--sshkeys` defer to the image's DEFAULT account, which NixOS declares as another name: the repository's own cloud-config now travels as a snippet for every distribution, carrying an explicit `users:` block. And the cloud-init drive moves to the SCSI bus — an image built for virtio alone has no ATA driver and never sees an IDE drive, so cloud-init hunted network datasources and the VM came up with no account at all. Checked end to end: NixOS answers with `/bin/sh`, Debian 13 with `/bin/bash`, sudo on both
|
||||
- The third-party image is verified against the sum the repository pins for it on the Proxmox path too, where a bare `wget` used to be enough. One accessor carries that sum for both paths, and the check runs even on a cached image — the case aimed at is a file substituted or truncated between deployments, which a presence test cannot see
|
||||
|
||||
## Changed
|
||||
|
||||
|
|
|
|||
|
|
@ -49,9 +49,9 @@ DISK_CONTENT = ("images", "rootdir")
|
|||
def ssh_argv(host: dict, remote: str, tty: bool = False) -> list:
|
||||
"""Commande ssh complète pour exécuter `remote` sur l'hôte Proxmox.
|
||||
|
||||
`host` : {"target": "root@10.0.0.5", "jump": "rebond", "port": "22"} —
|
||||
« target » suffit quand l'alias vient de ~/.ssh/config, qui porte déjà
|
||||
l'utilisateur, le port et le ProxyJump.
|
||||
`host` : {"target": "root@hyperviseur", "jump": "rebond", "port": "22"}
|
||||
— « target » suffit quand l'alias vient de ~/.ssh/config, qui porte
|
||||
déjà l'utilisateur, le port et le ProxyJump.
|
||||
"""
|
||||
argv = ["ssh"]
|
||||
if not tty:
|
||||
|
|
@ -384,8 +384,8 @@ def ssh_server_ip(text: str) -> str:
|
|||
« client_ip client_port SERVER_ip server_port » : le troisième champ. C'est
|
||||
la seule adresse dont on SAIT qu'elle mène à la machine, rebond compris.
|
||||
|
||||
Les candidats habituels se trompent ici. Mesuré sur une Proxmox imbriquée :
|
||||
« hostname -I » rend « 10.10.10.150 10.10.20.1 », et la seconde est le pont
|
||||
Les candidats habituels se trompent ici. Sur une Proxmox imbriquée,
|
||||
« hostname -I » rend PLUSIEURS adresses, et l'une d'elles est le pont
|
||||
interne que notre propre code vient de créer. La poser dans /etc/hosts
|
||||
ferait s'identifier le nœud par une adresse que personne ne joint.
|
||||
"""
|
||||
|
|
@ -740,8 +740,8 @@ INTERNAL_BRIDGE = "vmbr0"
|
|||
INTERNAL_CIDR = "10.10.10.1/24"
|
||||
|
||||
# Le réseau interne ne peut PAS être une constante : un Proxmox dans un
|
||||
# Proxmox hérite du réseau interne de son parent, et 10.10.10.1 y est
|
||||
# l'adresse de sa propre PASSERELLE. La poser sur son pont rend tout le /24
|
||||
# Proxmox hérite du réseau interne de son parent, et l'adresse de
|
||||
# INTERNAL_CIDR y est celle de sa propre PASSERELLE. La poser sur son pont rend tout le /24
|
||||
# local — la passerelle devient injoignable et la machine s'isole
|
||||
# instantanément, au milieu de la commande qui la configure. Vécu : « ifup »
|
||||
# n'a jamais rendu la main et la VM ne répondait plus, ni en ssh ni en ping.
|
||||
|
|
@ -760,8 +760,8 @@ INTERNAL_CANDIDATES = (
|
|||
)
|
||||
|
||||
# Tout ce que l'hôte sait déjà d'IPv4 : ses adresses ET ses routes. Les deux,
|
||||
# parce qu'une route sans adresse locale suffit à créer le conflit — la route
|
||||
# par défaut « via 10.10.10.1 » en est l'exemple exact.
|
||||
# parce qu'une route sans adresse locale suffit à créer le conflit — une
|
||||
# route par défaut « via » la passerelle d'un parent en est l'exemple exact.
|
||||
USED_NETS_CMD = "ip -o -4 addr show; ip -4 route show"
|
||||
|
||||
|
||||
|
|
@ -926,7 +926,7 @@ def ipconfig_for(pont_info: dict, vmid: int) -> str:
|
|||
|
||||
|
||||
def ip_from_ipconfig(ipconfig: str) -> str:
|
||||
"""Adresse fixe d'un « ip=10.10.10.150/24,gw=… », ou '' si c'est du DHCP.
|
||||
"""Adresse fixe d'un « ip=192.0.2.50/24,gw=… », ou '' si c'est du DHCP.
|
||||
|
||||
Quand c'est NOUS qui avons attribué l'adresse, la chercher ensuite est
|
||||
absurde : elle est connue avant que la VM ne démarre. La découverte (agent
|
||||
|
|
@ -936,21 +936,70 @@ def ip_from_ipconfig(ipconfig: str) -> str:
|
|||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def image_fetch_cmd(url: str, nom: str, repertoire: str = IMAGE_DIR) -> str:
|
||||
def image_fetch_cmd(
|
||||
url: str, nom: str, repertoire: str = IMAGE_DIR, sha256: str = ""
|
||||
) -> str:
|
||||
"""Télécharge l'image cloud SUR l'hôte Proxmox, une seule fois.
|
||||
|
||||
C'est là que le disque de la VM sera écrit : faire descendre l'image chez
|
||||
soi pour la renvoyer ensuite doublerait le transfert. Le test de présence
|
||||
évite de retélécharger 325 Mio à chaque VM.
|
||||
|
||||
`sha256` : la somme que le dépôt porte pour cette image, quand il en a
|
||||
une. Elle ne concerne que ce qu'aucune distribution ne publie — une image
|
||||
rebâtie par un tiers —, et c'est la seule chose qui distingue celle qui a
|
||||
été revue de n'importe quel fichier servi sous la même URL.
|
||||
|
||||
Vérifiée AUSSI quand l'image était déjà là : le cas qu'on veut prendre
|
||||
est précisément celui d'un fichier substitué ou tronqué entre deux
|
||||
déploiements, et le test de présence seul ne regarde que la taille.
|
||||
|
||||
Une somme qui ne correspond pas fait ÉCHOUER la commande, donc la suite :
|
||||
continuer reviendrait à installer un système que personne n'a regardé.
|
||||
"""
|
||||
cible = f"{repertoire}/{nom}"
|
||||
return (
|
||||
cmd = (
|
||||
f"mkdir -p {shlex.quote(repertoire)} && "
|
||||
f"if [ -s {shlex.quote(cible)} ]; then "
|
||||
f'echo "image déjà présente : {cible}"; else '
|
||||
f"wget -nv -O {shlex.quote(cible)} {shlex.quote(url)}; "
|
||||
f"fi"
|
||||
)
|
||||
if sha256:
|
||||
cmd += (
|
||||
f" && echo {shlex.quote(f'{sha256} {cible}')}" " | sha256sum -c -"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
def snippet_name(vmid: int) -> str:
|
||||
"""Le nom de l'extrait cloud-init d'une VM.
|
||||
|
||||
Porté par le VMID et non par le nom de la VM : deux VM peuvent porter le
|
||||
même nom sur deux nœuds, jamais le même VMID sur un cluster. Un extrait
|
||||
écrasé par un homonyme donnerait à une VM le compte d'une autre.
|
||||
"""
|
||||
return f"erplibre-{int(vmid)}.yml"
|
||||
|
||||
|
||||
def snippet_write_cmd(storage: str, nom: str, contenu: str) -> str:
|
||||
"""Écrit un extrait cloud-init SUR l'hôte Proxmox, à l'endroit qu'il dit.
|
||||
|
||||
Le chemin n'est pas déduit : « pvesm path » le demande à Proxmox. Un
|
||||
stockage « dir » range ses extraits sous son propre répertoire, et le
|
||||
supposer en /var/lib/vz marcherait pour « local » et pour lui seul.
|
||||
|
||||
« printf '%s' » et non « echo » : le contenu est un YAML de plusieurs
|
||||
dizaines de lignes, et echo interprète les séquences d'échappement sur
|
||||
certains shells — un « \n » dans un mot de passe haché suffirait à le
|
||||
corrompre en silence.
|
||||
"""
|
||||
cible = f"{storage}:snippets/{nom}"
|
||||
return (
|
||||
f"f=$(pvesm path {shlex.quote(cible)}) && "
|
||||
'mkdir -p "$(dirname "$f")" && '
|
||||
f"printf '%s' {shlex.quote(contenu)} > \"$f\""
|
||||
)
|
||||
|
||||
|
||||
def create_cmds(vmid: int, spec: dict) -> list:
|
||||
|
|
@ -970,18 +1019,29 @@ def create_cmds(vmid: int, spec: dict) -> list:
|
|||
# posé dans les deux cas, donc la console série ne se perd jamais ; seule
|
||||
# la nature de l'écran change.
|
||||
vga = "virtio-gl" if spec.get("gpu3d") else "serial0"
|
||||
# UEFI seulement pour les images qui n'ont pas de secteur d'amorçage BIOS,
|
||||
# jamais par défaut : mesuré sur un Proxmox 9, une VM NixOS créée en
|
||||
# SeaBIOS se déclare « running » avec une console MUETTE, quand la même en
|
||||
# OVMF démarre. Debian 13, sur le même hôte, démarre en SeaBIOS — d'où un
|
||||
# marqueur par distribution plutôt qu'un défaut renversé pour tous.
|
||||
#
|
||||
# Secure Boot désactivé (« pre-enrolled-keys=0 ») pour la raison qui vaut
|
||||
# déjà côté qemu : un chargeur non signé par les clés Microsoft est refusé,
|
||||
# et l'image ne démarre pas du tout.
|
||||
uefi = bool(spec.get("uefi"))
|
||||
cmds = [
|
||||
# 1. La coquille : processeur, mémoire, réseau, contrôleur, agent.
|
||||
"qm create {id} --name {nom} --memory {mem} --cores {cpu}"
|
||||
" --cpu host --ostype l26 --scsihw virtio-scsi-single"
|
||||
" --net0 virtio,bridge={pont} --agent enabled=1"
|
||||
" --serial0 socket --vga {vga}".format(
|
||||
" --serial0 socket --vga {vga}{bios}".format(
|
||||
id=vmid,
|
||||
nom=shlex.quote(nom),
|
||||
mem=int(spec["memory"]),
|
||||
cpu=int(spec["vcpus"]),
|
||||
pont=spec["bridge"],
|
||||
vga=vga,
|
||||
bios=" --bios ovmf" if uefi else "",
|
||||
),
|
||||
# 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+)
|
||||
# remplace l'ancien « qm importdisk » en une seule étape et attache
|
||||
|
|
@ -991,18 +1051,58 @@ def create_cmds(vmid: int, spec: dict) -> list:
|
|||
# 3. Le lecteur cloud-init, et l'ordre d'amorçage. Sans « boot order »,
|
||||
# Proxmox laisse le disque importé hors de la liste et la VM démarre
|
||||
# sur le réseau.
|
||||
f"qm set {vmid} --ide2 {stockage}:cloudinit"
|
||||
#
|
||||
# Sur le bus SCSI et non en IDE, contrairement à ce que la
|
||||
# documentation de Proxmox montre : une image cloud bâtie pour
|
||||
# virtio seul n'a pas de pilote ATA, et le lecteur IDE lui est
|
||||
# INVISIBLE. Mesuré sur une image NixOS — /sys/block ne portait pas
|
||||
# de « sr0 », /dev/disk/by-label pas de « cidata », et cloud-init
|
||||
# passait aux sources RÉSEAU faute de trouver la locale : la VM
|
||||
# démarrait sans compte ni clé. Le même lecteur en scsi1 apparaît,
|
||||
# et le contrôleur virtio-scsi est déjà là pour le disque.
|
||||
f"qm set {vmid} --scsi1 {stockage}:cloudinit"
|
||||
f" --boot order=scsi0 --bootdisk scsi0",
|
||||
]
|
||||
# 4. cloud-init : utilisateur, clé, réseau. La clé est un FICHIER sur
|
||||
# l'hôte — « --sshkeys » n'accepte pas la clé en ligne.
|
||||
ci = (
|
||||
f"qm set {vmid} --ciuser {shlex.quote(spec.get('user') or 'erplibre')}"
|
||||
)
|
||||
if spec.get("sshkey_path"):
|
||||
ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}"
|
||||
if spec.get("password"):
|
||||
ci += f" --cipassword {shlex.quote(spec['password'])}"
|
||||
if uefi:
|
||||
# Le disque EFI porte les variables du firmware. Sans lui, « --bios
|
||||
# ovmf » démarre quand même mais ne retient RIEN : l'entrée d'amorçage
|
||||
# que l'invité écrit à sa première installation est perdue au
|
||||
# redémarrage suivant.
|
||||
cmds.append(
|
||||
f"qm set {vmid} --efidisk0"
|
||||
f" {stockage}:0,efitype=4m,pre-enrolled-keys=0"
|
||||
)
|
||||
# 4. cloud-init. Deux formes, et la première est celle qu'on veut.
|
||||
#
|
||||
# « --cicustom user= » quand un user-data est fourni : c'est CELUI du
|
||||
# dépôt, le même que le chemin qemu envoie, avec son bloc « users: »
|
||||
# explicite. « --ciuser » et « --sshkeys » s'en remettent au compte par
|
||||
# DÉFAUT de l'image, et une image qui en déclare un autre les ignore —
|
||||
# mesuré sur NixOS, dont le cloud.cfg nomme « nixos » : la VM démarrait
|
||||
# avec ce compte-là, sans la clé, donc injoignable.
|
||||
#
|
||||
# Le réseau reste à Proxmox (« --ipconfig0 ») : « --cicustom user= » ne
|
||||
# remplace que la moitié utilisateur du cloud-init.
|
||||
if spec.get("user_data"):
|
||||
cmds.append(
|
||||
snippet_write_cmd(stockage, snippet_name(vmid), spec["user_data"])
|
||||
)
|
||||
ci = (
|
||||
f"qm set {vmid} --cicustom"
|
||||
f" user={stockage}:snippets/{snippet_name(vmid)}"
|
||||
)
|
||||
else:
|
||||
# La forme d'avant, gardée pour qui n'a pas de user-data à donner.
|
||||
# La clé est un FICHIER sur l'hôte — « --sshkeys » n'accepte pas la
|
||||
# clé en ligne.
|
||||
ci = (
|
||||
f"qm set {vmid} --ciuser"
|
||||
f" {shlex.quote(spec.get('user') or 'erplibre')}"
|
||||
)
|
||||
if spec.get("sshkey_path"):
|
||||
ci += f" --sshkeys {shlex.quote(spec['sshkey_path'])}"
|
||||
if spec.get("password"):
|
||||
ci += f" --cipassword {shlex.quote(spec['password'])}"
|
||||
ci += f" --ipconfig0 {spec.get('ipconfig') or 'ip=dhcp'}"
|
||||
# « --ipconfig0 » ne porte PAS le DNS : une VM en adresse fixe n'a alors
|
||||
# aucun résolveur, et rien ne le dit. En DHCP le bail s'en charge.
|
||||
|
|
|
|||
|
|
@ -182,6 +182,25 @@ NIXOS_VERSIONS: dict[str, tuple[str, str, int, str]] = {
|
|||
"25.11": ("25.11", "nixos-25.11", 2048, "40G"),
|
||||
}
|
||||
|
||||
# Les images qui n'ont AUCUN secteur d'amorçage BIOS : elles ne démarrent que
|
||||
# par UEFI.
|
||||
#
|
||||
# Ce chemin-ci amorce en UEFI pour TOUT LE MONDE et n'a donc jamais eu besoin
|
||||
# de le savoir ; celui de Proxmox part en SeaBIOS, et c'est lui qui lit cette
|
||||
# liste. Mesuré sur un Proxmox 9 : en SeaBIOS, une VM NixOS se déclare
|
||||
# « running » et sa console reste muette ; la même en OVMF démarre — systemd,
|
||||
# cloud-init, réseau.
|
||||
#
|
||||
# La liste reste COURTE plutôt que de basculer le défaut de tous : Debian 13,
|
||||
# mesurée sur le même hôte, démarre en SeaBIOS sans rien lui devoir.
|
||||
DISTROS_UEFI_SEUL: tuple[str, ...] = ("nixos",)
|
||||
|
||||
|
||||
def requiert_uefi(distro: str) -> bool:
|
||||
"""L'image de `distro` refuse-t-elle un amorçage BIOS hérité ?"""
|
||||
return distro in DISTROS_UEFI_SEUL
|
||||
|
||||
|
||||
DISTROS: dict[str, tuple[dict[str, tuple[str, str, int, str]], str]] = {
|
||||
"ubuntu": (UBUNTU_VERSIONS, "24.04"),
|
||||
"debian": (DEBIAN_VERSIONS, "12"),
|
||||
|
|
@ -1646,6 +1665,17 @@ def download_image(
|
|||
)
|
||||
|
||||
|
||||
def pinned_sha256(distro: str) -> str:
|
||||
"""La somme que le DÉPÔT porte pour l'image de `distro`, ou "".
|
||||
|
||||
Lue par les deux chemins de déploiement : celui de qemu vérifie le
|
||||
fichier qu'il vient de télécharger, celui de Proxmox fait vérifier sur
|
||||
l'hôte distant. Deux copies de la somme dériveraient, et la copie oubliée
|
||||
serait celle qui laisse passer une image que personne n'a regardée.
|
||||
"""
|
||||
return NIXOS_IMAGE_SHA256 if distro == "nixos" else ""
|
||||
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -1658,9 +1688,9 @@ def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
|
|||
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:
|
||||
attendu = pinned_sha256(distro)
|
||||
if not attendu 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""):
|
||||
|
|
|
|||
|
|
@ -67,7 +67,8 @@ class ProxmoxMenuMixin:
|
|||
|
||||
@staticmethod
|
||||
def _pve_label(host):
|
||||
"""« root@10.0.0.5 (par rebond) », pour l'afficher en tête de menu."""
|
||||
"""« root@hyperviseur (par rebond) », pour l'afficher en tête de
|
||||
menu."""
|
||||
if not host:
|
||||
return ""
|
||||
lab = host.get("target", "?")
|
||||
|
|
@ -676,6 +677,33 @@ class ProxmoxMenuMixin:
|
|||
return ""
|
||||
time.sleep(5)
|
||||
|
||||
def _pve_user_data(self, mod, distro, nom, cle_locale):
|
||||
"""Le user-data du DÉPÔT pour une VM Proxmox, ou "".
|
||||
|
||||
Le même que le chemin qemu envoie : un bloc « users: » explicite, avec
|
||||
le nom du compte, son shell, son sudo et ses clés. « --ciuser » et
|
||||
« --sshkeys » de Proxmox s'en remettent au compte par DÉFAUT de
|
||||
l'image, et une image qui en déclare un autre les ignore — mesuré sur
|
||||
NixOS, dont le cloud.cfg nomme « nixos » : la VM démarrait avec ce
|
||||
compte-là, sans la clé, donc injoignable.
|
||||
|
||||
Rend "" quand la clé publique est illisible : mieux vaut retomber sur
|
||||
la forme d'avant, qui pose au moins un compte, que d'écrire un
|
||||
user-data sans aucun moyen d'entrer.
|
||||
"""
|
||||
try:
|
||||
with open(os.path.expanduser(cle_locale), encoding="utf-8") as fh:
|
||||
cle = fh.read().strip()
|
||||
except OSError as exc:
|
||||
print(f" ⚠ {t('SSH key unreadable:')} {exc}")
|
||||
return ""
|
||||
if not cle:
|
||||
return ""
|
||||
args = mod.build_parser().parse_args(
|
||||
["--distro", distro, "--hostname", nom, "--user", "erplibre"]
|
||||
)
|
||||
return mod.build_cloud_config(args, None, [cle])
|
||||
|
||||
def _pve_push_key(self, chemin_local):
|
||||
"""Recopie la clé publique SUR l'hôte : « qm set --sshkeys » attend un
|
||||
FICHIER là-bas, pas une clé en ligne."""
|
||||
|
|
@ -936,8 +964,8 @@ class ProxmoxMenuMixin:
|
|||
"""Réseau du futur pont interne, CHOISI d'après l'hôte.
|
||||
|
||||
Pas une constante : un Proxmox dans un Proxmox hérite du réseau
|
||||
interne de son parent, et 10.10.10.1 y est l'adresse de sa propre
|
||||
PASSERELLE. La poser sur son pont rend tout le /24 local, la
|
||||
interne de son parent, et l'adresse de INTERNAL_CIDR y est celle
|
||||
de sa propre PASSERELLE. La poser sur son pont rend tout le /24 local, la
|
||||
passerelle devient injoignable, et la machine s'isole au milieu de la
|
||||
commande qui la configure. Vécu : « ifup » n'a jamais rendu la main et
|
||||
la VM ne répondait plus, ni en ssh ni en ping."""
|
||||
|
|
@ -1043,9 +1071,9 @@ class ProxmoxMenuMixin:
|
|||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
host = self._pve_host(ask=False)
|
||||
# Le réseau est LU sur l'hôte avant d'être proposé : l'annoncer
|
||||
# 10.10.10.1/24 pour en poser un autre serait mentir sur l'écran même
|
||||
# où l'on demande l'accord.
|
||||
# Le réseau est LU sur l'hôte avant d'être proposé : annoncer le
|
||||
# réseau par défaut pour en poser un autre serait mentir sur
|
||||
# l'écran même où l'on demande l'accord.
|
||||
cidr = self._pve_internal_cidr(host) if host else pve.INTERNAL_CIDR
|
||||
print(f"\n ⚠ {t('No network bridge on this host.')}")
|
||||
print(f" {t('qm create needs one. Two ways:')}")
|
||||
|
|
@ -1406,6 +1434,9 @@ class ProxmoxMenuMixin:
|
|||
"storage": spec["storage"],
|
||||
"bridge": spec["bridge"],
|
||||
"image": image,
|
||||
# L'image qui n'a pas de secteur d'amorçage BIOS : le catalogue le
|
||||
# sait, ce menu le transmet, et « qm create » en tire l'OVMF.
|
||||
"uefi": mod.requiert_uefi(vm.get("distro") or ""),
|
||||
"user": spec.get("user") or "erplibre",
|
||||
"start": spec.get("start", True),
|
||||
"ipconfig": vm.get("ipconfig") or "ip=dhcp",
|
||||
|
|
@ -1419,9 +1450,21 @@ class ProxmoxMenuMixin:
|
|||
}
|
||||
if spec.get("sshkey_path"):
|
||||
detail["sshkey_path"] = spec["sshkey_path"]
|
||||
return [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
||||
vm["vmid"], detail
|
||||
)
|
||||
# Le user-data du dépôt, pour TOUTES les distributions. La clé locale
|
||||
# vient de la spec de l'écran ; sans elle, on retombe sur la forme
|
||||
# d'avant, qui pose au moins un compte.
|
||||
cle_locale = spec.get("ssh_key_local") or self._qemu_default_ssh_key()
|
||||
if cle_locale:
|
||||
detail["user_data"] = self._pve_user_data(
|
||||
mod, vm.get("distro") or "", vm.get("name") or "", cle_locale
|
||||
)
|
||||
# La somme que le dépôt porte pour cette image, quand il en a une :
|
||||
# c'est la seule chose qui distingue une image tierce revue de
|
||||
# n'importe quel fichier servi sous la même URL.
|
||||
somme = mod.pinned_sha256(vm.get("distro") or "")
|
||||
return [
|
||||
pve.image_fetch_cmd(url, image, sha256=somme)
|
||||
] + pve.create_cmds(vm["vmid"], detail)
|
||||
|
||||
def _pve_deploy_spec(self, host, spec, mod, dry_run=False, coupee=False):
|
||||
"""Exécute la spec rendue par l'écran.
|
||||
|
|
@ -2343,17 +2386,22 @@ class ProxmoxMenuMixin:
|
|||
"storage": stockage,
|
||||
"bridge": pont,
|
||||
"image": image,
|
||||
"uefi": mod.requiert_uefi(distro),
|
||||
"user": "erplibre",
|
||||
"sshkey_path": "/root/.ssh/erplibre-deploy.pub",
|
||||
# Le user-data du dépôt, pour TOUTES les distributions : un seul
|
||||
# cloud-init à comprendre, et celui-là est déjà éprouvé.
|
||||
"user_data": self._pve_user_data(mod, distro, nom, cle_locale),
|
||||
"start": True,
|
||||
# DHCP sur un pont qui donne sur le LAN, adresse FIXE sur un pont
|
||||
# interne : là, aucun serveur DHCP ne répondrait et la VM
|
||||
# resterait muette.
|
||||
"ipconfig": ipconfig,
|
||||
}
|
||||
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
||||
vmid, spec
|
||||
)
|
||||
somme = mod.pinned_sha256(distro)
|
||||
etapes = [
|
||||
pve.image_fetch_cmd(url, image, sha256=somme)
|
||||
] + pve.create_cmds(vmid, spec)
|
||||
if dry_run:
|
||||
print(f"\n── {t('Would run on')} {host['target']} ──")
|
||||
print(f" # {t('SSH key ->')} {spec['sshkey_path']}")
|
||||
|
|
@ -2368,9 +2416,9 @@ class ProxmoxMenuMixin:
|
|||
if cle_locale and not self._pve_push_key(cle_locale):
|
||||
print(f" ⚠ {t('SSH key not pushed: password login only.')}")
|
||||
spec.pop("sshkey_path", None)
|
||||
etapes = [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
|
||||
vmid, spec
|
||||
)
|
||||
etapes = [
|
||||
pve.image_fetch_cmd(url, image, sha256=somme)
|
||||
] + pve.create_cmds(vmid, spec)
|
||||
for cmd in etapes:
|
||||
code, _out = self._pve_show(cmd, timeout=1800)
|
||||
if code:
|
||||
|
|
@ -2683,4 +2731,10 @@ class ProxmoxMenuMixin:
|
|||
url = mod.image_url(distro, code, "amd64", version)
|
||||
nom = mod.default_image_name(distro, code, "amd64", version)
|
||||
print(f"\n {nom}\n {url}")
|
||||
self._pve_show(pve.image_fetch_cmd(url, nom), timeout=1800)
|
||||
# La somme, ici comme au déploiement : une image téléchargée d'avance
|
||||
# est celle qu'un déploiement futur trouvera « déjà présente », et il
|
||||
# ne la regardera pas mieux que celui-ci.
|
||||
self._pve_show(
|
||||
pve.image_fetch_cmd(url, nom, sha256=mod.pinned_sha256(distro)),
|
||||
timeout=1800,
|
||||
)
|
||||
|
|
|
|||
183
test/test_proxmox_cloudinit.py
Normal file
183
test/test_proxmox_cloudinit.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Le cloud-init des VM créées SUR un hôte Proxmox.
|
||||
|
||||
Deux corrections que seule une VM réelle pouvait dicter, mesurées sur un
|
||||
Proxmox 9 :
|
||||
|
||||
**Le user-data est celui du DÉPÔT**, écrit comme extrait et donné par
|
||||
« --cicustom user= ». « --ciuser » et « --sshkeys » s'en remettent au compte
|
||||
par DÉFAUT de l'image : celle de NixOS en déclare un autre — « nixos » — et
|
||||
les ignorait. La VM démarrait avec ce compte-là, sans la clé, donc
|
||||
injoignable. Le user-data du dépôt porte un bloc « users: » explicite, avec le
|
||||
nom, le shell et le sudo — le même que le chemin qemu envoie, et déjà éprouvé.
|
||||
|
||||
**Le lecteur cloud-init est sur le bus SCSI**, contrairement à ce que la
|
||||
documentation de Proxmox montre. Une image bâtie pour virtio seul n'a pas de
|
||||
pilote ATA et ne VOIT pas un lecteur IDE : /sys/block sans « sr0 »,
|
||||
/dev/disk/by-label sans « cidata », et cloud-init qui passe aux sources
|
||||
réseau faute de trouver la locale. Le même lecteur en scsi1 apparaît.
|
||||
|
||||
Éprouvé de bout en bout, séquence régénérée sans retouche : NixOS rend
|
||||
« erplibre@… shell=/bin/sh sudo-ok », Debian 13 « shell=/bin/bash sudo-ok ».
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
||||
from script.proxmox import proxmox_deploy as pve # noqa: E402
|
||||
|
||||
SPEC = {
|
||||
"name": "essai",
|
||||
"storage": "local",
|
||||
"bridge": "vmbr0",
|
||||
"memory": 2048,
|
||||
"vcpus": 2,
|
||||
"disk": "20G",
|
||||
"image": "image.qcow2",
|
||||
"user": "erplibre",
|
||||
"start": True,
|
||||
"ipconfig": "ip=dhcp",
|
||||
}
|
||||
UD = "#cloud-config\nhostname: essai\nusers:\n - name: erplibre\n"
|
||||
|
||||
|
||||
class LeLecteurCloudInit(unittest.TestCase):
|
||||
def test_it_sits_on_the_scsi_bus(self):
|
||||
"""Une image bâtie pour virtio seul ne voit pas un lecteur IDE, et
|
||||
cloud-init passe alors aux sources réseau : la VM démarre sans compte
|
||||
ni clé. Le contrôleur virtio-scsi est déjà là pour le disque."""
|
||||
joint = " ".join(pve.create_cmds(100, SPEC))
|
||||
self.assertIn("--scsi1 local:cloudinit", joint)
|
||||
self.assertNotIn("--ide2", joint)
|
||||
|
||||
def test_the_boot_order_still_names_the_system_disk(self):
|
||||
"""Le lecteur cloud-init n'est pas amorçable : sans « boot order »,
|
||||
Proxmox laisse le disque importé hors de la liste."""
|
||||
ligne = [c for c in pve.create_cmds(100, SPEC) if "--scsi1" in c][0]
|
||||
self.assertIn("--boot order=scsi0", ligne)
|
||||
self.assertIn("--bootdisk scsi0", ligne)
|
||||
|
||||
|
||||
class LNomDeLExtrait(unittest.TestCase):
|
||||
def test_it_is_carried_by_the_vmid(self):
|
||||
"""Deux VM peuvent porter le même NOM sur deux nœuds, jamais le même
|
||||
VMID sur un cluster. Un extrait écrasé par un homonyme donnerait à
|
||||
une VM le compte d'une autre."""
|
||||
self.assertEqual("erplibre-9030.yml", pve.snippet_name(9030))
|
||||
self.assertNotEqual(pve.snippet_name(100), pve.snippet_name(101))
|
||||
|
||||
def test_it_ends_in_yml(self):
|
||||
"""Proxmox lit l'extrait comme du YAML ; l'extension est ce qui le
|
||||
distingue d'un hook ou d'un fichier de sauvegarde dans le dossier."""
|
||||
self.assertTrue(pve.snippet_name(1).endswith(".yml"))
|
||||
|
||||
|
||||
class LEcritureDeLExtrait(unittest.TestCase):
|
||||
def test_the_path_is_asked_of_proxmox(self):
|
||||
"""Un stockage « dir » range ses extraits sous son propre répertoire :
|
||||
supposer /var/lib/vz marcherait pour « local » et pour lui seul."""
|
||||
cmd = pve.snippet_write_cmd("autre", "x.yml", "a: b\n")
|
||||
self.assertIn("pvesm path", cmd)
|
||||
self.assertIn("autre:snippets/x.yml", cmd)
|
||||
|
||||
def test_the_directory_is_created(self):
|
||||
"""Un stockage neuf n'a pas encore son dossier d'extraits."""
|
||||
self.assertIn("mkdir -p", pve.snippet_write_cmd("local", "x", "y"))
|
||||
|
||||
def test_printf_and_not_echo(self):
|
||||
"""echo interprète les séquences d'échappement sur certains shells :
|
||||
un « \\n » dans un mot de passe haché serait corrompu en silence."""
|
||||
cmd = pve.snippet_write_cmd("local", "x", "a: b\n")
|
||||
self.assertIn("printf '%s'", cmd)
|
||||
self.assertNotIn("echo ", cmd)
|
||||
|
||||
def test_the_content_is_quoted_whole(self):
|
||||
"""Un YAML porte des deux-points, des espaces et des retours à la
|
||||
ligne : non cité, il se disperse en arguments."""
|
||||
cmd = pve.snippet_write_cmd("local", "x", "a: b\nc: 'd'\n")
|
||||
self.assertIn("a: b", cmd)
|
||||
self.assertIn("c: ", cmd)
|
||||
|
||||
|
||||
class LaSequenceAvecUserData(unittest.TestCase):
|
||||
def _cmds(self):
|
||||
return pve.create_cmds(100, dict(SPEC, user_data=UD))
|
||||
|
||||
def test_the_repository_user_data_is_given_to_the_vm(self):
|
||||
joint = " ".join(self._cmds())
|
||||
self.assertIn("--cicustom user=local:snippets/erplibre-100.yml", joint)
|
||||
|
||||
def test_the_default_user_keys_are_dropped(self):
|
||||
"""Les laisser ferait deux autorités sur le même compte."""
|
||||
joint = " ".join(self._cmds())
|
||||
self.assertNotIn("--ciuser", joint)
|
||||
self.assertNotIn("--sshkeys", joint)
|
||||
|
||||
def test_the_network_stays_with_proxmox(self):
|
||||
"""« --cicustom user= » ne remplace que la moitié utilisateur : le
|
||||
réseau vient toujours d'ipconfig0, et sans lui la VM est muette."""
|
||||
self.assertIn("--ipconfig0 ip=dhcp", " ".join(self._cmds()))
|
||||
|
||||
def test_the_snippet_is_written_before_it_is_referenced(self):
|
||||
"""Proxmox lit l'extrait quand il fabrique l'ISO : référencer un
|
||||
fichier absent laisse la VM sans user-data du tout."""
|
||||
cmds = self._cmds()
|
||||
i_ecrit = next(i for i, c in enumerate(cmds) if "pvesm path" in c)
|
||||
i_ref = next(i for i, c in enumerate(cmds) if "--cicustom" in c)
|
||||
self.assertLess(i_ecrit, i_ref)
|
||||
|
||||
def test_everything_comes_before_the_start(self):
|
||||
cmds = self._cmds()
|
||||
i_start = next(i for i, c in enumerate(cmds) if c == "qm start 100")
|
||||
for marque in ("pvesm path", "--cicustom"):
|
||||
with self.subTest(marque=marque):
|
||||
i = next(i for i, c in enumerate(cmds) if marque in c)
|
||||
self.assertLess(i, i_start)
|
||||
|
||||
|
||||
class LaFormeDAvantEstGardee(unittest.TestCase):
|
||||
"""Sans user-data, la séquence reste celle d'avant : `create_cmds` sert
|
||||
aussi aux tests longs, qui n'en fabriquent pas."""
|
||||
|
||||
def test_the_old_keys_come_back(self):
|
||||
cmds = pve.create_cmds(100, dict(SPEC, sshkey_path="/root/k.pub"))
|
||||
ci = [c for c in cmds if "--ciuser" in c][0]
|
||||
self.assertIn("--ciuser erplibre", ci)
|
||||
self.assertIn("--sshkeys /root/k.pub", ci)
|
||||
self.assertNotIn("--cicustom", " ".join(cmds))
|
||||
|
||||
def test_no_snippet_is_written(self):
|
||||
joint = " ".join(pve.create_cmds(100, SPEC))
|
||||
self.assertNotIn("pvesm path", joint)
|
||||
|
||||
|
||||
class LeMenuFabriqueLeUserData(unittest.TestCase):
|
||||
SRC = (RACINE / "script/todo/proxmox_menu.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_it_comes_from_the_repository_builder(self):
|
||||
"""Un user-data écrit à la main dans le menu divergerait de celui du
|
||||
chemin qemu, et c'est celui-là qui est éprouvé."""
|
||||
self.assertIn("mod.build_cloud_config(", self.SRC)
|
||||
self.assertIn("def _pve_user_data", self.SRC)
|
||||
|
||||
def test_both_call_sites_use_it(self):
|
||||
self.assertEqual(2, self.SRC.count('"user_data"'))
|
||||
|
||||
def test_an_unreadable_key_falls_back_instead_of_locking_out(self):
|
||||
"""Un user-data sans clé donnerait une VM sans aucun moyen d'entrer :
|
||||
mieux vaut la forme d'avant, qui pose au moins un compte."""
|
||||
i = self.SRC.index("def _pve_user_data")
|
||||
corps = self.SRC[i : i + 1600]
|
||||
self.assertIn("SSH key unreadable:", corps)
|
||||
self.assertIn('return ""', corps)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
137
test/test_proxmox_uefi.py
Normal file
137
test/test_proxmox_uefi.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""L'amorçage UEFI des VM créées SUR un hôte Proxmox.
|
||||
|
||||
Les deux chemins de déploiement ne partent pas du même firmware : celui de
|
||||
qemu amorce en UEFI pour tout le monde, celui de Proxmox part en SeaBIOS.
|
||||
Tant que toutes les images savaient faire les deux, la divergence ne se
|
||||
voyait pas.
|
||||
|
||||
Mesuré sur un Proxmox 9 : une VM NixOS créée en SeaBIOS se déclare
|
||||
« running » et sa console reste MUETTE ; la même en OVMF démarre — systemd,
|
||||
cloud-init, réseau, invite de connexion. Debian 13, sur le même hôte, démarre
|
||||
en SeaBIOS sans rien devoir à l'UEFI.
|
||||
|
||||
D'où ce que ces tests gardent : un marqueur PAR DISTRIBUTION, et non un
|
||||
défaut renversé pour tous. Renverser le défaut aurait ajouté un disque EFI à
|
||||
chaque VM du parc pour le besoin d'une seule image.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
||||
from script.proxmox import proxmox_deploy as pve # noqa: E402
|
||||
|
||||
|
||||
def _deploy_qemu():
|
||||
chemin = RACINE / "script/qemu/deploy_qemu.py"
|
||||
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
DQ = _deploy_qemu()
|
||||
|
||||
SPEC = {
|
||||
"name": "essai",
|
||||
"storage": "local",
|
||||
"bridge": "vmbr0",
|
||||
"memory": 2048,
|
||||
"vcpus": 2,
|
||||
"disk": "40G",
|
||||
"image": "image.qcow2",
|
||||
"user": "erplibre",
|
||||
"start": False,
|
||||
"ipconfig": "ip=dhcp",
|
||||
}
|
||||
|
||||
|
||||
class LeCatalogueSaitQuiEnABesoin(unittest.TestCase):
|
||||
def test_the_image_with_no_bios_sector_is_named(self):
|
||||
"""NixOS ne publie pas d'image cloud : celle du tiers qui la rebâtit
|
||||
n'a pas de secteur d'amorçage BIOS."""
|
||||
self.assertTrue(DQ.requiert_uefi("nixos"))
|
||||
|
||||
def test_the_others_are_left_alone(self):
|
||||
"""Mesuré : Debian 13 démarre en SeaBIOS sur un Proxmox 9. Les
|
||||
basculer coûterait un disque EFI par VM, pour rien."""
|
||||
for distro in ("ubuntu", "debian", "fedora", "arch", "opensuse"):
|
||||
with self.subTest(distro=distro):
|
||||
self.assertFalse(DQ.requiert_uefi(distro))
|
||||
|
||||
def test_an_unknown_distro_does_not_get_uefi(self):
|
||||
"""Le doute ne vaut pas un firmware : c'est le catalogue qui sait."""
|
||||
self.assertFalse(DQ.requiert_uefi(""))
|
||||
self.assertFalse(DQ.requiert_uefi("inconnue"))
|
||||
|
||||
|
||||
class LaSequenceDeCreation(unittest.TestCase):
|
||||
def _cmds(self, **extra):
|
||||
return pve.create_cmds(100, dict(SPEC, **extra))
|
||||
|
||||
def test_without_the_marker_nothing_changes(self):
|
||||
"""La VM ordinaire garde exactement la séquence d'avant."""
|
||||
joint = " ".join(self._cmds())
|
||||
self.assertNotIn("--bios", joint)
|
||||
self.assertNotIn("efidisk", joint)
|
||||
|
||||
def test_with_the_marker_the_firmware_is_ovmf(self):
|
||||
creation = self._cmds(uefi=True)[0]
|
||||
self.assertIn("qm create 100", creation)
|
||||
self.assertIn("--bios ovmf", creation)
|
||||
|
||||
def test_the_efi_disk_is_added(self):
|
||||
"""« --bios ovmf » sans disque EFI démarre, mais ne retient RIEN :
|
||||
l'entrée d'amorçage écrite par l'invité est perdue au redémarrage."""
|
||||
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c]
|
||||
self.assertEqual(1, len(efi))
|
||||
self.assertIn("efitype=4m", efi[0])
|
||||
|
||||
def test_the_efi_disk_lives_on_the_same_storage(self):
|
||||
"""Un stockage inventé fait échouer « qm set » après que la VM
|
||||
existe : elle reste alors à moitié créée."""
|
||||
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c][0]
|
||||
self.assertIn(f" {SPEC['storage']}:0,", efi)
|
||||
|
||||
def test_secure_boot_is_off(self):
|
||||
"""Un chargeur non signé par les clés Microsoft est refusé, et
|
||||
l'image ne démarre pas du tout — la même raison que côté qemu."""
|
||||
efi = [c for c in self._cmds(uefi=True) if "efidisk0" in c][0]
|
||||
self.assertIn("pre-enrolled-keys=0", efi)
|
||||
|
||||
def test_the_efi_disk_comes_before_the_start(self):
|
||||
"""Ajouter un disque à une VM qui tourne déjà ne l'amorce pas
|
||||
dessus."""
|
||||
cmds = self._cmds(uefi=True, start=True)
|
||||
i_efi = next(i for i, c in enumerate(cmds) if "efidisk0" in c)
|
||||
i_start = next(
|
||||
i for i, c in enumerate(cmds) if c.endswith("qm start 100")
|
||||
)
|
||||
self.assertLess(i_efi, i_start)
|
||||
|
||||
|
||||
class LeMenuTransmetLeMarqueur(unittest.TestCase):
|
||||
"""Un catalogue qui sait et un menu qui ne dit pas laisse la VM en
|
||||
SeaBIOS : les deux points d'appel sont tenus."""
|
||||
|
||||
SRC = (RACINE / "script/todo/proxmox_menu.py").read_text(encoding="utf-8")
|
||||
|
||||
def test_both_call_sites_pass_it(self):
|
||||
self.assertEqual(2, self.SRC.count('"uefi": mod.requiert_uefi('))
|
||||
|
||||
def test_it_is_read_from_the_catalogue_not_hardcoded(self):
|
||||
"""Écrire « nixos » ici ferait deux autorités sur une même question."""
|
||||
self.assertNotIn('"uefi": True', self.SRC)
|
||||
self.assertNotIn('"uefi": "nixos"', self.SRC)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -226,5 +226,87 @@ class CeQuUnSystemeDeclaratifNeRecoitPas(unittest.TestCase):
|
|||
)
|
||||
|
||||
|
||||
class LesDeuxCheminsLisentLaMemeSomme(unittest.TestCase):
|
||||
"""L'image tierce se vérifie sur les DEUX chemins de déploiement.
|
||||
|
||||
Aucune distribution ne publie cette image : la somme a été relevée une
|
||||
fois à la revue, et c'est la seule chose qui distingue le fichier revu de
|
||||
n'importe quel autre servi sous la même URL. Le chemin qemu vérifie ce
|
||||
qu'il vient de télécharger ; celui de Proxmox fait vérifier sur l'hôte,
|
||||
où l'image descend.
|
||||
|
||||
Deux copies de la somme dériveraient, et la copie oubliée serait celle
|
||||
qui laisse passer une image que personne n'a regardée.
|
||||
"""
|
||||
|
||||
def test_one_accessor_carries_it(self):
|
||||
self.assertEqual(DQ.NIXOS_IMAGE_SHA256, DQ.pinned_sha256("nixos"))
|
||||
self.assertEqual(64, len(DQ.pinned_sha256("nixos")))
|
||||
|
||||
def test_the_distros_that_publish_their_own_have_none(self):
|
||||
"""--verify lit alors le SHA256SUMS de la distribution : porter une
|
||||
somme figée en plus ferait deux autorités."""
|
||||
for distro in ("ubuntu", "debian", "fedora", "arch", ""):
|
||||
with self.subTest(distro=distro):
|
||||
self.assertEqual("", DQ.pinned_sha256(distro))
|
||||
|
||||
def test_the_qemu_side_reads_the_accessor(self):
|
||||
"""Un second test « distro == nixos » ailleurs se désaccorderait du
|
||||
jour où une autre image tierce entre au catalogue."""
|
||||
src = (RACINE / "script/qemu/deploy_qemu.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
i = src.index("def verify_pinned_sha256")
|
||||
self.assertIn("pinned_sha256(distro)", src[i : i + 700])
|
||||
|
||||
def test_the_proxmox_side_verifies_on_the_host(self):
|
||||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
cmd = pve.image_fetch_cmd(
|
||||
"http://x/i.qcow2", "i.qcow2", sha256="a" * 64
|
||||
)
|
||||
self.assertIn("sha256sum -c -", cmd)
|
||||
# Par « && » : une somme qui ne correspond pas doit ARRÊTER la suite,
|
||||
# et non se contenter d'un avertissement dans le journal.
|
||||
self.assertIn("&& echo", cmd)
|
||||
|
||||
def test_a_cached_image_is_verified_too(self):
|
||||
"""Le cas qu'on veut prendre est un fichier substitué ou tronqué
|
||||
entre deux déploiements : le test de présence ne regarde que la
|
||||
taille, et la vérification vient donc APRÈS le « fi »."""
|
||||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
cmd = pve.image_fetch_cmd(
|
||||
"http://x/i.qcow2", "i.qcow2", sha256="b" * 64
|
||||
)
|
||||
self.assertLess(cmd.index("fi"), cmd.index("sha256sum"))
|
||||
|
||||
def test_nothing_is_appended_without_a_sum(self):
|
||||
from script.proxmox import proxmox_deploy as pve
|
||||
|
||||
self.assertNotIn(
|
||||
"sha256sum", pve.image_fetch_cmd("http://x/i.qcow2", "i.qcow2")
|
||||
)
|
||||
|
||||
def test_the_menu_passes_it_at_every_call_site(self):
|
||||
"""Un appel qui l'oublie télécharge sans regarder."""
|
||||
src = (RACINE / "script/todo/proxmox_menu.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# Les appels qui TÉLÉCHARGENT, l'exemple d'affichage excepté : les
|
||||
# trois du déploiement, plus celui du téléchargement d'avance — une
|
||||
# image posée par lui est celle qu'un déploiement futur trouvera
|
||||
# « déjà présente », et il ne la regardera pas mieux.
|
||||
vrais = [
|
||||
l
|
||||
for l in src.splitlines()
|
||||
if "pve.image_fetch_cmd(" in l and "https://…" not in l
|
||||
]
|
||||
self.assertEqual(4, len(vrais))
|
||||
for ligne in vrais:
|
||||
with self.subTest(ligne=ligne.strip()[:50]):
|
||||
self.assertIn("sha256=", ligne)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue