diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 94cafff..a7e6b85 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -291,6 +291,53 @@ def plan_rows(vms, domains, extra_disk_gb=0, orphelin=None): return rows +# Les branches qu'on veut voir en premier. « ls-remote » les rend par ordre +# alphabétique : la liste commençait donc par « dependabot/pip/aiobotocore », +# et c'est CELLE-LÀ qui était proposée par défaut — rapporté. +BRANCHES_EN_TETE = ("develop", "master") + + +def branch_default(branches, courante=""): + """La branche à proposer : celle du DÉPÔT d'abord. + + Déployer avec la branche qu'on a sous les yeux est le cas courant ; à + défaut « develop », puis « master ». Le premier de la liste alphabétique + n'a aucune raison d'être un bon défaut, et c'était une branche de + dependabot. + """ + liste = list(branches or ()) + if not liste: + return "" + for candidate in (courante,) + BRANCHES_EN_TETE: + if candidate and candidate in liste: + return candidate + return liste[0] + + +def branch_order(branches, courante=""): + """La liste réordonnée : la branche du dépôt, develop, master, puis le + reste, et les branches de robot à la fin. + + Une liste de soixante entrées qui commence par six « dependabot/pip/… » + demande de dérouler pour trouver ce qu'on cherche. + """ + liste = list(branches or ()) + # Dédoublonnée : la branche du dépôt EST souvent « develop », et la voir + # deux fois de suite ferait douter de la liste. + tete, vues = [], set() + for b in (courante,) + BRANCHES_EN_TETE: + if b and b in liste and b not in vues: + tete.append(b) + vues.add(b) + milieu, robots = [], [] + for b in liste: + if b in vues: + continue + vues.add(b) + (robots if b.startswith("dependabot/") else milieu).append(b) + return tete + milieu + robots + + def gib(nbytes) -> int: """Octets -> Gio entiers. Le plan compte en Go partout ailleurs : mêler des unités sur la même ligne de totaux la rendrait illisible.""" @@ -550,7 +597,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): Un bloc reste DÉPLIÉ tant que la VM tourne, se replie dès qu'elle réussit — et reste ouvert si elle échoue, puisque c'est ce qu'on veut lire.""" + import shlex import subprocess + + shlex_quote = shlex.quote import threading from textual.app import App, ComposeResult @@ -579,6 +629,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): #hint { height: auto; color: $text-muted; padding: 0 1; } """ BINDINGS = [ + # « s » comme dans le tableau de bord : une VM qui vient d'être + # créée est souvent joignable tout de suite, et on veut y entrer + # sans quitter l'écran. Rapporté comme manquant. + ("s", "ssh", t("SSH")), ("c", "copy_current", t("Copy log")), ("C", "copy_all", t("Copy all logs")), ("q", "quit", t("Quit")), @@ -587,6 +641,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): def __init__(self): super().__init__() self._out = {name: "" for _jid, name, _p in jobs} + # Ce qui a DÉJÀ été écrit à l'écran, pour ne pas le doubler. + self._ecrites = {} + # Les VM créées sans erreur : celles où « s » peut entrer. + self._reussies = [] self._done = 0 self._t0 = time.time() self._slots = threading.Semaphore(max(1, parallelism)) @@ -609,7 +667,8 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): with Vertical(): yield Static("", id="summary") yield Static( - f" {t('c copy log · C copy all · q quit')}", id="hint" + f" {t('s ssh · c copy log · C copy all · q quit')}", + id="hint", ) yield Button(t("Copy all logs"), id="copyall") yield Footer() @@ -634,27 +693,65 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): def _job() -> None: with self._slots: t0 = time.time() + lignes = [] try: - res = subprocess.run( - parts, capture_output=True, text=True + # « Popen » et LECTURE LIGNE À LIGNE, pas + # « subprocess.run » : celui-ci ne rend la sortie + # qu'à la fin du travail, et le bloc restait vide + # pendant tout le déploiement — rapporté. Sur Proxmox, + # une VM demande le téléchargement d'une image de + # 325 Mio puis l'import du disque : plusieurs minutes + # de silence, et rien à l'écran. + proc = subprocess.Popen( + parts, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, ) - rc = res.returncode - out = (res.stdout or "") + (res.stderr or "") except (OSError, subprocess.SubprocessError) as exc: - rc, out = 1, str(exc) + self.call_from_thread( + self._finish, + jid, + name, + 1, + str(exc), + time.time() - t0, + ) + return + for ligne in proc.stdout: + ligne = ligne.rstrip("\n") + lignes.append(ligne) + self.call_from_thread(self._ecrire, name, ligne) + rc = proc.wait() + out = "\n".join(lignes) self.call_from_thread( self._finish, jid, name, rc, out, time.time() - t0 ) self.run_worker(_job, thread=True, group="deploy", exclusive=False) + def _ecrire(self, name, ligne): + """Une ligne, dès qu'elle arrive. Appelée depuis le fil.""" + self._ecrites[name] = True + try: + self.query_one(f"#log_{slug(name)}", RichLog).write(ligne) + except Exception: + pass + def _finish(self, jid, name, rc, out, secs): self._out[name] = out results.append((name, rc, out, secs)) self._done += 1 - log = self.query_one(f"#log_{slug(name)}", RichLog) - for line in out.strip().splitlines(): - log.write(line) + if rc == 0: + self._reussies.append(name) + # La sortie est déjà à l'écran, ligne par ligne : la réécrire + # la doublerait. Seul un échec avant même le démarrage du + # processus n'a rien écrit. + if not self._ecrites.get(name): + log = self.query_one(f"#log_{slug(name)}", RichLog) + for line in out.strip().splitlines(): + log.write(line) block = self.query_one(f"#{slug(name)}", Collapsible) mark = "✅" if rc == 0 else "❌" block.title = f"{mark} [{jid}] {name} · {fmt_dur(secs)}" + ( @@ -665,6 +762,27 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): block.collapsed = rc == 0 self._refresh_summary() + def action_ssh(self) -> None: + """Entre dans la VM créée, sans quitter l'écran. + + Par son NOM et non par son adresse : c'est l'entrée + ~/.ssh/config que le déploiement vient d'écrire qui sait comment + l'atteindre — et pour une VM posée sur un hôte Proxmox, elle + porte le rebond, seul chemin vers son réseau interne. + + « suspend() » rend le terminal : ssh a besoin du clavier, et + Textual le tient encore. + """ + import os + + if not self._reussies: + self.notify(t("No VM created yet."), severity="warning") + return + # La dernière créée : c'est celle qu'on regarde. + nom = self._reussies[-1] + with self.suspend(): + os.system(f"ssh {shlex_quote(nom)} || true") + # -- presse-papiers (OSC 52 : traverse SSH) --------------------- # def _copy(self, text, what): payload, cut = clip_payload(text) diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 7115e65..c2bbc5d 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -28,6 +28,8 @@ from script.todo.deploy_form_lib import ( FREE, RES_FIELDS, SELECT_TO_FIELD, + branch_default, + branch_order, build_vms, disk_note, entry_key, @@ -126,7 +128,12 @@ def run_proxmox_form(ctx, run_app: bool = True): catalog = ctx["catalog"] noms_pris = ctx["names"] vmids_pris = ctx["vmids"] - branches = ctx.get("branches") or ["master"] + # Ordre et défaut partagés avec le formulaire QEMU/KVM : la liste vient + # de « git ls-remote », alphabétique, et commençait donc par une branche + # de dependabot — proposée par défaut. Rapporté. + branches = branch_order( + ctx.get("branches") or ["master"], ctx.get("branch_current") + ) profiles = ctx.get("install_profiles") or [] stockages = ctx.get("storages") or [] ponts = ctx.get("bridges") or [] @@ -309,7 +316,9 @@ def run_proxmox_form(ctx, run_app: bool = True): ) yield Select( [(b, b) for b in branches], - value=branches[0], + value=branch_default( + branches, ctx.get("branch_current") + ), allow_blank=False, id="f_branch", ) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 2cf0df5..4dc4349 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -825,6 +825,8 @@ class ProxmoxMenuMixin: "internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR), "build_command": build_command, "branches": self._qemu_branch_list() or ["master"], + # La branche du dépôt : c'est elle qu'on déploie le plus souvent. + "branch_current": self._qemu_repo_branch(), "install_profiles": self._qemu_install_profiles(), # Même règle qu'en QEMU/KVM : un système peut IMPOSER ce qu'on # installe dessus. Un Proxmox imbriqué recevait sinon ERPLibre et diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 55d102c..cf5c849 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -1254,6 +1254,8 @@ class QemuDeployMixin: "native": native, "domains": self._qemu_list_domains(), "branches": self._qemu_branch_list() or ["master"], + # La branche du dépôt : c'est elle qu'on déploie le plus souvent. + "branch_current": self._qemu_repo_branch(), "install_profiles": self._qemu_install_profiles(), # Les systèmes qui IMPOSENT ce qu'on installe dessus. Sans cette # table, le formulaire posait ERPLibre + Odoo 18 sur une VM diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 71b9e1d..d0008d3 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -41,6 +41,8 @@ from script.todo.deploy_form_lib import ( # noqa: F401 SELECT_TO_FIELD, apply_overrides, apply_profile, + branch_default, + branch_order, build_spec, build_vms, clean_hostname, @@ -116,7 +118,9 @@ def run_deploy_form(ctx, run_app: bool = True): no_erplibre = { impose[1].strip() for impose in distro_profiles.values() if impose } - branches = ctx.get("branches") or ["master"] + branches = branch_order( + ctx.get("branches") or ["master"], ctx.get("branch_current") + ) host_cpu = ctx.get("host_cpu") or 2 free_ram = ctx.get("free_ram") or 0 free_disk = ctx.get("free_disk") or 0 @@ -332,8 +336,8 @@ def run_deploy_form(ctx, run_app: bool = True): ) yield Select( [(b, b) for b in branches], - value=( - "develop" if "develop" in branches else branches[0] + value=branch_default( + branches, ctx.get("branch_current") ), allow_blank=False, id="f_branch", diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index ee0542a..135340c 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -2304,6 +2304,24 @@ class QemuManageMixin: pass return None, None, arch + @staticmethod + def _qemu_repo_branch(): + """Branche du dépôt COURANT, ou '' — le défaut des formulaires. + + On déploie le plus souvent ce qu'on a sous les yeux ; le premier nom + de la liste alphabétique, lui, n'a aucune raison d'être bon.""" + try: + res = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + capture_output=True, + text=True, + timeout=10, + ) + except (OSError, subprocess.SubprocessError): + return "" + nom = (res.stdout or "").strip() + return "" if res.returncode or nom == "HEAD" else nom + def _qemu_branch_list(self): """Branches distantes d'ERPLibre, triées. Vide si le réseau manque. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c68d2c0..ed41548 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3986,6 +3986,14 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + "s ssh · c copy log · C copy all · q quit": { + "fr": "s ssh · c copier le log · C copier tout · q quitter", + "en": "s ssh · c copy log · C copy all · q quit", + }, + "No VM created yet.": { + "fr": "Aucune VM créée pour l'instant.", + "en": "No VM created yet.", + }, # « models » manquait alors que ses trois voisines de la même ligne # étaient traduites : le rapport de qualité de migration sortait # « 42 modules · 812 models · 1 204 vues ». diff --git a/test/test_todo_deploy_form_lib.py b/test/test_todo_deploy_form_lib.py index 637d850..16ed994 100644 --- a/test/test_todo_deploy_form_lib.py +++ b/test/test_todo_deploy_form_lib.py @@ -52,6 +52,58 @@ class TestRessourcesLibres(unittest.TestCase): self.assertEqual(lib.res_value("8", "", 2), 8) +class TestLesBranches(unittest.TestCase): + """« git ls-remote » rend les branches par ordre alphabétique. + + Rapporté : le formulaire Proxmox proposait « dependabot/pip/aiobotocore- + 3.1.3 » — la première de la liste. Le bon défaut est la branche du DÉPÔT + qu'on a sous les yeux, puis develop, puis master. + """ + + BRANCHES = [ + "dependabot/pip/aiobotocore-3.1.3", + "dependabot/pip/boto3-1.42.49", + "develop", + "dev_mobile", + "master", + ] + + def test_the_checkout_branch_wins(self): + self.assertEqual( + lib.branch_default(self.BRANCHES, "dev_mobile"), "dev_mobile" + ) + + def test_without_it_develop_then_master(self): + self.assertEqual(lib.branch_default(self.BRANCHES, ""), "develop") + sans_develop = [b for b in self.BRANCHES if b != "develop"] + self.assertEqual(lib.branch_default(sans_develop, ""), "master") + + def test_a_branch_that_no_longer_exists_is_ignored(self): + # On déploie une branche qui existe, pas celle qu'on avait localement. + self.assertEqual( + lib.branch_default(self.BRANCHES, "partie-en-fumée"), "develop" + ) + + def test_the_last_resort_is_the_first_but_never_by_default(self): + self.assertEqual(lib.branch_default(["zzz"], ""), "zzz") + self.assertEqual(lib.branch_default([], "develop"), "") + + def test_the_list_puts_the_useful_ones_first(self): + ordre = lib.branch_order(self.BRANCHES, "dev_mobile") + self.assertEqual(ordre[:3], ["dev_mobile", "develop", "master"]) + + def test_the_robots_go_last(self): + ordre = lib.branch_order(self.BRANCHES, "develop") + self.assertTrue(all(b.startswith("dependabot/") for b in ordre[-2:])) + + def test_nothing_is_lost_nor_duplicated(self): + # La branche du dépôt EST souvent « develop » : la voir deux fois + # ferait douter de la liste. + ordre = lib.branch_order(self.BRANCHES, "develop") + self.assertEqual(sorted(ordre), sorted(self.BRANCHES)) + self.assertEqual(len(ordre), len(set(ordre))) + + class TestPlaceDisque(unittest.TestCase): """La demande du plan ne dit pas si ça rentre : il faut la place à côté.""" diff --git a/test/test_todo_deploy_progress.py b/test/test_todo_deploy_progress.py new file mode 100644 index 0000000..c22ca8c --- /dev/null +++ b/test/test_todo_deploy_progress.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La vue de progression d'un déploiement : en direct, et on peut y entrer. + +Rapporté sur un déploiement Proxmox : « les logs ne sont pas live, ils sont +apparus à la toute fin » et « il manque les boutons comme s pour se connecter +en ssh ». Les deux étaient vrais de cette vue — elle exécutait chaque travail +avec `subprocess.run`, qui ne rend sa sortie qu'à la fin, et ses touches se +limitaient à copier et quitter. + +Une VM sur Proxmox demande le téléchargement d'une image de 325 Mio puis +l'import de son disque : plusieurs minutes d'un bloc vide. +""" + +import asyncio +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.deploy_form_lib import run_deploy_progress # noqa: E402 + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLeFlux(unittest.TestCase): + def test_the_lines_appear_while_the_job_runs(self): + """Le cœur du rapport : compté PENDANT, pas après.""" + jobs = [ + ( + "1", + "vm-flux", + [ + "bash", + "-c", + "for i in $(seq 1 6); do echo ligne $i; sleep 1; done", + ], + ) + ] + vu = [] + + async def scenario(): + from textual.widgets import RichLog + + app = run_deploy_progress(jobs, 1, run_app=False) + async with app.run_test(size=(120, 30)) as pilote: + await pilote.pause() + journal = app.query_one(RichLog) + for _ in range(3): + await asyncio.sleep(1.5) + await pilote.pause() + vu.append(len(journal.lines)) + vu.append(len(app._reussies)) + + asyncio.run(scenario()) + # Au moins une ligne AVANT la fin, et de plus en plus. + self.assertGreater(vu[0], 0, "rien à l'écran pendant le travail") + self.assertGreater(vu[2], vu[0], "l'affichage n'avance pas") + + def test_the_output_is_not_written_twice(self): + # `_finish` réécrivait tout : la sortie apparaîtrait en double. + jobs = [("1", "vm-court", ["bash", "-c", "echo une; echo deux"])] + vu = {} + + async def scenario(): + from textual.widgets import RichLog + + app = run_deploy_progress(jobs, 1, run_app=False) + async with app.run_test(size=(120, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.5) + await pilote.pause() + vu["lignes"] = len(app.query_one(RichLog).lines) + + asyncio.run(scenario()) + self.assertEqual(vu["lignes"], 2) + + def test_a_command_that_cannot_start_still_says_so(self): + # Le processus n'a rien écrit : c'est le seul cas où `_finish` doit + # poser la sortie lui-même. + jobs = [("1", "vm-absente", ["/n/existe/pas/du/tout"])] + vu = {} + + async def scenario(): + from textual.widgets import RichLog + + app = run_deploy_progress(jobs, 1, run_app=False) + async with app.run_test(size=(120, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.0) + await pilote.pause() + vu["lignes"] = len(app.query_one(RichLog).lines) + vu["resultats"] = list(app._reussies) + + asyncio.run(scenario()) + self.assertGreater(vu["lignes"], 0, "l'échec ne dit rien") + self.assertEqual(vu["resultats"], []) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLaToucheSsh(unittest.TestCase): + def test_the_key_is_offered(self): + app = run_deploy_progress([("1", "vm", ["true"])], 1, run_app=False) + self.assertIn("s", [b[0] for b in type(app).BINDINGS]) + + def test_it_targets_the_vm_that_was_created(self): + jobs = [("1", "vm-creee", ["bash", "-c", "echo ok"])] + vu = {} + + async def scenario(): + app = run_deploy_progress(jobs, 1, run_app=False) + async with app.run_test(size=(120, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.2) + await pilote.pause() + vu["reussies"] = list(app._reussies) + + asyncio.run(scenario()) + # Par son NOM : c'est l'entrée ~/.ssh/config qui sait l'atteindre, et + # pour une VM Proxmox elle porte le rebond. + self.assertEqual(vu["reussies"], ["vm-creee"]) + + def test_a_failed_job_is_not_offered(self): + jobs = [("1", "vm-ratee", ["false"])] + vu = {} + + async def scenario(): + app = run_deploy_progress(jobs, 1, run_app=False) + async with app.run_test(size=(120, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.2) + await pilote.pause() + vu["reussies"] = list(app._reussies) + + asyncio.run(scenario()) + self.assertEqual(vu["reussies"], []) + + +if __name__ == "__main__": + unittest.main(verbosity=2)