[ADD] qemu: greet the SSH login with the distro's own commands
The catalogue spans four package managers and the operator changes
distribution at every deployment, yet nothing in the VM said which one it
was. /etc/motd now carries the commands of the machine you just entered,
and the host's git identity lands in ~/.gitconfig so a commit made there
is not signed erplibre@<vm-name>.
The seven cloud images were mounted read-only to settle the mechanism:
sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears
once and never for ssh host 'command'. Checked: cloud-init schema reports
Valid schema on the 8 catalogue combinations, the YAML round-trip is
byte-identical, and the s390x initrd was rebuilt and unpacked. Its
early_command gained the `true` guard it lacked: the last diagnostic
returns 1 when enc1 is absent, stopping the installer it was meant to
explain.
--- FR ---
Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change
de distribution à chaque déploiement, sans que rien dans la VM ne dise
laquelle. /etc/motd porte maintenant les commandes de la machine où l'on
vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig :
un commit fait là ne porte plus erplibre@<nom-de-vm>.
Les sept images cloud ont été montées en lecture seule pour trancher le
mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche
le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'.
Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du
catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a
été reconstruit puis déplié. Son early_command a reçu la garde `true` qui
lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et
arrêtait l'installateur qu'il devait éclairer.
Assisted-by: Claude Opus 5
2026-08-17 22:57:40 -04:00
|
|
|
#!/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"),
|
2026-09-16 00:41:43 -04:00
|
|
|
("nixos", "25.11", "amd64"),
|
[ADD] qemu: greet the SSH login with the distro's own commands
The catalogue spans four package managers and the operator changes
distribution at every deployment, yet nothing in the VM said which one it
was. /etc/motd now carries the commands of the machine you just entered,
and the host's git identity lands in ~/.gitconfig so a commit made there
is not signed erplibre@<vm-name>.
The seven cloud images were mounted read-only to settle the mechanism:
sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears
once and never for ssh host 'command'. Checked: cloud-init schema reports
Valid schema on the 8 catalogue combinations, the YAML round-trip is
byte-identical, and the s390x initrd was rebuilt and unpacked. Its
early_command gained the `true` guard it lacked: the last diagnostic
returns 1 when enc1 is absent, stopping the installer it was meant to
explain.
--- FR ---
Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change
de distribution à chaque déploiement, sans que rien dans la VM ne dise
laquelle. /etc/motd porte maintenant les commandes de la machine où l'on
vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig :
un commit fait là ne porte plus erplibre@<nom-de-vm>.
Les sept images cloud ont été montées en lecture seule pour trancher le
mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche
le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'.
Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du
catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a
été reconstruit puis déplié. Son early_command a reçu la garde `true` qui
lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et
arrêtait l'installateur qu'il devait éclairer.
Assisted-by: Claude Opus 5
2026-08-17 22:57:40 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# 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",
|
2026-09-16 00:41:43 -04:00
|
|
|
"nixos": "nix",
|
[ADD] qemu: greet the SSH login with the distro's own commands
The catalogue spans four package managers and the operator changes
distribution at every deployment, yet nothing in the VM said which one it
was. /etc/motd now carries the commands of the machine you just entered,
and the host's git identity lands in ~/.gitconfig so a commit made there
is not signed erplibre@<vm-name>.
The seven cloud images were mounted read-only to settle the mechanism:
sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears
once and never for ssh host 'command'. Checked: cloud-init schema reports
Valid schema on the 8 catalogue combinations, the YAML round-trip is
byte-identical, and the s390x initrd was rebuilt and unpacked. Its
early_command gained the `true` guard it lacked: the last diagnostic
returns 1 when enc1 is absent, stopping the installer it was meant to
explain.
--- FR ---
Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change
de distribution à chaque déploiement, sans que rien dans la VM ne dise
laquelle. /etc/motd porte maintenant les commandes de la machine où l'on
vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig :
un commit fait là ne porte plus erplibre@<nom-de-vm>.
Les sept images cloud ont été montées en lecture seule pour trancher le
mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche
le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'.
Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du
catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a
été reconstruit puis déplié. Son early_command a reçu la garde `true` qui
lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et
arrêtait l'installateur qu'il devait éclairer.
Assisted-by: Claude Opus 5
2026-08-17 22:57:40 -04:00
|
|
|
}
|
|
|
|
|
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"),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-08-20 03:32:49 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
[ADD] qemu: greet the SSH login with the distro's own commands
The catalogue spans four package managers and the operator changes
distribution at every deployment, yet nothing in the VM said which one it
was. /etc/motd now carries the commands of the machine you just entered,
and the host's git identity lands in ~/.gitconfig so a commit made there
is not signed erplibre@<vm-name>.
The seven cloud images were mounted read-only to settle the mechanism:
sshd is PrintMotd no everywhere and pam_motd shows the file, so it appears
once and never for ssh host 'command'. Checked: cloud-init schema reports
Valid schema on the 8 catalogue combinations, the YAML round-trip is
byte-identical, and the s390x initrd was rebuilt and unpacked. Its
early_command gained the `true` guard it lacked: the last diagnostic
returns 1 when enc1 is absent, stopping the installer it was meant to
explain.
--- FR ---
Le catalogue couvre quatre gestionnaires de paquets et l'opérateur change
de distribution à chaque déploiement, sans que rien dans la VM ne dise
laquelle. /etc/motd porte maintenant les commandes de la machine où l'on
vient d'entrer, et l'identité git de l'hôte atterrit dans ~/.gitconfig :
un commit fait là ne porte plus erplibre@<nom-de-vm>.
Les sept images cloud ont été montées en lecture seule pour trancher le
mécanisme : sshd est en PrintMotd no partout et c'est pam_motd qui affiche
le fichier, donc un seul affichage, et jamais pour ssh hôte 'commande'.
Vérifié : cloud-init schema rend Valid schema sur les 8 combinaisons du
catalogue, l'aller-retour YAML est octet pour octet, et l'initrd s390x a
été reconstruit puis déplié. Son early_command a reçu la garde `true` qui
lui manquait : son dernier diagnostic rend 1 quand enc1 est absente, et
arrêtait l'installateur qu'il devait éclairer.
Assisted-by: Claude Opus 5
2026-08-17 22:57:40 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|