[FIX] déploiement qemu : nom d'hôte valide, fuseau connu de l'invité
Deux réglages que cloud-init applique au premier démarrage, et qui échouaient tous les deux SANS arrêter le déploiement. Un nom d'hôte n'accepte ni souligné ni point, là où un nom de domaine libvirt les tolère : la VM gardait le nom générique de son image. Un alias de fuseau hérité — la forme que plusieurs distributions récentes ont reléguée à un paquet séparé — faisait marquer l'exécution de cloud-init en erreur et laissait la machine en UTC, ce qui ne se voit qu'après coup sur des horodatages à +0000. Le nom est nettoyé, le fuseau rendu canonique par la table d'alias de tzdata. --- EN --- Two settings cloud-init applies at first boot, both of which failed WITHOUT stopping the deployment. A hostname accepts neither underscore nor dot, where a libvirt domain name tolerates them: the VM kept its image's generic name. A legacy timezone alias — the form several recent distributions moved to a separate package — marked the cloud-init run as failed and left the machine in UTC, which only shows up later on +0000 timestamps. The name is cleaned, the timezone made canonical through tzdata's alias table. Assisted-by: Claude Opus 5
This commit is contained in:
parent
a7706adf3d
commit
2892606690
2 changed files with 169 additions and 4 deletions
|
|
@ -1757,6 +1757,51 @@ def nested_module() -> str:
|
||||||
return "kvm_amd" if " svm" in info else "kvm_intel"
|
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 <canonique> <alias> ». 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:
|
def host_timezone() -> str:
|
||||||
"""Fuseau de l'hôte, au format zoneinfo (« America/Montreal »).
|
"""Fuseau de l'hôte, au format zoneinfo (« America/Montreal »).
|
||||||
|
|
||||||
|
|
@ -1777,13 +1822,13 @@ def host_timezone() -> str:
|
||||||
timeout=5,
|
timeout=5,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
if out:
|
if out:
|
||||||
return out
|
return canonical_timezone(out)
|
||||||
except (OSError, subprocess.SubprocessError):
|
except (OSError, subprocess.SubprocessError):
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
|
tz = Path("/etc/timezone").read_text(encoding="utf-8").strip()
|
||||||
if tz:
|
if tz:
|
||||||
return tz
|
return canonical_timezone(tz)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
try:
|
try:
|
||||||
|
|
@ -1791,7 +1836,9 @@ def host_timezone() -> str:
|
||||||
target = Path("/etc/localtime").resolve()
|
target = Path("/etc/localtime").resolve()
|
||||||
parts = target.parts
|
parts = target.parts
|
||||||
if "zoneinfo" in parts:
|
if "zoneinfo" in parts:
|
||||||
return "/".join(parts[parts.index("zoneinfo") + 1 :])
|
return canonical_timezone(
|
||||||
|
"/".join(parts[parts.index("zoneinfo") + 1 :])
|
||||||
|
)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
return "UTC"
|
return "UTC"
|
||||||
|
|
@ -4634,7 +4681,11 @@ def main() -> None:
|
||||||
"Erreur : --name est requis pour déployer une VM "
|
"Erreur : --name est requis pour déployer une VM "
|
||||||
"(ou utilisez --download-only)."
|
"(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)
|
pw_hash = resolve_password(args)
|
||||||
ssh_keys = load_ssh_keys(args.ssh_key)
|
ssh_keys = load_ssh_keys(args.ssh_key)
|
||||||
|
|
|
||||||
114
test/test_qemu_deploy_hote.py
Normal file
114
test/test_qemu_deploy_hote.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Reference in a new issue