diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 45a1590..61020ed 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2351,30 +2351,270 @@ AUR_GUIDE: tuple[tuple[str, str, str], ...] = ( # « systemctl cat » plutôt qu'une ligne disant de ne pas faire : l'unité est # un lien vers le store, et la voir une fois dit mieux que toute explication # pourquoi « systemctl enable » n'a rien à faire ici. +# +# « --rollback » est ce qui rend l'édition SÛRE, et ne se devine pas : une +# déclaration fautive se défait par une commande, là où les autres +# distributions laissent un système à réparer à la main. +# +# « ls /bin » est le piège inverse d'une commande utile — il rend VIDE, et +# pourtant /bin/bash s'exécute. envfs résout un nom à la demande sans jamais +# énumérer, donc tout ce qui cherche par motif ne trouve rien, quand le nom +# exact marche. Le Makefile tient par là (« SHELL := /bin/bash »), et une +# sonde écrite en glob conclut à tort que l'interpréteur manque. +# +# Ce que ce bloc ne reprend PAS : nix-shell, nixos-rebuild switch et +# nix-collect-garbage sont déjà dans le bloc du gestionnaire de paquets, qui +# dit comment poser un logiciel. Ici on ne parle que de ce qu'ERPLibre change +# à NixOS, et de ce que NixOS change à ERPLibre. NIXOS_GUIDE: tuple[tuple[str, str, str], ...] = ( ( - "conf/nixos/erplibre.nix", - "la déclaration d'ERPLibre, dans le dépôt", - "ERPLibre's declaration, in the repository", + "{el_dir}/conf/nixos/erplibre.nix", + "la déclaration, dans le dépôt", + "the declaration, in the checkout", ), ( "/etc/nixos/erplibre.nix", - "sa copie — RÉÉCRITE par make install_os", - "its copy — REWRITTEN by make install_os", + "RÉÉCRITE par make install_os", + "REWRITTEN by make install_os", ), ( "/etc/nixos/configuration.nix", - "vos déclarations à vous, qui restent", - "your own declarations, which do stay", + "make install_os n'y touche pas", + "make install_os never touches it", + ), + ( + "sudo nixos-rebuild switch --rollback", + "revenir à la génération d'avant", + "back to the previous generation", + ), + ( + "nixos-version", + "version du système", + "the system version", ), ( "systemctl cat erplibre", - "l'unité vient du store, pas de /etc", - "the unit comes from the store, not /etc", + "vient du store, pas de /etc", + "from the store, not from /etc", + ), + ( + "ls /bin", + "vide ; envfs résout sans lister", + "empty; envfs resolves, no list", ), ) +def nixos_rows(el_dir: str) -> tuple[tuple[str, str, str], ...]: + """Le guide NixOS, sa racine d'installation substituée. + + Le chemin du dépôt est le SEUL du bloc à ne pas être absolu, et le guide + est lu par quelqu'un qui vient d'entrer en ssh, donc posé dans son foyer. + Un chemin relatif l'envoie chercher un fichier là où il n'est pas. + """ + return tuple( + (cmd.replace("{el_dir}", el_dir or "~/git/erplibre"), fr, en) + for cmd, fr, en in NIXOS_GUIDE + ) + + +# Les outils posés DANS la VM, et les quelques commandes qu'on tape en +# entrant. Une entrée par clé du catalogue de script/todo/qemu_install.py ; +# un test garde que toute clé d'ici y existe, sans quoi le guide annoncerait +# un outil que le menu ne sait pas poser. +# +# Le TITRE de bloc est celui du guide, et non le libellé du catalogue : le +# second nomme une case de menu, en anglais, quand le premier est lu dans la +# langue de la VM. +# +# CE QUI EST ÉCRIT ICI DOIT ÊTRE VRAI SUR LA MACHINE. Le guide est lu par +# quelqu'un qui vient d'entrer en ssh et qui va TAPER ces lignes : une +# commande absente envoie chercher un binaire qui n'existe pas, ce qui coûte +# plus qu'un guide muet. Chaque ligne a été confrontée au code qui l'installe. +# +# « {el_dir} » est remplacé par la racine de l'installation, comme dans le +# bloc ERPLibre. +TOOL_GUIDE: dict[str, tuple[str, tuple[tuple[str, str, str], ...]]] = { + # nix POSÉ SUR UNE AUTRE DISTRIBUTION, et non NixOS : il n'y a ici ni + # /etc/nixos ni nixos-rebuild, et les lignes de NIXOS_GUIDE n'y valent + # pas. « profile add » et non « install » : l'alias est déprécié depuis + # nix 2.30, et l'installateur amont sert une version postérieure. + "nixanywhere": ( + "nix + nixos-anywhere", + ( + ( + "/etc/nix/nix.conf", + "nix-command et flakes, activés", + "nix-command and flakes, enabled", + ), + ( + "nix shell nixpkgs#", + "essayer, le temps d'un shell", + "try it, for one shell", + ), + ( + "nix profile add nixpkgs#", + "l'ajouter à son profil", + "add it to your profile", + ), + ( + "nixos-anywhere -f .# root@", + "installer NixOS à distance", + "install NixOS remotely", + ), + ), + ), + "pycharm": ( + "PyCharm", + ( + ( + "pycharm {el_dir}", + "ouvrir le dépôt dans l'IDE", + "open the checkout in the IDE", + ), + ( + "make pycharm_configure", + "rejouer exécutions et exclusions du .idea", + "replay the .idea run configs and exclusions", + ), + ), + ), + "android": ( + "Android Studio", + ( + ( + "studio", + "lancer Android Studio (alias : android-studio)", + "start Android Studio (alias: android-studio)", + ), + ( + "grep -cE 'vmx|svm' /proc/cpuinfo", + "0 : pas de KVM imbriqué, l'émulateur ne tournera pas", + "0: no nested KVM, the emulator will not run", + ), + ), + ), + "avd": ( + "Émulateur Android", + ( + ( + "~/android/emulator/emulator -avd erplibre", + "ouvrir l'AVD (par ssh -XC)", + "open the AVD (over ssh -XC)", + ), + ( + "~/android/platform-tools/adb devices", + "voir l'émulateur en marche", + "see the running emulator", + ), + ( + "id -nG | grep -w kvm", + "groupe kvm, sinon pas d'émulateur", + "kvm group, or no emulator", + ), + ), + ), + "mobile": ( + "ERPLibre mobile", + ( + ( + "cd {el_dir}/mobile/erplibre_home_mobile", + "le dépôt de l'application mobile", + "the mobile app checkout", + ), + ( + "npm test", + "rejouer les tests Vitest", + "replay the Vitest tests", + ), + ( + "npm start", + "servir l'application sans Android ni émulateur", + "serve the app without Android or emulator", + ), + ( + "tail -40 ~/erplibre-mobile-build.log", + "le journal détaillé de la compilation", + "the detailed build log", + ), + ), + ), + "forgejo": ( + "Forgejo", + ( + ( + "http://:3000", + "forge git, compte et mot de passe erplibre", + "git forge, account and password erplibre", + ), + ( + "sudo systemctl status forgejo", + "état du service", + "the service's state", + ), + ( + "sudo journalctl -u forgejo -f", + "suivre son journal", + "follow its log", + ), + ), + ), + "gnome_ext": ( + "Extensions GNOME", + ( + ( + "extension-manager", + "le gestionnaire d'extensions, sur le bureau", + "the extension manager, on the desktop", + ), + ( + "gnome-extensions enable ", + "activer depuis le bureau", + "enable from the desktop", + ), + ( + "dbus-run-session -- gnome-extensions enable ", + "activer depuis ssh", + "enable over ssh", + ), + ), + ), + "aidev": ( + "Outils d'IA", + ( + ("claude", "lancer l'agent de code", "start the coding agent"), + ( + "ls ~/.claude/commands", + "les commandes /commit et /todo_* posées", + "the /commit and /todo_* commands installed", + ), + ( + "rtk gain", + "les tokens économisés jusqu'ici", + "tokens saved so far", + ), + ( + "starship --version", + "l'invite du shell, accrochée au démarrage", + "the shell prompt, hooked at startup", + ), + ), + ), +} + + +def tool_rows(cle: str, el_dir: str) -> tuple[str, tuple]: + """(libellé, lignes) d'un outil, sa racine d'installation substituée.""" + entree = TOOL_GUIDE.get(cle) + if not entree: + return "", () + libelle, lignes = entree + return libelle, tuple( + (cmd.replace("{el_dir}", el_dir or "~/git/erplibre"), fr, en) + for cmd, fr, en in lignes + ) + + def zypper_guide(rolling: bool) -> tuple[tuple[str, str, str], ...]: """Aide-mémoire zypper. `rolling` : Tumbleweed plutôt que Leap. @@ -2558,6 +2798,7 @@ def build_motd( el_make: str = "", editor: str = "", desktop: bool = False, + tools: tuple = (), ) -> str: """Texte du /etc/motd de la VM. Fonction PURE : aucun I/O, donc testable. @@ -2591,15 +2832,28 @@ def build_motd( body.append("") el_rows = erplibre_guide(el_dir, el_make, editor) body += motd_block("ERPLibre", el_rows, lang, gloss_col(el_rows)) + # UN BLOC PAR OUTIL POSÉ, titré de son libellé. La liste vient de + # l'appelant, filtrée par la machine : annoncer un outil que l' + # architecture ou l'absence de bureau écarte enverrait chercher une + # commande qui ne sera jamais là. L'ordre est celui de la table, pour que + # deux VM au même équipement rendent le même guide. + for cle in TOOL_GUIDE: + if cle not in (tools or ()): + continue + libelle, lignes = tool_rows(cle, el_dir) + if not lignes: + continue + body.append("") + body += motd_block(libelle, lignes, lang, gloss_col(lignes)) # Même règle que le bloc AUR : il ne paraît qu'avec une installation, # parce que c'est elle qui pose le module dont ces lignes parlent. if mgr == "nix" and el_dir: body.append("") body += motd_block( _pick(("NixOS — déclaratif", "NixOS — declarative"), lang), - NIXOS_GUIDE, + nixos_rows(el_dir), lang, - gloss_col(NIXOS_GUIDE), + gloss_col(nixos_rows(el_dir)), ) if desktop: body.append("") @@ -3340,6 +3594,9 @@ def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: args.erplibre_make, editor, bool(args.desktop), + tuple( + c.strip() for c in args.vm_tools.split(",") if c.strip() + ), ), "", ) @@ -4940,6 +5197,14 @@ def build_parser() -> argparse.ArgumentParser: "permet. Les paquets GNOME sont posés par la commande d'installation, " "pas ici.", ) + g_vm.add_argument( + "--vm-tools", + default="", + help="Clés des outils que la commande d'installation va poser, " + "séparées par des virgules. Ils ne sont PAS installés ici : la liste " + "ne sert qu'au guide de connexion, qui les annonce et donne les " + "quelques commandes dont on a besoin en entrant.", + ) g_vm.add_argument( "--gpu", choices=("auto", "on", "off"), diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 4e3c0ec..556484f 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -1286,6 +1286,15 @@ class QemuDeployMixin: # ici plutôt qu'au petit bonheur, sinon l'installation se termine sur un # disque plein après une heure. extra += self._qemu_tools_disk_gb(vm_tools, arch, desktop, d) + # Ce que le guide de connexion annoncera. FILTRÉ par la machine, et + # non la liste cochée pour le parc : Android Studio n'existe qu'en + # x86_64 et les extensions GNOME n'ont pas de sens sans bureau — + # annoncer un outil qui ne sera pas posé enverrait chercher une + # commande absente. Rien n'est installé par deploy_qemu.py, qui ne + # s'en sert que pour écrire /etc/motd. + retenus = self._qemu_tools_for(vm_tools, arch, desktop, d) + if retenus: + parts += ["--vm-tools", ",".join(retenus)] # TOUJOURS, même sans supplément : sans le drapeau, deploy_qemu.py # reprend la taille par défaut du catalogue. Une VM réglée à 60 G mais # sans rien à installer repartait donc à 20 G, en silence. diff --git a/test/test_qemu_motd.py b/test/test_qemu_motd.py index 6518a1c..3ecb272 100644 --- a/test/test_qemu_motd.py +++ b/test/test_qemu_motd.py @@ -461,5 +461,210 @@ class LeGuideNixosSeVoitEtDitVrai(unittest.TestCase): self.assertIn("services.openssh.settings.PrintMotd = true;", module) +class LeCheminDuDepotEstEntierDansLeGuide(unittest.TestCase): + """Le guide est lu par quelqu'un qui vient d'entrer en ssh, donc posé + dans son foyer, et qui va TAPER ce qu'il lit. + + « conf/nixos/erplibre.nix » était la seule ligne du bloc à n'être ni un + chemin absolu ni une commande : depuis le foyer elle ne désigne rien, et + la racine change avec le profil — ~/git/erplibre en développement, + /opt/erplibre en production. Elle porte donc la racine, comme les autres + blocs le font déjà pour les leurs. + """ + + def _lignes(self, el_dir="~/git/erplibre"): + return dq.nixos_rows(el_dir) + + def test_the_checkout_path_is_complete(self): + chemins = [c for c, _, _ in self._lignes() if "conf/nixos" in c] + self.assertEqual(len(chemins), 1) + self.assertTrue(chemins[0].startswith("~/git/erplibre/"), chemins[0]) + + def test_the_root_follows_the_profile(self): + """Écrire « ~/git/erplibre » en dur enverrait une machine de + production chercher un dépôt qui n'y est pas.""" + chemins = [ + c for c, _, _ in self._lignes("/opt/erplibre") if "conf/nixos" in c + ] + self.assertEqual(chemins, ["/opt/erplibre/conf/nixos/erplibre.nix"]) + + def test_a_missing_root_falls_back_rather_than_breaking(self): + """Un marqueur non substitué serait affiché tel quel.""" + rendu = "\n".join(c for c, _, _ in self._lignes("")) + self.assertNotIn("{el_dir}", rendu) + self.assertIn("~/git/erplibre/conf/nixos/erplibre.nix", rendu) + + def test_no_line_is_a_bare_relative_path(self): + """La règle que la ligne fautive violait, sur TOUTES les lignes : ce + qui n'est pas une commande est un chemin qu'on peut ouvrir d'où l'on + est.""" + for cmd, _, _ in self._lignes(): + with self.subTest(cmd=cmd): + if "/" not in cmd or " " in cmd: + continue # une commande, pas un chemin + self.assertTrue( + cmd.startswith(("/", "~/")), + f"{cmd} ne s'ouvre pas depuis le foyer", + ) + + +class LeGuideNixosDitCeQueNixosChange(unittest.TestCase): + """Ce que le bloc ajoute, et qui ne se devine pas. + + Chaque ligne a été confrontée à une VM NixOS 25.11 vivante avant d'être + écrite : « nixos-version » rend bien sa version, « --rollback » est dans + le synopsis de nixos-rebuild, et « ls /bin » rend VIDE sur une machine où + /bin/bash s'exécute. + + Une recherche de paquet a été ESSAYÉE puis écartée : « nix-env -qaP » + ne rend rien sur cette image, dont le canal n'est pas peuplé, et + « nix search » se met à tirer un canal entier. Un guide qui envoie taper + une commande muette coûte plus qu'un guide qui se tait. + """ + + def _motd(self, **kw): + return dq.build_motd( + "nixos", "25.11", "amd64", el_dir="~/git/erplibre", **kw + ) + + def test_the_safety_net_of_editing_is_named(self): + """Une déclaration fautive se défait par une commande ; ailleurs elle + laisse un système à réparer à la main.""" + self.assertIn("nixos-rebuild switch --rollback", self._motd()) + + def test_the_version_is_reachable(self): + self.assertIn("nixos-version", self._motd()) + + def test_the_trap_of_an_empty_bin_is_told(self): + """envfs résout un nom sans jamais énumérer : tout ce qui cherche par + motif ne trouve rien, quand le nom exact marche.""" + motd = self._motd() + self.assertIn("ls /bin", motd) + self.assertIn("envfs", motd) + + def test_it_does_not_repeat_the_package_block(self): + """nix-shell, « nixos-rebuild switch » nu et nix-collect-garbage sont + déjà dans le bloc du gestionnaire de paquets. Les redire userait la + seule chose que ce bloc a — dire ce qui n'est écrit nulle part.""" + commandes = [c for c, _, _ in dq.nixos_rows("~/git/erplibre")] + for deja in ( + "nix-shell -p ", + "sudo nixos-rebuild switch", + "sudo nix-collect-garbage -d", + ): + with self.subTest(commande=deja): + self.assertNotIn(deja, commandes) + + def test_both_languages_carry_every_line(self): + """Un bloc à moitié traduit se voit tout de suite, et fait douter du + reste.""" + for _, fr, en in dq.nixos_rows("~/git/erplibre"): + with self.subTest(fr=fr): + self.assertTrue(fr.strip()) + self.assertTrue(en.strip()) + self.assertNotEqual(fr, en) + + +class LesOutilsPosesSAnnoncent(unittest.TestCase): + """Le guide ne disait rien des outils installés dans la VM : on entrait + en ssh sans savoir que nix, PyCharm ou la forge étaient là, ni par quelle + commande s'en servir. + + Ce qui est écrit doit être VRAI sur la machine : le lecteur va taper ces + lignes, et une commande absente coûte plus qu'un guide muet. + """ + + def _motd(self, tools, **kw): + return dq.build_motd( + "ubuntu", + "24.04", + "amd64", + el_dir="~/git/erplibre", + tools=tools, + **kw, + ) + + def test_only_what_was_installed_is_announced(self): + motd = self._motd(("pycharm",)) + self.assertIn("PyCharm", motd) + self.assertNotIn("nixos-anywhere", motd) + self.assertNotIn("Forgejo", motd) + + def test_a_vm_without_tools_gains_nothing(self): + """Le cas ordinaire ne doit pas gagner de bloc vide.""" + motd = self._motd(()) + for libelle, _lignes in dq.TOOL_GUIDE.values(): + with self.subTest(libelle=libelle): + self.assertNotIn(libelle, motd) + + def test_the_checkout_root_is_substituted(self): + """« {el_dir} » laissé tel quel ferait taper une accolade.""" + motd = self._motd(("pycharm",), el_make="install_odoo_18") + self.assertIn("pycharm ~/git/erplibre", motd) + self.assertNotIn("{el_dir}", motd) + + def test_nix_on_another_distribution_is_not_nixos(self): + """L'outil pose nix en démon SUR une distribution ordinaire : il n'y a + ni /etc/nixos ni nixos-rebuild, et reprendre les lignes du bloc NixOS + enverrait chercher des commandes qui n'existent pas ici.""" + motd = self._motd(("nixanywhere",)) + self.assertIn("nix shell nixpkgs#", motd) + self.assertNotIn("nixos-rebuild", motd) + self.assertNotIn("/etc/nixos", motd) + + def test_the_deprecated_profile_verb_is_not_taught(self): + """« nix profile install » est un alias déprécié depuis nix 2.30, et + l'installateur amont sert une version postérieure : le proposer ferait + répondre un avertissement à qui le tape.""" + motd = self._motd(("nixanywhere",)) + self.assertIn("nix profile add", motd) + self.assertNotIn("nix profile install", motd) + + def test_every_key_exists_in_the_catalogue(self): + """Une clé d'ici que le menu ne sait pas poser annoncerait un outil + qui n'arrivera jamais.""" + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + for cle in dq.TOOL_GUIDE: + with self.subTest(cle=cle): + self.assertIn(cle, TODO._QEMU_VM_TOOLS) + + def test_the_menu_hands_over_the_filtered_list(self): + """La liste cochée vaut pour le parc ; celle-ci est filtrée par la + machine. Android Studio n'existe qu'en x86_64 et PyCharm veut un + bureau — les annoncer ailleurs enverrait chercher une commande qui ne + sera jamais posée.""" + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + choisis = ("nixanywhere", "pycharm", "android") + + def parts(arch, desktop): + p = todo._qemu_build_deploy_parts( + "ubuntu", + "24.04", + arch, + "vm", + 4096, + 2, + "40G", + None, + "develop", + desktop=desktop, + vm_tools=choisis, + dry_run=True, + ) + return p[p.index("--vm-tools") + 1] if "--vm-tools" in p else "" + + self.assertEqual("nixanywhere,pycharm,android", parts("amd64", True)) + self.assertEqual("nixanywhere", parts("arm64", False)) + + if __name__ == "__main__": unittest.main()