From c2852089a0e4f2d906de70dd51bb8093403466e6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 22:57:56 -0400 Subject: [PATCH] [ADD] todo qemu: offer PyCharm, Android Studio and GNOME extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A graphical VM was a desktop and nothing else: every developer tool had to be installed by hand afterwards. A check list now carries them, filtered per machine — Android Studio is x86_64 only, Google publishes no Linux aarch64 build — and their disk cost reaches the plan before any qcow2 is created. They are installed BEFORE the clone: PyCharm writes the .idea/ of the repository, and the install that follows is what runs pycharm_configuration.py, through update_env_version.pycharm_update(). Its launcher is named studio, which is enough to conclude the install failed; it now answers to android-studio too. GNOME extensions come from the site by UUID, per running Shell: the same endpoint serves gTile v59 for GNOME 46 and v62 for 48. Checked with stubs: every tool can fail and the install's exit code still wins, 45 tests. --- FR --- Une VM graphique n'était qu'un bureau : chaque outil de développement restait à poser à la main. Une liste à cocher les porte, filtrés machine par machine — Android Studio n'existe qu'en x86_64, Google ne publiant aucune archive Linux aarch64 — et leur place disque atteint le plan avant qu'un seul qcow2 ne soit créé. Ils sont posés AVANT le clone : PyCharm écrit le .idea/ du dépôt, et c'est l'installation qui suit qui lance pycharm_configuration.py, via update_env_version.pycharm_update(). Son lanceur s'appelle studio, ce qui suffit à conclure à un échec ; il répond désormais aussi à android-studio. Les extensions GNOME viennent du site par UUID, selon le Shell qui tourne : le même point d'entrée sert gTile v59 pour GNOME 46 et v62 pour 48. Vérifié avec des leurres : chaque outil peut échouer sans que le code de sortie de l'installation ne change, 45 tests. Assisted-by: Claude Opus 5 --- script/qemu/README.base.md | 113 ++++++ script/qemu/README.fr.md | 57 +++ script/qemu/README.md | 56 +++ script/todo/qemu_deploy_form.py | 102 ++++++ script/todo/todo.py | 614 +++++++++++++++++++++++++++++++- script/todo/todo_i18n.py | 116 ++++++ test/test_qemu_desktop_tools.py | 272 ++++++++++++++ 7 files changed, 1322 insertions(+), 8 deletions(-) create mode 100644 test/test_qemu_desktop_tools.py diff --git a/script/qemu/README.base.md b/script/qemu/README.base.md index b031ad7..0ca8c1a 100644 --- a/script/qemu/README.base.md +++ b/script/qemu/README.base.md @@ -262,6 +262,33 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm (installed from the official JetBrains archive +into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). So on a fresh VM: open +PyCharm once on `~/git/erplibre`, close it, then re-run +`make install_odoo_18`. + +Each tool is filtered per VM — by architecture and by desktop flavour — and +its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -279,9 +306,38 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + L'utilisateur par défaut est `erplibre` (modifiable avec `--user`). @@ -294,6 +350,35 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm (posé depuis l'archive officielle +JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le +fermer, puis relancer `make install_odoo_18`. + +Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa +place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -314,9 +399,37 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Managing VMs diff --git a/script/qemu/README.fr.md b/script/qemu/README.fr.md index af7b826..2d86503 100644 --- a/script/qemu/README.fr.md +++ b/script/qemu/README.fr.md @@ -146,6 +146,35 @@ prévisualiser un dry-run, télécharger une image, lister les VM et afficher l'IP d'une VM — le menu demande les paramètres et construit la commande pour vous. +Quand une VM est graphique, le menu propose en plus une **liste à cocher +d'outils de développement** : PyCharm (posé depuis l'archive officielle +JetBrains dans `/opt/pycharm`, son lanceur ouvrant le dépôt ERPLibre), +Android Studio (`/opt/android-studio`, commande `studio` ou +`android-studio` ; x86_64 seulement — Google ne publie aucune archive Linux +aarch64) et un jeu +d'extensions GNOME suggérées. + +Les extensions empaquetées par la distribution sont installées sans être +activées — leur UUID n'est pas connu de façon fiable, et le gestionnaire +d'extensions est là pour choisir. Trois extensions nommées par leur UUID sont, +elles, installées **et activées**, directement depuis extensions.gnome.org : +**gTile**, **Freon** et **Tracker**. L'archive est prise pour la version de +GNOME Shell qui tourne vraiment dans la VM — le même point d'entrée sert gTile +v59 en GNOME 46 et v62 en GNOME 48, si bien qu'une URL figée poserait une +version faite pour une autre release. Une archive mal appariée n'est de toute +façon jamais chargée par GNOME : il compare `metadata.json` à sa propre +version et affiche l'extension comme obsolète plutôt que de casser la session. + +Les outils sont posés **avant** le clone et l'installation d'ERPLibre, et +l'ordre compte : PyCharm écrit le `.idea/` du dépôt à la première ouverture du +projet, et l'installation qui suit y lance `pycharm_configuration.py` +(`update_env_version.pycharm_update()`, qui se tait tant qu'il n'y a pas de +`.idea`). Sur une VM neuve : ouvrir PyCharm une fois sur `~/git/erplibre`, le +fermer, puis relancer `make install_odoo_18`. + +Chaque outil est filtré VM par VM — architecture et saveur de bureau — et sa +place disque s'ajoute au plan avant que rien ne soit créé. + ## Principales options - `--distro` — `ubuntu` (défaut), `debian` ou `fedora`. @@ -166,9 +195,37 @@ vous. - `--no-install-deps` — n'installe jamais les dépendances automatiquement. - `--dry-run` — affiche les commandes sans rien exécuter. - `--force` — écrase le disque de travail qcow2 existant. +- `--lang` — langue du guide affiché à la connexion SSH, `fr` (défaut) ou + `en`. Le menu TODO passe la sienne. +- `--erplibre-dir` — où ERPLibre sera installé dans la VM + (`~/git/erplibre`, ou `/opt/erplibre` en production). Ajoute la section + ERPLibre au guide de connexion ; omis, cette section est laissée de côté. +- `--erplibre-make` — la cible make qui a installé la VM + (ex. `install_odoo_18`), reprise dans le guide pour la mettre à jour. +- `--no-git-identity` — ne recopie pas les `user.name`, `user.email` et + `core.editor` de l'hôte dans le `~/.gitconfig` de la VM. Lancez `./script/qemu/deploy_qemu.py --help` pour la liste complète. +## Guide de connexion (`/etc/motd`) + +Chaque VM accueille celui qui s'y connecte en SSH avec les commandes de **sa** +distribution — `apt`, `dnf`, `zypper` ou `pacman` — et celles d'ERPLibre : +éditer le serveur, le redémarrer, mettre à jour des modules, mettre à jour +Odoo, inspecter l'instance, ouvrir le menu TODO. Il est écrit par cloud-init, +donc présent dès le premier démarrage : avant l'installation d'ERPLibre, et +encore là si elle échoue — le moment où l'on se connecte justement à la main. + +`--dry-run` affiche le guide généré avec le reste du user-data. Il ne +s'affiche PAS pour un `ssh hôte 'commande'` : les journaux d'installation +restent nets. + +L'identité git de l'hôte voyage avec lui, dans le `~/.gitconfig` de la VM : +un commit fait dans la VM porte alors votre nom plutôt que +`erplibre@`. L'éditeur suit le même chemin — `core.editor`, la +ligne `config.conf` du guide et le paquet installé dans la VM viennent d'une +seule table, de sorte que le guide ne nomme jamais une commande absente. + ## Gestion des VM Lister, arrêter et supprimer les VM (le disque qcow2 sous diff --git a/script/qemu/README.md b/script/qemu/README.md index 473a847..76a2407 100644 --- a/script/qemu/README.md +++ b/script/qemu/README.md @@ -136,6 +136,33 @@ Ubuntu VM (libvirt)**. From there you can deploy a VM, preview a dry-run, download an image, list VMs and show a VM IP address — the menu asks for the parameters and builds the command for you. +When a VM is graphical, the menu also offers a **check list of development +tools**: PyCharm (installed from the official JetBrains archive +into `/opt/pycharm`, its launcher opening the ERPLibre checkout), Android +Studio (`/opt/android-studio`, command `studio` or `android-studio`; x86_64 +only — Google publishes no Linux aarch64 build) and a set of suggested GNOME extensions. + +The extension packages of the distribution are installed but left disabled — +their UUID is not reliably known, and the Extension Manager is there to pick +from. Three extensions named by UUID are installed **and enabled**, straight +from extensions.gnome.org: **gTile**, **Freon** and **Tracker**. The archive +is fetched for the GNOME Shell version actually running in the VM — the same +endpoint serves gTile v59 for GNOME 46 and v62 for GNOME 48, so a frozen URL +would install a build made for another release. A mismatched build is never +loaded by GNOME anyway: it compares `metadata.json` with its own version and +shows the extension as outdated rather than breaking the session. + +The tools are installed **before** the clone and the ERPLibre install, and +the order matters: PyCharm writes the repository's `.idea/` the first time it +opens the project, and the install that follows runs +`pycharm_configuration.py` on it (`update_env_version.pycharm_update()`, +which skips silently when there is no `.idea` yet). So on a fresh VM: open +PyCharm once on `~/git/erplibre`, close it, then re-run +`make install_odoo_18`. + +Each tool is filtered per VM — by architecture and by desktop flavour — and +its disk cost is added to the plan before anything is created. + ## Main options - `--distro` — `ubuntu` (default), `debian` or `fedora`. @@ -153,9 +180,38 @@ parameters and builds the command for you. - `--no-install-deps` — never auto-install dependencies. - `--dry-run` — show the commands without executing anything. - `--force` — overwrite the existing working qcow2 disk. +- `--lang` — language of the SSH login guide, `fr` (default) or `en`. The + TODO menu passes its own language. +- `--erplibre-dir` — where ERPLibre will live in the VM + (`~/git/erplibre`, or `/opt/erplibre` in production). Adds the ERPLibre + section to the login guide; omitted, that section is left out. +- `--erplibre-make` — the make target that installed the VM + (e.g. `install_odoo_18`), shown in the guide as the way to update it. +- `--no-git-identity` — do not copy the host's `user.name`, `user.email` + and `core.editor` into the VM's `~/.gitconfig`. Run `./script/qemu/deploy_qemu.py --help` for the full list. +## Login guide (`/etc/motd`) + +Every VM greets you, at each interactive SSH login, with the commands of +**its own** distribution — `apt`, `dnf`, `zypper` or `pacman` — plus the +ERPLibre ones (edit the server, restart it, update modules, update Odoo, +inspect the instance, open the TODO menu). It is written by cloud-init, so +it is there from the first boot: before ERPLibre is installed, and still +there if that installation fails, which is exactly when you log in by hand. + +`--dry-run` prints the generated guide along with the rest of the user-data. +The guide is not shown to `ssh host 'command'`, so it never pollutes an +installation log. + +The host's git identity travels with it, into the VM's `~/.gitconfig`: a +commit made in the VM then carries your name instead of +`erplibre@`. The editor follows the same route — `core.editor`, the +`config.conf` line of the guide, and the package installed in the VM all +come from one table, so the guide never names a command the VM does not +have. + ## Managing VMs List, stop and remove VMs (the qcow2 disk under `/var/lib/libvirt/images` diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index d86f5e7..2e1e8f6 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -294,6 +294,7 @@ def build_spec(vms, domains, form): "ssh_key": form["ssh_key"], "timezone": form.get("timezone", ""), "desktop": form.get("desktop", ""), + "desktop_tools": tuple(form.get("desktop_tools") or ()), "python_provider": form.get("python_provider", ""), "app_store": form.get("app_store", "deb"), "install": form["install"], @@ -364,6 +365,13 @@ def run_deploy_form(ctx, run_app: bool = True): # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) + # Outils de développement d'une VM graphique : [(clé, libellé, indice)] et + # leurs contraintes, toutes décrites dans todo.py — le formulaire ne fait + # que les afficher et rendre les cases cochées. + desktop_tools = list(ctx.get("desktop_tools") or []) + tool_disk = dict(ctx.get("desktop_tool_disk") or {}) + tool_arches = dict(ctx.get("desktop_tool_arches") or {}) + tool_desktops = dict(ctx.get("desktop_tool_desktops") or {}) # Architectures pour lesquelles mise publie un binaire. mise_arches = set(ctx.get("mise_arches") or ()) # [(clé, libellé)] des magasins d'applications, et les distributions qui @@ -655,6 +663,21 @@ def run_deploy_form(ctx, run_app: bool = True): for i, (_k, label) in enumerate(app_stores): yield RadioButton(label, value=i == 0) yield Static("", id="storewarn") + if desktop_tools: + # Une case par outil, et non une liste déroulante : ils + # sont indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools (graphical VMs):"), + classes="grouptitle", + ) + for key, label, hint in desktop_tools: + gb = tool_disk.get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (defaults.get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") yield Static("ERPLibre", classes="grouptitle") yield Checkbox( t("Install ERPLibre"), @@ -816,9 +839,19 @@ def run_deploy_form(ctx, run_app: bool = True): # Le bureau pèse sur le disque de la VM QUI LE PORTE, et d'elle # seule : un supplément commun mentait dès que les types # différaient d'une machine à l'autre. + tools = self._desktop_tools() for row in self.rows: if row["vm"].get("desktop"): row["disk_gb"] += desktop_disk + # Même règle pour les outils, et pour la même raison : ils ne + # pèsent que sur les VM qui les reçoivent réellement. Android + # Studio n'existe qu'en x86_64, les extensions GNOME n'ont de + # sens que sous GNOME — une VM qui ne les aura pas ne doit pas + # se voir gonfler son disque. + row["disk_gb"] += sum( + tool_disk.get(k, 0) + for k in self._tools_for_vm(row["vm"], tools) + ) # Le plan doit MONTRER qu'une VM a été personnalisée : sans marque, # deux lignes aux ressources différentes n'ont aucune explication à # l'écran, et la surcharge est oubliée à la relecture. Le drapeau @@ -831,6 +864,70 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_plan() self._render_mise() self._render_store() + self._render_tools() + + def _desktop_tools(self): + """Clés des outils cochés, dans l'ordre de la liste.""" + picked = [] + for key, _label, _hint in desktop_tools: + try: + if self.query_one(f"#f_tool_{key}", Checkbox).value: + picked.append(key) + except Exception: + continue + return tuple(picked) + + def _tools_for_vm(self, vm, tools): + """Outils qu'une VM donnée recevra vraiment. + + Même filtre que todo.py côté déploiement : une VM ARM ne verra + jamais Android Studio, une VM Cinnamon jamais les extensions + GNOME, et un serveur aucun des trois.""" + if not vm.get("desktop"): + return [] + out = [] + for key in tools: + arches = tool_arches.get(key) or () + desks = tool_desktops.get(key) or () + if arches and vm["arch"] not in arches: + continue + if desks and vm["desktop"] not in desks: + continue + out.append(key) + return out + + def _render_tools(self): + """Grise les cases sans VM graphique, et NOMME ce qui sera écarté. + + Cocher Android Studio sur un parc ARM ne produit rien : le dire ici + évite de le découvrir dans le journal d'installation.""" + if not desktop_tools: + return + graphical = [vm for vm in self.vms if vm.get("desktop")] + for key, _label, _hint in desktop_tools: + self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( + not graphical + ) + if not graphical: + self.query_one("#toolwarn", Static).update( + f" {t('No graphical VM: these tools are not installed.')}" + ) + return + picked = self._desktop_tools() + skipped = sorted( + { + vm["name"] + for vm in graphical + for k in picked + if k not in self._tools_for_vm(vm, picked) + } + ) + self.query_one("#toolwarn", Static).update( + f" ⚠ {t('Partly skipped (arch or desktop):')} " + f"{', '.join(skipped)}" + if skipped + else "" + ) def _render_mise(self): """Grise le choix quand aucune VM retenue n'est servie par mise, @@ -1676,6 +1773,10 @@ def run_deploy_form(ctx, run_app: bool = True): self._recompute() # le disque annoncé inclut le +5 G ERPLibre elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value + elif str(event.checkbox.id or "").startswith("f_tool_"): + # Un IDE de plus, c'est un disque plus grand : le plan doit le + # montrer AVANT de déployer, pas après une heure d'installation. + self._recompute() # -- actions ---------------------------------------------------- # def action_select_all(self) -> None: @@ -1734,6 +1835,7 @@ def run_deploy_form(ctx, run_app: bool = True): or ctx.get("timezone") or "", "desktop": self._default_desktop(), + "desktop_tools": self._desktop_tools(), "python_provider": self._python_provider(), "app_store": self._app_store(), "install": install, diff --git a/script/todo/todo.py b/script/todo/todo.py index 4a3b38f..748f55a 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -4508,6 +4508,30 @@ class TODO: ~/git/erplibre (dev).""" return "/opt/erplibre" if prod else "$HOME/git/erplibre" + @staticmethod + def _qemu_guide_dir(prod): + """Répertoire d'ERPLibre tel que le GUIDE de connexion l'annonce. + + « ~/git/erplibre » plutôt que « $HOME/git/erplibre » : ce chemin n'est + pas exécuté par un script, il est lu par quelqu'un qui recopie la ligne + dans son shell — où les deux marchent — et le tilde est la forme qu'il + reconnaît. En production le chemin est absolu et la question ne se pose + pas.""" + return "/opt/erplibre" if prod else "~/git/erplibre" + + @staticmethod + def _qemu_make_target(install_cmd): + """Cible make qui installe Odoo dans `install_cmd`, pour le guide. + + Les profils s'écrivent « make install_os && make install_odoo_18 » : la + cible utile est la SECONDE, celle qui installe Odoo, et c'est aussi + celle qu'on relance après un « git pull ». Les profils qui n'en ont pas + (« ERPLibre seul », « mobile », « Déploiement ») rendent une chaîne + vide : le guide s'arrête alors à « git pull » plutôt que d'annoncer une + cible qui n'est pas celle de cette VM.""" + found = re.findall(r"make\s+(install_odoo\S*)", install_cmd or "") + return found[-1] if found else "" + def _qemu_odoo_service_cmd(self, prod=False): """Snippet shell (exécuté dans la VM) qui installe ERPLibre/Odoo comme service systemd puis l'active. N'est ajouté QUE pour les profils Odoo. @@ -4992,6 +5016,435 @@ class TODO: + "fi; " ) + # ------------------------------------------------------------------ # + # Outils de développement d'une VM graphique + # ------------------------------------------------------------------ # + # Chacun est une case à cocher, indépendante des autres, et chacun pèse sur + # le disque — le plan l'annonce AVANT de déployer, sinon l'installation se + # termine sur un disque plein après une heure d'attente. + # + # « disk_gb » compte le PIC, pas l'installé : l'archive téléchargée vit sur + # le disque le temps de l'extraction. PyCharm, c'est 1,2 Go d'archive et + # ~3 Go déplié ; Android Studio 1,5 Go et 3,5 Go, plus la place du premier + # SDK que l'utilisateur téléchargera. + # + # « arches » n'est pas une précaution : Google ne publie Android Studio + # QU'EN x86_64 (vérifié — toutes les variantes aarch64 de l'URL rendent 404, + # et le product-info.json de l'archive ne déclare qu'une cible + # « Linux/amd64 »). JetBrains, lui, publie bien une archive aarch64. + _QEMU_DESKTOP_TOOLS = { + "pycharm": { + "label": "PyCharm", + "hint": "Python IDE, opens the ERPLibre checkout", + "disk_gb": 5, + "arches": ("amd64", "arm64"), + "desktops": (), + }, + "android": { + "label": "Android Studio", + "hint": "ERPLibre mobile development (x86_64 only)", + "disk_gb": 8, + "arches": ("amd64",), + "desktops": (), + }, + "gnome_ext": { + "label": "GNOME extensions", + "hint": "suggested extensions + extension manager", + "disk_gb": 1, + "arches": (), + "desktops": ("gnome",), + }, + } + + @classmethod + def _qemu_desktop_tool_choices(cls): + """[(clé, libellé, indice)] pour le formulaire et l'invite en ligne.""" + return [ + (key, t(spec["label"]), t(spec["hint"])) + for key, spec in cls._QEMU_DESKTOP_TOOLS.items() + ] + + @classmethod + def _qemu_tools_for(cls, tools, arch, desktop): + """Outils RÉELLEMENT applicables à cette VM. + + Un outil demandé pour tout le parc ne convient pas forcément à chaque + machine : Android Studio n'existe qu'en x86_64, et les extensions GNOME + n'ont pas de sens sous Cinnamon. Filtrer ici plutôt que dans la commande + distante évite d'annoncer une installation qui ne se fera pas.""" + if not desktop: + return [] + out = [] + for key in tools or (): + spec = cls._QEMU_DESKTOP_TOOLS.get(key) + if not spec: + continue + if spec["arches"] and arch not in spec["arches"]: + continue + if spec["desktops"] and desktop not in spec["desktops"]: + continue + out.append(key) + return out + + @classmethod + def _qemu_tools_disk_gb(cls, tools, arch, desktop): + """Go à ajouter au disque pour les outils applicables à cette VM.""" + return sum( + cls._QEMU_DESKTOP_TOOLS[k]["disk_gb"] + for k in cls._qemu_tools_for(tools, arch, desktop) + ) + + # Archive officielle JetBrains, et non un paquet de distribution : aucun ne + # couvre les quatre gestionnaires (Arch l'a dans extra, Debian et Ubuntu ne + # l'ont qu'en snap — coupé ici —, Fedora et openSUSE pas du tout). + # + # L'URL ne porte AUCUN numéro de version : « code=PCC&latest » redirige vers + # la dernière stable. Vérifié, les deux : « distribution=linux » sort + # pycharm-2025.3.tar.gz et « distribution=linuxARM64 » son équivalent + # aarch64. Rien à mettre à jour dans ce dépôt quand JetBrains publie. + _QEMU_PYCHARM_URL = ( + "https://download.jetbrains.com/product?code=PCC&latest&distribution=" + ) + + # Android Studio n'a PAS d'URL « latest » : le répertoire de version + # (2026.1.3.8) et le nom de fichier (quail3-patch1) sont deux jetons + # INDÉPENDANTS, l'un ne se déduit pas de l'autre, et le flux updates.xml de + # Google ne publie ni l'un ni l'autre. On lit donc l'URL sur la page + # officielle, qui la porte en clair, et on retombe sur celle-ci si la page + # change de forme. Relevée et vérifiée (HTTP 200) le 2026-08-17. + _QEMU_ANDROID_URL = ( + "https://dl.google.com/dl/android/studio/ide-zips/2026.1.3.8/" + "android-studio-quail3-patch1-linux.tar.gz" + ) + _QEMU_ANDROID_PAGE = "https://developer.android.com/studio" + + @staticmethod + def _qemu_desktop_entry_cmd(name, label, exec_cmd, icon, categories): + """Écrit un lanceur .desktop. Sans lui, un outil déplié dans /opt + n'existe pas pour le bureau : il ne se lance qu'en tapant son chemin. + """ + return ( + f"sudo tee /usr/share/applications/{name}.desktop >/dev/null </dev/null " + "| head -1); " + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{link}; ' + + ( + f'[ -n "$b" ] && sudo ln -sf "$b" /usr/local/bin/{alias}; ' + if alias + else "" + ) + ) + + def _qemu_pycharm_remote_cmd(self, prod=False): + """Installe PyCharm et lui donne le dépôt ERPLibre comme projet. + + « Configuré sur git/erplibre » veut dire deux choses, et les deux sont + faites ici : le lanceur du bureau OUVRE ce dépôt, et + pycharm_configuration.py y écrit le .idea/ du projet (interpréteur, + configurations d'exécution, dossiers exclus) — la même chose que + « make pycharm_configure », mais avec le python du venv d'outils, seul à + disposer de xmltodict. + + Tout le bloc est gardé : un IDE qui ne s'installe pas ne doit pas faire + échouer l'installation d'ERPLibre, qui elle a duré une heure.""" + el_dir = self._qemu_install_dir(prod) + return ( + f'echo "== {t("Installing PyCharm (long)")} =="; ' + "{ " + 'case "$(uname -m)" in x86_64) jb=linux;; ' + 'aarch64|arm64) jb=linuxARM64;; *) jb="";; esac; ' + # if/else et non « || { …; false; } » : dans un groupe, un échec + # n'interrompt PAS la suite (set -e est suspendu à gauche d'un + # « && »), et l'architecture non servie partait quand même + # télécharger une URL sans valeur de distribution. + 'if [ -z "$jb" ]; then ' + f'echo " {t("no JetBrains build for")} $(uname -m)"; false; ' + "else " + # /var/tmp et non /tmp : sur Fedora et dérivés /tmp est un tmpfs, en + # RAM — 1,2 Go d'archive y tueraient une VM de 3 Go. + "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && " + f'curl -fsSL "{self._QEMU_PYCHARM_URL}$jb" -o "$tmp" && ' + "sudo mkdir -p /opt/pycharm && " + 'sudo tar -xzf "$tmp" -C /opt/pycharm --strip-components=1; ' + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd("/opt/pycharm", "pycharm") + + self._qemu_desktop_entry_cmd( + "pycharm", + "PyCharm (ERPLibre)", + f"/usr/local/bin/pycharm {el_dir}", + "/opt/pycharm/bin/pycharm.svg", + "Development;IDE;", + ) + # AUCUN appel à pycharm_configuration.py ici : l'installation + # ERPLibre le fait déjà. update_env_version.pycharm_update() teste + # « os.path.exists('.idea') » puis lance le script — une seule + # autorité, et elle sait se taire quand le projet n'existe pas + # encore. Doubler l'appel ne configurait rien de plus : ça écrivait + # « Missing ./.idea path » dans le journal d'une VM neuve, où + # PyCharm n'a évidemment jamais ouvert le dépôt. + + f'echo " {t("PyCharm installed:")} /opt/pycharm ' + f'({t("command")} pycharm, {t("project")} {el_dir})"; ' + f'echo " {t("open the project once and close PyCharm; the .idea " + "it writes is what the install configures")}"; ' + f'}} || echo " ⚠ {t("PyCharm not installed (see above)")}"; ' + ) + + def _qemu_android_studio_remote_cmd(self): + """Installe Android Studio, pour le développement mobile ERPLibre. + + L'émulateur, lui, exige KVM DANS la VM, donc la virtualisation + imbriquée : on le dit plutôt que de laisser découvrir l'échec au premier + lancement. Compiler et déployer sur un appareil réel par adb n'en + dépendent pas.""" + return ( + f'echo "== {t("Installing Android Studio (long)")} =="; ' + "{ " + 'if [ "$(uname -m)" != x86_64 ]; then ' + f'echo " {t("Android Studio: Google publishes x86_64 only")}"; ' + "false; " + "else " + # La page officielle porte l'URL en clair ; le repli garde une + # version connue qui répond, pour le jour où sa forme change. + f"url=$(curl -fsSL --max-time 30 {self._QEMU_ANDROID_PAGE} " + "| grep -oE 'https://[a-z0-9.-]*gvt1\\.com/[^\"]*linux\\.tar\\.gz' " + "| head -1); " + f'[ -n "$url" ] || url="{self._QEMU_ANDROID_URL}"; ' + "tmp=$(mktemp -p /var/tmp android-XXXX.tar.gz) && " + 'curl -fsSL "$url" -o "$tmp" && ' + "sudo mkdir -p /opt/android-studio && " + 'sudo tar -xzf "$tmp" -C /opt/android-studio ' + "--strip-components=1; " + 'rc=$?; rm -f "$tmp"; [ $rc -eq 0 ]; fi; } && { ' + + self._qemu_jetbrains_launcher_cmd( + "/opt/android-studio", "studio", alias="android-studio" + ) + + self._qemu_desktop_entry_cmd( + "android-studio", + "Android Studio", + "/usr/local/bin/studio", + "/opt/android-studio/bin/studio.svg", + "Development;IDE;", + ) + + f'echo " {t("Android Studio installed:")} /opt/android-studio ' + f'({t("command")} studio / android-studio)"; ' + "grep -q vmx /proc/cpuinfo 2>/dev/null " + "|| grep -q svm /proc/cpuinfo 2>/dev/null " + f'|| echo " {t("no nested KVM: the emulator will not run")}"; ' + f'}} || echo " ⚠ {t("Android Studio not installed (see above)")}"; ' + ) + + # Extensions GNOME suggérées, par gestionnaire de paquets. Les noms ne sont + # pas les mêmes d'une famille à l'autre (« dashtodock » sur Debian, + # « dash-to-dock » sur Fedora), et aucune liste n'existe en entier partout. + # + # D'où l'installation UNE PAR UNE : apt, dnf, zypper et pacman échouent tous + # sur la commande ENTIÈRE dès qu'un seul nom est inconnu. Un paquet absent + # est donc annoncé et sauté, au lieu de faire tomber les autres avec lui. + _QEMU_GNOME_EXT_PKGS = { + "apt": ( + "gnome-shell-extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + "gnome-shell-extension-dashtodock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + ), + "dnf": ( + "gnome-extensions-app", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + "gnome-shell-extension-caffeine", + "gnome-shell-extension-user-theme", + ), + "zypper": ( + "gnome-shell-extensions", + "gnome-tweaks", + "gnome-shell-extension-dash-to-dock", + "gnome-shell-extension-appindicator", + ), + "pacman": ( + "extension-manager", + "gnome-tweaks", + "gnome-shell-extensions", + ), + } + + # Extensions demandées nommément, par leur UUID sur extensions.gnome.org. + # Aucune n'est empaquetée par une distribution : on passe donc par le site. + # + # L'archive dépend de la version de GNOME Shell, et ce n'est pas une + # précaution de principe : mesuré le 2026-08-17, le même point d'entrée + # sert gTile v59 pour GNOME 46, v62 pour GNOME 48 et v52 pour GNOME 3.38. + # Une URL figée poserait donc, tôt ou tard, une archive faite pour une + # autre version. + # + # Ce que le site fait d'une version qu'il ne connaît PAS : il sert la plus + # récente (vérifié — « shell_version=99 » rend l'archive des GNOME 49/50), + # il ne répond pas 404. Sans conséquence fâcheuse pour autant : GNOME Shell + # refuse de CHARGER une extension dont metadata.json ne déclare pas la + # version courante. Une archive mal appariée reste donc inerte et affichée + # « obsolète » dans le gestionnaire — elle ne casse pas la session. + _QEMU_GNOME_EXT_UUIDS = ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ) + _QEMU_GNOME_EXT_SITE = "https://extensions.gnome.org/download-extension" + + def _qemu_gnome_ext_site_cmd(self): + """Installe les extensions nommées depuis extensions.gnome.org. + + Celles-là, on les ACTIVE — à la différence des paquets de la + distribution, dont on ne connaît pas l'UUID. Deux raisons, l'une et + l'autre vérifiées : le site rend l'archive faite pour le GNOME Shell de + cette VM, et une archive mal appariée n'est de toute façon jamais + chargée par GNOME, qui compare metadata.json à sa propre version. Ce + n'est donc pas l'activation qui peut casser une session. + + Le tout dans un groupe gardé : ni une panne de réseau ni une extension + retirée du site ne doivent faire échouer une installation d'une heure. + """ + uuids = " ".join(self._QEMU_GNOME_EXT_UUIDS) + site = self._QEMU_GNOME_EXT_SITE + return ( + "{ " + # « gnome-shell --version » rend « GNOME Shell 48.2 » : le dernier + # champ suffit, et évite une expression régulière à rallonge. + "v=$(gnome-shell --version 2>/dev/null | awk '{print $NF}'); " + 'if [ -z "$v" ]; then ' + + f'echo " {t("GNOME Shell not found, site extensions skipped")}"; ' + + "else " + # Le site attend le numéro MAJEUR depuis GNOME 40 (« 48 ») et + # « majeur.mineur » avant (« 3.38 ») : sans la bonne forme, il ne + # renvoie aucune archive. + "maj=${v%%.*}; " + 'if [ "$maj" -ge 40 ] 2>/dev/null; then sv="$maj"; ' + 'else sv=$(echo "$v" | cut -d. -f1,2); fi; ' + # gnome-extensions écrit dans ~/.local/share, mais l'activation + # passe par GSettings : sans bus de session — le cas d'un + # « ssh hôte commande » — dconf ne peut rien écrire. + # dbus-run-session en fournit un le temps de l'appel, et + # l'écriture atterrit bien dans le dconf de l'utilisateur. + 'gx() { if [ -z "$DBUS_SESSION_BUS_ADDRESS" ] && ' + "command -v dbus-run-session >/dev/null 2>&1; then " + 'dbus-run-session -- gnome-extensions "$@"; ' + 'else gnome-extensions "$@"; fi; }; ' + f"for u in {uuids}; do " + # « || echo » DANS la substitution : un mktemp qui échoue rendrait + # l'affectation non nulle, et « set -e » couperait toute la suite. + + "z=$(mktemp -p /var/tmp gext-XXXX.zip || echo /var/tmp/gext.zip); " + + 'if curl -fsSL --max-time 120 "' + + site + + '/$u.shell-extension.zip?shell_version=$sv" -o "$z" ' + + '&& gx install --force "$z" >/dev/null 2>&1; then ' + + 'gx enable "$u" >/dev/null 2>&1 || true; ' + + f'echo " {t("installed and enabled:")} $u"; else ' + + f'echo " {t("not available for this GNOME, skipped:")} ' + + '$u (GNOME $sv)"; fi; rm -f "$z"; done; ' + + f'echo " {t("log out and back in to load them")}"; ' + + "fi; } || true; " + ) + + def _qemu_gnome_ext_remote_cmd(self): + """Pose les extensions GNOME suggérées. + + Deux sources, et deux politiques, pour une raison : + - les paquets de la DISTRIBUTION sont installés sans être activés. On + ne connaît pas leur UUID de façon fiable, et activer à l'aveugle une + extension incompatible avec la version de GNOME Shell laisse la + session sur un écran noir — panne qu'on ne diagnostique pas depuis + une console série. Le gestionnaire graphique est posé pour choisir ; + - les extensions nommées par leur UUID sont, elles, ACTIVÉES : le site + rend l'archive faite pour ce GNOME-là, et une archive mal appariée + n'est jamais chargée par GNOME plutôt que de casser la session. + """ + pkgs = self._QEMU_GNOME_EXT_PKGS + return ( + f'echo "== {t("Suggested GNOME extensions")} =="; ' + "if command -v apt-get >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['apt'])}'; " + "I='sudo DEBIAN_FRONTEND=noninteractive apt-get " + "-o DPkg::Lock::Timeout=600 install -y'; " + "elif command -v dnf >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['dnf'])}'; I='sudo dnf install -y'; " + "elif command -v zypper >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['zypper'])}'; " + "I='sudo zypper --non-interactive install " + "--auto-agree-with-licenses'; " + "elif command -v pacman >/dev/null 2>&1; then " + f"EXT='{' '.join(pkgs['pacman'])}'; " + "I='sudo pacman -S --needed --noconfirm'; " + 'else EXT=""; fi; ' + 'for p in $EXT; do $I "$p" >/dev/null 2>&1 ' + f'|| echo " {t("not in the repos, skipped:")} $p"; done; ' + f'echo " {t("Enable them from Extension Manager, or:")} ' + 'gnome-extensions enable "; ' + + self._qemu_gnome_ext_site_cmd() + ) + + def _qemu_tools_remote_cmd(self, tools, prod=False): + """Bloc d'installation des outils cochés, dans l'ordre du plus utile au + plus lourd. Chacun se garde lui-même : aucun ne fait échouer les + autres, ni l'installation d'ERPLibre.""" + blocks = { + "gnome_ext": self._qemu_gnome_ext_remote_cmd, + "pycharm": lambda: self._qemu_pycharm_remote_cmd(prod), + "android": self._qemu_android_studio_remote_cmd, + } + return "".join(blocks[k]() for k in blocks if k in (tools or ())) + + def _qemu_editor_pkg(self): + """Paquet de l'éditeur de l'hôte, à installer dans la VM. + + L'éditeur atteint déjà la VM par deux chemins, tous deux posés par + deploy_qemu.py : « core.editor » dans son ~/.gitconfig, et la ligne + « éditer le serveur » du guide de connexion. Encore faut-il que le + binaire y soit — les images cloud n'ont ni nano ni vim garantis, et + certaines n'ont même pas vi. On l'ajoute donc aux outils d'amorçage, avec + curl, git et make, là où les dépôts viennent d'être rafraîchis. + + La table des éditeurs vit dans deploy_qemu.py : une seule autorité décide + du paquet installé, de la commande affichée et de core.editor. Sans + module importable, on n'installe rien plutôt que de deviner un nom.""" + try: + mod = self._qemu_import_module() + return mod.vm_editor(mod.invoking_home())[0] + except Exception: + return "" + + def _qemu_editor_suffix(self): + """« vim » -> « vim » précédé d'une espace, rien du tout sinon. + + La liste des outils d'amorçage est une chaîne shell entre apostrophes : + y concaténer une chaîne vide sans précaution laisserait une espace en + trop, inoffensive mais visible dans chaque log d'installation.""" + pkg = self._qemu_editor_pkg() + return f" {pkg}" if pkg else "" + # mise ne publie de binaire que pour ces architectures : 46 assets à la # v2026.8.4, aucun s390x — son propre script d'installation refuse cette # plateforme. Ailleurs, le choix « mise » est sans objet et on reste sur @@ -5038,6 +5491,7 @@ class TODO: desktop=False, python_provider="", app_store="deb", + tools=(), ): """Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau — le choix graphique ne dépend pas d'ERPLibre, et une VM peut être @@ -5047,7 +5501,10 @@ class TODO: installe dans /opt/erplibre (au lieu de ~/git/erplibre) + service SELinux confiné. `desktop` : ajoute GNOME et son accès distant. `python_provider` : « mise » pour un CPython précompilé, sinon le - comportement par défaut du dépôt (pyenv, qui compile).""" + comportement par défaut du dépôt (pyenv, qui compile). `tools` : outils + de développement cochés (PyCharm, Android Studio, extensions GNOME), + posés APRÈS ERPLibre — PyCharm a besoin du venv du dépôt pour écrire la + configuration du projet.""" if not branch: # Bureau seul : ni clone ni make, mais on garde le prologue — # attente de cloud-init et coupure des mises à jour automatiques, @@ -5059,6 +5516,7 @@ class TODO: + self._qemu_cloud_init_wait() + self._qemu_no_auto_upgrade(prod, app_store) + self._qemu_desktop_remote_cmd(desktop, app_store) + + self._qemu_tools_remote_cmd(tools, prod) ) if not final_cmd: final_cmd = f"make install_os && make {self.ERPLIBRE_ODOO_TARGET}" @@ -5084,6 +5542,7 @@ class TODO: # apt pendant l'installation. En PROD on ne touche à rien : les # correctifs de sécurité automatiques doivent rester actifs. no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store) + tools_cmd = self._qemu_tools_remote_cmd(tools, prod) return ( "set -e; " + self._qemu_cloud_init_wait() # Coupé AVANT les apt-get ci-dessous : sinon apt-daily peut reprendre @@ -5103,7 +5562,14 @@ class TODO: # la VM soit la plus rapide possible (miroirs à jour / les plus # rapides), puis installe. Supporte apt (Debian/Ubuntu), dnf/yum # (Fedora) et pacman (Arch). - "PKGS='curl git make'; " + # + # L'éditeur de l'hôte voyage avec eux : deploy_qemu.py a déjà écrit + # « core.editor » dans le ~/.gitconfig de la VM et l'a nommé dans le + # guide de connexion, mais aucune image cloud ne garantit vim ni + # nano. Le poser ici plutôt que par cloud-init : les dépôts y sont + # déjà rafraîchis, et une installation de paquet au premier boot + # retarderait le démarrage sans laisser de trace dans le suivi. + f"PKGS='curl git make{self._qemu_editor_suffix()}'; " "if command -v apt-get >/dev/null 2>&1; then " # Au 1er boot, cloud-init (install qemu-guest-agent) et/ou # apt-daily.service tiennent le verrou apt. IMPORTANT : @@ -5167,6 +5633,16 @@ class TODO: '{ echo "Outil manquant apres installation: $t ' '(reseau de la VM ?)"; exit 1; }; done; ' + self._qemu_mise_remote_cmd(python_provider) + # Les outils AVANT le clone et le make, et l'ordre compte : c'est + # PyCharm qui écrit le .idea du dépôt, en l'ouvrant une fois, et + # c'est l'installation qui, ensuite, y lance + # pycharm_configuration.py. Posés après, ils arrivaient trop tard + # pour cette étape-là. + # + # Le code de sortie de la commande distante reste celui de + # l'installation : chaque bloc d'outil se garde lui-même et rend 0, + # donc aucun ne peut faire passer un make échoué pour un succès. + + tools_cmd # Clone : /opt/erplibre en PROD (racine, puis chown à l'utilisateur # pour que make/venv s'exécutent sans sudo), ~/git/erplibre en dev. + ( @@ -5199,12 +5675,16 @@ class TODO: desktop=False, python_provider="", app_store="deb", + desktop_tools=(), ): """Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre le dashboard Textual. Quitter le dashboard n'arrête pas les installs. `ip_map` : IP déjà résolues (sinon on résout ici, EN PARALLÈLE). `final_cmd` : commande d'install selon le profil choisi. - `prod` : install /opt/erplibre + service SELinux confiné.""" + `prod` : install /opt/erplibre + service SELinux confiné. + `desktop_tools` : outils cochés pour tout le parc, filtrés machine par + machine (Android Studio n'existe qu'en x86_64, les extensions GNOME + n'ont pas de sens sous Cinnamon).""" from script.todo.qemu_install_monitor import ( launch_installs, run_monitor, @@ -5253,14 +5733,27 @@ class TODO: "version": v, "arch": a, } - if desk_map or branch_map or cmd_map: + # Les outils imposent une commande PAR VM même quand tout le + # reste est commun : ils dépendent de l'architecture de la + # machine et de sa saveur de bureau, que seule cette boucle + # connaît. + if desk_map or branch_map or cmd_map or desktop_tools: + # Le bureau de CETTE VM : sa saveur propre si la carte en + # donne une, sinon celle du parc. Prendre « rien » quand la + # carte est vide privait de bureau toute VM dont seule la + # branche ou le profil différait — la commande par défaut, + # elle, l'a toujours porté. + vm_desktop = desk_map.get( + name, "" if desk_map else desktop + ) entry["remote_cmd"] = self._qemu_erplibre_remote_cmd( branch_map.get(name, branch_def), cmd_map.get(name, cmd_def), prod, - desk_map.get(name, ""), + vm_desktop, python_provider, app_store, + self._qemu_tools_for(desktop_tools, a, vm_desktop), ) vms.append(entry) else: @@ -5306,10 +5799,12 @@ class TODO: desktop=False, python_provider="", app_store="deb", + desktop_tools=(), ): """Clone ERPLibre (branche donnée) dans la VM puis exécute la commande d'install du profil choisi (streamé). `ip` : IP déjà résolue ; - `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné.""" + `final_cmd` : commande d'install ; `prod` : /opt + SELinux confiné ; + `desktop_tools` : outils de développement cochés.""" if ip is None: ip = self._qemu_vm_ip(name) if not ip: @@ -5327,7 +5822,17 @@ class TODO: ) return remote = self._qemu_erplibre_remote_cmd( - branch, final_cmd, prod, desktop, python_provider, app_store + branch, + final_cmd, + prod, + desktop, + python_provider, + app_store, + # « or amd64 » comme _qemu_vm_meta : une architecture indéterminée + # ne doit pas faire disparaître silencieusement Android Studio. + self._qemu_tools_for( + desktop_tools, self._qemu_vm_arch(name) or "amd64", desktop + ), ) ssh_opts = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " @@ -5762,6 +6267,18 @@ class TODO: print( f" {t('VM type:')} {t('Graphical (server + desktop):')} {label}" ) + tools = spec.get("desktop_tools") or () + if tools: + # Les Go sont dits ici parce que c'est le dernier écran avant de + # créer les disques : un IDE de plus, c'est un disque plus grand, + # et cette page est celle qu'on relit pour s'en apercevoir. + named = ", ".join( + f"{t(self._QEMU_DESKTOP_TOOLS[k]['label'])} " + f"(+{self._QEMU_DESKTOP_TOOLS[k]['disk_gb']} Go)" + for k in tools + if k in self._QEMU_DESKTOP_TOOLS + ) + print(f" {t('Development tools:')} {named}") prov = spec.get("python_provider") if prov: print(f" {t('Python interpreter:')} {prov}") @@ -5787,6 +6304,9 @@ class TODO: timezone=None, locale=None, desktop=False, + prod=False, + install_cmd="", + desktop_tools=(), ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -5822,6 +6342,15 @@ class TODO: parts += ["--locale", locale] if desktop: parts.append("--desktop") + # Guide affiché à la connexion SSH de la VM : dans la langue du menu, et + # avec la section ERPLibre seulement là où ERPLibre sera installé — une + # VM déployée nue n'annonce pas un dépôt qui n'existe pas. + parts += ["--lang", get_lang()] + if branch: + parts += ["--erplibre-dir", self._qemu_guide_dir(prod)] + target = self._qemu_make_target(install_cmd) + if target: + parts += ["--erplibre-make", target] extra = 0 if branch: # ERPLibre dépasse le minimum : +5 Go de disque. @@ -5830,6 +6359,11 @@ class TODO: # GNOME et ses dépendances pèsent autant qu'ERPLibre : sans cette # marge, le disque se remplit en pleine installation du bureau. extra += self.QEMU_DESKTOP_EXTRA_DISK_GB + # Les IDE pèsent plus lourd que tout le reste : PyCharm et Android + # Studio, c'est l'archive téléchargée PUIS son contenu déplié. Compté + # 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(desktop_tools, arch, desktop) if extra: bigger = self._parse_disk_gb(disk) + extra parts += ["--disk-size", f"{bigger}G"] @@ -5858,7 +6392,17 @@ class TODO: locale=spec.get("locale"), # Le type suit la VM. Repli sur la valeur de spec pour la CLI, # qui ne pose la question qu'une fois pour tout le parc. - desktop=bool(vm.get("desktop", spec.get("desktop"))), + # + # La SAVEUR, et non un booléen : les extensions GNOME n'ont pas de + # sens sous Cinnamon, et c'est ici que se calcule la place disque + # des outils. « --desktop » ne regarde que la vérité de la valeur, + # une chaîne non vide lui va aussi bien. + desktop=vm.get("desktop", spec.get("desktop")) or "", + # Les deux servent au guide de connexion : où ERPLibre sera posé, et + # quelle cible make le remettra à jour. + prod=bool(install and install.get("prod")), + install_cmd=(install or {}).get("cmd") or "", + desktop_tools=spec.get("desktop_tools") or (), ) # ---------------------------------------------------------------- # @@ -6102,6 +6646,16 @@ class TODO: self._qemu_host_timezone() ), "snap_distros": self.QEMU_SNAP_DISTROS, + "desktop_tools": self._qemu_desktop_tool_choices(), + "desktop_tool_disk": { + k: v["disk_gb"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, + "desktop_tool_arches": { + k: v["arches"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, + "desktop_tool_desktops": { + k: v["desktops"] for k, v in self._QEMU_DESKTOP_TOOLS.items() + }, "desktop_suffixes": self._qemu_desktop_suffixes(), "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() @@ -6417,6 +6971,43 @@ class TODO: return self.QEMU_APP_STORES[int(answer) - 1][0] return "deb" + def _qemu_ask_desktop_tools(self, vms): + """Outils de développement des VM graphiques : liste à cocher. + + Ne se pose QUE si au moins une VM porte un bureau — sur un serveur, un + IDE graphique n'a rien pour s'afficher. La réponse vaut pour tout le + parc et sera filtrée machine par machine. + + Saisie par numéros séparés par des espaces ou des virgules, « tous » + pour tout cocher, vide pour rien : trois questions oui/non de plus + alourdiraient une séquence d'invites déjà longue.""" + graphical = [vm for vm in vms if vm.get("desktop")] + if not graphical: + return () + choices = self._qemu_desktop_tool_choices() + print(f"\n{t('Development tools for the graphical VMs:')}") + for i, (_key, label, hint) in enumerate(choices, 1): + print(f" [{i}] {label} — {hint}") + gb = ", ".join( + f"{label} +{self._QEMU_DESKTOP_TOOLS[key]['disk_gb']} Go" + for key, label, _hint in choices + ) + print(f" {t('Disk needed:')} {gb}") + answer = input( + f"{t('Numbers separated by spaces, [all], blank = none:')} " + ).strip() + if not answer: + return () + if answer.lower() in ("all", "tous", "toutes", "*"): + return tuple(key for key, _l, _h in choices) + picked = [] + for token in answer.replace(",", " ").split(): + if token.isdigit() and 1 <= int(token) <= len(choices): + key = choices[int(token) - 1][0] + if key not in picked: + picked.append(key) + return tuple(picked) + def _qemu_ask_python_provider(self, arches): """mise (CPython précompilé) ou pyenv (compilation). @@ -6513,6 +7104,7 @@ class TODO: _vm.setdefault("desktop", desktop) _vm["name"] = vm_name(_vm["name"], _vm.get("desktop"), suffixes) app_store = self._qemu_ask_app_store(vms) + desktop_tools = self._qemu_ask_desktop_tools(vms) python_provider = self._qemu_ask_python_provider( [vm["arch"] for vm in vms] ) @@ -6593,6 +7185,7 @@ class TODO: "timezone": timezone, "locale": locale, "desktop": desktop, + "desktop_tools": desktop_tools, "python_provider": python_provider, "app_store": app_store, "install": install, @@ -6676,6 +7269,9 @@ class TODO: desktop = next((d for d in desktop_map.values() if d), "") python_provider = spec.get("python_provider") or "" app_store = spec.get("app_store") or "deb" + # Outils de développement : cochés une fois pour tout le parc, puis + # filtrés machine par machine (architecture, saveur de bureau). + desktop_tools = tuple(spec.get("desktop_tools") or ()) # Branche par VM : « » sur une VM veut dire « celle du formulaire ». branch_map = { vm["name"]: (vm.get("branch") or install_branch or "") @@ -6767,6 +7363,7 @@ class TODO: desktop=desktop_map, python_provider=python_provider, app_store=app_store, + desktop_tools=desktop_tools, ) else: print( @@ -6784,6 +7381,7 @@ class TODO: desktop=desktop_map.get(name, ""), python_provider=python_provider, app_store=app_store, + desktop_tools=desktop_tools, ) # Sommaire TOTAL (déploiement + résolution IP + ssh_config + install diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6983f5c..bdec2d7 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7557,6 +7557,122 @@ TRANSLATIONS = { "en": "no recipient: nothing was sent", }, "mail_err_send_refused": {"fr": "envoi refusé :", "en": "send refused:"}, + # QEMU — outils de développement des VM graphiques + "Python IDE, ERPLibre project pre-configured": { + "fr": "IDE Python, projet ERPLibre pré-configuré", + "en": "Python IDE, ERPLibre project pre-configured", + }, + "ERPLibre mobile development (x86_64 only)": { + "fr": "développement mobile ERPLibre (x86_64 seulement)", + "en": "ERPLibre mobile development (x86_64 only)", + }, + "GNOME extensions": { + "fr": "Extensions GNOME", + "en": "GNOME extensions", + }, + "suggested extensions + extension manager": { + "fr": "extensions suggérées + gestionnaire d'extensions", + "en": "suggested extensions + extension manager", + }, + "Development tools for the graphical VMs:": { + "fr": "Outils de développement des VM graphiques :", + "en": "Development tools for the graphical VMs:", + }, + "Development tools (graphical VMs):": { + "fr": "Outils de développement (VM graphiques) :", + "en": "Development tools (graphical VMs):", + }, + "Development tools:": { + "fr": "Outils de développement :", + "en": "Development tools:", + }, + "Disk needed:": {"fr": "Disque nécessaire :", "en": "Disk needed:"}, + "Numbers separated by spaces, [all], blank = none:": { + "fr": "Numéros séparés par des espaces, [tous], vide = aucun :", + "en": "Numbers separated by spaces, [all], blank = none:", + }, + "No graphical VM: these tools are not installed.": { + "fr": "Aucune VM graphique : ces outils ne seront pas installés.", + "en": "No graphical VM: these tools are not installed.", + }, + "Partly skipped (arch or desktop):": { + "fr": "En partie écartés (architecture ou bureau) :", + "en": "Partly skipped (arch or desktop):", + }, + "Installing PyCharm (long)": { + "fr": "Installation de PyCharm (long)", + "en": "Installing PyCharm (long)", + }, + "no JetBrains build for": { + "fr": "aucune archive JetBrains pour", + "en": "no JetBrains build for", + }, + "PyCharm: project config skipped": { + "fr": "PyCharm : configuration du projet non écrite", + "en": "PyCharm: project config skipped", + }, + "PyCharm installed:": { + "fr": "PyCharm installé :", + "en": "PyCharm installed:", + }, + "PyCharm not installed (see above)": { + "fr": "PyCharm non installé (voir ci-dessus)", + "en": "PyCharm not installed (see above)", + }, + "Installing Android Studio (long)": { + "fr": "Installation d'Android Studio (long)", + "en": "Installing Android Studio (long)", + }, + "Android Studio: Google publishes x86_64 only": { + "fr": "Android Studio : Google ne publie que du x86_64", + "en": "Android Studio: Google publishes x86_64 only", + }, + "Android Studio installed:": { + "fr": "Android Studio installé :", + "en": "Android Studio installed:", + }, + "Android Studio not installed (see above)": { + "fr": "Android Studio non installé (voir ci-dessus)", + "en": "Android Studio not installed (see above)", + }, + "no nested KVM: the emulator will not run": { + "fr": "pas de KVM imbriqué : l'émulateur ne démarrera pas", + "en": "no nested KVM: the emulator will not run", + }, + "Suggested GNOME extensions": { + "fr": "Extensions GNOME suggérées", + "en": "Suggested GNOME extensions", + }, + "not in the repos, skipped:": { + "fr": "absent des dépôts, ignoré :", + "en": "not in the repos, skipped:", + }, + "Enable them from Extension Manager, or:": { + "fr": "À activer depuis le gestionnaire d'extensions, ou :", + "en": "Enable them from Extension Manager, or:", + }, + "GNOME Shell not found, site extensions skipped": { + "fr": "GNOME Shell introuvable, extensions du site ignorées", + "en": "GNOME Shell not found, site extensions skipped", + }, + "installed and enabled:": { + "fr": "installée et activée :", + "en": "installed and enabled:", + }, + "not available for this GNOME, skipped:": { + "fr": "indisponible pour ce GNOME, ignorée :", + "en": "not available for this GNOME, skipped:", + }, + "log out and back in to load them": { + "fr": "se déconnecter puis se reconnecter pour les charger", + "en": "log out and back in to load them", + }, + "open the project once and close PyCharm; the .idea it writes is what the install configures": { + "fr": "ouvrir le projet une fois puis fermer PyCharm : le .idea qu'il écrit est ce que l'installation configure", + "en": "open the project once and close PyCharm; the .idea it writes is what the install configures", + }, + "command": {"fr": "commande", "en": "command"}, + "project": {"fr": "projet", "en": "project"}, } diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py new file mode 100644 index 0000000..b28584c --- /dev/null +++ b/test/test_qemu_desktop_tools.py @@ -0,0 +1,272 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Outils de développement des VM graphiques : filtrage, disque, commande. + +Ce qui se vérifie ici sans VM : qu'un outil demandé pour tout le parc n'atterrit +que sur les machines qui peuvent le recevoir, que la place disque annoncée suit +ce filtrage, et qu'un outil qui échoue ne fait pas tomber l'installation +d'ERPLibre avec lui — celle-ci ayant duré une heure. +""" + +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + + +class TestToolFiltering(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def test_a_server_gets_no_graphical_tool(self): + """Un IDE sans bureau n'a rien pour s'afficher.""" + self.assertEqual([], self.todo._qemu_tools_for(self.all, "amd64", "")) + + def test_android_studio_is_x86_64_only(self): + """Google ne publie aucune archive Linux aarch64 : toutes les variantes + de l'URL rendent 404, et product-info.json ne déclare que Linux/amd64. + """ + self.assertIn( + "android", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + ) + for arch in ("arm64", "s390x"): + self.assertNotIn( + "android", + self.todo._qemu_tools_for(self.all, arch, "gnome"), + arch, + ) + + def test_pycharm_follows_jetbrains_two_architectures(self): + for arch in ("amd64", "arm64"): + self.assertIn( + "pycharm", + self.todo._qemu_tools_for(self.all, arch, "gnome"), + arch, + ) + self.assertNotIn( + "pycharm", self.todo._qemu_tools_for(self.all, "s390x", "gnome") + ) + + def test_gnome_extensions_only_under_gnome(self): + self.assertIn( + "gnome_ext", self.todo._qemu_tools_for(self.all, "amd64", "gnome") + ) + self.assertNotIn( + "gnome_ext", + self.todo._qemu_tools_for(self.all, "amd64", "cinnamon"), + ) + + def test_unknown_key_is_ignored(self): + self.assertEqual( + [], self.todo._qemu_tools_for(("nope",), "amd64", "gnome") + ) + + +class TestToolDisk(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def test_disk_follows_the_filtering(self): + """Une VM qui ne recevra pas Android Studio ne doit pas se voir gonfler + son disque de ses 8 Go.""" + full = self.todo._qemu_tools_disk_gb(self.all, "amd64", "gnome") + arm = self.todo._qemu_tools_disk_gb(self.all, "arm64", "gnome") + self.assertEqual( + full - arm, TODO._QEMU_DESKTOP_TOOLS["android"]["disk_gb"] + ) + + def test_a_server_costs_nothing(self): + self.assertEqual( + 0, self.todo._qemu_tools_disk_gb(self.all, "amd64", "") + ) + + def test_the_deploy_command_grows_the_disk(self): + """Le disque demandé à deploy_qemu.py doit inclure les outils : c'est + la seule valeur qui compte, celle du qcow2 réellement créé.""" + spec = { + "ssh_key": "", + "desktop": "gnome", + "desktop_tools": self.all, + "install": { + "branch": "develop", + "prod": False, + "cmd": "make install_os && make install_odoo_18", + "label": "x", + "monitor": True, + }, + } + vm = { + "distro": "ubuntu", + "version": "24.04", + "arch": "amd64", + "name": "v", + "ram": 3072, + "vcpus": 4, + "disk": "20G", + "desktop": "gnome", + } + parts = self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True) + size = parts[parts.index("--disk-size") + 1] + expected = ( + 20 + + TODO.ERPLIBRE_EXTRA_DISK_GB + + TODO.QEMU_DESKTOP_EXTRA_DISK_GB + + sum(s["disk_gb"] for s in TODO._QEMU_DESKTOP_TOOLS.values()) + ) + self.assertEqual(f"{expected}G", size) + + +class TestToolRemoteCommand(unittest.TestCase): + def setUp(self): + self.todo = TODO.__new__(TODO) + self.all = tuple(TODO._QEMU_DESKTOP_TOOLS) + + def _sh_ok(self, script): + """Le shell accepte-t-il ce script ? « bash -n » ne l'exécute pas.""" + return subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ) + + def test_every_combination_is_valid_shell(self): + combos = [ + (), + ("pycharm",), + ("android",), + ("gnome_ext",), + self.all, + ] + for tools in combos: + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "mise", "deb", tools + ) + res = self._sh_ok(script) + self.assertEqual(0, res.returncode, f"{tools} : {res.stderr}") + + def test_tools_come_before_the_clone_and_the_make(self): + """L'ordre est ce qui fait marcher la configuration du projet : PyCharm + écrit le .idea du dépôt en l'ouvrant une fois, et c'est l'installation + qui, ENSUITE, y lance pycharm_configuration.py.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertLess(script.index("PyCharm"), script.index("git clone")) + self.assertLess( + script.index("PyCharm"), script.index("make install_os") + ) + + def test_the_install_owns_the_project_configuration(self): + """update_env_version.pycharm_update() lance déjà le script, et sait se + taire sans .idea. Doubler l'appel n'écrivait qu'une erreur dans le + journal d'une VM neuve.""" + script = self.todo._qemu_erplibre_remote_cmd( + "develop", None, False, "gnome", "", "deb", ("pycharm",) + ) + self.assertNotIn("pycharm_configuration", script) + + def test_a_failing_tool_never_masks_a_failing_install(self): + """Le code de sortie doit rester celui de l'installation : c'est lui que + lit le tableau de bord pour dire ✅ ou ❌.""" + script = ( + "set -e\n" + "curl() { return 7; }; sudo() { return 7; }\n" + + self.todo._qemu_tools_remote_cmd(("pycharm", "android"), False) + + "\nexit 3\n" # l'installation qui suit, en échec + ) + res = subprocess.run( + ["bash", "-c", script], capture_output=True, text=True + ) + self.assertEqual(3, res.returncode, res.stdout + res.stderr) + + def test_a_failing_tool_still_returns_zero(self): + """Le bloc d'outils est lui-même gardé : PyCharm indisponible ne doit + pas transformer une installation réussie en échec.""" + script = "set -e\n" + self.todo._qemu_tools_remote_cmd(self.all, False) + # Tout ce qui pourrait réussir est neutralisé : ni réseau, ni sudo. + stub = ( + "curl() { return 7; }; sudo() { return 7; }; tar() { return 7; }; " + "export -f curl sudo tar 2>/dev/null || true\n" + ) + res = subprocess.run( + ["bash", "-c", stub + script], capture_output=True, text=True + ) + self.assertEqual(0, res.returncode, res.stderr) + self.assertIn("PyCharm", res.stdout) + + +class TestToolDiscoverability(unittest.TestCase): + """Ce qui est installé doit pouvoir être TROUVÉ. Vécu : Android Studio + posé dans /opt, lanceur nommé « studio », et l'utilisateur conclut à un + échec parce que « android-studio » ne répond pas.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + + def test_android_studio_answers_to_both_names(self): + cmd = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/usr/local/bin/studio;", cmd) + self.assertIn("/usr/local/bin/android-studio;", cmd) + + def test_the_log_says_where_it_landed(self): + pycharm = self.todo._qemu_pycharm_remote_cmd() + android = self.todo._qemu_android_studio_remote_cmd() + self.assertIn("/opt/pycharm", pycharm) + self.assertIn("/opt/android-studio", android) + + def test_pycharm_says_what_creates_the_project(self): + """Le .idea n'existe qu'après une première ouverture de PyCharm : le + journal le dit, plutôt que de laisser croire à un échec.""" + cmd = self.todo._qemu_pycharm_remote_cmd() + self.assertIn(".idea", cmd) + + +class TestGnomeSiteExtensions(unittest.TestCase): + """Extensions posées depuis extensions.gnome.org, par leur UUID.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.block = self.todo._qemu_gnome_ext_remote_cmd() + + def test_the_three_requested_extensions_are_there(self): + for uuid in ( + "gTile@vibou", + "freon@UshakovVasilii_Github.yahoo.com", + "tracker@aliakseiz.github.com", + ): + self.assertIn(uuid, self.block, uuid) + + def test_the_archive_follows_the_running_gnome(self): + """Le site sert une archive DIFFERENTE selon la version demandée — + gTile v59 en GNOME 46, v62 en 48 : figer une URL poserait une archive + faite pour une autre version.""" + self.assertIn("shell_version=$sv", self.block) + self.assertIn("gnome-shell --version", self.block) + + def test_valid_shell_and_never_fails_the_install(self): + script = "set -e\n" + self.block + self.assertEqual( + 0, + subprocess.run( + ["bash", "-n"], input=script, text=True, capture_output=True + ).returncode, + ) + # Sans gnome-shell dans le PATH, le bloc doit se taire proprement. + res = subprocess.run( + ["bash", "-c", "PATH=/nonexistent; " + script], + capture_output=True, + text=True, + ) + self.assertEqual(0, res.returncode, res.stderr) + + def test_a_session_bus_is_provided_for_dconf(self): + """Un « ssh hote commande » n'a pas de bus de session : sans lui, + l'activation ne peut rien ecrire dans dconf.""" + self.assertIn("dbus-run-session", self.block) + + +if __name__ == "__main__": + unittest.main()