2026-09-16 00:42:31 -04:00
|
|
|
#!/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)
|
|
|
|
|
|
|
|
|
|
|
[REF] qemu firmware : une table pour les deux voies, fuseau décodé
Deux tables opposées vivaient dans le même fichier : l'une forçait le
BIOS, l'autre l'UEFI, chacune lue d'un seul côté. Elles pouvaient se
contredire sans que rien ne le dise. FIRMWARE_IMPOSE les remplace, et les
deux suites passent inchangées. « --bios » l'emportait sur un fait
physique : forcé sur une image sans secteur d'amorçage, il donnait une VM
« running » à console muette. Il est ignoré, et l'appelant l'apprend.
Le fuseau était lu en UTF-8 strict sous un « except OSError » : un octet
qui n'en est pas faisait échouer tout le déploiement pour une traduction
de confort.
--- EN ---
Two opposing tables lived in one file: one forced BIOS, the other UEFI,
each read by one side only. They could contradict each other silently.
FIRMWARE_IMPOSE replaces both, and both suites pass unchanged. "--bios"
overrode a physical fact: forced on an image with no boot sector it gave a
"running" VM with a mute console. It is ignored now, and the caller is
told.
The timezone was read as strict UTF-8 under an "except OSError": a byte
that is not one failed the whole deployment for a convenience
translation.
Assisted-by: Claude Opus 5
2026-09-16 00:42:48 -04:00
|
|
|
class TousLesConstructeursDeSpecLeDisent(unittest.TestCase):
|
|
|
|
|
"""create_cmds lit « uefi » dans le spec, et un spec qui l'omet vaut
|
|
|
|
|
SeaBIOS sans rien dire.
|
|
|
|
|
|
|
|
|
|
Compter les appels dans UN fichier laissait échapper tout constructeur
|
|
|
|
|
écrit ailleurs, et il y en avait un. Le garde-fou cherche donc les
|
|
|
|
|
appelants plutôt que de les supposer.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def _appelants(self):
|
|
|
|
|
trouves = []
|
|
|
|
|
for dossier in ("script", "long_test"):
|
|
|
|
|
for chemin in sorted((RACINE / dossier).rglob("*.py")):
|
|
|
|
|
if chemin.name == "proxmox_deploy.py":
|
|
|
|
|
continue
|
|
|
|
|
texte = chemin.read_text(encoding="utf-8")
|
|
|
|
|
if "create_cmds(" in texte:
|
|
|
|
|
trouves.append((chemin, texte))
|
|
|
|
|
return trouves
|
|
|
|
|
|
|
|
|
|
def test_the_callers_are_found_at_all(self):
|
|
|
|
|
"""Un test qui ne trouve plus personne passerait en restant vert."""
|
|
|
|
|
self.assertTrue(self._appelants())
|
|
|
|
|
|
|
|
|
|
def test_every_one_of_them_declares_the_firmware(self):
|
|
|
|
|
for chemin, texte in self._appelants():
|
|
|
|
|
with self.subTest(fichier=chemin.name):
|
|
|
|
|
self.assertIn('"uefi"', texte)
|
|
|
|
|
|
|
|
|
|
def test_none_of_them_decides_it_itself(self):
|
|
|
|
|
"""Écrire « nixos » à côté du spec ferait deux autorités sur une même
|
|
|
|
|
question, et la seconde vieillirait sans qu'on le sache."""
|
|
|
|
|
for chemin, texte in self._appelants():
|
|
|
|
|
with self.subTest(fichier=chemin.name):
|
|
|
|
|
self.assertNotIn('"uefi": True', texte)
|
|
|
|
|
self.assertNotIn('"uefi": "nixos"', texte)
|
2026-09-16 00:42:31 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|