diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index a7e6b85..793c52a 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -591,7 +591,9 @@ def res_row_widgets(index, vm, presets, labels=None, null=None): return widgets -def run_deploy_progress(jobs, parallelism, run_app: bool = True): +def run_deploy_progress( + jobs, parallelism, run_app: bool = True, ssh_cmds=None, suite="" +): """Déploie `jobs` = [(id, nom, argv)] en parallèle, un bloc repliable par VM. Renvoie [(nom, rc, sortie, durée)]. `run_app=False` renvoie l'app. @@ -680,9 +682,14 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): self.run_job(jid, name, parts) def _refresh_summary(self): + # CE QUI SUIT, dit ici : l'installation d'ERPLibre ne démarre + # qu'au moment où l'on quitte cet écran, et rien ne le disait — + # on attendait devant une fenêtre pourtant terminée. + fini = self._done >= len(jobs) + apres = f"\n → {suite}" if (fini and suite) else "" self.query_one("#summary", Static).update( f" {self._done}/{len(jobs)} — " - f"{fmt_dur(time.time() - self._t0)}" + f"{fmt_dur(time.time() - self._t0)}{apres}" ) # `thread=True` : subprocess.run est bloquant ; le faire dans un @@ -765,10 +772,11 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): 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. + La commande vient de l'APPELANT quand il sait joindre la VM : sur + Proxmox, l'entrée ~/.ssh/config n'est écrite qu'après le + déploiement, et le nom de la VM peut désigner une machine LOCALE + homonyme — « s » ouvrait alors la mauvaise. Le déploiement passe + donc « ssh -J user@ », qui ne dépend de rien. « suspend() » rend le terminal : ssh a besoin du clavier, et Textual le tient encore. @@ -780,8 +788,10 @@ def run_deploy_progress(jobs, parallelism, run_app: bool = True): return # La dernière créée : c'est celle qu'on regarde. nom = self._reussies[-1] + cmd = (ssh_cmds or {}).get(nom) or f"ssh {shlex_quote(nom)}" with self.suspend(): - os.system(f"ssh {shlex_quote(nom)} || true") + print(f"\n→ {cmd}\n") + os.system(f"{cmd} || true") # -- presse-papiers (OSC 52 : traverse SSH) --------------------- # def _copy(self, text, what): diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index fb18eca..71f0a2f 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -967,7 +967,33 @@ class ProxmoxMenuMixin: # sortie ; celle-ci les écrit, ce qui vaut mieux qu'un défilement. session = self._pve_log_dir() print(f"\n {t('Log:')} {session}") - resultats = run_deploy_progress(travaux, spec.get("parallelism") or 1) + # Comment joindre chaque VM SANS dépendre de ~/.ssh/config, qui n'est + # écrit qu'après : par le rebond de l'hôte, explicitement. C'est ce que + # « s » utilise dans la vue de progression — sans quoi il partait sur + # le nom de la VM, donc sur une locale homonyme (rapporté). + cibles_ssh = {} + for vm in spec["vms"]: + ip = pve.ip_from_ipconfig(vm.get("ipconfig") or "") + if ip: + compte = (spec.get("user") or "erplibre") + "@" + ip + cibles_ssh[vm["name"]] = ( + f"ssh -J {shlex.quote(host['target'])} " + f"{shlex.quote(compte)}" + ) + # Ce qui attend derrière cet écran : sans le dire, on reste devant + # une fenêtre « terminée » sans savoir que l'installation d'ERPLibre + # démarre en la quittant. + suite = "" + if spec.get("install"): + suite = t("Quit (q) to start the ERPLibre install") + elif spec.get("monitor", True): + suite = t("Quit (q) to follow the VM starting up") + resultats = run_deploy_progress( + travaux, + spec.get("parallelism") or 1, + ssh_cmds=cibles_ssh, + suite=suite, + ) reussies = [nom for nom, code, _o, _d in resultats if code == 0] for nom, code, sortie, duree in resultats: chemin = self._pve_write_log( @@ -1215,6 +1241,10 @@ class ProxmoxMenuMixin: "sudo": host.get("sudo") or "", "jump": host.get("jump") or "", "vmid": vm.get("vmid"), + # L'adresse INTERNE : elle n'est pas routable d'ici, mais + # elle l'est depuis l'hôte. Avec le rebond, le tableau de + # bord entre dans la VM sans dépendre de ~/.ssh/config. + "addr": vm.get("adresse") or "", } for vm in joignables if vm.get("vmid") diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 972da02..58e3265 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1309,10 +1309,14 @@ def read_domstats() -> str: # disque et le cumul écrit de TOUTES ses VM d'un coup. Le « du » qui suit # donne la taille RÉELLEMENT occupée : sur un stockage en fichiers, Proxmox # rapporte « disk: 0 » — il ne la calcule pas. +# +# « -sB1 » et NON « -sb » : le second rend la taille APPARENTE, et un disque +# raw creux de 6 Go la donne entière. La colonne affichait donc « 6.0G/6.0G », +# un disque plein, quand l'invité n'avait écrit que 1,2 Go — rapporté. PVE_STATS_CMD = ( "pvesh get /cluster/resources --type vm --output-format json;" " echo '---ERPLIBRE-DU---';" - " du -sb /var/lib/vz/images/*/ 2>/dev/null || true" + " du -sB1 /var/lib/vz/images/*/ 2>/dev/null || true" ) # Une VM distante se relève moins souvent qu'une locale : chaque tour coûte # une poignée de main ssh (mesuré 1 s), quand « virsh domstats » coûte 0,03 s @@ -1337,7 +1341,7 @@ def parse_pvestats(text: str) -> dict: ressources = json.loads(brut.strip() or "[]") except ValueError: return {} - # {vmid: octets} depuis « du -sb /var/lib/vz/images// ». + # {vmid: octets} depuis « du -sB1 /var/lib/vz/images// ». occupe = {} for ligne in tailles.splitlines(): parts = ligne.split() @@ -1404,6 +1408,46 @@ def read_pvestats(vms, now=None) -> dict: return dict(stats) +def vm_ssh_prefix(vm) -> str: + """« ssh … » pour entrer dans CETTE VM, adresse comprise. + + Une VM d'un hôte Proxmox vit derrière lui : son adresse n'est pas + routable d'ici, et seul le rebond y mène. On le construit explicitement + plutôt que de compter sur un alias ~/.ssh/config, qui peut ne pas exister + — ou, pire, désigner une VM LOCALE homonyme. C'est ce qui a fait ouvrir + la mauvaise machine avec « s ». + """ + info = (vm or {}).get("pve") or {} + adresse = info.get("addr") + if info.get("target") and adresse: + saut = f"-J {shlex.quote(info['jump'])} " if info.get("jump") else "" + return ( + f"ssh {SSH_OPTS} {saut}-J {shlex.quote(info['target'])} " + f"erplibre@{adresse}" + ) + return f"ssh {SSH_OPTS} erplibre@{(vm or {}).get('ip')}" + + +def pve_host_cmd(info, remote, tty=False) -> str: + """Commande shell qui exécute `remote` SUR l'hôte Proxmox d'une VM. + + Chaque action du tableau de bord qui parlait à libvirt par le NOM frappait + la mauvaise machine dès qu'un domaine local portait le même : la console + ouvrait celle de la VM locale, la pause suspendait la locale. L'hôte est + la seule autorité pour une VM distante, et le VMID son seul identifiant. + """ + sudo = (info or {}).get("sudo") or "" + cible = (info or {}).get("target") or "" + prefixe = f"{sudo}sh -c {shlex.quote(remote)}" if sudo else remote + saut = ( + f"-J {shlex.quote(info['jump'])} " if (info or {}).get("jump") else "" + ) + return ( + f"ssh {'-t ' if tty else ''}{saut}{shlex.quote(cible)} " + f"{shlex.quote(prefixe)}" + ) + + def arm_balloon(names) -> None: """Arme la période de collecte du ballon (5 s) sur chaque VM. @@ -2384,8 +2428,10 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm = self._vm_by_name(self._selected) if not vm: return + cmd = vm_ssh_prefix(vm) with self.suspend(): - os.system(f"ssh {SSH_OPTS} erplibre@{vm['ip']} || true") + print(f"\n→ {cmd}\n") + os.system(f"{cmd} || true") def action_console(self) -> None: """Console série de la VM, sans quitter le suivi. @@ -2402,18 +2448,33 @@ def run_monitor(manifest_path: str, run_app: bool = True): vm = self._vm_by_name(self._selected) if not vm: return - name = shlex.quote(vm["name"]) + info = vm.get("pve") + if info: + # VM d'un hôte Proxmox : sa console est « qm terminal », sur + # l'hôte. « virsh console » ouvrait celle du domaine + # LOCAL homonyme — la mauvaise machine, sans le dire. + cmd = pve_host_cmd( + info, f"qm terminal {int(info.get('vmid') or 0)}", tty=True + ) + titre = ( + f"qm terminal {info.get('vmid')} @ {info.get('target')}" + ) + sortie = "Ctrl+O" + else: + cmd = f"sudo virsh console {shlex.quote(vm['name'])}" + titre = f"virsh console {vm['name']}" + sortie = "Ctrl+]" with self.suspend(): # La console n'affiche que ce qui arrive APRÈS l'attachement : # sur une VM déjà démarrée l'écran reste noir tant qu'on n'a # rien envoyé. On le dit, plutôt que de laisser croire à un gel. - print(f"\n→ virsh console {vm['name']}") + print(f"\n→ {titre}") print( " Écran vide ? Appuyez sur Entrée : la console ne montre" " que la sortie qui suit l'attachement." ) - print(" Ctrl+] puis Entrée pour revenir au suivi.\n") - os.system(f"sudo virsh console {name} || true") + print(f" {sortie} puis Entrée pour revenir au suivi.\n") + os.system(f"{cmd} || true") def action_web(self) -> None: """Ouvre l'UI web de la VM (Odoo :8069) dans un navigateur CLI @@ -2552,15 +2613,30 @@ def run_monitor(manifest_path: str, run_app: bool = True): # -- pause / reprise de tout le parc -------------------------------- # @staticmethod - def _virsh_bulk(action, names): - for n in names: + def _virsh_bulk(action, cibles): + """Suspend/reprend chaque VM, chacune par SON hyperviseur. + + `cibles` : [(nom, info_pve|None)]. Une VM distante se suspend par + son VMID sur son hôte — « virsh suspend » aurait mis en + pause le domaine LOCAL homonyme.""" + for nom, info in cibles: try: - subprocess.run( - ["sudo", "virsh", action, n], - capture_output=True, - text=True, - timeout=30, - ) + if info: + vmid = int(info.get("vmid") or 0) + subprocess.run( + pve_host_cmd(info, f"qm {action} {vmid}"), + shell=True, + capture_output=True, + text=True, + timeout=60, + ) + else: + subprocess.run( + ["sudo", "virsh", action, nom], + capture_output=True, + text=True, + timeout=30, + ) except (OSError, subprocess.SubprocessError): pass @@ -2701,8 +2777,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): with self.suspend(): print(f"\n=== {title} — {vm['name']} ===") os.system( - f"ssh {SSH_OPTS} erplibre@{vm['ip']} " - f"{shlex.quote(cmd)} || true" + f"{vm_ssh_prefix(vm)} " f"{shlex.quote(cmd)} || true" ) input("\nEntrée pour revenir au suivi… ") @@ -2751,7 +2826,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): async def _bulk_worker(self, action): want = "running" if action == "suspend" else "paused" targets = [ - vm["name"] + (vm["name"], vm.get("pve")) for vm in vms if self._domstate.get(vm["name"]) == want ] diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 3e28aed..e3e7b54 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3986,6 +3986,14 @@ TRANSLATIONS = { "fr": "Fichiers orphelins :", "en": "Orphan files:", }, + "Quit (q) to start the ERPLibre install": { + "fr": "Quitter (q) pour lancer l'installation d'ERPLibre", + "en": "Quit (q) to start the ERPLibre install", + }, + "Quit (q) to follow the VM starting up": { + "fr": "Quitter (q) pour suivre la mise en route de la VM", + "en": "Quit (q) to follow the VM starting up", + }, "A local VM already bears this name:": { "fr": "Une VM locale porte déjà ce nom :", "en": "A local VM already bears this name:", diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 4d522a6..f34a470 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -178,6 +178,88 @@ class TestLaBonneMachine(unittest.TestCase): self.assertIn("ip=cible", script) +class TestLeDisque(unittest.TestCase): + """La colonne Disque annonçait un disque PLEIN qui ne l'était pas. + + Rapporté : « 6.0G/6.0G » sur une VM dont l'invité disait « 845M utilisés + sur 5.8G ». La mesure venait de « du -sb », qui rend la taille APPARENTE : + un disque raw creux la donne entière. « du -sB1 » compte les blocs + réellement occupés — 1,2 Go, ce qui correspond. + """ + + def test_the_command_counts_real_blocks(self): + self.assertIn("du -sB1", mon.PVE_STATS_CMD) + self.assertNotIn("du -sb", mon.PVE_STATS_CMD) + + def test_the_measure_is_read_per_vmid(self): + texte = ( + '[{"vmid":101,"name":"vm-a","disk":0,"maxdisk":6442450944,' + '"mem":1,"maxmem":2,"diskwrite":0,"status":"running","uptime":1}]\n' + "---ERPLIBRE-DU---\n" + "1268518912\t/var/lib/vz/images/101/\n" + "4294967296\t/var/lib/vz/images/999/\n" + ) + rec = mon.parse_pvestats(texte)["vm-a"] + self.assertEqual(rec["disk_used"], 1268518912) + self.assertEqual(rec["disk_total"], 6442450944) + self.assertEqual( + mon.fmt_pair(rec["disk_used"], rec["disk_total"]), "1.2G/6.0G" + ) + + +class TestOuVaLaCommande(unittest.TestCase): + """Chaque action doit viser la BONNE machine. + + Une VM distante et un domaine local peuvent porter le même nom : « s » + ouvrait la locale, la console ouvrait la console de la locale, et la pause + suspendait la locale. Le VMID et le rebond sont les seules désignations + qui ne trompent pas. + """ + + LOCALE = {"name": "vm-a", "ip": "192.168.123.118"} + DISTANTE = { + "name": "vm-a", + "ip": "pve1+vm-a", + "pve": { + "target": "erplibre@pve1", + "sudo": "sudo ", + "jump": "", + "vmid": 101, + "addr": "10.10.10.151", + }, + } + + def test_ssh_to_a_local_vm_uses_its_address(self): + self.assertIn( + "erplibre@192.168.123.118", mon.vm_ssh_prefix(self.LOCALE) + ) + self.assertNotIn("-J", mon.vm_ssh_prefix(self.LOCALE)) + + def test_ssh_to_a_remote_vm_goes_through_the_jump(self): + cmd = mon.vm_ssh_prefix(self.DISTANTE) + self.assertIn("-J", cmd) + self.assertIn("erplibre@pve1", cmd) + self.assertIn("erplibre@10.10.10.151", cmd) + + def test_without_an_address_it_falls_back_to_the_alias(self): + vm = {"name": "vm-a", "ip": "pve1+vm-a", "pve": {"target": "pve1"}} + self.assertIn("erplibre@pve1+vm-a", mon.vm_ssh_prefix(vm)) + + def test_the_console_of_a_remote_vm_is_qm_terminal(self): + cmd = mon.pve_host_cmd( + self.DISTANTE["pve"], "qm terminal 101", tty=True + ) + self.assertIn("ssh -t", cmd) + self.assertIn("qm terminal 101", cmd) + self.assertIn("sudo", cmd) + # Et surtout PAS virsh, qui viserait le domaine local homonyme. + self.assertNotIn("virsh", cmd) + + def test_a_host_without_sudo_is_not_wrapped(self): + cmd = mon.pve_host_cmd({"target": "root@pve1", "sudo": ""}, "qm list") + self.assertNotIn("sh -c", cmd) + + class TestLEtat(unittest.TestCase): """Une VM absente de « virsh list » passait pour EFFACÉE.""" diff --git a/test/test_todo_deploy_progress.py b/test/test_todo_deploy_progress.py index c22ca8c..b4f7f14 100644 --- a/test/test_todo_deploy_progress.py +++ b/test/test_todo_deploy_progress.py @@ -142,5 +142,80 @@ class TestLaToucheSsh(unittest.TestCase): self.assertEqual(vu["reussies"], []) +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQuiSuit(unittest.TestCase): + """Rapporté : on attendait devant une fenêtre « terminée » sans savoir + que l'installation d'ERPLibre démarre en la quittant.""" + + def _sommaire(self, suite, attendre=3.0): + # Un travail qui DURE : sinon il finit avant le premier relevé, et le + # test ne prouve rien de l'avant/après. + jobs = [("1", "vm-a", ["bash", "-c", "sleep 2; echo ok"])] + vu = {} + + async def scenario(): + from textual.widgets import Static + + app = run_deploy_progress(jobs, 1, run_app=False, suite=suite) + async with app.run_test(size=(140, 30)) as pilote: + await pilote.pause() + vu["pendant"] = str(app.query_one("#summary", Static).render()) + await asyncio.sleep(attendre) + await pilote.pause() + vu["apres"] = str(app.query_one("#summary", Static).render()) + + asyncio.run(scenario()) + return vu + + def test_it_says_what_follows_once_everything_is_done(self): + vu = self._sommaire("Quitter (q) pour lancer l'installation") + self.assertNotIn("Quitter", vu["pendant"]) + self.assertIn("Quitter", vu["apres"]) + + def test_nothing_is_promised_when_nothing_follows(self): + vu = self._sommaire("") + self.assertNotIn("→", vu["apres"]) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestLaCibleSsh(unittest.TestCase): + """« s » utilisait le NOM de la VM. Sur Proxmox, l'entrée ~/.ssh/config + n'existe pas encore à ce moment — et ce nom peut désigner une machine + LOCALE homonyme, qui s'ouvrait alors à sa place.""" + + def _lance(self, ssh_cmds): + import contextlib + import os + + jobs = [("1", "erplibre-ubuntu-2604", ["bash", "-c", "echo ok"])] + vu = [] + + async def scenario(): + app = run_deploy_progress( + jobs, 1, run_app=False, ssh_cmds=ssh_cmds + ) + async with app.run_test(size=(140, 30)) as pilote: + await pilote.pause() + await asyncio.sleep(1.2) + await pilote.pause() + vrai = os.system + os.system = vu.append + app.suspend = lambda: contextlib.nullcontext() + try: + app.action_ssh() + finally: + os.system = vrai + + asyncio.run(scenario()) + return vu[0] if vu else "" + + def test_the_given_command_wins(self): + cible = "ssh -J erplibre-proxmox-9 erplibre@10.10.10.151" + self.assertIn(cible, self._lance({"erplibre-ubuntu-2604": cible})) + + def test_without_one_it_falls_back_to_the_name(self): + self.assertIn("ssh erplibre-ubuntu-2604", self._lance(None)) + + if __name__ == "__main__": unittest.main(verbosity=2)