Deux réglages régionaux échouaient sur toute VM Debian, et le seul signe en était le mot « error » dans un compte-rendu qui le porte à chaque fois. « update-locale » refuse un locale qui n'est pas généré, et le module du clavier finit par redémarrer console-setup, absent de l'image genericcloud : le réglage est écrit avant cet échec, donc le poser nous-mêmes ne perd que la console texte. Et l'installateur nix n'écrit plus dans ~/.bashrc : un fichier que l'utilisateur possède ne se modifie pas pour la durée d'un amorçage. --- EN --- Two regional settings failed on every Debian VM, and the only sign was the word "error" in a report that carries it every time. "update-locale" refuses a locale that is not generated, and the keyboard module ends by restarting console-setup, absent from the genericcloud image: the setting is written before that failure, so placing it ourselves loses only the text console. And the nix installer no longer writes into ~/.bashrc: a file the user owns is not edited for the duration of a bootstrap. Assisted-by: Claude Opus 5
153 lines
6.3 KiB
Python
153 lines
6.3 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 seed ne demande à l'invité que ce que son image sait honorer.
|
|
|
|
Deux réglages régionaux échouaient sur toute VM Debian, et le seul signe en
|
|
était le mot « error » dans le compte-rendu de cloud-init que le déploiement
|
|
imprime — un mot qui, s'il s'affiche à chaque fois, n'avertit plus de rien.
|
|
|
|
- le LOCALE : « update-locale LANG=xx_YY.UTF-8 » refuse un locale qui n'est
|
|
pas généré, et un locale ne se génère qu'à partir de /etc/locale.gen. La VM
|
|
restait en C.UTF-8 ;
|
|
- le CLAVIER : le module de cloud-init finit par « systemctl restart
|
|
console-setup », service absent de l'image genericcloud. Le réglage est
|
|
écrit AVANT cet échec, dans /etc/default/keyboard — c'est le fichier que
|
|
localed et X relisent —, donc le poser nous-mêmes ne perd rien d'autre que
|
|
la disposition de la console texte, qui demande ce paquet.
|
|
|
|
Ces tests gardent aussi la contrainte qui domine toutes les autres ici : le
|
|
user-data reste un YAML valide. Une erreur de syntaxe fait rejeter la
|
|
configuration ENTIÈRE, sans message : la VM démarre sans compte ni clé.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
sys.argv = ["todo.py"]
|
|
|
|
RACINE = Path(__file__).resolve().parents[1]
|
|
|
|
try:
|
|
import yaml
|
|
except ImportError: # pragma: no cover
|
|
yaml = None
|
|
|
|
|
|
def _deploy_qemu():
|
|
chemin = RACINE / "script/qemu/deploy_qemu.py"
|
|
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod
|
|
|
|
|
|
DQ = _deploy_qemu()
|
|
|
|
|
|
def _cloud_config(distro):
|
|
args = DQ.build_parser().parse_args(
|
|
["--distro", distro, "--hostname", "x"]
|
|
)
|
|
return DQ.build_cloud_config(args, None, ["ssh-ed25519 AAAA essai"])
|
|
|
|
|
|
class LeLocale(unittest.TestCase):
|
|
def test_the_locale_is_generated_before_cloud_init_applies_it(self):
|
|
"""« bootcmd » et non « runcmd » : le premier tourne à l'étape init,
|
|
avant les modules de configuration dont « locale » fait partie. Le
|
|
second viendrait après, et n'aurait plus rien à rattraper."""
|
|
lignes = DQ.locale_bootcmd_lines("fr_CA.UTF-8")
|
|
self.assertEqual("bootcmd:", lignes[0])
|
|
self.assertIn("locale-gen", lignes[1])
|
|
|
|
def test_the_line_is_added_to_locale_gen_only_once(self):
|
|
"""bootcmd tourne à CHAQUE démarrage : sans le grep, /etc/locale.gen
|
|
s'allonge d'une ligne identique par boot."""
|
|
self.assertIn(
|
|
"grep -qxF 'fr_CA.UTF-8 UTF-8'",
|
|
" ".join(DQ.locale_bootcmd_lines("fr_CA.UTF-8")),
|
|
)
|
|
|
|
def test_a_host_without_locale_gen_is_left_alone(self):
|
|
"""Fedora, la famille RHEL et openSUSE embarquent leurs locales déjà
|
|
générées, et n'ont pas ce fichier."""
|
|
self.assertIn(
|
|
"[ -f /etc/locale.gen ] || exit 0",
|
|
" ".join(DQ.locale_bootcmd_lines("fr_CA.UTF-8")),
|
|
)
|
|
|
|
def test_a_builtin_locale_asks_for_nothing(self):
|
|
"""C.UTF-8 et POSIX sont dans la bibliothèque C, pas dans
|
|
locale.gen : les demander ferait échouer locale-gen, donc bootcmd,
|
|
donc cloud-init — l'erreur qu'on retire."""
|
|
for locale in ("C.UTF-8", "POSIX", ""):
|
|
with self.subTest(locale=locale):
|
|
self.assertEqual([], DQ.locale_bootcmd_lines(locale))
|
|
|
|
|
|
class LeClavier(unittest.TestCase):
|
|
def test_debian_gets_the_file_and_not_the_cloud_init_key(self):
|
|
"""Le module de cloud-init n'y aboutit pas : console-setup manque."""
|
|
self.assertEqual([], DQ.keyboard_lines("debian", "ca", "multix"))
|
|
fichiers = DQ.keyboard_files("debian", "ca", "multix")
|
|
self.assertEqual("/etc/default/keyboard", fichiers[0][0])
|
|
self.assertIn("XKBLAYOUT=ca", fichiers[0][2])
|
|
self.assertIn("XKBVARIANT=multix", fichiers[0][2])
|
|
|
|
def test_elsewhere_cloud_init_keeps_the_job(self):
|
|
"""Une VM Ubuntu applique le clavier sans erreur : lui retirer le
|
|
bloc perdrait la disposition de sa console, elle qui l'obtient."""
|
|
for distro in ("ubuntu", "fedora", "arch", "opensuse", "nixos"):
|
|
with self.subTest(distro=distro):
|
|
self.assertEqual(
|
|
["keyboard:", " layout: ca", " variant: multix"],
|
|
DQ.keyboard_lines(distro, "ca", "multix"),
|
|
)
|
|
self.assertEqual([], DQ.keyboard_files(distro, "ca", "multix"))
|
|
|
|
def test_the_file_carries_a_model(self):
|
|
"""localed lit les quatre champs ; un XKBMODEL vide laisse X deviner."""
|
|
self.assertIn("XKBMODEL=", DQ.keyboard_files("debian", "ca", "")[0][2])
|
|
|
|
|
|
class LeSeedEntier(unittest.TestCase):
|
|
"""Ce que la configuration produite doit rester, distro par distro."""
|
|
|
|
def setUp(self):
|
|
if yaml is None:
|
|
self.skipTest("PyYAML absent")
|
|
|
|
def test_the_user_data_stays_valid_yaml(self):
|
|
"""Une erreur de syntaxe fait rejeter le user-data en ENTIER, sans
|
|
message : la VM démarre nue, sans compte ni clé SSH."""
|
|
for distro in ("debian", "ubuntu", "fedora", "arch", "nixos"):
|
|
with self.subTest(distro=distro):
|
|
doc = yaml.safe_load(_cloud_config(distro))
|
|
self.assertIsInstance(doc, dict)
|
|
self.assertIn("users", doc)
|
|
|
|
def test_bootcmd_is_a_list_of_arguments_not_a_shell_string(self):
|
|
"""La forme [sh, -c, "…"] met le corps hors de portée de YAML :
|
|
accolades, apostrophes et redirections y passent sans échappement."""
|
|
doc = yaml.safe_load(_cloud_config("debian"))
|
|
self.assertIsInstance(doc["bootcmd"][0], list)
|
|
self.assertEqual(["sh", "-c"], doc["bootcmd"][0][:2])
|
|
|
|
def test_debian_asks_cloud_init_for_no_keyboard(self):
|
|
doc = yaml.safe_load(_cloud_config("debian"))
|
|
self.assertIsNone(doc.get("keyboard"))
|
|
chemins = [f["path"] for f in doc["write_files"]]
|
|
self.assertIn("/etc/default/keyboard", chemins)
|
|
|
|
def test_ubuntu_keeps_asking(self):
|
|
doc = yaml.safe_load(_cloud_config("ubuntu"))
|
|
self.assertEqual("ca", doc["keyboard"]["layout"])
|
|
chemins = [f["path"] for f in doc["write_files"]]
|
|
self.assertNotIn("/etc/default/keyboard", chemins)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|