[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
This commit is contained in:
Mathieu Benoit 2026-08-17 22:57:40 -04:00
parent c87cd2f5bd
commit 57ab0b506f
2 changed files with 991 additions and 2 deletions

View file

@ -46,6 +46,7 @@ Exemples
from __future__ import annotations
import argparse
import base64
import getpass
import grp
import gzip
@ -1482,6 +1483,557 @@ def user_groups(distro: str) -> str:
return "users, wheel"
# --------------------------------------------------------------------------- #
# Guide de connexion (/etc/motd) et identité git de la VM
# --------------------------------------------------------------------------- #
# Le catalogue couvre quatre gestionnaires de paquets, et l'opérateur change de
# distribution d'un déploiement à l'autre. Le guide met SOUS LES YEUX, à la
# connexion, les commandes de la machine où l'on vient d'entrer : apt là où
# c'est apt, zypper là où c'est zypper.
#
# Le mécanisme est /etc/motd, et il est le même partout. Vérifié dans les images
# cloud elles-mêmes, montées en lecture seule : sshd y est en « PrintMotd no »
# et c'est pam_motd qui affiche le fichier. Trois conséquences tenues pour
# acquises ici :
# - il suffit d'ÉCRIRE /etc/motd. Ajouter « PrintMotd yes » afficherait le
# guide DEUX FOIS — sshd lit /etc/motd en dur, PAM le lit aussi ;
# - « ssh hôte 'commande' » ne l'affiche PAS (openssh coupe les deux chemins
# dès qu'une commande est passée), donc le suivi d'installation reste net.
# Un « ssh hôte < script » l'afficherait, lui : aucun n'est utilisé ici ;
# - openSUSE ajoute son « Have a lot of fun... » APRÈS le guide : il vient de
# /usr/lib/motd.d/welcome, que pam_motd lit après le fichier. On le laisse.
#
# Ubuntu n'a PAS de /etc/motd (son postinst base-files ne le crée pas, à la
# différence de Debian) : le fichier est donc créé, pas remplacé. Sur Debian il
# écrase les cinq lignes de base-files, ce qui ne fâche pas dpkg — /etc/motd n'y
# est ni un conffile ni même un fichier du paquet.
# Étiquette lisible d'une distribution. openSUSE livre DEUX produits sous un
# seul nom de distro (Leap, numéroté ; Tumbleweed, rolling) : la version tranche.
DISTRO_LABELS: dict[str, str] = {
"ubuntu": "Ubuntu",
"debian": "Debian",
"fedora": "Fedora",
"almalinux": "AlmaLinux",
"rocky": "Rocky Linux",
"opensuse": "openSUSE",
"arch": "Arch Linux",
}
# Gestionnaire de paquets de chaque distribution du catalogue.
DISTRO_PKG: dict[str, str] = {
"ubuntu": "apt",
"debian": "apt",
"fedora": "dnf",
"almalinux": "dnf",
"rocky": "dnf",
"opensuse": "zypper",
"arch": "pacman",
}
def distro_label(distro: str, version: str) -> str:
"""« Ubuntu 24.04 », « openSUSE Leap 16.0 », « Arch Linux »…"""
name = DISTRO_LABELS.get(distro, distro)
if distro == "opensuse":
if version == "tumbleweed":
return f"{name} Tumbleweed"
return f"{name} Leap {version}"
if distro == "arch":
# Rolling release : « latest » n'apprend rien à personne.
return name
return f"{name} {version}"
# Aide-mémoire par gestionnaire de paquets : (commande, glose fr, glose en).
# Chaque ligne vient du manuel amont de l'outil, pas de mémoire, et doit
# fonctionner TELLE QUELLE — c'est un guide, pas une piste à vérifier.
#
# dnf : les formes écrites ici valent pour dnf4 (AlmaLinux/Rocky 9 ET 10, tous
# deux en dnf 4.x) comme pour dnf5 (Fedora 41+). Les raccourcis de dnf4 ont
# disparu de dnf5 : « dnf history » seul, « grouplist », « whatprovides »,
# « list installed » sans tirets y échouent tous. Les formes longues passent
# partout, et ne coûtent rien.
PKG_GUIDE: dict[str, tuple[tuple[str, str, str], ...]] = {
"apt": (
("sudo apt update", "rafraîchir l'index", "refresh the index"),
(
"sudo apt upgrade",
"mettre à jour le système",
"upgrade the system",
),
("sudo apt install <paquet>", "installer", "install"),
("sudo apt remove <paquet>", "retirer", "remove"),
("apt search <motif>", "chercher", "search"),
("apt show <paquet>", "détails d'un paquet", "package details"),
("apt list --installed", "lister l'installé", "list installed"),
("sudo apt autoremove", "purger les orphelins", "purge orphans"),
),
"dnf": (
(
"sudo dnf upgrade",
"mettre à jour le système",
"upgrade the system",
),
("sudo dnf install <paquet>", "installer", "install"),
("sudo dnf remove <paquet>", "retirer", "remove"),
("dnf check-update", "mises à jour disponibles", "available updates"),
("dnf search <motif>", "chercher", "search"),
("dnf info <paquet>", "détails d'un paquet", "package details"),
("dnf list --installed", "lister l'installé", "list installed"),
("dnf history list", "journal des opérations", "transaction log"),
),
"pacman": (
(
"sudo pacman -Syu",
"mettre à jour le système",
"upgrade the system",
),
# Jamais « -Sy » seul : la base de paquets serait à jour et le système
# non, donc une installation tirerait des binaires liés à des
# bibliothèques absentes. Arch ne supporte que la mise à jour complète,
# d'où la forme « -Syu <paquet> » pour installer.
(
"sudo pacman -Syu <paquet>",
"installer (jamais « -Sy » seul)",
"install (never a bare « -Sy »)",
),
("sudo pacman -Rns <paquet>", "retirer", "remove"),
("pacman -Ss <motif>", "chercher", "search"),
("pacman -Si <paquet>", "détails d'un paquet", "package details"),
("pacman -Q", "lister l'installé", "list installed"),
("pacman -Qdtq", "orphelins", "orphans"),
("sudo pacman -Sc", "nettoyer le cache", "clean the cache"),
),
}
def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]:
"""Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap.
La ligne de mise à jour n'est PAS la même, et ce n'est pas une préférence de
style : « up » sur Leap, dont la version est figée, et « dup » sur
Tumbleweed, où chaque mise à jour est un instantané complet de la
distribution. La doc amont est catégorique — « on Tumbleweed you will never
have to use zypper-up » — et « up » y laisse traîner des paquets retirés des
dépôts, donc des dépendances bancales. Le déploiement sait laquelle des deux
il installe : autant que le guide le sache aussi.
"""
upgrade = (
("sudo zypper dup", "mettre à jour (rolling)", "upgrade (rolling)")
if rolling
else (
"sudo zypper up",
"mettre à jour le système",
"upgrade the system",
)
)
return (
("sudo zypper ref", "rafraîchir les dépôts", "refresh the repos"),
upgrade,
("sudo zypper in <paquet>", "installer", "install"),
("sudo zypper rm <paquet>", "retirer", "remove"),
("zypper se <motif>", "chercher", "search"),
("zypper info <paquet>", "détails d'un paquet", "package details"),
("zypper se -i", "lister l'installé", "list installed"),
("zypper lu", "mises à jour disponibles", "available updates"),
(
"sudo zypper ps -s",
"à redémarrer après MAJ",
"restart after upgrade",
),
)
# Lignes valables partout, quelle que soit la distribution.
SYSTEM_GUIDE: tuple[tuple[str, str, str], ...] = (
("hostname -I", "adresse IP de la VM", "the VM's IP address"),
("df -h /", "espace disque", "disk space"),
("free -h", "mémoire", "memory"),
)
# Ajoutées SEULEMENT quand il n'y a pas de section ERPLibre : celle-ci montre
# déjà les deux commandes, sur un service qui existe vraiment. Sur une VM
# déployée sans ERPLibre, elles manqueraient.
SERVICE_GUIDE: tuple[tuple[str, str, str], ...] = (
("systemctl status <service>", "état d'un service", "a service's state"),
("journalctl -u <service> -f", "suivre son journal", "follow its log"),
)
def erplibre_guide(
el_dir: str, el_make: str = "", editor: str = ""
) -> tuple[tuple[str, str, str], ...]:
"""Commandes ERPLibre de la VM.
`el_dir` : racine de l'installation (~/git/erplibre en développement,
/opt/erplibre en production). Toutes les autres lignes sont relatives à ce
répertoire, d'où le « cd » en tête.
`el_make` : cible make qui a installé la VM, réutilisée pour la mettre à
jour. Vide, le guide s'arrête à « git pull » plutôt que d'annoncer une cible
qui n'est pas celle du profil retenu.
`editor` : éditeur de l'hôte, quand il a pu être déterminé. Sans lui on
nomme le fichier de configuration sans nommer d'éditeur — « vi » n'est pas
garanti sur toutes les images cloud, et un guide qui propose une commande
absente est pire que muet.
"""
rows = [
(f"cd {el_dir}", "aller au dépôt", "go to the checkout"),
("make todo", "menu ERPLibre (TODO)", "ERPLibre menu (TODO)"),
]
if editor:
rows.append(
(
f"{editor} config.conf",
"éditer le serveur",
"edit the server",
)
)
else:
rows.append(
(
"config.conf",
"configuration du serveur",
"the server's config",
)
)
rows += [
(
"sudo systemctl restart erplibre",
"redémarrer le serveur",
"restart the server",
),
("systemctl status erplibre", "état du serveur", "server state"),
("journalctl -u erplibre -f", "suivre son journal", "follow its log"),
("./run.sh -d <base>", "lancer à la main", "run it by hand"),
(
"./script/addons/update_addons_all.sh <base>",
"mise à jour des modules",
"update the modules",
),
(
f"git pull && make {el_make}" if el_make else "git pull",
(
"mise à jour ERPLibre/Odoo"
if el_make
else "mettre à jour le dépôt"
),
"update ERPLibre/Odoo" if el_make else "update the checkout",
),
("http://<ip>:8069", "interface web", "web interface"),
]
return tuple(rows)
# Plancher de largeur de l'encadré. Au-dessus, il SUIT le contenu : un cadre
# plus étroit que ce qu'il encadre serait pire qu'un cadre large. C'est au
# contenu de rester sous 80 colonnes — un guide qui se replie sur un terminal
# standard est illisible, et un test le vérifie pour les sept distributions.
MOTD_MIN_WIDTH = 62
def _pick(pair: tuple[str, str], lang: str) -> str:
"""Membre fr ou en d'un couple de libellés."""
return pair[1] if lang == "en" else pair[0]
def gloss_col(*blocks: tuple[tuple[str, str, str], ...]) -> int:
"""Colonne où commencent les gloses de ces blocs : la commande la plus
longue, plus deux espaces."""
return max(len(cmd) for rows in blocks for cmd, _fr, _en in rows) + 2
def motd_block(
title: str, rows: tuple[tuple[str, str, str], ...], lang: str, col: int
) -> list[str]:
"""Un bloc du guide : un titre, puis « commande <espaces> glose » alignées.
`col` est donné plutôt que déduit du bloc : les blocs de commandes courtes
partagent une colonne commune, sans quoi le bloc « système » se tasserait à
treize caractères là où celui des paquets en occupe trente. Le bloc
ERPLibre, lui, garde la sienne — sa commande la plus longue fait
43 caractères, et l'imposer au guide entier ferait déborder les lignes de
80 colonnes.
"""
out = [f" {title}"]
for cmd, gloss_fr, gloss_en in rows:
out.append(f" {cmd.ljust(col)}{_pick((gloss_fr, gloss_en), lang)}")
return out
def build_motd(
distro: str,
version: str,
arch: str,
lang: str = "fr",
el_dir: str = "",
el_make: str = "",
editor: str = "",
) -> str:
"""Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable.
La section ERPLibre n'apparaît qu'avec `el_dir` : une VM déployée sans
installation ne doit pas annoncer un dépôt et un service qui n'existent pas.
"""
body: list[str] = []
mgr = DISTRO_PKG.get(distro, "")
if mgr == "zypper":
pkg_rows = zypper_guide(version == "tumbleweed")
else:
pkg_rows = PKG_GUIDE.get(mgr, ())
sys_rows = SYSTEM_GUIDE if el_dir else SYSTEM_GUIDE + SERVICE_GUIDE
narrow = gloss_col(pkg_rows or sys_rows, sys_rows)
if pkg_rows:
body += motd_block(
f"{_pick(('Paquets', 'Packages'), lang)} — {mgr}",
pkg_rows,
lang,
narrow,
)
if el_dir:
body.append("")
el_rows = erplibre_guide(el_dir, el_make, editor)
body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows))
body.append("")
body += motd_block(
_pick(("Système", "System"), lang), sys_rows, lang, narrow
)
title = f"ERPLibre · {distro_label(distro, version)} · {arch}"
width = max(
MOTD_MIN_WIDTH, max([len(line) for line in body] + [len(title)]) + 4
)
head = [
"╭" + "─" * (width - 2) + "╮",
"│ " + title.ljust(width - 4) + " │",
"╰" + "─" * (width - 2) + "╯",
]
foot = [
"",
" "
+ _pick(
(
"Guide écrit au déploiement par",
"Guide written at deploy time by",
),
lang,
)
+ " script/qemu/deploy_qemu.py",
]
return "\n".join(head + [""] + body + foot) + "\n"
def invoking_home() -> Path:
"""Foyer de l'utilisateur qui a lancé le script, sudo compris.
Le script tourne sous sudo : `Path.home()` y renvoie /root, où il n'y a
aucune configuration git à reprendre.
"""
try:
return Path(os.path.expanduser(f"~{invoking_user()}"))
except (KeyError, RuntimeError):
return Path.home()
def _git_global(key: str, home: Path) -> str:
"""Valeur d'une clé de la configuration git GLOBALE de `home`.
HOME est forcé plutôt que de lire ~/.gitconfig à la main : git accepte DEUX
emplacements pour sa configuration globale (~/.gitconfig et
~/.config/git/config), et lui poser la question évite de trancher à sa place.
"""
try:
res = subprocess.run(
["git", "config", "--global", "--get", key],
capture_output=True,
text=True,
timeout=5,
env=dict(os.environ, HOME=str(home)),
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout.strip() if res.returncode == 0 else ""
def host_editor(home: Path) -> str:
"""Éditeur que l'hôte utilise, dans l'ordre où git le résout lui-même.
core.editor, puis $VISUAL/$EDITOR, puis /usr/bin/editor — le lien des
alternatives Debian, qui est LA réponse à « quel éditeur ce système
utilise-t-il » quand rien n'est configuré. Ailleurs ce lien n'existe pas et
on ne devine pas : mieux vaut ne rien écrire que d'imposer un éditeur.
GIT_EDITOR est volontairement IGNORÉ. Les outils qui appellent git sans
interaction le posent à « true » pour empêcher toute ouverture d'éditeur ;
le recopier dans la VM y désactiverait silencieusement l'éditeur de git.
"""
editor = _git_global("core.editor", home)
if not editor:
editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or ""
if not editor:
try:
editor = Path("/usr/bin/editor").resolve(strict=True).name
except OSError:
editor = ""
return editor.strip()
def editor_binary(editor: str) -> str:
"""Binaire seul d'une commande d'éditeur (« code --wait » -> « code »).
Le guide affiche le binaire, pas la commande complète : les options de git
(attente de fermeture, fichier temporaire) n'ont pas de sens pour ouvrir un
fichier de configuration à la main.
"""
if not editor.strip():
return ""
return editor.split()[0].rsplit("/", 1)[-1]
# Éditeurs que la VM sait se donner : binaire de l'hôte -> (paquet, binaire dans
# la VM). Le nom du paquet est le MÊME sur apt, dnf, zypper et pacman pour ces
# trois-là — vérifié pour chacun ; « vi » est fourni par vim, et le paquet
# neovim installe « nvim ».
#
# Cette table est la SEULE autorité, et elle décide de trois choses à la fois :
# le paquet que l'installation ajoute, la commande que le guide affiche, et la
# valeur de core.editor dans la VM. Les tenir liées est le point : un
# « core.editor = code » pointant un binaire absent fait échouer « git commit »
# (« cannot run code »), et un guide qui nomme une commande absente est pire que
# muet. Un éditeur hors de cette table est donc ignoré — pas deviné.
EDITOR_PACKAGES: dict[str, tuple[str, str]] = {
"vim": ("vim", "vim"),
"vi": ("vim", "vim"),
"nvim": ("neovim", "nvim"),
"neovim": ("neovim", "nvim"),
"nano": ("nano", "nano"),
}
def vm_editor(home: Path) -> tuple[str, str]:
"""(paquet, binaire) de l'éditeur à donner à la VM, ou deux chaînes vides."""
return EDITOR_PACKAGES.get(editor_binary(host_editor(home)), ("", ""))
def build_gitconfig(name: str, email: str, editor: str) -> str:
"""~/.gitconfig de la VM. Chaîne vide si l'hôte n'a rien à transmettre.
Une VM de développement sert à produire des commits, et un commit sans
identité est refusé par git (« Please tell me who you are ») : reprendre
celle de l'hôte évite de la retaper sur chaque machine, et surtout évite les
commits signés d'un « erplibre@<nom-de-vm> » que personne ne reconnaît.
INDENTATION EN ESPACES, jamais en tabulation. git accepte les deux, mais ce
texte part dans un scalaire bloc YAML où une tabulation en tête de ligne est
une erreur FATALE : cloud-init rejette alors le user-data en entier et la VM
démarre sans utilisateur ni clé SSH, donc inaccessible.
"""
lines: list[str] = []
if name or email:
lines.append("[user]")
if name:
lines.append(f" name = {name}")
if email:
lines.append(f" email = {email}")
if editor:
lines += ["[core]", f" editor = {editor}"]
return "\n".join(lines) + "\n" if lines else ""
def write_files_lines(
entries: list[tuple[str, str, str, str]],
) -> list[str]:
"""Bloc « write_files » de cloud-init pour des fichiers TEXTE.
entries : (chemin, mode, contenu, propriétaire) ; propriétaire vide = root.
Deux règles YAML dont le non-respect coûte TOUTE la configuration — une
erreur de syntaxe fait rejeter le user-data en ENTIER, sans message sur la
console : la VM démarre nue, sans utilisateur ni clé SSH, inaccessible.
- Le mode est une CHAÎNE, entre guillemets. « permissions: 644 » non quoté
est lu comme 644 DÉCIMAL et appliqué tel quel, soit 0o1204 soit le bit
setuid allumé et des droits absurdes, sans le moindre avertissement.
- Le contenu est un scalaire bloc « | » indenté de six espaces, dont la
PREMIÈRE ligne non vide fixe l'indentation de référence : les suivantes
doivent être au moins aussi indentées. Les caractères d'encadrement
UTF-8 passent sans échappement.
Un propriétaire impose « defer: true » : write_files tourne à l'étape init,
AVANT la création des utilisateurs, donc le chown vers le compte de la VM
échouerait. Reporté à l'étape finale, il passe — et le suivi d'installation
attend de toute façon la fin de cloud-init avant de se connecter.
"""
out = ["write_files:"]
for path, mode, content, owner in entries:
out.append(f" - path: {path}")
out.append(f" permissions: '{mode}'")
if owner:
out.append(f" owner: {owner}:{owner}")
out.append(" defer: true")
out.append(" content: |")
# textwrap.indent laisse les lignes vides VIDES : six espaces résiduels
# survivraient au scalaire bloc et se retrouveraient dans le fichier,
# invisibles en revue et bien présents à l'écran.
out += textwrap.indent(content.rstrip("\n"), " ").split("\n")
return out
# Préfixe des fichiers d'accueil embarqués dans l'initrd de l'installateur.
# Un préfixe, et non un répertoire : le cpio est déplié séquentiellement et les
# répertoires parents manquants ne sont pas créés — une entrée
# « erplibre/etc-motd » sans entrée « erplibre » ferait échouer le dépliage de
# l'initrd ENTIER, donc l'installation. Les fichiers restent à la racine.
INSTALLER_GUIDE_PREFIX = "erplibre-"
def installer_guide_name(path: str) -> str:
"""Nom dans l'initrd du fichier destiné au chemin `path` de la VM.
« /etc/motd » -> « erplibre-etc-motd ». Un nom PLAT, dérivé du chemin : les
deux fonctions qui s'en servent (le preseed qui copie, l'initrd qui range)
le calculent de la même façon, donc elles ne peuvent pas diverger.
"""
return INSTALLER_GUIDE_PREFIX + path.strip("/").replace("/", "-")
def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""Fichiers d'accueil de la VM : le guide de connexion, l'identité git.
Une seule source pour les deux voies de déploiement — cloud-init l'écrit
par write_files, l'installateur Debian par son late_command.
"""
home = invoking_home()
editor = "" if args.no_git_identity else vm_editor(home)[1]
files = [
(
"/etc/motd",
"0644",
build_motd(
args.distro,
args.version,
args.arch,
args.lang,
args.erplibre_dir,
args.erplibre_make,
editor,
),
"",
)
]
if args.no_git_identity:
return files
gitconfig = build_gitconfig(
_git_global("user.name", home),
_git_global("user.email", home),
editor,
)
if gitconfig:
files.append(
(f"/home/{args.user}/.gitconfig", "0644", gitconfig, args.user)
)
return files
def build_cloud_config(
args: argparse.Namespace, pw_hash: str | None, ssh_keys: list[str]
) -> str:
@ -1523,6 +2075,11 @@ def build_cloud_config(
f" layout: {args.keyboard_layout}",
f" variant: {args.keyboard_variant}",
]
# Guide de connexion et identité git : posés par cloud-init, donc présents
# dès le PREMIER boot. C'est le point : ils sont là avant l'installation
# d'ERPLibre, et encore là si elle échoue — le moment où l'on se connecte
# justement à la main.
lines += write_files_lines(guide_files(args))
# apt update/upgrade désactivés par défaut : sur un réseau lent/instable
# ils font pendre cloud-init au 1er boot (et retardent la dispo SSH). SSH
# est déjà présent dans les images cloud ; on l'active via runcmd sans apt.
@ -1933,6 +2490,30 @@ def build_preseed(
f"chmod 700 /target/home/{user}/.ssh",
f"chmod 600 /target/home/{user}/.ssh/authorized_keys",
]
# Guide de connexion et identité git : les mêmes fichiers que sur les autres
# distributions, mais ici il n'y a pas de cloud-init pour les écrire. Ils
# voyagent DANS l'initrd, à côté du preseed, et le late_command ne fait que
# les copier.
#
# Pourquoi pas leur contenu dans le preseed : la valeur d'une question tient
# sur UNE ligne, et celle-ci fait déjà 1165 caractères avec une clé RSA-4096.
# Y ajouter 1,2 Kio de guide — encodé ou en trente echo, la longueur est la
# même — doublerait une ligne dont aucune limite n'est documentée pour
# cdebconf. Et une troncature ne coûterait pas le guide : elle couperait le
# late_command au milieu, donc ni sudoers ni clé SSH, donc une VM
# inaccessible.
for path, mode, _content, owner in guide_files(args):
src = "/" + installer_guide_name(path)
post.append(f"cp {src} /target{path} || true")
post.append(f"chmod {mode} /target{path} || true")
if owner:
post.append(f"in-target chown {owner}:{owner} {path} || true")
# Le code de sortie du late_command est celui de sa DERNIÈRE commande, et
# d-i s'arrête sur « Failed to run preseeded command » dès qu'il n'est pas
# nul. Sans ce « true », un chmod qui échoue bloque l'installation sur un
# écran que personne ne regarde — c'est déjà la garde de
# partman/early_command, quelques lignes plus haut.
post.append("true")
# Diagnostic réseau, écrit sur la console AVANT que netcfg ne décide.
# netcfg n'essaie aucun DHCP sur s390x et tombe droit sur l'adressage
# statique ; ses propres traces vont dans le syslog INTERNE de d-i, qu'on
@ -1956,6 +2537,11 @@ def build_preseed(
"ip link set enc1 up > /dev/console 2>&1",
"echo '=== EL: enc1 activee avant netcfg ===' > /dev/console",
"ip -o link show enc1 > /dev/console 2>&1",
# Même garde que partman/early_command : « ip -o link show » rend 1
# quand l'interface n'existe pas, et le code de sortie du early_command
# est celui de sa dernière commande. Une ligne de DIAGNOSTIC bloquait
# donc l'installation qu'elle devait servir à comprendre.
"true",
]
lines.append("d-i preseed/early_command string " + " ; ".join(early))
lines.append("d-i preseed/late_command string " + " ; ".join(post))
@ -1963,7 +2549,11 @@ def build_preseed(
def build_installer_initrd(
preseed: str, initrd_src: Path, out: Path, runner: Runner
preseed: str,
initrd_src: Path,
out: Path,
runner: Runner,
guide: list[tuple[str, str, str, str]] | None = None,
) -> None:
"""Glisse le preseed DANS l'initrd de l'installateur.
@ -1975,9 +2565,16 @@ def build_installer_initrd(
La méthode est celle de la documentation Debian — décompresser, ajouter le
fichier au cpio, recompresser — et non une concaténation d'archives, que
le noyau accepte mais que d-i ne parcourt pas de la même façon.
`guide` : les fichiers d'accueil de la VM (guide de connexion, identité
git), rangés à côté du preseed. L'initrd EST le système de fichiers de
l'installateur : le late_command n'a plus qu'à les copier vers /target,
sans avoir à transporter leur contenu dans une valeur de preseed.
"""
if runner.dry_run:
print(f"[dry-run] preseed -> {out}")
for path, _mode, _content, _owner in guide or []:
print(f"[dry-run] + {installer_guide_name(path)} -> {path}")
return
if not shutil.which("cpio"):
sys.exit(
@ -1987,6 +2584,11 @@ def build_installer_initrd(
with tempfile.TemporaryDirectory() as tmp:
work = Path(tmp)
(work / "preseed.cfg").write_text(preseed, encoding="utf-8")
members = ["preseed.cfg"]
for path, _mode, content, _owner in guide or []:
name = installer_guide_name(path)
(work / name).write_text(content, encoding="utf-8")
members.append(name)
# network-console DÉSACTIVÉ, par le levier que d-i prévoit pour cela.
#
# Sur IBM Z, d-i propose de poursuivre par SSH — la console y est
@ -2008,9 +2610,10 @@ def build_installer_initrd(
plain = work / "initrd"
with gzip.open(initrd_src, "rb") as src, open(plain, "wb") as dst:
shutil.copyfileobj(src, dst)
members.append("var/lib/dpkg/info/network-console.isinstallable")
subprocess.run(
["cpio", "-H", "newc", "-o", "-A", "-F", str(plain)],
input="preseed.cfg\nvar/lib/dpkg/info/network-console.isinstallable\n",
input="\n".join(members) + "\n",
text=True,
cwd=work,
check=True,
@ -2598,6 +3201,36 @@ def build_parser() -> argparse.ArgumentParser:
action="store_true",
help="N'exécute pas package_upgrade au premier boot.",
)
g_cloud.add_argument(
"--lang",
choices=("fr", "en"),
default="fr",
help="Langue du guide affiché à la connexion SSH (défaut : fr). "
"todo.py passe la langue de son menu.",
)
g_cloud.add_argument(
"--erplibre-dir",
default="",
metavar="CHEMIN",
help="Racine d'ERPLibre dans la VM (~/git/erplibre en dev, "
"/opt/erplibre en prod). Ajoute la section ERPLibre au guide de "
"connexion. Vide, elle est omise : une VM déployée sans installation "
"n'annonce pas un dépôt et un service qui n'existent pas.",
)
g_cloud.add_argument(
"--erplibre-make",
default="",
metavar="CIBLE",
help="Cible make qui a installé la VM (ex. install_odoo_18), reprise "
"dans le guide pour la mettre à jour. Vide : le guide s'arrête à "
"« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.",
)
g_cloud.add_argument(
"--no-git-identity",
action="store_true",
help="N'injecte pas l'identité git de l'hôte (user.name, user.email, "
"core.editor) dans le ~/.gitconfig de la VM.",
)
g_cloud.add_argument(
"--apt-update",
action="store_true",

356
test/test_qemu_motd.py Normal file
View file

@ -0,0 +1,356 @@
#!/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"),
)
# 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",
}
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"),
)
if __name__ == "__main__":
unittest.main()