erplibre/test/test_qemu_motd.py
Mathieu Benoit dc878e30d0 [FIX] nixos : afficher le guide de connexion, appliquer la locale
Le déploiement écrit /etc/motd partout et compte sur pam_motd pour le
montrer — vrai des quatre images cloud, faux ici : sshd rend « printmotd
no » et le PAM n'en contient aucun. Le guide était écrit, complet, et
personne ne le lisait. Il gagne un bloc propre à NixOS, dont le piège
qu'il existe pour dire — /etc/nixos/erplibre.nix est réécrit par
« make install_os », et ce qu'on y ajoute disparaît sans un mot.

La locale demandée ne s'appliquait pas : cloud-init passe par locale-gen
et update-locale, absents ici. Mesuré — fr_CA demandé, en_US obtenu. Rien
n'est imposé à une NixOS qu'on avait déjà.

--- EN ---

Deployment writes /etc/motd everywhere and relies on pam_motd to show it —
true of the four cloud images, false here: sshd returns "printmotd no" and
the PAM stack holds none. The guide was written, complete, and nobody read
it. It gains a NixOS block, including the trap it exists to name —
/etc/nixos/erplibre.nix is rewritten by "make install_os", and what you
add there vanishes without a word.

The requested locale did not apply: cloud-init goes through locale-gen and
update-locale, absent here. Measured — fr_CA asked, en_US obtained.
Nothing is imposed on a NixOS one already had.

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

465 lines
18 KiB
Python

