erplibre/test/test_qemu_timezone.py
Mathieu Benoit fce1d5b00b [ADD] qemu : NixOS déployable, de son image à ses dépendances
Neuvième système du catalogue, et le seul dont aucune distribution ne
publie d'image cloud : celle d'un tiers est épinglée et sa somme vérifiée
à chaque téléchargement. Son seed diffère aussi — networkd prend la clé
d'un bloc pour un NOM d'interface là où netplan honore un « match: », et
sshd refuse un compte dont le shell n'existe pas, d'où /bin/sh.

Ses dépendances se DÉCLARENT dans conf/nixos/erplibre.nix : envfs répond
à /usr/bin/env, nix-ld donne aux roues manylinux leur chargeur. Vérifié
sur une VM : le verrou de 362 paquets s'installe et Odoo répond.

--- EN ---

Ninth system of the catalogue, and the only one no distribution publishes
a cloud image for: a third party's is pinned and its sum verified at every
download. Its seed differs too — networkd reads a block's key as an
interface NAME where netplan honours a "match:", and sshd refuses an
account whose shell does not exist, hence /bin/sh.

Its dependencies are DECLARED in conf/nixos/erplibre.nix: envfs answers
/usr/bin/env, nix-ld gives manylinux wheels their loader. Checked on a VM:
the 362-package lock installs and Odoo answers.

Assisted-by: Claude Opus 5
2026-09-16 21:31:03 -04:00

177 lines
6.7 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 :
un lien peut désigner un AUTRE lien — « Universal » mène à « UTC », qui mène
à « Etc/UTC ». S'arrêter au premier rendrait un nom qui reste un alias, donc
le défaut même qu'on répare.
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. La
# dernière paire enchaîne deux liens, ce qu'aucune autre n'éprouve.
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):
"""« Universal » pointe « UTC », qui pointe « Etc/UTC ».
C'est ce que la lecture ligne à ligne, qui rend au premier lien
trouvé, ne sait pas faire : elle rendrait « UTC », un alias que
l'image cloud peut très bien ne pas porter non plus.
"""
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), "")
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)
def test_two_passes_and_not_one(self):
"""Le tour unique rendrait un alias d'alias inchangé au deuxième
niveau : c'est précisément ce que ce fichier garde."""
i = self.SRC.index("def canonical_timezone(")
corps = self.SRC[i : i + 1800]
self.assertIn("for _ in range(2):", corps)
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 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()