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
185 lines
7.1 KiB
Python
185 lines
7.1 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Ce qu'une VM reçoit de l'hôte : son nom d'hôte et son fuseau.
|
|
|
|
Deux réglages que cloud-init applique au premier démarrage, et qui échouent
|
|
tous les deux SANS arrêter le déploiement. La VM démarre, sshd répond, tout a
|
|
l'air d'aller — et l'on découvre après coup qu'elle porte le nom générique de
|
|
son image, ou qu'elle horodate en UTC pendant que le reste du dépôt est en
|
|
heure locale.
|
|
|
|
Le nom d'hôte n'accepte ni souligné ni point d'exclamation, là où un nom de
|
|
domaine libvirt les tolère : les deux ne se ressemblent qu'en général, et un
|
|
nom de VM lisible peut donc être un nom d'hôte invalide.
|
|
|
|
Le fuseau, lui, doit exister DANS L'INVITÉ. Un alias hérité peut vivre sur
|
|
l'hôte et manquer à la VM, plusieurs distributions récentes ayant relégué ces
|
|
alias à un paquet séparé.
|
|
"""
|
|
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|
sys.path.insert(0, str(RACINE))
|
|
|
|
from script.qemu.deploy_qemu import ( # noqa: E402
|
|
FIRMWARE_IMPOSE,
|
|
amorcage_bios,
|
|
canonical_timezone,
|
|
hostname_valide,
|
|
requiert_uefi,
|
|
)
|
|
|
|
|
|
class TestNomDHote(unittest.TestCase):
|
|
def test_le_souligne_devient_un_tiret(self):
|
|
"""Le nom des VM du test long en porte : « el-cache-ubuntu_2404-… »."""
|
|
self.assertEqual(
|
|
hostname_valide("el-cache-ubuntu_2404-erplibre_odoo_18-1"),
|
|
"el-cache-ubuntu-2404-erplibre-odoo-18-1",
|
|
)
|
|
|
|
def test_un_nom_deja_valide_ne_bouge_pas(self):
|
|
self.assertEqual(hostname_valide("erplibre-arch"), "erplibre-arch")
|
|
|
|
def test_les_tirets_de_bord_tombent(self):
|
|
"""Un nom d'hôte ne peut ni commencer ni finir par un tiret."""
|
|
self.assertEqual(hostname_valide("_vm_"), "vm")
|
|
self.assertEqual(hostname_valide("--essai--"), "essai")
|
|
|
|
def test_les_tirets_ne_sattroupent_pas(self):
|
|
self.assertEqual(hostname_valide("a__.__b"), "a-b")
|
|
|
|
def test_un_nom_vide_a_un_repli(self):
|
|
"""Rendre du vide ferait refuser le nom par l'invité, sans que rien
|
|
d'autre qu'un avertissement ne le dise."""
|
|
for entree in ("", "___", "..."):
|
|
self.assertEqual(hostname_valide(entree), "vm")
|
|
|
|
def test_le_nom_est_borne(self):
|
|
"""Une étiquette de nom d'hôte tient en 63 octets."""
|
|
self.assertEqual(len(hostname_valide("x" * 200)), 63)
|
|
|
|
|
|
class TestFuseau(unittest.TestCase):
|
|
def table(self, lignes):
|
|
f = tempfile.NamedTemporaryFile(
|
|
"w", suffix=".zi", delete=False, encoding="utf-8"
|
|
)
|
|
f.write("\n".join(lignes) + "\n")
|
|
f.close()
|
|
self.addCleanup(lambda: Path(f.name).unlink(missing_ok=True))
|
|
return f.name
|
|
|
|
def test_un_alias_herite_est_traduit(self):
|
|
"""Un fuseau que l'invité refuse fait marquer l'exécution de
|
|
cloud-init en erreur, et la VM reste en UTC."""
|
|
table = self.table(
|
|
["# commentaire", "L America/Toronto Canada/Eastern", "Z autre"]
|
|
)
|
|
self.assertEqual(
|
|
canonical_timezone("Canada/Eastern", table), "America/Toronto"
|
|
)
|
|
|
|
def test_un_nom_canonique_ne_bouge_pas(self):
|
|
table = self.table(["L America/Toronto Canada/Eastern"])
|
|
self.assertEqual(
|
|
canonical_timezone("America/Toronto", table), "America/Toronto"
|
|
)
|
|
|
|
def test_une_table_absente_rend_le_nom_tel_quel(self):
|
|
"""Un fuseau non traduit vaut mieux qu'un déploiement refusé."""
|
|
self.assertEqual(
|
|
canonical_timezone("Canada/Eastern", "/nexiste/pas.zi"),
|
|
"Canada/Eastern",
|
|
)
|
|
|
|
def test_le_vide_reste_vide(self):
|
|
self.assertEqual(canonical_timezone("", "/nexiste/pas.zi"), "")
|
|
|
|
def test_seules_les_lignes_de_lien_comptent(self):
|
|
"""« Z » ouvre une zone, « R » une règle : les confondre traduirait un
|
|
fuseau en n'importe quoi."""
|
|
table = self.table(
|
|
["Z Canada/Eastern -5:00 Canada E%sT", "R Canada 1974 ma"]
|
|
)
|
|
self.assertEqual(
|
|
canonical_timezone("Canada/Eastern", table), "Canada/Eastern"
|
|
)
|
|
|
|
|
|
class TestAmorcage(unittest.TestCase):
|
|
"""Quelle image démarre en UEFI, laquelle exige le BIOS.
|
|
|
|
L'échec est MUET : le micrologiciel charge et démarre le chargeur, puis se
|
|
fige sans écrire un octet. Vu du déploiement, il ne reste qu'une VM « en
|
|
cours d'exécution » sans console et sans bail DHCP, et la cause est à
|
|
chercher pendant une heure. D'où une table plutôt qu'un diagnostic à
|
|
refaire.
|
|
"""
|
|
|
|
def test_une_distribution_qui_lexige_part_en_bios(self):
|
|
self.assertTrue(amorcage_bios("fedora", False))
|
|
|
|
def test_les_autres_gardent_uefi(self):
|
|
"""UEFI reste le défaut : les images cloud récentes n'embarquent plus
|
|
le chargeur BIOS et partent en boucle sous SeaBIOS."""
|
|
for d in (
|
|
"debian",
|
|
"ubuntu",
|
|
"arch",
|
|
"rocky",
|
|
"almalinux",
|
|
"opensuse",
|
|
):
|
|
self.assertFalse(amorcage_bios(d, False), d)
|
|
|
|
def test_la_demande_explicite_lemporte(self):
|
|
"""« --bios » sert aussi quand OVMF manque de la machine hôte."""
|
|
for d in ("debian", "fedora", "inconnue"):
|
|
self.assertTrue(amorcage_bios(d, True), d)
|
|
|
|
def test_une_distribution_inconnue_garde_le_defaut(self):
|
|
self.assertFalse(amorcage_bios("inconnue", False))
|
|
|
|
def test_la_demande_ne_peut_pas_inventer_un_secteur_damorcage(self):
|
|
"""La seule limite de « la demande l'emporte » : une image sans
|
|
secteur d'amorçage BIOS n'en gagne pas un parce qu'on l'a tapé. Forcée,
|
|
elle se déclare « running » avec une console muette — soit la panne
|
|
que ce drapeau est censé éviter ailleurs."""
|
|
self.assertFalse(amorcage_bios("nixos", True))
|
|
self.assertFalse(amorcage_bios("nixos", False))
|
|
|
|
|
|
class TestUneSeuleTableDeFirmware(unittest.TestCase):
|
|
"""Les deux voies de déploiement partent de défauts OPPOSÉS — libvirt en
|
|
UEFI, Proxmox en SeaBIOS — et lisent la MÊME table. Deux tables se
|
|
contrediraient sans que rien ne le dise, chacune n'étant lue que d'un
|
|
côté."""
|
|
|
|
def test_les_deux_lectures_viennent_de_la_table(self):
|
|
for distro, impose in FIRMWARE_IMPOSE.items():
|
|
with self.subTest(distro=distro):
|
|
self.assertEqual(impose == "uefi", requiert_uefi(distro))
|
|
self.assertEqual(
|
|
impose == "bios", amorcage_bios(distro, False)
|
|
)
|
|
|
|
def test_la_table_ne_connait_que_deux_valeurs(self):
|
|
"""Une troisième valeur serait lue comme « rien d'imposé » par les
|
|
deux lectures, en silence."""
|
|
self.assertLessEqual(set(FIRMWARE_IMPOSE.values()), {"bios", "uefi"})
|
|
|
|
def test_une_distribution_hors_table_nimpose_rien(self):
|
|
self.assertFalse(requiert_uefi(""))
|
|
self.assertFalse(requiert_uefi("inconnue"))
|
|
self.assertFalse(amorcage_bios("inconnue", False))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|