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
222 lines
8.9 KiB
Python
222 lines
8.9 KiB
Python
#!/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 :
|
|
l'INVARIANT que le nom rendu n'est jamais lui-même un alias. Le format
|
|
autorise qu'un lien désigne un autre lien, et s'arrêter au premier rendrait
|
|
alors un nom qui reste un alias — le défaut même qu'on répare.
|
|
|
|
La chaîne qui l'éprouve est FABRIQUÉE, et la table plus bas contredit sur ce
|
|
point le tzdata publié, où « Universal » vise « Etc/UTC » directement : aucun
|
|
lien n'y désigne un autre lien. L'inventer est la seule façon d'exercer le
|
|
second tour, et la dernière classe vérifie l'invariant sur la VRAIE table,
|
|
qu'une chaîne y apparaisse un jour ou non.
|
|
|
|
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. Les
|
|
# deux dernières lignes enchaînent deux liens — un cas que le format autorise
|
|
# et que le tzdata publié ne contient pas, d'où la fabrication.
|
|
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):
|
|
"""Dans CETTE table, « Universal » pointe « UTC », qui pointe
|
|
« Etc/UTC ».
|
|
|
|
Un seul tour rendrait « UTC », un alias que l'image cloud peut très
|
|
bien ne pas porter non plus. Le tzdata publié n'enchaîne aucun lien :
|
|
la chaîne est fabriquée pour exercer le second tour, le format
|
|
l'autorisant.
|
|
"""
|
|
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), "")
|
|
|
|
def test_a_third_link_is_left_where_two_passes_reach(self):
|
|
"""La BORNE, celle que la docstring annonce : deux tours, pas une
|
|
boucle. Une table qui enchaîne trois liens est incohérente, et s'y
|
|
arrêter vaut mieux que tourner sur une table qui se mord la queue."""
|
|
chemin = os.path.join(os.path.dirname(self.table), "trois.zi")
|
|
with open(chemin, "w", encoding="utf-8") as fh:
|
|
fh.write("L Etc/UTC A\nL A B\nL B C\n")
|
|
self.assertEqual(DQ.canonical_timezone("C", chemin), "A")
|
|
|
|
def test_a_byte_outside_utf8_does_not_stop_the_deployment(self):
|
|
"""Le fichier est lu en UTF-8 strict. Un octet qui n'en est pas
|
|
lèverait une erreur de DÉCODAGE, qui n'est pas une erreur de
|
|
fichier : non rattrapée, elle ferait échouer tout le déploiement
|
|
pour une traduction de confort."""
|
|
chemin = os.path.join(os.path.dirname(self.table), "binaire.zi")
|
|
with open(chemin, "wb") as fh:
|
|
fh.write(b"L America/Toronto Canada/Eastern\n\xff\xfe\n")
|
|
self.assertEqual(
|
|
DQ.canonical_timezone("Canada/Eastern", chemin), "Canada/Eastern"
|
|
)
|
|
|
|
|
|
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)
|
|
|
|
|
|
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 _liens(self):
|
|
alias = {}
|
|
with open(DQ.TZ_ALIASES, encoding="utf-8") as fh:
|
|
for ligne in fh:
|
|
champs = ligne.split()
|
|
if len(champs) >= 3 and champs[0] == "L":
|
|
alias[champs[2]] = champs[1]
|
|
return alias
|
|
|
|
def test_no_alias_survives_the_translation(self):
|
|
"""L'invariant, sur la vraie table et sur TOUS ses liens : le nom
|
|
rendu n'est pas lui-même un alias.
|
|
|
|
Ce test est celui qui compte le jour où le tzdata enchaînera deux
|
|
liens — il restera vert sans qu'on y touche, alors qu'une implémen-
|
|
tation à un seul tour deviendrait fausse ce jour-là."""
|
|
alias = self._liens()
|
|
self.assertTrue(alias, "table sans lien")
|
|
for nom in alias:
|
|
with self.subTest(alias=nom):
|
|
self.assertNotIn(DQ.canonical_timezone(nom), alias)
|
|
|
|
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()
|