[ADD] qemu deploy : poser rtk, starship et un agent dans la VM
Une case de plus au catalogue des outils, donc une case dans les deux écrans sans y toucher. Cochée, elle découvre le choix de l'agent — Claude Code ou opencode — et l'identité git, pré-remplie avec celle de l'hôte : c'est ce que la VM reçoit déjà, et un champ vide la ferait croire absente. Ce qui est saisi prime, champ par champ. Phase « before », où chaque outil se garde lui-même. Chaque pose est aussi privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship. --- EN --- One more entry in the tool catalogue, hence one more box on both screens for free. Ticked, it reveals the agent choice — Claude Code or opencode — and the git identity, prefilled from the host: that is what the VM already gets, and an empty field would suggest none. What is typed wins, field by field. Phase « before », where each tool guards itself. Every install is also denied stdin and time-bounded: « || true » covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence « -y » for starship. Assisted-by: Claude Opus 5
This commit is contained in:
parent
988ea6bcd5
commit
68eff72243
9 changed files with 510 additions and 11 deletions
|
|
@ -2302,9 +2302,12 @@ def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
|
|||
]
|
||||
if args.no_git_identity:
|
||||
return files
|
||||
# Ce que le formulaire a saisi PRIME sur l'identité de l'hôte, champ par
|
||||
# champ : remplir le seul courriel ne doit pas effacer le nom. Vide, on
|
||||
# retombe sur l'hôte, qui reste le comportement par défaut.
|
||||
gitconfig = build_gitconfig(
|
||||
_git_global("user.name", home),
|
||||
_git_global("user.email", home),
|
||||
getattr(args, "git_name", "") or _git_global("user.name", home),
|
||||
getattr(args, "git_email", "") or _git_global("user.email", home),
|
||||
editor,
|
||||
)
|
||||
if gitconfig:
|
||||
|
|
@ -3603,6 +3606,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||
"dans le guide pour la mettre à jour. Vide : le guide s'arrête à "
|
||||
"« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.",
|
||||
)
|
||||
g_cloud.add_argument(
|
||||
"--git-name",
|
||||
default="",
|
||||
help="Nom pour le ~/.gitconfig de la VM (défaut : celui de l'hôte).",
|
||||
)
|
||||
g_cloud.add_argument(
|
||||
"--git-email",
|
||||
default="",
|
||||
help="Courriel pour le ~/.gitconfig de la VM (défaut : l'hôte).",
|
||||
)
|
||||
g_cloud.add_argument(
|
||||
"--no-git-identity",
|
||||
action="store_true",
|
||||
|
|
|
|||
|
|
@ -396,6 +396,11 @@ def build_spec(vms, domains, form):
|
|||
# Au niveau du déploiement : la 3D est une propriété du matériel de la
|
||||
# VM, pas de ce qu'on installe dedans.
|
||||
"gpu3d": form.get("gpu3d", False),
|
||||
# Au niveau du déploiement : l'agent choisi et l'identité git valent
|
||||
# pour tout le parc, comme le fuseau ou le magasin d'applications.
|
||||
"ai_agent": form.get("ai_agent", ""),
|
||||
"git_name": form.get("git_name", ""),
|
||||
"git_email": form.get("git_email", ""),
|
||||
"add_ssh_config": form["add_ssh_config"],
|
||||
"parallelism": form["parallelism"],
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class QemuDeployMixin:
|
|||
python_provider="",
|
||||
app_store="deb",
|
||||
tools=(),
|
||||
ai_agent="",
|
||||
):
|
||||
"""Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau
|
||||
— le choix graphique ne dépend pas d'ERPLibre, et une VM peut être
|
||||
|
|
@ -76,7 +77,7 @@ class QemuDeployMixin:
|
|||
+ self._qemu_cloud_init_wait()
|
||||
+ self._qemu_no_auto_upgrade(prod, app_store)
|
||||
+ self._qemu_desktop_remote_cmd(desktop, app_store)
|
||||
+ self._qemu_tools_remote_cmd(tools, prod)
|
||||
+ self._qemu_tools_remote_cmd(tools, prod, ai_agent=ai_agent)
|
||||
+ note
|
||||
)
|
||||
if not final_cmd:
|
||||
|
|
@ -103,7 +104,9 @@ class QemuDeployMixin:
|
|||
# apt pendant l'installation. En PROD on ne touche à rien : les
|
||||
# correctifs de sécurité automatiques doivent rester actifs.
|
||||
no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store)
|
||||
tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before")
|
||||
tools_cmd = self._qemu_tools_remote_cmd(
|
||||
tools, prod, "before", ai_agent
|
||||
)
|
||||
# La compilation mobile vient APRÈS l'installation : elle a besoin du
|
||||
# dépôt, du venv d'outils qui synchronise le manifeste, et de node que
|
||||
# « make install_os » installe. Liée par « && » et NON gardée, pour que
|
||||
|
|
@ -277,6 +280,7 @@ class QemuDeployMixin:
|
|||
vm_tools=(),
|
||||
pve=None,
|
||||
meta=None,
|
||||
ai_agent="",
|
||||
):
|
||||
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
|
||||
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
|
||||
|
|
@ -314,6 +318,7 @@ class QemuDeployMixin:
|
|||
"" if desk_map else desktop,
|
||||
python_provider,
|
||||
app_store,
|
||||
ai_agent=ai_agent,
|
||||
)
|
||||
try:
|
||||
mod = self._qemu_import_module()
|
||||
|
|
@ -369,6 +374,7 @@ class QemuDeployMixin:
|
|||
python_provider,
|
||||
app_store,
|
||||
self._qemu_tools_for(vm_tools, a, vm_desktop, d),
|
||||
ai_agent,
|
||||
)
|
||||
vms.append(entry)
|
||||
else:
|
||||
|
|
@ -415,6 +421,7 @@ class QemuDeployMixin:
|
|||
python_provider="",
|
||||
app_store="deb",
|
||||
vm_tools=(),
|
||||
ai_agent="",
|
||||
):
|
||||
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
|
||||
d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
|
||||
|
|
@ -455,6 +462,7 @@ class QemuDeployMixin:
|
|||
self._qemu_tools_for(
|
||||
vm_tools, vm_arch or "amd64", desktop, vm_distro or ""
|
||||
),
|
||||
ai_agent,
|
||||
)
|
||||
ssh_opts = (
|
||||
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
|
||||
|
|
@ -976,6 +984,8 @@ class QemuDeployMixin:
|
|||
install_cmd="",
|
||||
vm_tools=(),
|
||||
gpu3d=False,
|
||||
git_name="",
|
||||
git_email="",
|
||||
):
|
||||
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
|
||||
dry-run ET le déploiement réel)."""
|
||||
|
|
@ -1015,6 +1025,13 @@ class QemuDeployMixin:
|
|||
# « on » et non « auto » : auto s'abstient sur une VM sans écran,
|
||||
# or c'est précisément ce que la case permet de demander.
|
||||
parts += ["--gpu", "on"]
|
||||
# L'identité git de la VM. Sans ces options, deploy_qemu recopie celle
|
||||
# de l'HÔTE : le formulaire la montre et permet de la changer, il ne
|
||||
# la remplace pas par du vide.
|
||||
if git_name:
|
||||
parts += ["--git-name", git_name]
|
||||
if git_email:
|
||||
parts += ["--git-email", git_email]
|
||||
# Guide affiché à la connexion SSH de la VM : dans la langue du menu, et
|
||||
# avec la section ERPLibre seulement là où ERPLibre sera installé — une
|
||||
# VM déployée nue n'annonce pas un dépôt qui n'existe pas.
|
||||
|
|
@ -1087,6 +1104,8 @@ class QemuDeployMixin:
|
|||
),
|
||||
vm_tools=spec.get("vm_tools") or (),
|
||||
gpu3d=bool(spec.get("gpu3d")),
|
||||
git_name=spec.get("git_name") or "",
|
||||
git_email=spec.get("git_email") or "",
|
||||
)
|
||||
|
||||
def _qemu_arches_for(self, distro, arch):
|
||||
|
|
@ -1703,6 +1722,35 @@ class QemuDeployMixin:
|
|||
picked.append(key)
|
||||
return tuple(picked)
|
||||
|
||||
def _qemu_ask_ai_tools(self, vm_tools):
|
||||
"""(agent, nom, courriel) — rien à poser si l'outil n'est pas coché.
|
||||
|
||||
Le nom et le courriel sont proposés avec l'identité de l'HÔTE, qui
|
||||
est ce que la VM reçoit aujourd'hui : une réponse vide la garde. Sans
|
||||
ce défaut affiché, un champ vide se lirait comme une identité absente
|
||||
et inviterait à la ressaisir pour rien.
|
||||
"""
|
||||
from script.todo import dev_tools
|
||||
|
||||
if "aidev" not in (vm_tools or ()):
|
||||
return "", "", ""
|
||||
noms = list(dev_tools.AGENTS)
|
||||
print(f" {t('AI coding tools')} :")
|
||||
for i, nom in enumerate(noms, 1):
|
||||
marque = " ←" if nom == dev_tools.AGENT_DEFAUT else ""
|
||||
print(f" [{i}] {nom}{marque}")
|
||||
rep = input(" " + t("Choice: ")).strip()
|
||||
agent = (
|
||||
noms[int(rep) - 1]
|
||||
if rep.isdigit() and 1 <= int(rep) <= len(noms)
|
||||
else dev_tools.AGENT_DEFAUT
|
||||
)
|
||||
hote_nom = self._qemu_host_git("user.name")
|
||||
hote_mail = self._qemu_host_git("user.email")
|
||||
nom = input(f" {t('Name for git')} [{hote_nom}] : ").strip()
|
||||
mail = input(f" {t('Email for git')} [{hote_mail}] : ").strip()
|
||||
return agent, nom, mail
|
||||
|
||||
def _qemu_ask_python_provider(self, arches):
|
||||
"""mise (CPython précompilé) ou pyenv (compilation).
|
||||
|
||||
|
|
@ -1859,6 +1907,10 @@ class QemuDeployMixin:
|
|||
)
|
||||
)
|
||||
|
||||
# Ces trois réponses n'ont d'objet que si l'outil est coché : les
|
||||
# poser toujours ferait trois questions de plus à qui n'en veut pas.
|
||||
ai_agent, git_name, git_email = self._qemu_ask_ai_tools(vm_tools)
|
||||
|
||||
add_ssh_config = self._is_yes_default_yes(
|
||||
input(t("Add each VM to ~/.ssh/config? (Y/n): "))
|
||||
)
|
||||
|
|
@ -1922,6 +1974,9 @@ class QemuDeployMixin:
|
|||
# décochée (voir _qemu_run_spec).
|
||||
"monitor": monitor,
|
||||
"gpu3d": gpu3d,
|
||||
"ai_agent": ai_agent,
|
||||
"git_name": git_name,
|
||||
"git_email": git_email,
|
||||
"add_ssh_config": add_ssh_config,
|
||||
"parallelism": parallelism,
|
||||
}
|
||||
|
|
@ -2040,6 +2095,7 @@ class QemuDeployMixin:
|
|||
desktop = next((d for d in desktop_map.values() if d), "")
|
||||
python_provider = spec.get("python_provider") or ""
|
||||
app_store = spec.get("app_store") or "deb"
|
||||
ai_agent = spec.get("ai_agent") or ""
|
||||
# Outils de développement : cochés une fois pour tout le parc, puis
|
||||
# filtrés machine par machine (architecture, saveur de bureau).
|
||||
vm_tools = tuple(spec.get("vm_tools") or ())
|
||||
|
|
@ -2141,6 +2197,7 @@ class QemuDeployMixin:
|
|||
python_provider=python_provider,
|
||||
app_store=app_store,
|
||||
vm_tools=vm_tools,
|
||||
ai_agent=ai_agent,
|
||||
)
|
||||
elif install:
|
||||
print(
|
||||
|
|
@ -2159,6 +2216,7 @@ class QemuDeployMixin:
|
|||
python_provider=python_provider,
|
||||
app_store=app_store,
|
||||
vm_tools=vm_tools,
|
||||
ai_agent=ai_agent,
|
||||
)
|
||||
|
||||
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install
|
||||
|
|
|
|||
|
|
@ -348,6 +348,33 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
value=defaults.get("gpu3d", False),
|
||||
id="f_gpu3d",
|
||||
)
|
||||
# Révélés par la case « AI coding tools » du bloc des
|
||||
# outils : sans elle, ni l'agent ni l'identité git n'ont
|
||||
# d'objet, et trois widgets de plus encombrent un écran
|
||||
# déjà dense. Le nom et le courriel sont pré-remplis avec
|
||||
# l'identité de l'HÔTE — c'est ce que la VM reçoit
|
||||
# aujourd'hui, et un champ vide la ferait croire absente.
|
||||
yield Static(
|
||||
f" {t('AI coding tools')}",
|
||||
id="t_ai",
|
||||
classes="grouptitle",
|
||||
)
|
||||
yield Select(
|
||||
[("Claude Code", "claude"), ("opencode", "opencode")],
|
||||
value=defaults.get("ai_agent") or "claude",
|
||||
allow_blank=False,
|
||||
id="f_ai_agent",
|
||||
)
|
||||
yield Input(
|
||||
value=defaults.get("git_name", ""),
|
||||
placeholder=t("Name for git"),
|
||||
id="f_git_name",
|
||||
)
|
||||
yield Input(
|
||||
value=defaults.get("git_email", ""),
|
||||
placeholder=t("Email for git"),
|
||||
id="f_git_email",
|
||||
)
|
||||
# Le parallélisme reste dans « Déploiement » : c'est le
|
||||
# nombre de VM menées de front, pas une option
|
||||
# d'installation.
|
||||
|
|
@ -376,10 +403,25 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
yield Static("", id="totals")
|
||||
yield Footer()
|
||||
|
||||
# Les widgets que la case « AI coding tools » découvre.
|
||||
_AI_WIDGETS = ("#t_ai", "#f_ai_agent", "#f_git_name", "#f_git_email")
|
||||
|
||||
def _sync_ai(self) -> None:
|
||||
"""Montre ou cache le bloc IA selon la case des outils.
|
||||
|
||||
Cacher plutôt que griser : un champ grisé occupe la place et se
|
||||
lit comme un réglage qu'on aurait le droit de changer."""
|
||||
case = self.query("#f_tool_aidev")
|
||||
vu = bool(case) and bool(case.first(Checkbox).value)
|
||||
for sel in self._AI_WIDGETS:
|
||||
for widget in self.query(sel):
|
||||
widget.display = vu
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = t("Deploy ERPLibre VM(s)!")
|
||||
self._reload_catalog(first_load=True)
|
||||
self._sync_install_deps()
|
||||
self._sync_ai()
|
||||
|
||||
# -- catalogue et recalcul ------------------------------------- #
|
||||
def _entries(self):
|
||||
|
|
@ -880,6 +922,9 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
|
||||
elif event.checkbox.id == "f_par_all":
|
||||
self.query_one("#f_par", Select).disabled = event.value
|
||||
elif event.checkbox.id == "f_tool_aidev":
|
||||
self._sync_ai()
|
||||
self._recompute()
|
||||
elif str(event.checkbox.id or "").startswith("f_tool_"):
|
||||
# Un IDE de plus, c'est un disque plus grand : le plan doit le
|
||||
# montrer AVANT de déployer, pas après une heure d'installation.
|
||||
|
|
@ -921,6 +966,11 @@ def run_deploy_form(ctx, run_app: bool = True):
|
|||
# elle, et le tableau de bord ne s'ouvrait plus du tout.
|
||||
"monitor": self.query_one("#f_monitor", Checkbox).value,
|
||||
"gpu3d": self.query_one("#f_gpu3d", Checkbox).value,
|
||||
"ai_agent": self.query_one("#f_ai_agent", Select).value,
|
||||
"git_name": self.query_one("#f_git_name", Input).value.strip(),
|
||||
"git_email": self.query_one(
|
||||
"#f_git_email", Input
|
||||
).value.strip(),
|
||||
"res_label": (
|
||||
t("custom")
|
||||
if self.profile == "custom"
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@
|
|||
"""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
|
||||
|
||||
|
||||
|
|
@ -843,6 +845,26 @@ class QemuInstallMixin:
|
|||
#
|
||||
# Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
|
||||
# l'émulateur lui-même.
|
||||
# Ni bureau ni famille de paquets : les quatre outils sont des
|
||||
# installateurs amont, aucun n'est dans les dépôts des distributions
|
||||
# supportées. Une VM serveur les prend donc aussi bien qu'une VM
|
||||
# graphique — c'est en SSH qu'on s'en sert.
|
||||
#
|
||||
# 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",
|
||||
"hint": "rtk, starship, and one agent",
|
||||
"disk_gb": 2,
|
||||
"arches": (),
|
||||
"desktops": (),
|
||||
"needs_desktop": False,
|
||||
"families": (),
|
||||
# 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",
|
||||
},
|
||||
"avd": {
|
||||
"label": "Android emulator (Pixel)",
|
||||
"hint": "AVD viewable over ssh -X",
|
||||
|
|
@ -909,9 +931,31 @@ class QemuInstallMixin:
|
|||
"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."""
|
||||
|
|
@ -2004,7 +2048,57 @@ class QemuInstallMixin:
|
|||
"""Émulateur seul."""
|
||||
return self._qemu_after_remote_cmd(("avd",), prod)
|
||||
|
||||
def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"):
|
||||
def _qemu_aidev_remote_cmd(self, agent=""):
|
||||
"""rtk, starship et UN agent, posés dans la VM.
|
||||
|
||||
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 » la lui fait rater tout de suite, « timeout » borne le
|
||||
reste. C'est aussi pourquoi starship reçoit « -y ».
|
||||
|
||||
L'accroche du prompt et la ligne de PATH sont posées UNE fois :
|
||||
sans le « grep » qui précède, chaque redéploiement d'une même VM
|
||||
rallonge son ~/.bashrc d'une ligne identique.
|
||||
|
||||
Le répertoire est écrit en « $HOME » et non en « ~ » : entre
|
||||
guillemets, le tilde n'est pas étendu par le shell, et le PATH
|
||||
porterait alors un chemin qui n'existe pas.
|
||||
"""
|
||||
commande, repertoire = dev_tools.AGENTS.get(
|
||||
agent or dev_tools.AGENT_DEFAUT,
|
||||
dev_tools.AGENTS[dev_tools.AGENT_DEFAUT],
|
||||
)
|
||||
repertoire = repertoire.replace("~/", "$HOME/", 1)
|
||||
prompt = dev_tools.STARSHIP_LINE["bash"]
|
||||
path_line = f'export PATH="{repertoire}:$PATH"'
|
||||
|
||||
def pose(cmd, secondes):
|
||||
return (
|
||||
f"timeout {secondes} sh -c {shlex.quote(cmd)}"
|
||||
" </dev/null || true; "
|
||||
)
|
||||
|
||||
def une_fois(ligne, motif):
|
||||
return (
|
||||
f"grep -qF {shlex.quote(motif)} ~/.bashrc 2>/dev/null"
|
||||
f" || echo {shlex.quote(ligne)} >> ~/.bashrc; "
|
||||
)
|
||||
|
||||
return (
|
||||
f'echo "== {t("AI coding tools")} =="; '
|
||||
+ pose(dev_tools.RTK_UPSTREAM, 300)
|
||||
+ pose(dev_tools.STARSHIP_UPSTREAM_YES, 300)
|
||||
+ une_fois(prompt, "starship init bash")
|
||||
+ pose(commande, 600)
|
||||
+ une_fois(path_line, repertoire)
|
||||
)
|
||||
|
||||
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 —
|
||||
|
|
@ -2017,6 +2111,9 @@ class QemuInstallMixin:
|
|||
# Un seul bloc pour les deux options : voir _qemu_after_remote_cmd.
|
||||
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),
|
||||
"gnome_ext": self._qemu_gnome_ext_remote_cmd,
|
||||
"pycharm": lambda: self._qemu_pycharm_remote_cmd(prod),
|
||||
"android": self._qemu_android_studio_remote_cmd,
|
||||
|
|
|
|||
|
|
@ -5008,6 +5008,23 @@ TRANSLATIONS = {
|
|||
),
|
||||
},
|
||||
# Récupération de fichiers dans le disque d'une VM (libguestfs)
|
||||
# Outils d'assistance posés dans une VM au déploiement.
|
||||
"AI coding tools": {
|
||||
"fr": "Outils d'assistance IA",
|
||||
"en": "AI coding tools",
|
||||
},
|
||||
"rtk, starship, and one agent": {
|
||||
"fr": "rtk, starship, et un agent",
|
||||
"en": "rtk, starship, and one agent",
|
||||
},
|
||||
"Name for git": {
|
||||
"fr": "Nom pour git",
|
||||
"en": "Name for git",
|
||||
},
|
||||
"Email for git": {
|
||||
"fr": "Courriel pour git",
|
||||
"en": "Email for git",
|
||||
},
|
||||
"3D per VM": {
|
||||
"fr": "3D par VM",
|
||||
"en": "3D per VM",
|
||||
|
|
|
|||
258
test/test_qemu_ai_tools.py
Normal file
258
test/test_qemu_ai_tools.py
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
"""Les outils d'assistance posés dans une VM au déploiement.
|
||||
|
||||
rtk, starship et UN agent — Claude Code ou opencode. Aucun n'est dans les
|
||||
dépôts des distributions supportées : ce sont quatre installateurs amont,
|
||||
donc quatre curl vers l'extérieur, lancés sur un SSH sans terminal.
|
||||
|
||||
Ce que ces tests gardent :
|
||||
|
||||
- une seule autorité pour les URL amont : l'hôte et la VM posent les mêmes
|
||||
outils, et deux copies dérivent dès que l'amont en change une ;
|
||||
- aucune pose ne peut PENDRE : « || true » couvre l'échec, pas l'attente
|
||||
d'une réponse que personne ne donnera ;
|
||||
- les lignes ajoutées au ~/.bashrc le sont UNE fois, sinon chaque
|
||||
redéploiement d'une même VM le rallonge ;
|
||||
- l'identité git saisie prime sur celle de l'hôte, champ par champ.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.argv = ["todo.py"]
|
||||
from script.todo import dev_tools # noqa: E402
|
||||
from script.todo.deploy_form_lib import build_spec # noqa: E402
|
||||
from script.todo.todo import TODO # noqa: E402
|
||||
|
||||
RACINE = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _deploy_qemu():
|
||||
chemin = RACINE / "script/qemu/deploy_qemu.py"
|
||||
spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
DQ = _deploy_qemu()
|
||||
|
||||
|
||||
class UneSeuleAutorite(unittest.TestCase):
|
||||
"""Les mêmes outils se posent sur l'hôte et dans une VM."""
|
||||
|
||||
def test_the_host_reads_the_shared_table(self):
|
||||
"""Deux copies d'une URL dérivent dès que l'amont en change une, et
|
||||
la seconde est celle qu'on oublie."""
|
||||
self.assertIs(TODO._UPSTREAM_TOOLS, dev_tools.AGENTS)
|
||||
self.assertIs(TODO._STARSHIP_LINE, dev_tools.STARSHIP_LINE)
|
||||
self.assertEqual(TODO._STARSHIP_UPSTREAM, dev_tools.STARSHIP_UPSTREAM)
|
||||
|
||||
def test_both_agents_are_offered(self):
|
||||
self.assertEqual(["claude", "opencode"], sorted(dev_tools.AGENTS))
|
||||
|
||||
def test_the_default_agent_is_one_of_them(self):
|
||||
"""Le premier de la table ferait dépendre le défaut de l'ordre
|
||||
d'écriture d'un dictionnaire."""
|
||||
self.assertIn(dev_tools.AGENT_DEFAUT, dev_tools.AGENTS)
|
||||
|
||||
|
||||
class LeCatalogue(unittest.TestCase):
|
||||
def test_the_tool_exists_and_needs_no_desktop(self):
|
||||
"""On s'en sert en SSH : une VM serveur le prend aussi."""
|
||||
spec = TODO._QEMU_VM_TOOLS["aidev"]
|
||||
self.assertFalse(spec["needs_desktop"])
|
||||
self.assertEqual((), spec["arches"])
|
||||
self.assertEqual((), spec["families"])
|
||||
|
||||
def test_it_is_posed_before_the_clone(self):
|
||||
"""« before » est la phase où chaque outil se garde lui-même. En
|
||||
« after », un curl sans réponse rendrait la VM rouge."""
|
||||
self.assertEqual("before", TODO._QEMU_VM_TOOLS["aidev"]["phase"])
|
||||
|
||||
|
||||
class LaCommandeDistante(unittest.TestCase):
|
||||
def _cmd(self, agent=""):
|
||||
return TODO.__new__(TODO)._qemu_aidev_remote_cmd(agent)
|
||||
|
||||
def test_it_poses_the_three_tools(self):
|
||||
cmd = self._cmd("claude")
|
||||
self.assertIn("rtk", cmd)
|
||||
self.assertIn("starship", cmd)
|
||||
self.assertIn("claude.ai", cmd)
|
||||
|
||||
def test_the_chosen_agent_is_the_one_posed(self):
|
||||
"""Un seul agent, celui qu'on a demandé : poser les deux
|
||||
installerait un outil que personne n'a coché."""
|
||||
claude = self._cmd("claude")
|
||||
opencode = self._cmd("opencode")
|
||||
self.assertIn("claude.ai", claude)
|
||||
self.assertNotIn("opencode.ai", claude)
|
||||
self.assertIn("opencode.ai", opencode)
|
||||
self.assertNotIn("claude.ai", opencode)
|
||||
|
||||
def test_an_unknown_agent_falls_back(self):
|
||||
"""Une valeur inattendue ne doit pas faire tomber un déploiement."""
|
||||
self.assertIn("claude.ai", self._cmd("gemini"))
|
||||
self.assertIn("claude.ai", self._cmd(""))
|
||||
|
||||
def test_no_pose_can_hang(self):
|
||||
"""« || true » couvre l'ÉCHEC, pas l'ATTENTE. Un installateur qui
|
||||
pose une question resterait pendu sur un SSH sans terminal, et le
|
||||
déploiement avec lui."""
|
||||
cmd = self._cmd("claude")
|
||||
self.assertEqual(3, cmd.count("</dev/null"))
|
||||
self.assertEqual(3, cmd.count("timeout "))
|
||||
self.assertEqual(3, cmd.count("|| true"))
|
||||
|
||||
def test_starship_is_told_not_to_ask(self):
|
||||
"""Sans « -y », son installateur attend une confirmation."""
|
||||
self.assertIn("-s -- -y", self._cmd("claude"))
|
||||
|
||||
def test_the_rc_lines_are_written_once(self):
|
||||
"""Sans le « grep » qui précède, chaque redéploiement d'une même VM
|
||||
rallonge son ~/.bashrc d'une ligne identique."""
|
||||
cmd = self._cmd("claude")
|
||||
self.assertEqual(2, cmd.count("grep -qF"))
|
||||
for morceau in cmd.split(";"):
|
||||
if ">> ~/.bashrc" in morceau:
|
||||
self.assertIn("||", morceau)
|
||||
|
||||
def test_the_path_uses_home_not_a_tilde(self):
|
||||
"""Entre guillemets, le tilde n'est pas étendu : le PATH porterait
|
||||
un répertoire nommé « ~ », qui n'existe pas."""
|
||||
cmd = self._cmd("opencode")
|
||||
self.assertIn('export PATH="$HOME/.opencode/bin:$PATH"', cmd)
|
||||
self.assertNotIn('PATH="~/', cmd)
|
||||
|
||||
def test_it_is_valid_shell(self):
|
||||
"""Une commande mal citée casse tout le bloc des outils, pas
|
||||
seulement le sien."""
|
||||
for agent in ("claude", "opencode", ""):
|
||||
with self.subTest(agent=agent):
|
||||
fini = subprocess.run(
|
||||
["bash", "-n"],
|
||||
input=self._cmd(agent),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
self.assertEqual(0, fini.returncode, fini.stderr)
|
||||
|
||||
def test_nothing_is_posed_without_the_box(self):
|
||||
todo = TODO.__new__(TODO)
|
||||
cmd = todo._qemu_erplibre_remote_cmd("develop", tools=())
|
||||
self.assertNotIn("starship", cmd)
|
||||
|
||||
def test_the_agent_reaches_the_remote_command(self):
|
||||
"""L'épreuve du bout en bout : le choix doit traverser toute la
|
||||
chaîne, sans quoi il reste un réglage sans effet."""
|
||||
todo = TODO.__new__(TODO)
|
||||
cmd = todo._qemu_erplibre_remote_cmd(
|
||||
"develop", tools=("aidev",), ai_agent="opencode"
|
||||
)
|
||||
self.assertIn("opencode.ai", cmd)
|
||||
|
||||
|
||||
class LIdentiteGit(unittest.TestCase):
|
||||
"""Le formulaire montre l'identité de l'hôte et permet de la changer."""
|
||||
|
||||
def _args(self, **kw):
|
||||
argv = ["--distro", "ubuntu", "--hostname", "vm"]
|
||||
for cle, valeur in kw.items():
|
||||
argv += [f"--{cle.replace('_', '-')}", valeur]
|
||||
return DQ.build_parser().parse_args(argv)
|
||||
|
||||
def _identite(self, cc):
|
||||
lu = {}
|
||||
for ligne in cc.split("\n"):
|
||||
for champ in ("name", "email"):
|
||||
if ligne.strip().startswith(f"{champ} = "):
|
||||
lu[champ] = ligne.split(" = ", 1)[1]
|
||||
return lu
|
||||
|
||||
def test_what_was_typed_wins(self):
|
||||
cc = DQ.build_cloud_config(
|
||||
self._args(
|
||||
git_name="Une Personne", git_email="qui@exemple.invalid"
|
||||
),
|
||||
None,
|
||||
[],
|
||||
)
|
||||
self.assertEqual(
|
||||
{"name": "Une Personne", "email": "qui@exemple.invalid"},
|
||||
self._identite(cc),
|
||||
)
|
||||
|
||||
def test_each_field_stands_alone(self):
|
||||
"""Remplir le seul courriel ne doit pas effacer le nom : les deux
|
||||
retombent sur l'hôte SÉPARÉMENT."""
|
||||
cc = DQ.build_cloud_config(
|
||||
self._args(git_email="qui@exemple.invalid"), None, []
|
||||
)
|
||||
lu = self._identite(cc)
|
||||
self.assertEqual("qui@exemple.invalid", lu.get("email"))
|
||||
self.assertNotEqual("", lu.get("name", ""))
|
||||
|
||||
def test_the_option_reaches_the_command(self):
|
||||
"""Le réglage doit atteindre deploy_qemu, sinon il n'existe pas."""
|
||||
todo = TODO.__new__(TODO)
|
||||
cmd = todo._qemu_build_deploy_parts(
|
||||
"ubuntu",
|
||||
"24.04",
|
||||
"amd64",
|
||||
"vm",
|
||||
2048,
|
||||
2,
|
||||
"20G",
|
||||
"",
|
||||
"",
|
||||
True,
|
||||
git_name="Une Personne",
|
||||
git_email="qui@exemple.invalid",
|
||||
)
|
||||
self.assertIn("--git-name", cmd)
|
||||
self.assertIn("Une Personne", cmd)
|
||||
self.assertIn("--git-email", cmd)
|
||||
|
||||
def test_an_empty_identity_says_nothing(self):
|
||||
"""Vide, deploy_qemu recopie l'hôte : ajouter l'option avec une
|
||||
valeur vide écraserait cette identité par du rien."""
|
||||
todo = TODO.__new__(TODO)
|
||||
cmd = todo._qemu_build_deploy_parts(
|
||||
"ubuntu", "24.04", "amd64", "vm", 2048, 2, "20G", "", "", True
|
||||
)
|
||||
self.assertNotIn("--git-name", cmd)
|
||||
self.assertNotIn("--git-email", cmd)
|
||||
|
||||
|
||||
class LaSpec(unittest.TestCase):
|
||||
def test_the_three_settings_reach_the_spec(self):
|
||||
"""Absents de l'assemblée, les réglages du formulaire n'atteignent
|
||||
jamais le déploiement — la case resterait décorative."""
|
||||
spec = build_spec(
|
||||
[],
|
||||
[],
|
||||
{
|
||||
"res_label": "x1",
|
||||
"ssh_key": "",
|
||||
"install": None,
|
||||
"add_ssh_config": False,
|
||||
"parallelism": 1,
|
||||
"ai_agent": "opencode",
|
||||
"git_name": "Une Personne",
|
||||
"git_email": "qui@exemple.invalid",
|
||||
},
|
||||
)
|
||||
self.assertEqual("opencode", spec["ai_agent"])
|
||||
self.assertEqual("Une Personne", spec["git_name"])
|
||||
self.assertEqual("qui@exemple.invalid", spec["git_email"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -288,9 +288,10 @@ class TestMobileBuild(unittest.TestCase):
|
|||
|
||||
def test_it_runs_on_a_server_vm(self):
|
||||
"""Elle compile, elle n'affiche rien : un bureau serait du gaspillage.
|
||||
L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X."""
|
||||
L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X, et
|
||||
les outils d'assistance encore moins : on s'en sert en SSH."""
|
||||
got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
|
||||
self.assertEqual(["mobile", "forgejo", "avd"], got)
|
||||
self.assertEqual(["mobile", "forgejo", "aidev", "avd"], got)
|
||||
# Forgejo est là pour la même raison que la compilation : un
|
||||
# service ne demande pas d'écran.
|
||||
|
||||
|
|
|
|||
|
|
@ -182,10 +182,6 @@ class LAbiFigee(unittest.TestCase):
|
|||
self.assertNotIn("⚠", vivante)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
|
|
@ -281,3 +277,7 @@ class LesGroupesGpuDeLInvite(unittest.TestCase):
|
|||
self._args(distro="debian", gpu="off"), "$6$x$y", []
|
||||
)
|
||||
self.assertNotIn("groupadd", pre)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
Loading…
Reference in a new issue