diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 11459cf..e6ec03d 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -1757,6 +1757,51 @@ def nested_module() -> str: return "kvm_amd" if " svm" in info else "kvm_intel" +def hostname_valide(nom: str) -> str: + """Un nom d'hôte acceptable, tiré du nom de la VM. + + Tout ce qui n'est ni lettre, ni chiffre, ni tiret devient un tiret ; les + tirets de tête et de queue tombent, un nom d'hôte ne pouvant pas en porter. + Vide au bout du compte, « vm » sert de repli plutôt que de laisser passer + un nom que l'invité refusera. + """ + propre = re.sub(r"[^A-Za-z0-9-]", "-", nom).strip("-") + propre = re.sub(r"-{2,}", "-", propre) + return propre[:63] or "vm" + + +# La table des alias de fuseaux, telle que tzdata la publie. Nommée ici pour +# qu'un test puisse en fournir une autre sans dépendre du tzdata de la machine +# qui l'exécute. +TZ_ALIASES = "/usr/share/zoneinfo/tzdata.zi" + + +def canonical_timezone(tz: str, table: str = TZ_ALIASES) -> str: + """Le nom canonique d'un fuseau, quand le système sait le dire. + + Un alias hérité comme « Canada/Eastern » n'existe plus dans le tzdata de + plusieurs distributions récentes, qui l'ont relégué à un paquet séparé. La + VM refuse alors le fuseau, cloud-init marque son exécution en erreur et la + machine reste en UTC — ce qui ne se voit qu'après coup, sur des horodatages + à +0000. + + La table des alias est « /usr/share/zoneinfo/tzdata.zi », dont chaque ligne + de lien s'écrit « L ». Absente ou illisible, le nom est + rendu tel quel : un fuseau non traduit vaut mieux qu'un déploiement refusé. + """ + if not tz: + return tz + try: + with open(table, encoding="utf-8") as fh: + for ligne in fh: + champs = ligne.split() + if len(champs) >= 3 and champs[0] == "L" and champs[2] == tz: + return champs[1] + except OSError: + pass + return tz + + def host_timezone() -> str: """Fuseau de l'hôte, au format zoneinfo (« America/Montreal »). @@ -1777,13 +1822,13 @@ def host_timezone() -> str: timeout=5, ).stdout.strip() if out: - return out + return canonical_timezone(out) except (OSError, subprocess.SubprocessError): pass try: tz = Path("/etc/timezone").read_text(encoding="utf-8").strip() if tz: - return tz + return canonical_timezone(tz) except OSError: pass try: @@ -1791,7 +1836,9 @@ def host_timezone() -> str: target = Path("/etc/localtime").resolve() parts = target.parts if "zoneinfo" in parts: - return "/".join(parts[parts.index("zoneinfo") + 1 :]) + return canonical_timezone( + "/".join(parts[parts.index("zoneinfo") + 1 :]) + ) except OSError: pass return "UTC" @@ -4634,7 +4681,11 @@ def main() -> None: "Erreur : --name est requis pour déployer une VM " "(ou utilisez --download-only)." ) - args.hostname = args.hostname or args.name + # Un nom d'hôte ne connaît que lettres, chiffres et tirets : le souligné + # y est refusé, et la VM garde alors le nom générique de son image sans que + # rien d'autre qu'un avertissement de cloud-init ne le dise. Le nom de + # DOMAINE, lui, peut le porter — les deux ne se ressemblent qu'en général. + args.hostname = args.hostname or hostname_valide(args.name) pw_hash = resolve_password(args) ssh_keys = load_ssh_keys(args.ssh_key) diff --git a/test/test_qemu_deploy_hote.py b/test/test_qemu_deploy_hote.py new file mode 100644 index 0000000..a42fa96 --- /dev/null +++ b/test/test_qemu_deploy_hote.py @@ -0,0 +1,114 @@ +#!/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 + canonical_timezone, + hostname_valide, +) + + +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" + ) + + +if __name__ == "__main__": + unittest.main()