#!/usr/bin/env python3 # © 2021-2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Menu QEMU/KVM : ce qui s'installe DANS la VM.\n\nCe fichier ne cr\u00e9e aucune VM : il fabrique les commandes distantes qu'on y\nex\u00e9cutera. Profils ERPLibre et service Odoo, bureau GNOME et ses saveurs,\nmagasins d'applications, fuseaux, miroirs de paquets, et les outils de\nd\u00e9veloppement (PyCharm, Android Studio, extensions GNOME, compilation mobile,\nAVD, Forgejo).\n\nFronti\u00e8re claire : ici on \u00e9crit du shell destin\u00e9 \u00e0 l'invit\u00e9 ; dans\nqemu_deploy.py on d\u00e9cide QUELLES VM le recevront.""" import re import shlex from script.todo import dev_tools from script.todo.todo_i18n import t class QemuInstallMixin: """Menu QEMU/KVM : ce qui s'installe DANS la VM.\n\nCe fichier ne cr\u00e9e aucune VM : il fabrique les commandes distantes qu'on y\nex\u00e9cutera. Profils ERPLibre et service Odoo, bureau GNOME et ses saveurs,\nmagasins d'applications, fuseaux, miroirs de paquets, et les outils de\nd\u00e9veloppement (PyCharm, Android Studio, extensions GNOME, compilation mobile,\nAVD, Forgejo).\n\nFronti\u00e8re claire : ici on \u00e9crit du shell destin\u00e9 \u00e0 l'invit\u00e9 ; dans\nqemu_deploy.py on d\u00e9cide QUELLES VM le recevront.""" # Préparation hôte QEMU/libvirt du profil « ERPLibre Déploiement ». # Délègue à deploy_qemu.py --setup-host : les noms de paquets y sont déjà # définis pour apt/dnf/pacman/zypper/brew (TOOL_PACKAGES, DAEMON_PACKAGES), # et il fait ce que l'ancien one-liner ne faisait PAS — démarrer le démon, # ajouter l'utilisateur au groupe libvirt et activer le réseau « default ». # Sans le groupe, virt-install retombe sur qemu:///session où « default » # n'existe pas : la VM échoue alors que tous les paquets sont installés. # L'ancien one-liner finissait par « || true » et masquait ses erreurs. # Le redémarrage est consenti ICI et nulle part ailleurs : la VM vient # d'être créée, personne ne la regarde, et le noyau fraîchement installé # doit être chargé avant que libvirt puisse monter virbr0. Sur un poste de # travail, la question se pose — voir _qemu_ensure_tools. _QEMU_QEMU_PKGS = ( "./script/qemu/deploy_qemu.py --setup-host --assume-yes" " --reboot-if-needed --assume-yes-reboot" ) def _qemu_ask_prod(self): """Environnement cible : dev (défaut) ou prod. En PROD : ERPLibre est installé dans /opt/erplibre (au lieu de ~/git/erplibre) et le service systemd reste CONFINÉ par SELinux (pas d'unconfined).""" print(f"\n{t('Target environment?')}") print(f" [1] {t('Development (~/git/erplibre, SELinux relaxed)')} *") print(f" [2] {t('Production (/opt/erplibre, SELinux enforced)')}") sel = input(t("Choice (1-2, default 1): ")).strip() return sel == "2" def _qemu_install_profiles(self): """Profils installables : [(libellé, commande)]. Le premier est le défaut. Partagé par l'invite en ligne et le formulaire TUI.""" profiles = [ ( f"ERPLibre + Odoo {v}", f"make install_os && make install_odoo_{v}", ) for v in ("18", "17", "16", "15", "14", "13", "12") ] profiles += [ ( t("ERPLibre + all Odoo versions"), "make install_os && make install_odoo_all_version", ), ( t("ERPLibre only (no Odoo)"), "make install_os && ./script/install/install_erplibre.sh", ), ( t("ERPLibre mobile (home)"), "make install_os && ./mobile/install_and_run.sh", ), ( t("ERPLibre Deployment (+ QEMU + dev)"), "make install_os && make install_dev && " + self._QEMU_QEMU_PKGS, ), ( t("Proxmox VE hypervisor (no Odoo)"), "./script/proxmox/install_proxmox.sh", ), ] return profiles # Un système qui IMPOSE ce qu'on installe dessus. Choisir « Proxmox VE » # comme système, c'est demander qu'il soit installé : ni ERPLibre, ni # Odoo n'ont leur place sur un hyperviseur, et les y poser par défaut # était le contraire de ce que le choix exprimait. _QEMU_DISTRO_PROFILE = {"proxmox": "Proxmox VE hypervisor (no Odoo)"} def _qemu_distro_profile(self, distro): """(libellé, commande) du profil qu'un système impose, ou None. Une seule règle, lue par l'invite en ligne comme par le formulaire : chacun la redisait, et le formulaire l'avait justement oubliée.""" voulu = self._QEMU_DISTRO_PROFILE.get(distro) if not voulu: return None cible = t(voulu) for entree in self._qemu_install_profiles(): if entree[0] == cible: return entree return None def _qemu_no_erplibre_cmds(self): """Les commandes d'installation qui NE posent pas ERPLibre. Déduites de la table des systèmes imposés : le profil hyperviseur Proxmox n'installe ni ERPLibre ni Odoo. Rien n'est écrit en dur ici, pour qu'ajouter un système à la table suffise.""" cmds = set() for distro in self._QEMU_DISTRO_PROFILE: impose = self._qemu_distro_profile(distro) if impose: cmds.add(impose[1]) return cmds def _qemu_installs_erplibre(self, branch, install_cmd=""): """Cette VM va-t-elle VRAIMENT poser ERPLibre ? Décidé sur la COMMANDE, pas sur la case : elle seule sait si le dépôt sera cloné. C'est ce qui règle les cinq gigaoctets de marge et la section ERPLibre du guide affiché à la connexion. Sans commande connue, on répond oui : mieux vaut cinq gigaoctets de trop qu'une installation qui remplit le disque.""" if not branch: return False if not install_cmd: return True return install_cmd.strip() not in self._qemu_no_erplibre_cmds() def _qemu_pick_install_profile(self, distro=""): """Choix de CE QU'ON installe sur la VM. Renvoie (label, commande finale exécutée dans ~/git/erplibre). Le profil qu'un système impose passe en tête, et devient donc le défaut de la réponse vide. """ profiles = self._qemu_install_profiles() impose = self._qemu_distro_profile(distro) if impose: profiles.sort(key=lambda p: p[0] != impose[0]) print(f"\n{t('What to install on the VM(s)?')}") for i, (label, _cmd) in enumerate(profiles, 1): print(f" [{i}] {label}{' *' if i == 1 else ''}") sel = input(t("Choice (number, blank = Odoo 18): ")).strip() try: idx = int(sel) - 1 if 0 <= idx < len(profiles): return profiles[idx] except ValueError: pass return profiles[0] # défaut : ERPLibre + Odoo 18 @staticmethod def _qemu_install_dir(prod): """Répertoire d'installation ERPLibre dans la VM : /opt/erplibre en PROD (hors /home -> service SELinux confiné possible), sinon ~/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. DEV : ERPLibre sous ~/home. Un service système ne peut PAS exécuter du user_home_t sous SELinux, et « SELinuxContext=unconfined » ne suffit pas (transition init_t -> unconfined_t refusée -> toujours 203/EXEC). Sur une VM de dev jetable, on passe donc SELinux en PERMISSIF (relâché). PROD : ERPLibre sous /opt/erplibre (hors user_home_t) -> le service reste CONFINÉ par SELinux ; on restaure les contextes (restorecon).""" svc_dir = self._qemu_install_dir(prod) selinux_shell = ( 'SELINUX_LINE=""; ' # pas de SELinuxContext (inefficace) ) if prod: pre = ( "command -v restorecon >/dev/null 2>&1 && " "sudo restorecon -R /opt/erplibre >/dev/null 2>&1 || true; " ) else: # DEV : SELinux permissif (persistant) si actif -> le service peut # exécuter run.sh/venv sous /home. pre = ( "if command -v getenforce >/dev/null 2>&1 && " '[ "$(getenforce)" = "Enforcing" ]; then ' "sudo setenforce 0 || true; " "sudo sed -i 's/^SELINUX=enforcing/SELINUX=permissive/' " "/etc/selinux/config 2>/dev/null || true; fi; " ) # NixOS est reconnu DANS la VM, et non d'après ce que l'hôte croit # savoir : la même commande sert au déploiement, au test long et à un # « --hote » qu'on n'a pas créé. /etc y est généré depuis le store et # monté en lecture seule — le tee plus bas échoue sur « Read-only file # system », et l'installation rend 1 à sa dernière étape après que # tout le reste a réussi. L'unité vient du module déclaratif posé par # « make install_os » ; il ne reste qu'à la relancer, une fois le # dépôt en place. nixos = ( "if [ -f /etc/os-release ] && grep -q '^ID=nixos' " "/etc/os-release; then " "if systemctl cat erplibre.service >/dev/null 2>&1; then " "sudo systemctl restart erplibre.service; " "else " 'echo "erplibre.service non déclaré : ' '« make install_os » pose le module NixOS qui le porte." >&2; ' "exit 1; fi; " "else " ) return ( nixos + f'SVC_USER=$(whoami); SVC_GROUP=$(id -gn); SVC_DIR="{svc_dir}"; ' + pre + selinux_shell + "sudo tee /etc/systemd/system/erplibre.service >/dev/null </dev/null 2>&1; then " 'echo "== ' + t("Waiting for cloud-init to finish (up to 15 min)") + ' =="; ' "sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 " "|| true; " + f'echo " {t("cloud-init:")} $(cloud-init status 2>/dev/null ' '| head -1)"; ' "fi; " # Le service détaché de l'agent invité, s'il court encore. Le nom # est celui que le déploiement lui donne ; « --collect » l'efface # une fois fini, donc « is-active » redevient faux tout seul. "if systemctl is-active --quiet erplibre-qga 2>/dev/null; then " f'echo " {t("waiting for the guest agent install (apt lock)")}"; ' "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; " "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " "fi; " # « status --wait » rend la main dès que cloud-init se déclare en # ERREUR, alors que son étape finale écrit encore l'autorité, les # variables et le sudoers : attendre l'unité, faute de quoi elles # arrivent après cette session, qui vivra sans elles. + attente_cloud_final() + "; " # Les variables du cache sont écrites par cloud-init PENDANT # l'attente : cette session, ouverte avant, ne les a pas reçues. + cache_env_reload() + "; " ) @staticmethod def _qemu_vm_ready_report(): """Relevé de mise en route, pour une VM où l'on n'installe RIEN. Sans lui, la commande distante valait « true » : le suivi affichait un ✅ instantané sur un journal vide, ce qui n'apprend rien de la machine qu'on vient de créer. Ici, il y a une fin claire (le marqueur de sortie que pose le lanceur) et de quoi juger qu'elle est prête : système, noyau, adresse, disque, mémoire, et le verdict de cloud-init. """ return ( f'echo "===> {t("VM start-up")}"; ' ". /etc/os-release 2>/dev/null || true; " f'echo " {t("system:")} ${{PRETTY_NAME:-?}}"; ' f'echo " {t("kernel:")} $(uname -r) ($(uname -m))"; ' f'echo " {t("address:")} ' "$(hostname -I 2>/dev/null | awk '{print $1}')\"; " f'echo " {t("disk:")} ' '$(df -h / | awk \'NR==2 {print $3"/"$2" ("$5")"}\')"; ' f'echo " {t("memory:")} ' '$(free -h 2>/dev/null | awk \'NR==2 {print $3"/"$2}\')"; ' f'echo " {t("uptime:")} $(uptime -p 2>/dev/null || true)"; ' f'echo "<=== {t("VM start-up")}"; ' ) @staticmethod def _qemu_no_auto_upgrade(prod, app_store="deb"): """Coupe les mises à jour automatiques sur une VM de DÉVELOPPEMENT. Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa connexion et la base intermédiaire est restée à moitié migrée. Effet secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou apt pendant l'installation. En PROD on ne touche à rien : les correctifs de sécurité automatiques doivent rester actifs.""" if prod: return "" return ( "if command -v apt-get >/dev/null 2>&1; then " # Les SERVICES autant que les minuteurs. « disable --now » sur un # minuteur l'empêche de repartir mais n'interrompt pas l'apt-get # qu'il a DÉJÀ lancé : celui-ci garde /var/lib/apt/lists/lock # jusqu'au bout de sa mise à jour, et l'installation qui suit # répète « Impossible d'obtenir le verrou » pendant des minutes. "sudo systemctl stop apt-daily.service apt-daily-upgrade.service " ">/dev/null 2>&1 || true; " "sudo systemctl disable --now unattended-upgrades.service " "apt-daily.timer apt-daily-upgrade.timer " ">/dev/null 2>&1 || true; " 'printf \'APT::Periodic::Update-Package-Lists "0";\\n' 'APT::Periodic::Unattended-Upgrade "0";\\n\' ' "| sudo tee /etc/apt/apt.conf.d/99-erplibre-no-auto-upgrade " ">/dev/null; " "fi; " "if command -v dnf >/dev/null 2>&1; then " "sudo systemctl disable --now dnf-automatic.timer " "dnf-automatic-install.timer >/dev/null 2>&1 || true; " "fi; " # snapd : 57 s sur le CHEMIN CRITIQUE du démarrage, mesurés par # « systemd-analyze critical-chain » sur une VM s390x — # multi-user.target attend snapd.seeded. C'est du temps payé pour # rien quand aucun snap n'est voulu. On désactive plutôt que # désinstaller, pour rester réversible d'un « systemctl enable ». # # Sauf si le magasin RETENU est snap : le couper puis laisser un # postinst appeler « snap install » est exactement ce qui figeait # une VM graphique trente minutes durant. + ( "" if app_store == "snap" else "sudo systemctl disable --now snapd.seeded.service " "snapd.service snapd.socket snapd.apparmor.service " ">/dev/null 2>&1 || true; " ) ) # Miroirs openSUSE préférés, du plus proche au dernier recours. Le # redirecteur officiel n'est PAS géographique pour cette distribution : # mesuré depuis Montréal sur les métadonnées oss s390x (15 Mo), # download.opensuse.org met 23,8 s — il sert depuis l'Europe — contre # 2,7 s pour mirrors.rit.edu. Les trois familles dnf, elles, choisissent # déjà un miroir canadien toutes seules ; rien à faire de ce côté. # # Chaque miroir est SONDÉ sur le chemin de l'architecture ET du produit # courants, puis le premier qui répond gagne. C'est nécessaire : aucun ne # réplique tout. Relevé le 2026-08-12 — # csclub Leap oui, Tumbleweed non (404) # rit.edu zsystems oui ; injoignable ce jour-là (curl 7) # leaseweb Tumbleweed x86_64 et Leap oui, ports zsystems non # D'où plusieurs entrées plutôt qu'une : avec la seule rit.edu, sa panne # renvoyait tout le monde sur download.opensuse.org, servi d'Europe. # Ordonnées par proximité de Montréal. Aucun sondage concluant : on garde # les dépôts de l'image, donc le comportement d'avant. _QEMU_ZYPPER_MIRRORS = ( "https://mirror.csclub.uwaterloo.ca/opensuse", "https://mirrors.rit.edu/opensuse", "https://mirror.us.leaseweb.net/opensuse", ) # Miroirs Arch canadiens, du plus rapide au suivant. Mesuré depuis # Montréal sur extra.db : quantum5 2,0 s, xenyth 7,1 s, contre 8,0 s pour # geo.mirror.pkgbuild.com — le miroir « géographique » officiel n'est donc # pas le meilleur ici. Arch n'est proposé qu'en amd64 dans le catalogue, # et ces deux-là ne servent que x86_64 (Arch Linux ARM a ses propres # miroirs) : la garde d'architecture le dit quand même. _QEMU_PACMAN_MIRRORS = ( "https://mirror.quantum5.ca/archlinux/$repo/os/$arch", "https://mirror.xenyth.net/archlinux/$repo/os/$arch", ) def _qemu_pacman_mirror_cmd(self): """Place les miroirs canadiens EN TÊTE de la mirrorlist. reflector écrase le fichier avec « --save » : il faut donc écrire après lui, pas avant. Ses miroirs restent dessous, comme repli.""" # « \\n » et non un vrai saut de ligne : la commande distante est UNE # chaîne, passée à bash -c après shlex.quote. Un retour littéral y # survivrait, mais rendrait la chaîne illisible et fragile à relire. # « $repo » et « $arch » restent littéraux : c'est pacman qui les # substitue, d'où les guillemets SIMPLES autour du format. lines = "".join(f"Server = {m}\\n" for m in self._QEMU_PACMAN_MIRRORS) first = self._QEMU_PACMAN_MIRRORS[0].split("/")[2] return ( '[ "$(uname -m)" = x86_64 ] && { ' # Idempotent : la préparation Arch passe deux fois quand une VM # est graphique (bureau puis ERPLibre), et empiler les mêmes # miroirs à chaque passage allongerait la liste sans rien gagner. f'grep -q "{first}" /etc/pacman.d/mirrorlist 2>/dev/null || {{ ' f"printf '{lines}' | sudo tee /etc/pacman.d/mirrorlist.el " "> /dev/null; " "sudo sh -c 'cat /etc/pacman.d/mirrorlist " ">> /etc/pacman.d/mirrorlist.el " "&& mv /etc/pacman.d/mirrorlist.el /etc/pacman.d/mirrorlist'; " "}; }; " ) def _qemu_pacman_prepare_cmd(self): """Préparation Arch : verrou, miroirs proches, mise à jour COMPLÈTE. Les trois sont indissociables, et il faut les faire AVANT la moindre installation. Une image cloud Arch est un instantané dont la base de paquets pointe des versions déjà retirées des miroirs : « pacman -S » s'y arrête sur « failed retrieving file … 404 » — vécu sur llvm-libs et perl. Arch ne supporte pas la mise à jour partielle. Ce bloc ne vivait QUE dans le chemin ERPLibre. Or le bureau s'installe AVANT lui : une VM graphique échouait donc toujours, sans jamais atteindre le code qui l'aurait sauvée.""" return ( "if command -v pacman >/dev/null 2>&1; then " # Verrou périmé (cloud-init interrompu) : le retirer SEULEMENT si # aucun pacman ne tourne, sinon on attend qu'il se libère. "pgrep -x pacman >/dev/null 2>&1 " "|| sudo rm -f /var/lib/pacman/db.lck; " # reflector d'abord, nos miroirs ensuite : « --save » écrase le # fichier, écrire avant lui ne servirait à rien. "sudo pacman -Sy --needed --noconfirm reflector || true; " "sudo reflector --latest 20 --protocol https --sort rate " "--save /etc/pacman.d/mirrorlist || true; " + self._qemu_pacman_mirror_cmd() + "sudo pacman -Syu --noconfirm || true; " "fi; " ) @staticmethod def _qemu_yay_install_cmd(): """Pose yay, l'assistant AUR, sur un invité Arch. « yay-bin » et non « yay » : le paquet source compile son propre Go, ce qui coûte plusieurs minutes et le compilateur avec ; le binaire précompilé donne le même outil. makepkg REFUSE de tourner en root et sort en erreur ; le clonage et la construction restent donc sous l'utilisateur de la VM, qui appelle sudo pour la seule installation finale. Le NOPASSWD posé par cloud-init rend ce sudo silencieux. yay est un bonus, pas une condition : le bloc se termine par « true » pour qu'un AUR injoignable ne fasse pas échouer, sous « set -e », une installation par ailleurs complète. """ return ( "command -v yay >/dev/null 2>&1 || { " "sudo pacman -S --needed --noconfirm base-devel git && " "yd=$(mktemp -d) && " "git clone --depth 1 https://aur.archlinux.org/yay-bin.git " '"$yd" && ( cd "$yd" && makepkg -si --noconfirm ); ' # « rm -rf » sur une variable vide rend 0 en silence sous -f : le # nettoyage n'a donc pas besoin de savoir si le clonage a eu lieu. 'rm -rf "$yd"; ' # « || true » ferme le groupe ENTIER, et il porte. Le groupe est # le DERNIER membre de la liste « || », donc set -e s'y applique # et le premier sudo en échec emporterait toute l'installation. # Un membre de plus l'y suspend, et rend le bloc inoffensif. "} || true; " "command -v yay >/dev/null 2>&1 " '&& echo " yay installé" || echo " ⚠ yay non installé"; ' ) def _qemu_zypper_mirror_cmd(self): """Réécrit l'hôte des dépôts zypper vers un miroir plus proche.""" mirrors = " ".join(self._QEMU_ZYPPER_MIRRORS) # Leap et Tumbleweed n'ont pas le même arbre de dépôts : la rolling # isole les architectures secondaires sous /ports/, Leap 16 unifie tout # et garde s390x dans l'arbre principal (les /ports/ y rendent 404). return ( ". /etc/os-release; " 'case "$ID" in *tumbleweed*) zp=tumbleweed; ' '[ "$(uname -m)" = s390x ] && zp=ports/zsystems/tumbleweed;; ' '*) zp="distribution/leap/$VERSION_ID";; esac; ' f"for zm in {mirrors}; do " "if curl -fsS --max-time 20 -o /dev/null " '"$zm/$zp/repo/oss/repodata/repomd.xml"; then ' "sudo sed -i " '"s|https\\?://download\\.opensuse\\.org|$zm|g" ' "/etc/zypp/repos.d/*.repo 2>/dev/null || true; " f'echo " {t("openSUSE mirror:")} $zm"; break; fi; done; ' ) @staticmethod def _qemu_tunnel_hint(port, kind): """Deux lignes imprimees DANS la VM : le tunnel a monter depuis le poste de travail, avec l'adresse deja remplie. Un port annonce sans chemin pour y arriver n'aide personne : le reseau libvirt n'est pas route depuis l'exterieur de son hote.""" local = port + 1 return ( "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " f'echo " {t("From your workstation:")} ' f'ssh -L {local}:$ip:{port} @"; ' f'echo " {t("then point your client at")} ' f'localhost:{local} ({kind})"; ' ) def _qemu_desktop_remote_cmd(self, flavour="gnome", app_store="deb"): """Bloc shell installant le bureau choisi + son accès distant, quelle que soit la distribution. Même aiguillage que l'installation ERPLibre, et même traitement du verrou apt : cette étape passe par la commande distante et non par cloud-init, où ses 1 à 2 Go allongeraient un démarrage déjà long sans laisser la moindre trace dans le suivi.""" de = self._QEMU_DESKTOP.get(flavour) or self._QEMU_DESKTOP["gnome"] rem = self._QEMU_DESKTOP_REMOTE label = de["label"] return ( f'echo "== {t("Installing the desktop (long):")} {label} =="; ' "if command -v apt-get >/dev/null 2>&1; then " # « DPkg::Lock::Timeout » ne couvre PAS le verrou des listes : # il ne vaut que pour celui de dpkg. « apt-get update » échoue # donc en moins d'une seconde quand une tâche quotidienne le # tient, et dormir dix secondes entre deux essais coûte des # minutes à ne rien faire. On repasse plus souvent, et on rend la # main dès que le verrou se libère. # Bornée par le TEMPS, et non par un nombre d'essais. Un essai # coûte moins d'une seconde quand le verrou est tenu, mais des # MINUTES quand le cache répond 504 sur chaque index : soixante # essais valaient alors des heures d'attente muette, là où on # voulait cinq minutes. "fin=$(( $(date +%s) + 300 )); " "until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " '[ "$(date +%s)" -ge "$fin" ] && ' # Le dire ICI. Sans cette ligne, l'installation continue sur un # index jamais rafraîchi et échoue plus bas sur « Impossible de # trouver le paquet », qui accuse le dépôt et non le verrou. f'{{ echo " ⚠ {t("apt-get update never succeeded in 5 min (lock held, or nothing served)")}"; ' "break; }; sleep 2; done; " "sudo DEBIAN_FRONTEND=noninteractive " "apt-get -o DPkg::Lock::Timeout=600 install -y " f"{de['apt']} {rem['apt']['packages']} " f"{self._qemu_apt_store_pkgs(app_store)}; " "elif command -v dnf >/dev/null 2>&1; then " # Cascade d'environnements : le premier qui existe gagne. Un # environnement absent fait rendre 1 à dnf sans rien installer, # d'où le « || » plutôt qu'une détection préalable. "de_ok=0; " f"for e in {de['dnf_env']}; do " 'sudo dnf -y group install "$e" && { de_ok=1; break; }; done; ' '[ "$de_ok" = 1 ] || echo "Aucun environnement graphique dnf ' "trouve pour " + label + '"; ' f"sudo dnf install -y {rem['dnf']['packages']}; " "elif command -v pacman >/dev/null 2>&1; then " "pgrep -x pacman >/dev/null 2>&1 " "|| sudo rm -f /var/lib/pacman/db.lck; " + self._qemu_pacman_prepare_cmd() + f"sudo pacman -S --needed --noconfirm {de['pacman']} " f"{rem['pacman']['packages']}; " "elif command -v zypper >/dev/null 2>&1; then " "sudo zypper --non-interactive refresh || true; " # « --auto-agree-with-licenses » appartient à la SOUS-COMMANDE # install, pas aux options globales : placé avant, zypper répond # « The flag --auto-agree-with-licenses is not known ». "sudo zypper --non-interactive install " f"--auto-agree-with-licenses {de['zypper']} " f"{rem['zypper']['packages']}; " "else echo 'Gestionnaire de paquets inconnu'; exit 1; fi; " # Le bureau ne sert à rien s'il ne démarre pas tout seul : les # images cloud démarrent en multi-user.target. "sudo systemctl set-default graphical.target || true; " f"sudo systemctl enable {de['service']} >/dev/null 2>&1 || true; " # Et il faut le DÉMARRER, pas seulement l'activer. Deux raisons, # toutes deux mesurées sur erplibre-ubuntu-2604-gnome : # # - graphical.target était DÉJÀ atteinte quand le paquet est # arrivé, et une cible active ne rattrape pas un service ajouté # après coup : display-manager.service est resté inactif ; # - sur Debian et Ubuntu, « systemctl enable gdm » rend 0 sans # rien faire — l'unité n'a pas de « WantedBy », seulement # « Alias=display-manager.service » que le paquet a déjà posé. # # Résultat : GNOME installé, gdm3 installé, cible graphique par # défaut… et la console de la VM restait en mode texte jusqu'au # premier redémarrage. L'écran, c'est justement ce qu'on est venu # chercher sur une VM graphique. "if sudo systemctl start display-manager.service 2>/dev/null || " f"sudo systemctl start {de['service']} 2>/dev/null; then " f'echo " {t("graphical session started")}"; ' f'else echo " ⚠ {t("graphical session not started; reboot the VM")}"; ' "fi; " # xrdp là où il existe ; sur Arch c'est TigerVNC, qui se configure # par utilisateur et n'a pas de service à activer d'office. "if command -v xrdp >/dev/null 2>&1; then " "sudo systemctl enable --now xrdp >/dev/null 2>&1 || true; " f'echo " {t("Remote desktop:")} RDP 3389"; ' # L'IP est sur le reseau PRIVE de libvirt : annoncer le port sans # dire comment l'atteindre ne sert a rien. La VM connait sa propre # adresse ; seul le nom de l'hote libvirt manque, et c'est le # lecteur qui l'a. La console SPICE, elle, est en « listen=none » # et suppose virt-viewer SUR l'hote — inutilisable quand cet hote # est lui-meme une VM sans interface graphique. + self._qemu_tunnel_hint(3389, "RDP") + "elif command -v vncserver >/dev/null 2>&1; then " f'echo " {t("Remote desktop:")} VNC 5901 ' '(vncpasswd puis vncserver :1)"; ' + self._qemu_tunnel_hint(5901, "VNC") + "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_VM_TOOLS = { "pycharm": { "label": "PyCharm", "help": ( "JetBrains archive in /opt, launcher /usr/local/bin/pycharm", "the checkout as project, .idea written like pycharm_configure", ), "hint": "Python IDE, opens the ERPLibre checkout", "disk_gb": 5, "arches": ("amd64", "arm64"), "desktops": (), "needs_desktop": True, # L'archive JetBrains est liée dynamiquement : elle ne s'exécute pas # sur un système sans /lib64/ld-linux. Les quatre familles # impératives, donc, et pas « toutes ». "families": ("apt", "dnf", "pacman", "zypper"), "phase": "before", }, "android": { "label": "Android Studio", "help": ( "Android Studio in /opt, desktop launcher and studio command", "x86_64 only: Google publishes no other build", ), "hint": "ERPLibre mobile development (x86_64 only)", "disk_gb": 8, "arches": ("amd64",), "desktops": (), "needs_desktop": True, # Même raison que PyCharm : archive amont liée dynamiquement. "families": ("apt", "dnf", "pacman", "zypper"), "phase": "before", }, "gnome_ext": { "label": "GNOME extensions", "help": ( "the extension manager, plus the suggested extensions", "distribution packages installed without being enabled", "extensions named by UUID are enabled", ), "hint": "suggested extensions + extension manager", "disk_gb": 1, "arches": (), "desktops": ("gnome",), "needs_desktop": True, # Les paquets viennent des dépôts de la distribution, et le # gestionnaire d'extensions n'existe qu'à travers eux. "families": ("apt", "dnf", "pacman", "zypper"), "phase": "before", }, # Le seul outil qui ne demande PAS de bureau : il compile, il n'affiche # rien. Une VM serveur le prend, une VM graphique aussi — et sur # celle-ci le SDK est partagé avec Android Studio plutôt que doublé. # # « families » le borne à apt, et ce n'est pas un choix : l'installateur # du dépôt mobile, install-android.sh, commence par # « sudo apt install openjdk-17-jdk ». Ailleurs il s'arrête là. Lever # cette limite se fait dans CE script-là, pas ici. # # Disque : ~1,5 Go de SDK et plateformes, ~2,5 Go de NDK, whisper.cpp # et sentencepiece clonés, node_modules, et les artefacts Gradle. "mobile": { "label": "ERPLibre mobile (build)", "help": ( "Android SDK, then the debug APK and the Vitest run", "a failed build marks the VM as failed", ), "hint": "APK debug + Vitest, validates the VM", "disk_gb": 12, "arches": ("amd64",), "desktops": (), "needs_desktop": False, "families": ("apt",), # APRÈS l'installation : le build a besoin du dépôt mobile, que le # manifeste ajoute, et du venv d'outils pour le synchroniser. "phase": "after", }, # Forgejo est un SERVICE, pas un outil de bureau : une VM serveur le # prend aussi bien qu'une VM graphique. Son binaire est STATIQUE — le # même fichier sur apt, dnf, pacman et zypper — donc aucune famille de # paquets n'est exclue, et c'est ce qui le rend portable sur toutes les # plateformes ERPLibre sans une branche par distribution. # # Les architectures, elles, sont bornées par l'amont : Forgejo publie # amd64, arm64 et arm-6, et RIEN pour s390x. Sur celle-là il faudrait le # bâtir en Go ; la case se grise plutôt que de poser un binaire qui ne # s'exécute pas. # # Disque : ~115 Mo de binaire (34 Mo téléchargés en .xz), la base SQLite # et les dépôts que l'utilisateur y poussera. "forgejo": { "label": "Forgejo (git forge)", "help": ( "git forge on port 3000, SQLite database", "system account, systemd service, admin account", "posed by script/forgejo/install_forgejo.sh", ), "hint": "self-hosted git forge on :3000, SQLite", "disk_gb": 2, "arches": ("amd64", "arm64"), "desktops": (), "needs_desktop": False, # Binaire statique, mais son installateur écrit dans /usr/local, # crée un compte système et pose une unité systemd à la main : # rien de tout cela n'a de sens sur un système déclaratif. "families": ("apt", "dnf", "pacman", "zypper"), # APRÈS l'installation : le script vit dans le dépôt, donc après le # clone. Rien d'autre ne l'y oblige — Forgejo ne dépend ni du venv # ni d'Odoo. "phase": "after", }, # Ni bureau ni famille de paquets : l'essentiel vient d'installateurs # amont, qu'aucun dépôt de distribution ne porte, et les trois outils # de terminal (tig, htop, vim) existent sous le même nom dans les # quatre. Une VM serveur le prend donc aussi bien qu'une VM graphique # — c'est en SSH qu'on s'en sert. # # L'outil travaille en DEUX temps : les installations avant le clone, # puis ce qui a besoin du dépôt — hooks git et commandes Claude. La # phase déclarée ici est la première ; la seconde est un complément # gardé, ajouté par _qemu_after_remote_cmd. # # Disque : les binaires sont petits (rtk et starship sont statiques, # l'agent est un bundle node) ; la marge couvre leurs caches. "aidev": { "label": "AI coding tools", "help": ( "tig, htop and vim", "rtk, plus its global auto-rewrite hook", "starship, hooked into ~/.bashrc", "the chosen agent: Claude Code or opencode", "git: merge.conflictStyle zdiff3, core.editor vim if unset", "git hooks of the checkout: commit-msg, pre-commit", "the Claude commands: /commit, /git_prepare_merge, /todo_*", "source .venv.erplibre/bin/activate in the shell history", ), "hint": "rtk, starship, one agent, git and Claude ready", "disk_gb": 2, "arches": (), "desktops": (), "needs_desktop": False, # Trois installateurs « curl | sh » qui posent des binaires liés # dynamiquement, et des paquets par le gestionnaire du # système : aucun des deux gestes n'existe sur NixOS. "families": ("apt", "dnf", "pacman", "zypper"), # AVANT le clone : chaque outil s'y garde lui-même, et aucun ne # doit faire échouer l'installation d'ERPLibre pour un curl qui # ne répond pas. "phase": "before", }, # Nix sur une distribution qui n'est pas NixOS : le gestionnaire de # paquets seul, posé à côté de celui du système, et l'installateur # qui s'en sert pour porter NixOS sur une AUTRE machine, jointe par # SSH — un serveur loué, une VM du parc, une carte ARM. # # « families » exclut « nix » et ce n'est pas un oubli : sur NixOS, # nix EST le système. L'option n'y aurait rien à poser. # # Disque : le store porte la fermeture d'un système NixOS complet et # le noyau kexec qui le démarre à distance. "nixanywhere": { "label": "nix + nixos-anywhere", "help": ( "the official multi-user installer, nix as a daemon", "experimental-features: nix-command and flakes", "nixos-anywhere, by nix profile install", "it installs NixOS on any machine reachable over SSH", ), "hint": ( "nix, flakes, and nixos-anywhere to install NixOS elsewhere" ), "disk_gb": 8, # L'amont bâtit ce qu'on lui demande plutôt que de servir un # binaire : les deux architectures que nix supporte pleinement. "arches": ("amd64", "arm64"), "desktops": (), "needs_desktop": False, "families": ("apt", "dnf", "pacman", "zypper"), # AVANT le clone, comme les autres poses amont : l'outil se garde # lui-même et ne fait échouer ni les autres ni ERPLibre. "phase": "before", }, # L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur # l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM # dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le # bloc vérifie et annonce plutôt que de laisser découvrir. # # Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus # l'émulateur lui-même. "avd": { "label": "Android emulator (Pixel)", "help": ( "Android SDK, then a Pixel AVD named erplibre", "software rendering: it opens over ssh -X", "needs nested virtualization on the host", ), "hint": "AVD viewable over ssh -X", "disk_gb": 6, "arches": ("amd64",), "desktops": (), "needs_desktop": False, "families": ("apt",), "phase": "after", }, } # Famille de paquets de chaque distribution, pour borner un outil à ce qui # sait l'installer. _QEMU_DISTRO_FAMILY = { "ubuntu": "apt", "debian": "apt", "fedora": "dnf", "almalinux": "dnf", "rocky": "dnf", "opensuse": "zypper", "arch": "pacman", "nixos": "nix", } def _qemu_guest_context(self): """Ce que les DEUX écrans de déploiement doivent savoir du système invité : type de VM, magasin d'applications, outils, fuseaux, Python. Une seule méthode et non deux blocs jumeaux dans les constructeurs de contexte : c'est en n'en remplissant qu'un que l'écran Proxmox avait perdu la moitié des réglages. Rien ici ne parle d'hyperviseur — c'est exactement ce qui rend le bloc commun.""" outils = self._QEMU_VM_TOOLS return { "desktops": [ (k, v["label"]) for k, v in self._QEMU_DESKTOP.items() ], "desktop_suffixes": self._qemu_desktop_suffixes(), "desktop_disk_gb": self.QEMU_DESKTOP_EXTRA_DISK_GB, "app_stores": [(k, t(lbl)) for k, lbl in self.QEMU_APP_STORES], "snap_distros": self.QEMU_SNAP_DISTROS, "timezone": self._qemu_host_timezone(), "timezones": self._qemu_timezone_choices( self._qemu_host_timezone() ), "mise_arches": self.QEMU_MISE_ARCHES, "vm_tools": self._qemu_vm_tool_choices(), "vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()}, # Ce que l'aide « ? » du formulaire affiche, outil par outil : # une case cochée engage parfois huit poses, et son libellé n'en # nomme que trois. "vm_tool_help": { k: tuple(t(x) for x in v.get("help", ())) for k, v in outils.items() }, "vm_tool_arches": {k: v["arches"] for k, v in outils.items()}, "vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()}, # « after » = l'outil vit DANS le dépôt ERPLibre (compilation # mobile, AVD, script Forgejo) : sans installation, il n'existe # pas, et la commande distante le saute en le nommant. "vm_tool_phases": { k: v.get("phase", "before") for k, v in outils.items() }, "vm_tool_needs_desktop": { k: v["needs_desktop"] for k, v in outils.items() }, "vm_tool_families": {k: v["families"] for k, v in outils.items()}, "distro_family": dict(self._QEMU_DISTRO_FAMILY), "defaults": { "install": True, "add_ssh_config": True, "monitor": True, "prod": False, # L'identité git que la VM reçoit AUJOURD'HUI, celle de # l'hôte : les champs la montrent plutôt que de s'ouvrir # vides, ce qui la ferait croire absente. Les laisser vides # garde ce comportement, les modifier le remplace. "ai_agent": dev_tools.AGENT_DEFAUT, "git_name": self._qemu_host_git("user.name"), "git_email": self._qemu_host_git("user.email"), }, } def _qemu_host_git(self, cle): """Valeur globale « git config » de l'hôte, ou ''. Passe par deploy_qemu, seule autorité sur cette lecture : git accepte DEUX emplacements pour sa configuration globale et le script sait lequel interroger. Sans module importable on ne devine pas — un champ vide reprend l'identité de l'hôte au déploiement, ce qui est déjà le comportement par défaut. """ try: mod = self._qemu_import_module() return mod._git_global(cle, mod.invoking_home()) or "" except Exception: return "" @classmethod def _qemu_vm_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_VM_TOOLS.items() ] @classmethod def _qemu_tools_for(cls, tools, arch, desktop, distro="", phase=""): """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, les extensions GNOME n'ont pas de sens sous Cinnamon, et la compilation mobile ne sait s'installer que sur les distributions apt. Filtrer ici plutôt que dans la commande distante évite d'annoncer une installation qui ne se fera pas. `phase` restreint au moment d'exécution : « before » avant le clone, « after » après l'installation. Vide, les deux sont rendus.""" out = [] for key in tools or (): spec = cls._QEMU_VM_TOOLS.get(key) if not spec: continue if spec["needs_desktop"] and not desktop: continue if spec["arches"] and arch not in spec["arches"]: continue if spec["desktops"] and desktop not in spec["desktops"]: continue family = cls._QEMU_DISTRO_FAMILY.get(distro, "") if spec["families"] and distro and family not in spec["families"]: continue if phase and spec["phase"] != phase: continue out.append(key) return out @classmethod def _qemu_tools_disk_gb(cls, tools, arch, desktop, distro=""): """Go à ajouter au disque pour les outils applicables à cette VM.""" return sum( cls._QEMU_VM_TOOLS[k]["disk_gb"] for k in cls._qemu_tools_for(tools, arch, desktop, distro) ) # 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). # # La ligne COMMUNITY, et non le produit unifié. Mesuré dans une VM : # « code=PCC&latest » sert maintenant pycharm-2025.3, le build unifié, qui # s'arrête sur sa licence — son journal dit « NoValidIdeLicense » puis # « Get licenses: request requires authentication », et le projet ne # s'ouvre jamais. Aucune ouverture, donc aucun .idea, donc rien à # configurer ensuite. Community ne demande aucun compte, et elle est # toujours publiée et corrigée : 2025.2.6.2 date du 2026-07-29. # # Aucun numéro figé ici : on prend la plus récente archive # « pycharm-community- » du flux officiel des versions, pour # l'architecture de la VM. _QEMU_PYCHARM_FEED = ( "https://data.services.jetbrains.com/products/releases" "?code=PCC&type=release" ) # Repli quand le flux est injoignable : la redirection « dernière version ». # Elle sert le build unifié, donc on le DIT — l'utilisateur devra ouvrir un # compte JetBrains, et mieux vaut l'apprendre dans le journal qu'au premier # lancement. _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 " # Déjà posé ? On ne retélécharge pas. Rejouer une # installation est le cas NORMAL — une qui est morte, un outil # ajouté après coup — et le téléchargement en est la partie # longue : mesuré, ~5 min pour Android Studio, autant pour # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) # rejoue de toute façon, lui est idempotent et bon marché. "if [ -x /opt/pycharm/bin/pycharm.sh ]; then " f'echo " {t("already there, download skipped")}"; ' "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. # Le flux dit quelle archive Community prendre pour cette # architecture. En python plutôt qu'en shell : il fait la requête, # lit le JSON et rend une ligne — sans jq, absent des images cloud. "url=$(python3 - \"$jb\" <<'ELPYJB'\n" "import json, sys, urllib.request\n" "key = sys.argv[1]\n" "try:\n" f' with urllib.request.urlopen("{self._QEMU_PYCHARM_FEED}",\n' " timeout=30) as fh:\n" " data = json.load(fh)\n" "except Exception:\n" " sys.exit(0)\n" 'for rel in data.get("PCC", []):\n' ' link = (rel.get("downloads") or {}).get(key, {}).get("link", "")\n' ' if "pycharm-community-" in link:\n' " print(link)\n" " break\n" "ELPYJB\n" "); " 'if [ -z "$url" ]; then ' f'url="{self._QEMU_PYCHARM_URL}$jb"; ' f'echo " {t("release feed unreachable: unified build, it will ask for a JetBrains account")}"; ' "fi; " "tmp=$(mktemp -p /var/tmp pycharm-XXXX.tar.gz) && " 'curl -fsSL "$url" -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; 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)")}"; ' ) # Serveur X virtuel, par gestionnaire de paquets. Les noms ne se # ressemblent pas d'une famille à l'autre — relevés dans chaque dépôt, pas # devinés. _QEMU_XVFB_PKG = { "apt": "xvfb", "dnf": "xorg-x11-server-Xvfb", "zypper": "xorg-x11-server-Xvfb", "pacman": "xorg-server-xvfb", } # Attente maximale du .idea, en tours de 5 s — cinq minutes. Mesuré sur une # VM Ubuntu 26.04 à 16 Go : le projet est écrit en 195 s, indexation du # dépôt en cours. On n'attend donc PAS la fin de cette indexation, qui dure # bien plus et dont personne n'a besoin ici : pycharm_configuration.py ne # réclame que le .iml et misc.xml. _QEMU_PYCHARM_OPEN_TRIES = 60 def _qemu_pkg_install_cmd(self, paquets, quiet=True): """Pose des paquets avec le gestionnaire de paquets présent dans la VM. `paquets` : une chaîne (ou une suite) quand le nom vaut pour les quatre familles, ou {apt, dnf, zypper, pacman} quand il diverge de l'une à l'autre. `quiet` renvoie la sortie au néant, pour ce qui n'a rien à raconter. Chaque branche rend 0, y compris celle qui échoue : les appelants posent du CONFORT — un serveur X sans écran, des outils de terminal — et aucun ne doit emporter l'installation d'ERPLibre. Le bloc rend 0 aussi sur une machine qui n'a aucun des quatre gestionnaires, ce qui laisse « set -e » tranquille. """ if isinstance(paquets, dict): noms = paquets else: liste = paquets if isinstance(paquets, str) else " ".join(paquets) noms = dict.fromkeys(("apt", "dnf", "zypper", "pacman"), liste) muet = " >/dev/null 2>&1" if quiet else "" return ( "if command -v apt-get >/dev/null 2>&1; then " "sudo DEBIAN_FRONTEND=noninteractive apt-get " f"-o DPkg::Lock::Timeout=600 install -y {noms['apt']}" f"{muet} || true; " "elif command -v dnf >/dev/null 2>&1; then " f"sudo dnf install -y {noms['dnf']}{muet} || true; " "elif command -v zypper >/dev/null 2>&1; then " "sudo zypper --non-interactive install --auto-agree-with-licenses " f"{noms['zypper']}{muet} || true; " "elif command -v pacman >/dev/null 2>&1; then " f"sudo pacman -S --needed --noconfirm {noms['pacman']}" f"{muet} || true; fi; " ) def _qemu_xvfb_install_cmd(self): """Pose Xvfb avec le gestionnaire de paquets présent, sans bruit.""" return self._qemu_pkg_install_cmd(self._QEMU_XVFB_PKG) def _qemu_pycharm_project_cmd(self, prod=False): """Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran. C'est PyCharm, et lui seul, qui écrit ce répertoire : ni le dépôt ni pycharm_configuration.py ne savent le fabriquer — ce dernier exige un .iml puis un misc.xml, et s'arrête sinon. Sans cette ouverture, l'étape pycharm_update() de l'installation ne trouve rien à configurer. Xvfb parce que l'IDE réclame un affichage, même pour ouvrir un projet et s'arrêter. Il tourne DANS la VM : l'hôte qui orchestre n'a besoin d'aucune bibliothèque graphique, et rien ne transite par « ssh -X ». TROIS fenêtres bloqueraient une session où personne ne peut cliquer, et chacune a été rencontrée avant d'être écartée : politique de confidentialité, partage de données, et surtout « faites-vous confiance à ce projet ? ». C'est celle-là qui figeait tout — le journal s'arrêtait 1,3 s après le démarrage, sans jamais ouvrir le projet, et il a fallu « idea.trust.all.projects » pour le débloquer. Le consentement, lui, est écrit REFUSÉ : aucune statistique ne part. Mesuré sur une VM Ubuntu 26.04 à 16 Go : .idea complet en 195 s, et pycharm_configuration.py écrit ensuite ses exclusions dans le .iml. Tout est gardé. Sans Xvfb, sans PyCharm, ou sans .idea au bout du délai, on le dit et l'installation continue : elle n'en dépend pas, elle en profite seulement. """ el_dir = self._qemu_install_dir(prod) return ( f'echo "== {t("Creating the PyCharm project (first open)")} =="; ' "{ if ! command -v pycharm >/dev/null 2>&1; then " f'echo " {t("PyCharm missing, step skipped")}"; false; ' "else " "command -v xvfb-run >/dev/null 2>&1 || { " + self._qemu_xvfb_install_cmd() + "}; " "if ! command -v xvfb-run >/dev/null 2>&1; then " f'echo " {t("no Xvfb here, open PyCharm by hand")}"; false; ' "else " # Réponses aux fenêtres de première ouverture. En python plutôt # qu'en shell : l'horodatage en millisecondes et le « » de # la propriété se passeraient mal de guillemets imbriqués. "python3 - <<'ELPYC' || true\n" "import pathlib, time\n" "h = pathlib.Path.home()\n" 'c = h / ".local/share/JetBrains/consentOptions"\n' "c.mkdir(parents=True, exist_ok=True)\n" '(c / "accepted").write_text(\n' ' "rsch.send.usage.stat:1.1:0:%d\\n" % (time.time() * 1000)\n' ")\n" '(h / ".pycharm-headless.vmoptions").write_text(\n' ' "-Djb.privacy.policy.text=\\n"\n' ' "-Djb.consents.confirmation.enabled=false\\n"\n' ' "-Didea.trust.all.projects=true\\n"\n' ' "-Didea.suppress.statistics.report=true\\n"\n' ")\n" "ELPYC\n" # « setsid » donne au tout son PROPRE groupe de processus, et # c'est le groupe qu'on tuera. Sans lui, « $! » est le PID de # xvfb-run — un script — et le tuer n'atteint ni PyCharm, ni Xvfb, # ni les cef_server qu'il a lancés. Mesuré sur # erplibre-ubuntu-2604-gnome : PyCharm tournait encore 45 minutes # plus tard avec 1,9 Go, et la compilation de l'APK qui suivait # s'est fait tuer par le noyau, faute de mémoire. # Les watches inotify AVANT d'ouvrir : le dépôt mobile pose # 123 000 fichiers d'assets, et la limite par défaut est dépassée # dès l'analyse — « inotify_add_watch(...): No space left on # device », puis « watch root cannot be watched: -2 », puis aucun # .idea écrit. Mesuré sur erplibre-ubuntu-2604-gnome, deux fois. # 524288 est la valeur que JetBrains documente lui-même. "cur=$(cat /proc/sys/fs/inotify/max_user_watches 2>/dev/null " '|| echo 0); if [ "$cur" -lt 524288 ] 2>/dev/null; then ' 'echo "fs.inotify.max_user_watches=524288" ' "| sudo tee /etc/sysctl.d/60-erplibre-inotify.conf >/dev/null && " "sudo sysctl -q -p /etc/sysctl.d/60-erplibre-inotify.conf " f'2>/dev/null; echo " {t("inotify watches raised for the IDE")}"; ' "fi; " # DEUX tentatives, et c'est mesuré : la première ouverture d'un # dépôt neuf indexe 212 000 fichiers, plante son configurateur # d'interpréteur (« PythonSdkConfigurator - homeDir is null ») et # n'écrit AUCUN .idea, même au bout de cinq minutes. La seconde, sur # les caches que la première a laissés, l'écrit en 25 secondes — # constaté sur deux VM différentes. ": > /tmp/pycharm-first-run.log; " "for attempt in 1 2; do " 'PYCHARM_VM_OPTIONS="$HOME/.pycharm-headless.vmoptions" ' f"setsid xvfb-run -a pycharm {el_dir} " ">> /tmp/pycharm-first-run.log 2>&1 & " "pid=$!; ok=0; " f"for i in $(seq 1 {self._QEMU_PYCHARM_OPEN_TRIES}); do " f"if ls {el_dir}/.idea/*.iml >/dev/null 2>&1 && " f"[ -f {el_dir}/.idea/misc.xml ]; then ok=1; break; fi; " "sleep 5; done; " # Cinq secondes de plus : les fichiers apparaissent PENDANT leur # écriture, et un TERM à l'instant où misc.xml naît le tronquerait. "sleep 5; kill -TERM -$pid 2>/dev/null || " "kill -TERM $pid 2>/dev/null; " "for i in $(seq 1 12); do kill -0 -$pid 2>/dev/null || break; " "sleep 5; done; kill -KILL -$pid 2>/dev/null; " # Filet, et il a sa raison d'être : ce qui survit ici mange la # mémoire de TOUTES les étapes suivantes. # # Par NOM de processus (« -x »), jamais par ligne de commande. Un # « pkill -f /opt/pycharm » attrape aussi le ssh QUI PORTE cette # installation — sa ligne de commande contient le script entier, # donc ce chemin. Vécu : une installation est morte en silence, sa # session ssh emportée, 48 minutes perdues. Mesuré ensuite : par # nom, 3 processus réels attrapés et 0 faux ; par ligne de commande, # 4 dont le ssh. Les noms sont ceux relevés dans la VM — pycharm, # Xvfb, fsnotifier, cef_server — et « -u » borne au compte courant. # # « pgrep -c » IMPRIME 0 et rend 1 quand il ne trouve rien : un # « || echo 0 » donnerait « 0\n0 », qui n'est pas « 0 ». « wc -l » # rend un seul nombre et un code 0. 'left=$(pgrep -u "$(id -u)" -x ' '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null | wc -l); ' '[ "$left" = 0 ] || { ' f'echo " {t("closing what survived the first open:")} $left"; ' 'pkill -u "$(id -u)" -x ' '"pycharm|cef_server|fsnotifier|Xvfb" 2>/dev/null; sleep 2; }; ' '[ "$ok" = 1 ] && break; ' f'echo " {t("no project yet, second try on the warm caches")}"; ' "done; " '[ "$ok" = 1 ]; fi; fi; } && ' f'echo " {t("project created, the install will configure it")}" ' f'|| echo " ⚠ {t("no .idea: open PyCharm once, then")} ' 'make pycharm_configure"; ' ) 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 " # Déjà posé ? On ne retélécharge pas. Rejouer une # installation est le cas NORMAL — une qui est morte, un outil # ajouté après coup — et le téléchargement en est la partie # longue : mesuré, ~5 min pour Android Studio, autant pour # PyCharm. Le reste de l'étape (lanceur, alias, raccourci) # rejoue de toute façon, lui est idempotent et bon marché. "if [ -x /opt/android-studio/bin/studio ]; then " f'echo " {t("already there, download skipped")}"; ' "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; 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;", ) + # Le SDK partagé, vu depuis la SESSION graphique. install-android.sh # écrit ses exports dans ~/.bashrc, que GNOME ne lit pas : Android # Studio lancé depuis le menu ne verrait donc pas le SDK et # proposerait d'en télécharger un second. environment.d est le # canal que la session utilisateur lit vraiment. "mkdir -p ~/.config/environment.d && " "printf 'ANDROID_HOME=%s/android\\nANDROID_SDK_ROOT=%s/android\\n'" ' "$HOME" "$HOME" ' "> ~/.config/environment.d/10-erplibre-android.conf; " # repositories.cfg absent, et l'assistant de première ouverture # s'arrête sur une erreur au lieu de proposer quoi que ce soit. "mkdir -p ~/.android && touch ~/.android/repositories.cfg; " + f'echo " {t("Android Studio installed:")} /opt/android-studio ' f'({t("command")} studio / android-studio)"; ' f'echo " {t("SDK shared through ANDROID_HOME:")} $HOME/android"; ' "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. Deux échecs, deux messages. Un téléchargement raté — réseau coupé, cache qui n'a pas l'archive — n'apprend rien sur la version de GNOME : le site sert une archive même à une version qu'il ne connaît pas. Seul un refus de « gnome-extensions install » la met en cause. Et l'appel à se reconnecter ne vient que si au moins une extension a été posée : sans quoi il n'y a rien à charger. """ 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; }; ' # `n` compte les extensions réellement posées. + f"n=0; for u in {uuids}; do " # L'archive ne va que dans un nom tiré par mktemp, créé par ce # compte seul : un nom fixe dans /var/tmp, ouvert à tous, pourrait # y être posé d'avance par un autre. L'affectation est DANS la # condition, si bien qu'un mktemp qui échoue ne fait pas tomber # « set -e » ; « z » reste alors vide, et l'extension est sautée # comme un téléchargement raté, sans rien à effacer. + "if ! z=$(mktemp -p /var/tmp gext-XXXX.zip 2>/dev/null); " + 'then z=""; fi; ' + 'if [ -z "$z" ] || ! curl -fsSL --max-time 120 "' + site + '/$u.shell-extension.zip?shell_version=$sv" -o "$z"; then ' + f'echo " ⚠ {t("download impossible (network or cache):")} ' + '$u"; ' + 'elif ! gx install --force "$z" >/dev/null 2>&1; then ' + f'echo " {t("not available for this GNOME, skipped:")} ' + '$u (GNOME $sv)"; ' + "else " + 'gx enable "$u" >/dev/null 2>&1 || true; n=$((n+1)); ' + f'echo " {t("installed and enabled:")} $u"; ' + 'fi; if [ -n "$z" ]; then rm -f "$z"; fi; done; ' + 'if [ "$n" -gt 0 ]; then ' + f'echo " {t("log out and back in to load them")}"; fi; ' + "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() ) # Diagnostic de la compilation mobile : motif rencontré dans le journal # détaillé -> cause nommée. Du plus précis au plus général, le premier qui # correspond gagne. # # Cette liste est faite pour GRANDIR. Une compilation Android échoue de # cent façons, et le journal fait des dizaines de mégaoctets : sans cette # traduction, « la VM est rouge » n'apprend rien et il faut tout rouvrir. # Chaque panne rencontrée sur une VM mérite d'y laisser sa ligne. _QEMU_MOBILE_DIAG = ( ("No space left on device", "disk full"), ("Failed to find target with hash string", "SDK platform missing"), ("SDK location not found", "SDK not found (ANDROID_HOME)"), ("have not been accepted", "SDK licences not accepted"), ("NDK not configured", "NDK missing"), # Vécu : Capacitor 8 réclame un JDK 21 quand l'installateur amont pose # un 17, et Gradle s'arrête là. ( "Cannot find a Java installation", "JDK required by the project missing", ), # Vécu aussi : le JDK est là, mais Gradle TOURNE sur un plus ancien. ("invalid source release", "Gradle running on too old a JDK"), ("cannot overwrite", "SDK already there (upstream installer replays)"), # Vécu : sentencepiece bâtit protoc pour la CIBLE et l'exécute sur # l'hôte. Le message est cryptique ; la cause, non. ("Exec format error", "cross-compiled protoc run on the host"), ("Unsupported class file major version", "JDK/Gradle mismatch"), ("Could not determine java version", "JDK/Gradle mismatch"), ( "Could not resolve all files for configuration", "Gradle dependency unreachable (network?)", ), ("npm ERR!", "npm dependencies"), ("Test Files", "Vitest tests failed"), # Vécu : le manifeste rend 0 sans avoir cloné, et l'étape suivante # tombe sur un cd impossible. Le motif nomme la vraie cause. # # SANS APOSTROPHE, et ce n'est pas cosmétique : ces motifs partent dans # un « grep -q '' », entre apostrophes. « can't cd to » fermait # la chaîne et rendait tout le bloc invalide — attrapé par bash -n. ("cd: can", "mobile repository missing"), # Vécu aussi : sans python3.12-venv, .venv.erplibre n'existe pas, et # rien de ce qui suit ne peut synchroniser le manifeste. ("virtual environment", "ERPLibre venv missing (incomplete install)"), ("No module named", "ERPLibre venv incomplete (no pip: python3-venv)"), # Vécu sur erplibre-ubuntu-2604-gnome : le noyau a tué le démon Gradle # (6,8 Go de RSS sur 12 Go, sans swap), et Gradle n'en sait rien — il # dit seulement que son démon « a disparu ». Le motif nomme la mémoire, # et le contexte l'établit au lieu de le supposer. # Vécu, et c'est en amont : « Too many zip entries 123678 (MAX=65535) ». # Un APK est un ZIP classique, borné à 65 535 entrées, et le dépôt # mobile embarque 122 684 fichiers sous assets/public/repos — des # dépôts Odoo entiers — pour 337 fichiers qui sont l'application. Rien # ici ne peut le corriger : c'est au projet mobile de ne pas les # empaqueter. On le NOMME, avec le chiffre, plutôt que de laisser lire # 5 000 lignes de Gradle. ( "Too many zip entries", "too many asset files for one APK (ZIP limit: 65535 entries)", ), ("daemon disappeared", "Gradle daemon killed: out of memory", "mmem"), ("Cannot allocate memory", "out of memory", "mmem"), ("Java heap space", "Gradle heap too small", "mmem"), ("FAILED", "Gradle task failed"), ) def _qemu_mobile_diag_cmd(self): """Fonction shell qui NOMME la cause d'un échec, à partir du journal. Un « la VM est rouge » n'apprend rien quand le journal fait des dizaines de mégaoctets. On cherche donc les motifs connus, et à défaut on montre les dernières lignes — c'est toujours mieux que rien. La recherche porte sur la FIN du journal, pas sur tout. Vécu : le diagnostic a annoncé « licences SDK non acceptées » quand la panne était un JDK manquant — le motif venait de la revue de licences d'une étape RÉUSSIE, trois étapes plus haut. Nommer la mauvaise cause coûte plus cher que se taire.""" lines = "" for entry in self._QEMU_MOBILE_DIAG: pat, cause = entry[0], entry[1] extra = f"{entry[2]}; " if len(entry) > 2 else "" lines += ( f"grep -q '{pat}' \"$d\" && {{ " f'echo " {t("probable cause:")} {t(cause)}"; ' f'{extra}rm -f "$d"; return 0; }}; ' ) return ( # Le contexte mémoire, lu dans /proc et dans le journal du noyau : # une cause « mémoire » se PROUVE, l'affirmer sans le compte de # l'oom-killer serait une supposition de plus. Pas d'awk ni de sed # ici : leurs programmes demandent des guillemets, et tout ceci # voyage déjà dans un ssh entre apostrophes. "mmem() { m=$(grep MemTotal /proc/meminfo | tr -dc 0-9); " "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " "k=$(sudo dmesg 2>/dev/null | grep -c oom-kill); " f'echo " {t("memory:")} $((m/1024)) {t("MB RAM,")} ' f'$((w/1024)) {t("MB swap, kernel OOM kills:")} $k"; }}; ' 'mdiag() { d=$(mktemp); tail -400 "$1" > "$d"; ' + lines + f'echo " {t("no known pattern, last lines:")}"; ' 'tail -12 "$1" | sed "s/^/ /"; rm -f "$d"; }; ' ) def _qemu_android_prologue_cmd(self): """Ce que la compilation mobile et l'émulateur partagent : le journal détaillé, le coureur d'étapes, le diagnostic, et l'environnement du SDK. Écrit UNE fois même quand les deux options sont cochées — deux prologues, ce serait deux journaux et deux SDK.""" return ( self._qemu_mobile_diag_cmd() + # Le détail va dans un fichier À PART. Une compilation Gradle écrit # des dizaines de milliers de lignes, dont des centaines portant le # mot « error » sans qu'aucune ne soit une panne : les verser dans # le journal d'installation rendrait son compteur d'erreurs # inutilisable, et le diagnostic illisible. 'M="$HOME/erplibre-mobile-build.log"; : > "$M"; ' f'echo " {t("detailed log in the VM:")} $M"; ' 'mstep() { lbl="$1"; shift; echo " -> $lbl"; ' 'if sh -c "$*" >> "$M" 2>&1; then return 0; fi; ' f'echo " ⚠ {t("FAILED:")} $lbl"; mdiag "$M"; return 1; }}; ' # Le SDK vit dans $HOME/android, l'emplacement qu'emploie # l'installateur du dépôt. Android Studio, s'il est là, le trouvera # par ANDROID_HOME : un seul SDK sur la machine, pas deux. 'export ANDROID_HOME="$HOME/android"; ' 'export ANDROID_SDK_ROOT="$HOME/android"; ' 'export PATH="$PATH:$ANDROID_HOME/cmdline-tools/latest/bin' ':$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator"; ' # ~/.bashrc n'est pas lu par un « ssh hôte commande » : ce que # l'installateur y écrit ne sert qu'aux sessions futures, pas à la # compilation qui suit immédiatement. # # Le JDK le PLUS RÉCENT installé, et non celui des alternatives. # Mesuré : avec JAVA_HOME sur le 17 que pose l'installateur amont, # Gradle tourne en 17 et s'arrête sur « invalid source release: 21 » # — les modules de Capacitor 8 compilent en 21. Le tri est # « sort -V », donc java-21 passe après java-17, pas avant. "export JAVA_HOME=$(ls -d /usr/lib/jvm/java-*-openjdk-* " "2>/dev/null | sort -V | tail -1); " '[ -n "$JAVA_HOME" ] || export JAVA_HOME=$(dirname $(dirname ' "$(readlink -f $(command -v javac 2>/dev/null " "|| command -v java 2>/dev/null) 2>/dev/null) 2>/dev/null) " "2>/dev/null); " 'export PATH="$JAVA_HOME/bin:$PATH"; ' ) def _qemu_android_sdk_steps(self, el_dir): """Les étapes qui posent le SDK : dépôt mobile, prérequis, installateur amont, plateforme réclamée par le projet. Communes aux deux options.""" return ( # Le « test -f » n'est pas une ceinture de plus : c'est la seule # vérité disponible. update_manifest_local_mobile.sh finit par # « kill $DAEMON_PID » et rend donc 0 même quand il n'a rien cloné — # vécu, faute de .venv.erplibre. L'étape passait, et c'est le « cd » # suivant qui échouait, deux étapes plus loin. # Le venv d'ERPLibre d'abord, et nommément : tout ce qui suit en # dépend — c'est lui qui porte « repo », qui synchronise le # manifeste. Vécu avec le profil « ERPLibre seul », dont le code # note lui-même « problem installing with q, the script depend on # odoo » : sans venv, le manifeste rendait 0 sans rien cloner et # l'échec ne se voyait que deux étapes plus loin. f'mstep "{t("ERPLibre venv (everything below needs it)")}" ' # « activate », et non « bin/python » : sans python3-venv, le venv # naît INFIRME — bin/python existe (un lien), mais ni pip ni # activate ni site-packages. La sonde passait, et l'échec ne se # voyait que deux étapes plus loin, en « No module named git ». f"'test -f {el_dir}/.venv.erplibre/bin/activate' && " f'mstep "{t("mobile repository (additive manifest)")}" ' f"'cd {el_dir} && ./script/manifest/update_manifest_local_mobile.sh; " "test -f mobile/erplibre_home_mobile/install-android.sh' && " f'mstep "{t("prerequisites of the upstream installer")}" ' # libpulse0 : l'émulateur a DEUX binaires qemu, et seul le # « headless » se passe de PulseAudio. Celui qui ouvre une FENÊTRE — # le cas d'un « ssh -X » — lie libpulse.so.0, absente des images # cloud, et s'arrête sur « cannot open shared object file » même # avec « -no-audio ». Mesuré : c'est la SEULE bibliothèque qui # manque, tout le reste des dépendances Qt voyage dans le bundle. # # openjdk-21 EN PLUS du 17 que pose l'installateur amont : mesuré, # Gradle s'arrête sur « Cannot find a Java installation matching # {languageVersion=21} » — les modules de Capacitor 8 réclament 21. # Les deux JDK cohabitent, et Gradle choisit par sa chaîne d'outils. # unzip et xauth, eux, manquent des images cloud. "'sudo DEBIAN_FRONTEND=noninteractive apt-get " "-o DPkg::Lock::Timeout=600 install -y unzip wget xauth " "libpulse0 openjdk-21-jdk' && " # L'installateur amont n'est PAS idempotent : au second passage il # s'arrête sur « mv: cannot overwrite latest/cmdline-tools ». Mesuré. # On ne le rejoue donc que s'il reste quelque chose à poser — un # déploiement qui se répète ne doit pas échouer sur une réussite # précédente. f'mstep "{t("Android SDK, licences, NDK")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && " "{ [ -x $HOME/android/cmdline-tools/latest/bin/sdkmanager ] " "|| ./install-android.sh; }' && " f'mstep "{t("SDK platform required by the project")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && " 'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' 'android/variables.gradle) && [ -n "$v" ] && ' 'yes | sdkmanager "platforms;android-$v" ' '"build-tools;$v.0.0"\' && ' ) def _qemu_mobile_build_steps(self, el_dir): """Étapes de compilation de l'application mobile, puis ses tests. C'est la seule étape qui peut faire échouer la VM, et c'est voulu : une machine dont l'application ne compile pas n'est pas une machine prête. Le code de sortie remonte donc jusqu'au tableau de bord. Le dépôt mobile porte son propre installateur Android — JDK, outils en ligne de commande, licences acceptées, plateformes, NDK, whisper.cpp et sentencepiece. On l'appelle plutôt que de le réécrire : une seconde implémentation dériverait de la première sans prévenir. Deux choses lui manquent pourtant, et on les ajoute ici : - unzip et wget, qu'il suppose présents et qu'aucune image cloud ne livre ; - la plateforme que le projet réclame VRAIMENT. Son installateur pose android-34 quand android/variables.gradle demande compileSdk 36 ; plutôt que de figer 36 ici, on lit le chiffre dans le fichier. L'étape est bornée à apt (voir _QEMU_VM_TOOLS) : cet installateur commence par « sudo apt install openjdk-17-jdk » et s'arrête là ailleurs. La lever se fait dans ce script-là, pas ici. """ return ( # Du swap AVANT de compiler, et ce n'est pas de la prudence : le # démon Gradle a atteint 6,8 Go de RSS hors tas — son -Xmx1536m ne # le borne pas — sur une VM de 12 Go SANS swap, et le noyau l'a tué # deux fois de suite. « --max-workers=2 » n'y a rien changé : # mesuré, le pic est passé de 10,3 à 11,2 Go. C'est donc de la marge # qu'il faut, pas moins de parallélisme. # # Jamais bloquant : une image sur btrfs refuse un fichier d'échange # ordinaire, et une compilation qui tient en mémoire n'en a pas # besoin. On le dit et on continue. "w=$(grep SwapTotal /proc/meminfo | tr -dc 0-9); " 'if [ "$w" -lt 2000000 ]; then ' "if sudo fallocate -l 4G /swapfile-erplibre 2>/dev/null && " "sudo chmod 600 /swapfile-erplibre && " "sudo mkswap -q /swapfile-erplibre >/dev/null 2>&1 && " "sudo swapon /swapfile-erplibre 2>/dev/null; then " "grep -q swapfile-erplibre /etc/fstab 2>/dev/null || " 'echo "/swapfile-erplibre none swap sw 0 0" ' "| sudo tee -a /etc/fstab >/dev/null; " f'echo " {t("4 GB of swap added for the build")}"; ' "else sudo rm -f /swapfile-erplibre 2>/dev/null; " f'echo " {t("no swap could be added; build may run short")}"; ' "fi; fi; " f'mstep "{t("npm dependencies")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm ci' && " f'mstep "{t("web bundle (vite build)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm run build' && " # Le transfert des dépôts du manifeste DANS l'application est # vérifié, et son compte-rendu se lit dans le journal # d'installation — d'où l'appel HORS mstep, qui enverrait la sortie # dans le journal détaillé de la VM. # # Ces dépôts entrent en PACKS, et c'est ce qui rend la chose # possible : un APK est un ZIP borné à 65535 entrées, quand les # 139 dépôts pèsent plus de 116 000 fichiers. Un fichier par source # donnait « Too many zip entries 123678 (MAX=65535) » et rien du # tout ; regroupés, ils tiennent en 391 tranches — mesuré, avec # 3 002 entrées dans l'APK. # # Lié par « && » : un transfert vide fait échouer la VM, au même # titre qu'un APK manquant. Une application qui ne porte pas le code # qu'elle est censée montrer n'est pas l'application demandée. f'echo " -> {t("repo transfer into the app")}" && ' f"(cd {el_dir} && ./script/mobile/check_bundle_transfer.py" f" --workspace {el_dir}) && " f'mstep "{t("native sync (capacitor)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npx cap sync android' && " # UNE seule ABI, celle de la VM — qui est aussi celle de # l'émulateur. Deux raisons, la seconde décisive : # - quatre ABI, c'est quatre fois la compilation de whisper.cpp # et de sentencepiece, pour trois qui ne serviront jamais ici ; # - sentencepiece bâtit son « protoc » POUR LA CIBLE puis tente de # l'exécuter sur l'hôte. En arm64 cela donne « Exec format # error » et la compilation s'arrête — mesuré. En x86_64 la # cible et l'hôte coïncident, et le défaut ne se manifeste pas. # Un APK arm64 demandera un correctif au projet mobile. f'mstep "{t("debug APK (gradle)")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile/android && " "./gradlew --no-daemon assembleDebug " "-Pandroid.injected.build.abi=x86_64' && " f'mstep "{t("Vitest tests")}" ' f"'cd {el_dir}/mobile/erplibre_home_mobile && npm test' && " # L'APK est la preuve, pas le code de sortie de Gradle : une tâche # peut rendre 0 sans avoir rien produit. # DEUX emplacements, et il faut les deux. Avec une ABI injectée, # AGP écrit dans « intermediates/apk/debug » et non dans # « outputs/apk/debug » : mesuré, une compilation RÉUSSIE était # rapportée « aucun APK produit » parce que je ne regardais que le # second. Un contrôle qui cherche au mauvais endroit ne vaut pas # mieux que pas de contrôle. f"apk=$(ls {el_dir}/mobile/erplibre_home_mobile/android/app/build" "/outputs/apk/debug/*.apk " f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" "/intermediates/apk/debug/*.apk 2>/dev/null | head -1); " 'if [ -n "$apk" ]; then ' f'echo " ✅ {t("APK built:")} $apk"; ' # Capacitor sert la même application dans un navigateur : sur une # VM graphique, c'est la voie de déverminage sans Android ni # émulateur. On la NOMME plutôt que d'imposer Chromium — sur # Ubuntu il n'existe qu'en snap, et snapd est justement coupé sur # ces VM. Le navigateur du bureau fait l'affaire. # # DANS la branche de succès, et c'est tout l'enjeu : placé après le # « fi », cet echo devenait la dernière commande du bloc et rendait # 0 — une VM sans APK repassait au vert. f'echo " {t("browser debugging (no Android):")} ' f"cd {el_dir}/mobile/erplibre_home_mobile " '&& npm start"; else ' f'echo " ⚠ {t("no APK produced")}"; false; fi' ) def _qemu_avd_steps(self, el_dir): """Étapes créant un émulateur prêt à s'ouvrir depuis le poste de travail. Le modèle n'est pas figé : on demande au SDK la liste de ses profils et on retient le Pixel le plus récent au plus petit écran — ni Pro, ni XL, ni pliant, ni tablette. Sur un écran distant, chaque pixel traverse le réseau : le petit modèle n'est pas une coquetterie. L'image système suit la plateforme du projet, et redescend si elle n'est pas publiée — Google ne fournit pas d'image pour toutes les API. Le rendu est réglé en logiciel DANS la configuration de l'AVD plutôt qu'en option de lancement : par « ssh -X » il n'y a pas de GLX direct, et l'émulateur s'ouvrirait sur un écran noir. Ainsi « emulator -avd erplibre » suffit, sans rien à retenir. Le mode est « swangle » — ANGLE sur SwiftShader — et non « swiftshader_indirect », qui n'existe PLUS : l'émulateur 37.1 répond « Selected GPU option 'swiftshader_indirect' is not valid, switching to auto », puis « Your GPU drivers may have a bug », avant de retomber de lui-même sur swangle. Il fonctionnait, en affichant deux erreurs qui laissaient croire à une panne. Les modes valides sont exactement quatre, que « emulator -help-gpu » énumère : auto, host, swiftshader, swangle. """ return ( f'echo " == {t("Android emulator (AVD)")} =="; ' # KVM dans la VM : sans lui l'émulateur x86 refuse de démarrer. On # le dit ici, où c'est réparable (virtualisation imbriquée sur # l'hôte), plutôt qu'au premier lancement. "if [ ! -e /dev/kvm ]; then " f'echo " ⚠ {t("no /dev/kvm: nested virtualisation is off on the host")}"; ' "else " # /dev/kvm est en root:kvm 0660 : sans appartenir au groupe, # l'émulateur refuse de démarrer sur « ProbeKVM: This user doesn't # have permissions to use KVM ». Mesuré. L'appartenance ne prend # qu'à la prochaine session — ce qui tombe bien, la session utile # est justement celle du « ssh -X » qui viendra ensuite. "sudo usermod -aG kvm $(id -un) 2>/dev/null || true; " f'echo " {t("user added to the kvm group (effective at next login)")}"; ' "fi; " f'mstep "{t("emulator and system image")}" ' '\'v=$(sed -n "s/.*compileSdkVersion *= *\\([0-9]*\\).*/\\1/p" ' f"{el_dir}/mobile/erplibre_home_mobile/android/variables.gradle); " "for a in $v 36 35 34; do " 'img="system-images;android-$a;google_apis;x86_64"; ' 'if yes | sdkmanager "emulator" "$img"; then ' 'echo "$img" > $HOME/.erplibre-avd-image; break; fi; done; ' "test -s $HOME/.erplibre-avd-image' && " f'mstep "{t("Pixel profile, smallest screen")}" ' # Le plus récent des Pixel simples : on trie sur le NUMÉRO, pas sur # l'ordre d'affichage, et on écarte les grands modèles. '\'avdmanager list device | grep -oE "pixel_[0-9]+a?" ' '| grep -vE "pro|xl|fold|tablet" | sort -t_ -k2 -n | tail -1 ' "> $HOME/.erplibre-avd-device; test -s $HOME/.erplibre-avd-device' && " f'mstep "{t("create the AVD")}" ' "'img=$(cat $HOME/.erplibre-avd-image); " "dev=$(cat $HOME/.erplibre-avd-device); " 'echo no | avdmanager create avd -n erplibre -k "$img" ' '-d "$dev" --force && ' # Rendu logiciel, écrit dans la config : par ssh -X il n'y a pas # de GLX direct, et « auto » donnerait un écran noir. Ces deux # clés-là SURVIVENT, elles ne viennent pas du profil du téléphone. # # L'écran, en revanche, ne s'écrit PAS ici : l'émulateur réécrit # config.ini depuis le profil Pixel au premier démarrage, et les # hw.lcd.* y étaient effacés — l'AVD repartait en 1080x2400 # densité 420. C'est donc au LANCEMENT qu'il se règle, par # _QEMU_EMULATOR_FLAGS, et la commande affichée plus bas les porte. 'printf "hw.gpu.enabled=yes\\nhw.gpu.mode=swangle\\n" ' ">> $HOME/.android/avd/erplibre.avd/config.ini' && " f'echo " ✅ {t("AVD ready:")} ' "$(cat $HOME/.erplibre-avd-device) / " '$(cat $HOME/.erplibre-avd-image)"; ' # La commande à copier, avec l'adresse déjà remplie : un émulateur # dont on ignore comment l'ouvrir ne sert à personne. "ip=$(hostname -I 2>/dev/null | awk '{print $1}'); " # Chemins ABSOLUS, et c'est le point : « ssh hôte 'commande' » # ne lit NI ~/.profile NI ~/.bashrc — Ubuntu place même un # « return » en tête du second pour les shells non interactifs. # Le PATH que l'installateur y écrit ne s'applique donc jamais # à ces commandes, et « emulator » y répond « command not # found ». Vécu, sur la ligne que ce message affichait lui-même. f'echo " {t("open it from your workstation:")} ' # « -XC » et non « -X » : la compression X11 change tout sur un # écran distant. Les autres drapeaux viennent de la même autorité # que le lancement du menu : écran réduit, densité qui va avec, et # pas d'instantané en attente si on tue l'émulateur. 'ssh -XC erplibre@$ip \\"$HOME/android/emulator/emulator ' f'-avd erplibre {self._QEMU_EMULATOR_FLAGS}\\""; ' f'echo " {t("then install the APK:")} ' # « -t » : l'ABI injectée fait marquer l'APK « testOnly » par AGP, # et adb le refuse sans ce drapeau — « INSTALL_FAILED_TEST_ONLY ». # Mesuré sur l'émulateur. 'ssh erplibre@$ip \\"$HOME/android/platform-tools/adb install -r -t ' f"{el_dir}/mobile/erplibre_home_mobile/android/app/build" '/outputs/apk/debug/app-debug.apk\\""; ' # La voie scrcpy, nommée ici parce que c'est la première # question qui vient après « ça se lance mais c'est lent » : # X11 transporte des pixels bruts, scrcpy un flux H.264 encodé # par l'appareil. Le détail du tunnel vit dans le menu # « Remote desktop tunnel », choix 4. + f'echo " {t("smoother, without X11:")} TODO > Execute > Deploy > QEMU/KVM > tunnel > 4"' ) def _qemu_forgejo_steps(self, el_dir): """Pose Forgejo dans la VM, par le script dédié du dépôt. Tout le travail est DANS le script — architecture, version, somme de contrôle, compte système, configuration, service, compte administrateur. Ce bloc ne fait que l'appeler : une seule autorité, et la même commande sert un déploiement de VM et une installation à la main sur une machine existante. Pas de garde, comme la compilation mobile : une VM dont la forge demandée n'existe pas n'est pas la VM demandée. Le script, lui, est rejouable — il ne retélécharge pas un binaire déjà en place et ne réécrit jamais une configuration existante. """ return ( f'echo "== {t("Forgejo (git forge)")} =="; ' f"{el_dir}/script/forgejo/install_forgejo.sh" ) def _qemu_after_remote_cmd(self, tools, prod=False): """Phase d'APRÈS l'installation : prologue commun, SDK commun, puis ce qui a été coché. Un seul prologue et un seul SDK même quand les deux options le sont : deux prologues, et le second tronquerait le journal détaillé du premier. Les groupes sont joints par « && » et non par « ; ». C'est ce qui fait qu'un APK manquant reste l'échec de la VM : collé par « ; », un émulateur créé avec succès effacerait le verdict de la compilation.""" picked = [ k for k in ("aidev", "forgejo", "mobile", "avd") if k in (tools or ()) and k in self._QEMU_VM_TOOLS ] if not picked: return "" el_dir = self._qemu_install_dir(prod) parts = [] # La pré-configuration IA en tête : quelques secondes de copies, contre # une minute pour Forgejo et une heure pour le SDK. Elle est aussi la # seule à rendre toujours 0 — ce qui suit porte le verdict de la VM. if "aidev" in picked: parts.append(f"{{ {self._qemu_aidev_after_cmd(prod)}; }}") # Forgejo ensuite : une minute, contre une heure pour le SDK et l'APK. # Un échec rapide se voit tôt plutôt qu'après le long. if "forgejo" in picked: parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}") groups = [] if "mobile" in picked: groups.append(self._qemu_mobile_build_steps(el_dir)) if "avd" in picked: groups.append(self._qemu_avd_steps(el_dir)) if groups: # UN seul prologue et un seul SDK même quand les deux options le # sont : deux prologues, et le second tronquerait le journal # détaillé du premier. parts.append( "{ " + f'echo "== {t("ERPLibre mobile, Android SDK (long)")} =="; ' + self._qemu_android_prologue_cmd() + self._qemu_android_sdk_steps(el_dir) # Chaque groupe entre ACCOLADES. Sans elles, « && » ne lie que # la première commande du groupe suivant : mesuré, un APK # manquant laissait tourner l'émulateur puis rendait 0 — la VM # repassait au vert alors que rien n'avait compilé. + " && ".join(f"{{ {g}; }}" for g in groups) + "; }" ) return " && ".join(parts) + "; " def _qemu_mobile_remote_cmd(self, prod=False): """Compilation mobile seule — la forme que testent les tests.""" return self._qemu_after_remote_cmd(("mobile",), prod) def _qemu_avd_remote_cmd(self, prod=False): """Émulateur seul.""" return self._qemu_after_remote_cmd(("avd",), prod) # Les outils de terminal qui accompagnent les assistants. Ceux-là SONT # empaquetés, sous le même nom dans les quatre familles : rien à résoudre. _QEMU_AIDEV_PKGS = "tig htop vim" # La commande qu'une VM neuve rend à la première flèche du haut. Le chemin # est relatif : le venv est à la racine du dépôt, et c'est de là qu'on # l'active, en dev comme en production. _QEMU_AIDEV_HISTORY = "source .venv.erplibre/bin/activate" # Les commandes Claude déployées dans la VM : nom de la commande -> gabarit # de conf/. La même liste qu'au menu de l'hôte. Les deux « todo_ » vont # ensemble — /todo_plan_max produit la spécification que /todo_add_command # implémente, et l'une sans l'autre laisse la moitié de la chaîne. _QEMU_AIDEV_CLAUDE_CMDS = ( ("commit", "template_claude_commands_commit.md"), ("git_prepare_merge", "template_claude_commands_git_prepare_merge.md"), ("todo_plan_max", "template_claude_commands_todo_plan_max.md"), ("todo_add_command", "template_claude_commands_todo_add_command.md"), ( "todo_generate_code", "template_claude_commands_todo_generate_code.md", ), ) def _qemu_aidev_remote_cmd(self, agent=""): """Les outils d'assistance et leur pré-configuration, dans la VM. Trois installateurs amont — rtk, starship, UN agent —, trois paquets de terminal, et les réglages qui font qu'on s'en sert sans rien retaper : le hook global de rtk, l'accroche du prompt, le PATH, la configuration git, et une entrée d'historique pour activer le venv. Chaque pose est bornée dans le temps ET privée d'entrée standard. Le contrat de la phase « before » veut qu'un outil ne fasse échouer ni les autres ni l'installation d'ERPLibre : « || true » couvre l'échec, mais pas l'ATTENTE. Un installateur amont qui pose une question resterait pendu sur un SSH sans terminal, et le déploiement avec lui ; « /dev/null" f" || echo {shlex.quote(ligne)} >> {fichier}; " ) def verdict(var, nom, repli=""): # `var` reçoit le chemin du binaire : « command -v » d'abord, puis # `repli`, le chemin où l'installateur le pose hors du PATH de ce # shell. Aucune ligne ne peut faire tomber « set -e » : l'« if » # absorbe le test, et la version se lit sous « timeout », sans # entrée, comme une pose. trouve = f'echo "{repli}"' if repli else "true" return ( f'{var}="$(command -v {nom} || {trouve})"; ' f'if [ -x "${var}" ]; then echo " {nom}: ' f'$(timeout 10 "${var}" --version &1' ' | head -n 1 || true)"; ' f'else echo " ⚠ {nom} {t("not installed (see above)")}"; ' "fi; " ) return ( f'echo "== {t("AI coding tools")} =="; ' + self._qemu_pkg_install_cmd(self._QEMU_AIDEV_PKGS) + pose(dev_tools.RTK_UPSTREAM, 300) # Par son chemin absolu, et non par « rtk » nu : le PATH de cette # commande distante a été figé au démarrage du shell SSH, avant que # l'installateur ne pose le binaire. Le code 127 qu'on obtiendrait # sinon ne dirait pas que le hook n'a pas été écrit. + verdict("RTK", "rtk", f"{local_bin}/rtk") + '[ -x "$RTK" ] && timeout 60 "$RTK" init --global' " /dev/null 2>&1 || true; " # En root, starship atterrit dans /usr/local/bin, que le PATH de # ce shell porte déjà : « command -v » le trouve sans repli. Ces # 300 s ne bornent que curl : l'installateur root porte sa propre # borne, plus courte, derrière sudo — voir STARSHIP_UPSTREAM_VM. + pose(dev_tools.STARSHIP_UPSTREAM_VM, 300) + verdict("STARSHIP", "starship") + une_fois(prompt, "starship init bash") + pose(commande, 600) + verdict("AGENT", nom_agent, f"{repertoire}/{nom_agent}") + une_fois(local_line, local_bin) # Claude Code s'installe DANS ~/.local/bin : la ligne serait la # même, écrite deux fois dans le journal pour un seul effet. + ( une_fois(path_line, repertoire) if repertoire != local_bin else "" ) + self._qemu_aidev_git_cmd() + une_fois( self._QEMU_AIDEV_HISTORY, self._QEMU_AIDEV_HISTORY, "~/.bash_history", ) # bash crée son historique en 600 ; une redirection le crée selon # l'umask, soit lisible par tous sur les images visées. + "chmod 600 ~/.bash_history 2>/dev/null || true; " ) def _qemu_aidev_git_cmd(self): """Les deux réglages git globaux de la pré-configuration. zdiff3 ajoute la base commune aux marqueurs de conflit et sort de la zone contestée les lignes que les deux côtés ont en commun : il reste moins à arbitrer à la main. Le style demande git 2.35, que toutes les plateformes supportées dépassent. L'éditeur, lui, n'est posé QUE s'il n'y en a pas : deploy_qemu.py transmet celui de l'hôte dans le ~/.gitconfig de la VM et l'annonce dans le guide de connexion, et l'écraser ici ferait deux autorités sur un même réglage. vim est le repli, et son paquet vient d'être posé. Le tout dans un « if » : la phase « before » d'une VM sans installation ERPLibre n'a pas vu l'amorçage qui pose git, et « set -e » ferait tomber le déploiement sur une commande introuvable. """ return ( "if command -v git >/dev/null 2>&1; then " "git config --global merge.conflictStyle zdiff3 || true; " "git config --global --get core.editor >/dev/null 2>&1 " "|| git config --global core.editor vim || true; fi; " ) def _qemu_aidev_after_cmd(self, prod=False): """La part de la pré-configuration qui a besoin du dépôt cloné. Les hooks vivent dans le dépôt (`script/git/hooks`) et les gabarits des commandes Claude dans `conf/` : avant le clone, ni l'un ni l'autre n'existe. Le bit d'exécution fait partie de l'installation des hooks — sans lui git les ignore SANS RIEN DIRE, et le garde-fou du message de commit passe inaperçu. Rendu SANS point-virgule final, comme les autres étapes de la phase « après » : l'appelant enveloppe le bloc dans des accolades, et un « ; ; » y est une erreur de syntaxe qui emporte tout le groupe. Le bloc rend toujours 0. Une pré-configuration est un confort : c'est l'installation qui porte le verdict de la VM, pas elle. """ el_dir = self._qemu_install_dir(prod) hooks = self._GIT_HOOKS_PATH cibles = " ".join(f"{el_dir}/{hooks}/{h}" for h in self._GIT_HOOKS) etapes = [ f'echo "== {t("AI coding tools, pre-configuration")} =="', # « git -C » et non le cwd : le dépôt porte des dépôts imbriqués # (odoo18.0/addons/…), et core.hooksPath écrit dans l'un d'eux # laisserait la racine sans garde-fou, sans le moindre message. f"git -C {el_dir} config core.hooksPath {hooks} || true", f"chmod +x {cibles} 2>/dev/null || true", "mkdir -p ~/.claude/commands || true", ] etapes += [ f"cp -f {el_dir}/conf/{gabarit}" f" ~/.claude/commands/{nom}.md 2>/dev/null || true" for nom, gabarit in self._QEMU_AIDEV_CLAUDE_CMDS ] etapes.append(self._qemu_aidev_identity_cmd()) etapes.append( 'echo " ~/.claude/commands: ' "$(ls ~/.claude/commands/*.md 2>/dev/null | wc -l)" f'/{len(self._QEMU_AIDEV_CLAUDE_CMDS)}"' ) return "; ".join(etapes) # Ce que le gabarit de /commit porte en exemple, et que l'identité de la # VM remplace : une ligne « git -c user.name=… » qu'on recopie. _QEMU_AIDEV_PLACEHOLDERS = ( ("Your Name", "user.name"), ("your@email.com", "user.email"), ) def _qemu_aidev_identity_cmd(self): """Substitue l'identité git de la VM dans la commande /commit. En python3 et non en sed : le remplacement se fait alors sur du texte littéral, là où un nom qui porterait « & » ou le séparateur choisi changerait de sens dans un « s/// ». python3 est présent dans toutes les images visées — cloud-init, qui les amorce, en dépend lui-même. Une identité absente laisse le gabarit tel quel : l'exemple reste lisible, et rien ne prétend une identité qu'on n'a pas. """ remplacements = "".join( f'.replace("{marque}", g("{cle}") or "{marque}")' for marque, cle in self._QEMU_AIDEV_PLACEHOLDERS ) code = ( "import pathlib,subprocess as s;" 'g=lambda k:s.run(["git","config","--global","--get",k],' "capture_output=True,text=True).stdout.strip();" 'p=pathlib.Path("~/.claude/commands/commit.md").expanduser();' f"p.write_text(p.read_text(){remplacements})" ) return ( "command -v python3 >/dev/null 2>&1 && " f"python3 -c {shlex.quote(code)} 2>/dev/null || true" ) # L'installateur OFFICIEL, en mode multi-utilisateur : il pose /nix, le # groupe nixbld et le démon. C'est le mode qui survit à plusieurs comptes # sur la machine, là où le mode mono-utilisateur donne /nix à celui qui # installe. « --yes » donne d'avance la confirmation que personne ne # tapera : la pose se fait par SSH, sans terminal. _QEMU_NIX_UPSTREAM = ( "curl -fsSL https://nixos.org/nix/install -o /tmp/nix-install.sh" " && sh /tmp/nix-install.sh --daemon --yes" ) # Ces deux fonctions restent « expérimentales » en amont, et # nixos-anywhere ne se distribue QUE par un flake : sans elles, # « nix profile install github:… » refuse la référence. _QEMU_NIX_FEATURES = "nix-command flakes" _QEMU_NIX_CONF_LINE = f"experimental-features = {_QEMU_NIX_FEATURES}" # L'installateur de NixOS À DISTANCE, par nix-community : il démarre la # machine cible sur un noyau kexec, partitionne, et y pose le système # décrit par un flake. La cible n'a besoin que d'un SSH root. _QEMU_NIXANYWHERE_FLAKE = "github:nix-community/nixos-anywhere" def _qemu_nixanywhere_remote_cmd(self): """Nix et nixos-anywhere dans la VM, sur le moule d'aidev. Trois gestes : l'installateur amont, les fonctions expérimentales que les flakes réclament, puis nixos-anywhere dans le profil de l'utilisateur. La VM devient alors une machine d'où l'on INSTALLE NixOS ailleurs — elle-même reste sur sa distribution. Chaque pose est bornée par « timeout » et privée d'entrée standard, pour la raison qui vaut pour toute la phase « before » : « || true » couvre l'échec, pas l'ATTENTE, et un installateur qui pose une question resterait pendu sur un SSH sans terminal. Nix est appelé par son CHEMIN ABSOLU. Le PATH de cette commande distante a été figé à l'ouverture du shell SSH, avant que l'installateur ne pose quoi que ce soit ; « nix » nu rendrait 127 sans dire que rien n'a été installé. Les fonctions expérimentales sont redonnées sur la ligne de commande en plus d'être écrites dans /etc/nix/nix.conf : l'écriture demande sudo et peut échouer, l'appel doit réussir quand même. """ nix = "/nix/var/nix/profiles/default/bin/nix" outil = "$HOME/.nix-profile/bin/nixos-anywhere" def pose(cmd, secondes): return ( f"timeout {secondes} sh -c {shlex.quote(cmd)}" " /dev/null || true; " f"grep -qF {shlex.quote(self._QEMU_NIX_CONF_LINE)}" " /etc/nix/nix.conf 2>/dev/null" f" || echo {shlex.quote(self._QEMU_NIX_CONF_LINE)}" " | sudo tee -a /etc/nix/nix.conf >/dev/null || true; " "sudo systemctl restart nix-daemon 2>/dev/null || true; " ) installe = ( f'[ -x "{nix}" ] && timeout 900 "{nix}"' " --extra-experimental-features" f" {shlex.quote(self._QEMU_NIX_FEATURES)}" f" profile install {shlex.quote(self._QEMU_NIXANYWHERE_FLAKE)}" " /dev/null 2>&1 || true; " ) return ( f'echo "== {t("nix + nixos-anywhere")} =="; ' + pose(self._QEMU_NIX_UPSTREAM, 900) + conf + installe # Rien n'est ajouté à ~/.bashrc, et c'est délibéré : l'installateur # écrit lui-même /etc/bash.bashrc, /etc/profile.d/nix.sh et les # fichiers zsh et fish. Une ligne de plus dans le ~/.bashrc de # l'utilisateur serait posée APRÈS le « return » que ce fichier # exécute pour tout shell non interactif — donc jamais atteinte par # « ssh hôte 'commande' », le seul cas qu'elle prétendait couvrir. # # Le verdict porte sur le BINAIRE, pas sur le code de retour des # poses : toutes rendent 0 par construction. + f'[ -x "{outil}" ]' f' && echo " {t("nixos-anywhere ready")}"' f' || echo " {t("nixos-anywhere missing, see the log above")}"; ' ) def _qemu_tools_remote_cmd( self, tools, prod=False, phase="before", ai_agent="" ): """Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd. « before » : posé avant le clone. Chaque outil s'y garde lui-même — aucun ne fait échouer les autres, ni l'installation d'ERPLibre. « after » : la compilation mobile, qui vient après l'installation dont elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une VM dont l'application ne compile pas doit être rouge.""" if phase == "after": # Un seul bloc pour toutes les options : voir # _qemu_after_remote_cmd. « aidev » y entre bien qu'il soit déclaré # « before » — ses installations le sont, son complément non. return self._qemu_after_remote_cmd(tools, prod) blocks = { # En tête : quelques secondes de curl, contre des minutes pour un # IDE. Ce qui échoue vite se voit tôt. "aidev": lambda: self._qemu_aidev_remote_cmd(ai_agent), "nixanywhere": self._qemu_nixanywhere_remote_cmd, "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(fn() for k, fn in blocks.items() 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 # pyenv, ce que le formulaire et l'invite disent avant de déployer. QEMU_MISE_ARCHES = ("amd64", "arm64") def _qemu_mise_remote_cmd(self, python_provider): """Pose mise DANS la VM et fixe EL_PYTHON_PROVIDER pour l'installation. mise s'installe par défaut dans ~/.local/bin, qui n'est PAS dans le PATH d'un « ssh hôte 'commande' » : ni ~/.profile ni ~/.bashrc n'y sont lus. On le pose donc dans /usr/local/bin, présent dans le PATH par défaut — même raison que pour cargo et rustc. Sans mise utilisable, rien n'est écrit : lib_python_provider.sh retombe alors sur pyenv toute seule. L'installateur est téléchargé dans un fichier, PUIS exécuté. Dans « curl … | sh || repli », le statut du tube est celui de sh, qui rend 0 sur une entrée vide : sans pipefail, le repli ne se déclencherait jamais, et un téléchargement raté passerait pour une pose réussie. Le statut de curl, lu seul, distingue les deux échecs — rien obtenu, ou un installateur qui a échoué — et chacun a son message. Aucun ne fait tomber « set -e » : ils sont testés dans un « if ». Le fichier ne s'obtient que de mktemp : un nom aléatoire, créé par ce compte seul. Sans mktemp, rien n'est téléchargé. Un nom fixe dans /tmp, qu'un autre compte peut créer d'avance, serait exécuté par root.""" if python_provider == "pyenv": # Explicite : même si mise se trouvait déjà dans l'image, on ne # l'utilise pas. Sans cela le mode « auto » du dépôt le prendrait. return "export EL_PYTHON_PROVIDER=pyenv; " if python_provider != "mise": return "" return ( f'echo "== {t("Installing mise (precompiled Python)")} =="; ' "if command -v mise >/dev/null 2>&1; then " 'echo " mise: $(mise --version)"; ' "else " # L'affectation est DANS la condition : un mktemp qui échoue prend # la branche du téléchargement impossible au lieu de faire tomber # « set -e ». `f` y reste vide, et « rm -f "" » rend 0. "if ! f=$(mktemp 2>/dev/null) " '|| ! curl -fsSL https://mise.run -o "$f"; then ' 'echo " ⚠ ' + t( "mise download impossible (network or cache): " "pyenv will take over" ) + '"; ' # La variable est passée À sudo, pas exportée avant : « sudo -E » # dépend de env_reset dans sudoers et n'est pas garanti. + 'elif ! sudo MISE_INSTALL_PATH=/usr/local/bin/mise sh "$f"' "