2026-09-14 14:41:19 -04:00
|
|
|
#!/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
|
[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
|
|
|
FIRMWARE_IMPOSE,
|
[FIX] déploiement qemu : amorcer Fedora en BIOS, sa chaîne UEFI se fige
Aucune VM Fedora ne démarrait : pas de console, pas de bail DHCP, une machine
« en cours d'exécution » qui ne fait rien. Le micrologiciel charge et DÉMARRE
le chargeur — il l'annonce — puis se fige sans écrire un octet sur le disque.
La même image en BIOS démarre son noyau : ce n'est donc ni l'image, saine, ni
sa partition EFI, dont le chemin de repli est bien là. Ni l'entropie ni la
machine q35 n'y changent rien. Une table nomme les distributions concernées et
« --bios » l'emporte toujours, pour l'hôte qui n'a pas OVMF.
--- EN ---
No Fedora VM would start: no console, no DHCP lease, a machine "running" that
does nothing. The firmware loads and STARTS the loader — it says so — then
freezes without writing a byte to disk. The same image under BIOS boots its
kernel: so it is neither the image, which is sound, nor its EFI partition,
whose fallback path is present. Neither entropy nor the q35 machine changes
anything. A table names the distributions concerned, and "--bios" always
wins, for the host that has no OVMF.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
amorcage_bios,
|
2026-09-14 14:41:19 -04:00
|
|
|
canonical_timezone,
|
|
|
|
|
hostname_valide,
|
[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
|
|
|
requiert_uefi,
|
2026-09-14 14:41:19 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
[FIX] déploiement qemu : amorcer Fedora en BIOS, sa chaîne UEFI se fige
Aucune VM Fedora ne démarrait : pas de console, pas de bail DHCP, une machine
« en cours d'exécution » qui ne fait rien. Le micrologiciel charge et DÉMARRE
le chargeur — il l'annonce — puis se fige sans écrire un octet sur le disque.
La même image en BIOS démarre son noyau : ce n'est donc ni l'image, saine, ni
sa partition EFI, dont le chemin de repli est bien là. Ni l'entropie ni la
machine q35 n'y changent rien. Une table nomme les distributions concernées et
« --bios » l'emporte toujours, pour l'hôte qui n'a pas OVMF.
--- EN ---
No Fedora VM would start: no console, no DHCP lease, a machine "running" that
does nothing. The firmware loads and STARTS the loader — it says so — then
freezes without writing a byte to disk. The same image under BIOS boots its
kernel: so it is neither the image, which is sound, nor its EFI partition,
whose fallback path is present. Neither entropy nor the q35 machine changes
anything. A table names the distributions concerned, and "--bios" always
wins, for the host that has no OVMF.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
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))
|
|
|
|
|
|
[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
|
|
|
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))
|
|
|
|
|
|
[FIX] déploiement qemu : amorcer Fedora en BIOS, sa chaîne UEFI se fige
Aucune VM Fedora ne démarrait : pas de console, pas de bail DHCP, une machine
« en cours d'exécution » qui ne fait rien. Le micrologiciel charge et DÉMARRE
le chargeur — il l'annonce — puis se fige sans écrire un octet sur le disque.
La même image en BIOS démarre son noyau : ce n'est donc ni l'image, saine, ni
sa partition EFI, dont le chemin de repli est bien là. Ni l'entropie ni la
machine q35 n'y changent rien. Une table nomme les distributions concernées et
« --bios » l'emporte toujours, pour l'hôte qui n'a pas OVMF.
--- EN ---
No Fedora VM would start: no console, no DHCP lease, a machine "running" that
does nothing. The firmware loads and STARTS the loader — it says so — then
freezes without writing a byte to disk. The same image under BIOS boots its
kernel: so it is neither the image, which is sound, nor its EFI partition,
whose fallback path is present. Neither entropy nor the q35 machine changes
anything. A table names the distributions concerned, and "--bios" always
wins, for the host that has no OVMF.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
|
2026-09-14 14:41:19 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|