#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Guide de connexion SSH des VM QEMU et identité git injectée.
Ces fonctions sont PURES : elles rendent du texte. Les tester ne demande donc
ni VM ni réseau, alors qu'une erreur y coûte cher — un user-data invalide fait
rejeter TOUTE la configuration cloud-init, et la VM démarre sans utilisateur ni
clé SSH, donc inaccessible.
"""
import importlib.util
import os
import unittest
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEPLOY_QEMU = os.path.join(REPO, "script", "qemu", "deploy_qemu.py")
# script/qemu/ n'est pas un paquet : todo.py importe déjà ce fichier de cette
# façon (_qemu_import_module), le test fait pareil.
_spec = importlib.util.spec_from_file_location("deploy_qemu", DEPLOY_QEMU)
dq = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(dq)
try:
import yaml
except ImportError: # pragma: no cover - PyYAML absent du venv d'outils
yaml = None
# Une combinaison par distribution du catalogue, dont les DEUX produits
# openSUSE : ils n'ont pas la même commande de mise à jour.
COMBOS = (
("ubuntu", "24.04", "amd64"),
("debian", "12", "s390x"),
("fedora", "43", "amd64"),
("almalinux", "9", "arm64"),
("rocky", "10", "amd64"),
("opensuse", "16.0", "amd64"),
("opensuse", "tumbleweed", "amd64"),
("arch", "latest", "amd64"),
("nixos", "25.11", "amd64"),
)
# Largeur d'un terminal standard. Au-delà, le guide se replie et devient
# illisible — c'est le seul défaut qui ne se voit qu'une fois la VM déployée.
TERM_WIDTH = 80
class TestMotdContent(unittest.TestCase):
def test_each_distro_gets_its_package_manager(self):
expected = {
"ubuntu": "apt",
"debian": "apt",
"fedora": "dnf",
"almalinux": "dnf",
"rocky": "dnf",
"opensuse": "zypper",
"arch": "pacman",
"nixos": "nix",
}
for distro, version, arch in COMBOS:
motd = dq.build_motd(distro, version, arch)
mgr = expected[distro]
self.assertIn(f"Paquets — {mgr}", motd, distro)
for other in set(expected.values()) - {mgr}:
self.assertNotIn(f" {other} install", motd, distro)
def test_leap_updates_with_up_and_tumbleweed_with_dup(self):
"""La distinction coûte cher à rater : « up » sur Tumbleweed laisse
traîner des paquets retirés des dépôts."""
leap = dq.build_motd("opensuse", "16.0", "amd64")
rolling = dq.build_motd("opensuse", "tumbleweed", "amd64")
self.assertIn("sudo zypper up", leap)
self.assertNotIn("sudo zypper dup", leap)
self.assertIn("sudo zypper dup", rolling)
self.assertNotIn("sudo zypper up ", rolling)
def test_pacman_never_suggests_a_bare_sy(self):
motd = dq.build_motd("arch", "latest", "amd64")
for line in motd.splitlines():
self.assertNotIn("pacman -Sy ", line)
self.assertNotIn("pacman -Sy\n", line)
def test_header_names_the_distribution(self):
self.assertIn(
"openSUSE Leap 16.0", dq.build_motd("opensuse", "16.0", "x")
)
self.assertIn(
"openSUSE Tumbleweed", dq.build_motd("opensuse", "tumbleweed", "x")
)
# Rolling release : le numéro « latest » n'apprend rien.
self.assertIn("Arch Linux ·", dq.build_motd("arch", "latest", "amd64"))
class TestMotdErplibreSection(unittest.TestCase):
def test_absent_without_install_dir(self):
"""Une VM déployée sans ERPLibre ne doit pas annoncer un dépôt ni un
service qui n'existent pas."""
motd = dq.build_motd("ubuntu", "24.04", "amd64")
self.assertNotIn("ERPLibre\n", motd.split("╯", 1)[1])
self.assertNotIn("erplibre.service", motd)
self.assertNotIn("make todo", motd)
# En l'absence de section ERPLibre, les commandes de service doivent
# apparaître dans le bloc système : sinon elles manqueraient partout.
self.assertIn("systemctl status <service>", motd)
self.assertIn("journalctl -u <service> -f", motd)
def test_covers_what_an_operator_needs(self):
motd = dq.build_motd(
"ubuntu",
"24.04",
"amd64",
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
for needed in (
"cd ~/git/erplibre", # aller au dépôt
"make todo", # menu ERPLibre
"vim config.conf", # éditer le serveur
"sudo systemctl restart erplibre", # redémarrer
"systemctl status erplibre", # inspecter
"journalctl -u erplibre -f", # inspecter
"update_addons_all.sh <base>", # mise à jour des modules
"git pull && make install_odoo_18", # mise à jour Odoo
"http://<ip>:8069", # interface web
):
self.assertIn(needed, motd)
def test_no_editor_names_no_command(self):
"""Sans éditeur connu, on nomme le fichier : « vi » n'est pas garanti
sur toutes les images cloud, et un guide qui propose une commande
absente est pire que muet."""
motd = dq.build_motd("arch", "latest", "amd64", "fr", "/opt/erplibre")
self.assertIn("config.conf", motd)
self.assertNotIn("vi config.conf", motd)
self.assertNotIn("nano config.conf", motd)
def test_no_make_target_stops_at_git_pull(self):
"""Les profils sans Odoo (« ERPLibre seul », « mobile ») ne doivent pas
se voir annoncer une cible make qui n'est pas la leur."""
motd = dq.build_motd(
"ubuntu", "24.04", "amd64", "fr", "~/git/erplibre", "", "vim"
)
self.assertIn("git pull", motd)
self.assertNotIn("git pull && make", motd)
class TestMotdLayout(unittest.TestCase):
def test_never_wider_than_a_standard_terminal(self):
for distro, version, arch in COMBOS:
for lang in ("fr", "en"):
for el_dir in ("", "~/git/erplibre", "/opt/erplibre"):
motd = dq.build_motd(
distro,
version,
arch,
lang,
el_dir,
"install_odoo_18" if el_dir else "",
"vim" if el_dir else "",
)
for line in motd.splitlines():
self.assertLessEqual(
len(line),
TERM_WIDTH,
f"{distro} {version} {lang} {el_dir} : {line}",
)
def test_the_frame_is_never_narrower_than_what_it_frames(self):
for distro, version, arch in COMBOS:
motd = dq.build_motd(
distro,
version,
arch,
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
lines = motd.splitlines()
frame = len(lines[0])
self.assertTrue(lines[0].startswith("╭"))
for line in lines:
self.assertLessEqual(len(line), frame, f"{distro} : {line}")
def test_no_tab_anywhere(self):
"""Une tabulation en tête de ligne est une erreur FATALE dans un
scalaire bloc YAML : cloud-init rejette alors tout le user-data."""
motd = dq.build_motd(
"ubuntu",
"24.04",
"amd64",
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
self.assertNotIn("\t", motd)
def test_english_is_really_english(self):
motd = dq.build_motd(
"ubuntu",
"24.04",
"amd64",
"en",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
self.assertIn("Packages — apt", motd)
self.assertIn("upgrade the system", motd)
self.assertNotIn("mettre à jour", motd)
class TestGitConfig(unittest.TestCase):
def test_sections_and_values(self):
cfg = dq.build_gitconfig("Ada Lovelace", "ada@example.org", "vim")
self.assertIn("[user]", cfg)
self.assertIn("name = Ada Lovelace", cfg)
self.assertIn("email = ada@example.org", cfg)
self.assertIn("[core]", cfg)
self.assertIn("editor = vim", cfg)
def test_indented_with_spaces_never_tabs(self):
"""git accepte les deux ; le scalaire bloc YAML qui transporte ce texte,
non — une tabulation y fait rejeter tout le user-data."""
self.assertNotIn("\t", dq.build_gitconfig("A", "a@b.c", "vim"))
def test_empty_when_the_host_has_nothing_to_pass(self):
self.assertEqual("", dq.build_gitconfig("", "", ""))
def test_partial_identity_omits_the_missing_key(self):
cfg = dq.build_gitconfig("Ada", "", "")
self.assertIn("name = Ada", cfg)
self.assertNotIn("email", cfg)
self.assertNotIn("[core]", cfg)
class TestEditorResolution(unittest.TestCase):
def test_known_editors_map_to_package_and_binary(self):
self.assertEqual(("vim", "vim"), dq.EDITOR_PACKAGES["vi"])
self.assertEqual(("neovim", "nvim"), dq.EDITOR_PACKAGES["nvim"])
self.assertEqual(("nano", "nano"), dq.EDITOR_PACKAGES["nano"])
def test_unknown_editor_is_ignored_not_guessed(self):
"""« code » n'est dans aucun dépôt de distribution : l'annoncer
donnerait un core.editor qui fait échouer « git commit »."""
self.assertNotIn("code", dq.EDITOR_PACKAGES)
self.assertEqual(("", ""), dq.EDITOR_PACKAGES.get("code", ("", "")))
def test_binary_drops_the_path_and_the_options(self):
self.assertEqual("code", dq.editor_binary("/usr/bin/code --wait"))
self.assertEqual("vim", dq.editor_binary("vim"))
self.assertEqual("", dq.editor_binary(" "))
class TestWriteFilesBlock(unittest.TestCase):
def _block(self):
return dq.write_files_lines(
[
(
"/etc/motd",
"0644",
dq.build_motd("ubuntu", "24.04", "amd64"),
"",
),
(
"/home/erplibre/.gitconfig",
"0644",
dq.build_gitconfig("Ada", "ada@example.org", "vim"),
"erplibre",
),
]
)
def test_permissions_are_quoted(self):
"""« permissions: 644 » non quoté est lu en DÉCIMAL et appliqué tel
quel : 0o1204, soit le bit setuid, sans le moindre avertissement."""
block = "\n".join(self._block())
self.assertIn("permissions: '0644'", block)
self.assertNotIn("permissions: 0644", block)
def test_defer_only_for_owned_files(self):
"""write_files tourne AVANT la création des utilisateurs : sans
« defer », le chown vers le compte de la VM échoue."""
block = "\n".join(self._block())
self.assertEqual(1, block.count("defer: true"))
self.assertEqual(1, block.count("owner: erplibre:erplibre"))
@unittest.skipIf(yaml is None, "PyYAML absent")
def test_yaml_round_trip_is_byte_identical(self):
"""Le scalaire bloc doit rendre EXACTEMENT le texte d'origine : une
indentation mal calculée passerait la validation en abîmant le fichier
écrit dans la VM."""
motd = dq.build_motd(
"ubuntu",
"24.04",
"amd64",
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
# « + "\n" » comme build_cloud_config, qui termine toujours le
# document : sans ce saut final, un scalaire bloc en fin de flux perd sa
# dernière fin de ligne.
doc = (
"\n".join(dq.write_files_lines([("/etc/motd", "0644", motd, "")]))
+ "\n"
)
self.assertEqual(
motd, yaml.safe_load(doc)["write_files"][0]["content"]
)
@unittest.skipIf(yaml is None, "PyYAML absent")
def test_every_distro_produces_parsable_yaml(self):
for distro, version, arch in COMBOS:
motd = dq.build_motd(
distro,
version,
arch,
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
)
doc = (
"\n".join(
dq.write_files_lines([("/etc/motd", "0644", motd, "")])
)
+ "\n"
)
self.assertEqual(
motd, yaml.safe_load(doc)["write_files"][0]["content"], distro
)
class TestInstallerGuideNames(unittest.TestCase):
def test_names_are_flat(self):
"""Le cpio est déplié séquentiellement et ne crée pas les répertoires
parents manquants : une entrée « erplibre/etc-motd » sans entrée
« erplibre » ferait échouer le dépliage de l'initrd entier."""
for path in ("/etc/motd", "/home/erplibre/.gitconfig"):
name = dq.installer_guide_name(path)
self.assertNotIn("/", name)
self.assertTrue(name.startswith("erplibre-"))
def test_two_paths_never_collide(self):
self.assertNotEqual(
dq.installer_guide_name("/etc/motd"),
dq.installer_guide_name("/home/erplibre/.gitconfig"),
)
class TestDesktopBlock(unittest.TestCase):
"""Le bloc « Bureau » : présent seulement là où un bureau existe.
Vécu : une VM graphique restait sur une console texte, GNOME installé et
gdm3 installé — graphical.target était déjà atteinte quand le paquet est
arrivé. La commande qui répare tient sur une ligne, encore faut-il la lire
quelque part. Sur un serveur, elle ne mènerait à aucune unité : le bloc
n'y apparaît pas.
"""
def _motd(self, desktop):
return dq.build_motd(
"ubuntu",
"26.04",
"amd64",
"fr",
"~/git/erplibre",
"install_odoo_18",
"vim",
desktop,
)
def test_a_server_gets_no_desktop_block(self):
self.assertNotIn("Bureau", self._motd(False))
def test_a_graphical_vm_gets_it(self):
self.assertIn("Bureau", self._motd(True))
def test_it_carries_the_command_that_repairs(self):
"""« --now » et non « enable » seul : sur Debian et Ubuntu, l'unité n'a
pas de WantedBy, et « enable » rend 0 sans rien faire."""
motd = self._motd(True)
self.assertIn("systemctl enable --now gdm", motd)
self.assertIn("systemctl status display-manager", motd)
def test_it_says_why_now_matters(self):
self.assertIn("--now", self._motd(True))
def test_it_stays_inside_the_frame(self):
"""Le guide est encadré : une ligne trop longue casse la boîte."""
lines = self._motd(True).splitlines()
width = max(len(line) for line in lines)
border = [line for line in lines if line.startswith("╭")][0]
self.assertEqual(len(border), width)
def test_the_default_is_no_block(self):
"""Un appelant qui n'en sait rien n'annonce pas un bureau."""
motd = dq.build_motd("ubuntu", "26.04", "amd64", "fr")
self.assertNotIn("Bureau", motd)
class LeGuideNixosSeVoitEtDitVrai(unittest.TestCase):
"""Sur NixOS rien ne lisait /etc/motd — mesuré sur une VM installée :
sshd en « printmotd no » et aucun pam_motd dans son PAM. Le fichier était
écrit, complet, et personne ne le montrait. Le module le fait afficher ;
ces tests gardent ce qu'il montre.
"""
def _motd(self, distro="nixos", **kw):
return dq.build_motd(
distro, "25.11", "amd64", el_dir="~/git/erplibre", **kw
)
def test_the_file_that_gets_rewritten_is_named_as_such(self):
"""Le piège que ce bloc existe pour dire : « make install_os » repose
/etc/nixos/erplibre.nix depuis le dépôt, et ce qu'on y avait ajouté
disparaît sans un mot."""
motd = self._motd()
self.assertIn("/etc/nixos/erplibre.nix", motd)
self.assertIn("make install_os", motd)
def test_where_ones_own_declarations_survive(self):
"""Un guide qui nomme le piège sans nommer l'issue laisse l'opérateur
devant un fichier qu'il n'ose plus toucher."""
self.assertIn("/etc/nixos/configuration.nix", self._motd())
def test_the_source_of_truth_is_the_repository(self):
self.assertIn("conf/nixos/erplibre.nix", self._motd())
def test_the_block_is_translated(self):
for lang, attendu in (("fr", "déclaratif"), ("en", "declarative")):
with self.subTest(lang=lang):
self.assertIn(attendu, self._motd(lang=lang))
def test_the_other_distributions_are_left_alone(self):
"""Rien de ceci ne veut dire quoi que ce soit ailleurs."""
for distro in ("debian", "ubuntu", "fedora", "arch", "opensuse"):
with self.subTest(distro=distro):
self.assertNotIn("/etc/nixos/", self._motd(distro))
def test_a_vm_without_erplibre_says_nothing_of_it(self):
"""Même règle que le bloc AUR : c'est l'installation qui pose le
module dont ces lignes parlent."""
nu = dq.build_motd("nixos", "25.11", "amd64")
self.assertNotIn("conf/nixos/erplibre.nix", nu)
def test_the_module_turns_the_display_on(self):
"""Écrire le guide sans rien pour le lire ne sert personne."""
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
module = (racine / "conf/nixos/erplibre.nix").read_text(
encoding="utf-8"
)
self.assertIn("services.openssh.settings.PrintMotd = true;", module)
if __name__ == "__main__":
unittest.main()