diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 7f6b5ee..491fc47 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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 ", "installer", "install"), + ("sudo apt remove ", "retirer", "remove"), + ("apt search ", "chercher", "search"), + ("apt show ", "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 ", "installer", "install"), + ("sudo dnf remove ", "retirer", "remove"), + ("dnf check-update", "mises à jour disponibles", "available updates"), + ("dnf search ", "chercher", "search"), + ("dnf info ", "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 » pour installer. + ( + "sudo pacman -Syu ", + "installer (jamais « -Sy » seul)", + "install (never a bare « -Sy »)", + ), + ("sudo pacman -Rns ", "retirer", "remove"), + ("pacman -Ss ", "chercher", "search"), + ("pacman -Si ", "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 ", "installer", "install"), + ("sudo zypper rm ", "retirer", "remove"), + ("zypper se ", "chercher", "search"), + ("zypper info ", "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 ", "état d'un service", "a service's state"), + ("journalctl -u -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 ", "lancer à la main", "run it by hand"), + ( + "./script/addons/update_addons_all.sh ", + "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://: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 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@ » 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", diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py new file mode 100644 index 0000000..1fde3ff --- /dev/null +++ b/test/test_qemu_motd.py @@ -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 ", motd) + self.assertIn("journalctl -u -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 ", # mise à jour des modules + "git pull && make install_odoo_18", # mise à jour Odoo + "http://: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()