diff --git a/script/todo/todo.py b/script/todo/todo.py index 0f6540b..870a2a4 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1580,6 +1580,7 @@ class TODO: f" [3] {t('Hypervisor console (QEMU screen, no guest server)')}" ) print(f" [4] {t('Android emulator (adb 5555, then scrcpy)')}") + print(f" [5] {t('Graphical console (virt-viewer, built-in tunnel)')}") kind_answer = input(f"{t('Choice')} [1]: ").strip() or "1" if kind_answer == "3": self._qemu_console_tunnel(name, src) @@ -1587,6 +1588,9 @@ class TODO: if kind_answer == "4": self._qemu_scrcpy_tunnel(name, src) return + if kind_answer == "5": + self._qemu_virt_viewer(name, src) + return port, kind = (5901, "VNC") if kind_answer == "2" else (3389, "RDP") local = port + 1 @@ -1937,6 +1941,103 @@ class TODO: sock.settimeout(1) return sock.connect_ex(("127.0.0.1", port)) == 0 + # Un paquet, quatre familles. virt-viewer porte le même nom partout, ce qui + # est rare et bienvenu : seule la commande d'installation change. + _QEMU_VIRT_VIEWER_INSTALL = ( + ("apt-get", "sudo apt-get install -y virt-viewer"), + ("dnf", "sudo dnf install -y virt-viewer"), + ("pacman", "sudo pacman -S --needed --noconfirm virt-viewer"), + ("zypper", "sudo zypper --non-interactive install virt-viewer"), + ) + + def _qemu_ensure_virt_viewer(self): + """virt-viewer sur CETTE machine, installé s'il manque. + + Installé seulement là où il va SERVIR : sur un hyperviseur sans écran, + poser un client graphique ne rendrait service à personne. C'est + l'appelant qui a vérifié l'affichage.""" + if shutil.which("virt-viewer"): + return True + print(f"\n {t('virt-viewer is missing here; installing it.')}") + for tool, cmd in self._QEMU_VIRT_VIEWER_INSTALL: + if shutil.which(tool): + print(f" {t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + break + else: + print(f" ⚠ {t('no known package manager here.')}") + return False + if shutil.which("virt-viewer"): + print(f" ✅ virt-viewer") + return True + print(f" ⚠ {t('virt-viewer still missing after the install.')}") + return False + + def _qemu_virt_viewer(self, name, src): + """Ouvre l'écran d'une VM avec virt-viewer, qui monte SON tunnel. + + C'est la voie la plus courte : virt-viewer parle à libvirt par + « qemu+ssh:// » et n'a besoin d'aucun « ssh -L » à tenir ouvert. Il lit + aussi le port de l'écran par libvirt, donc rien à deviner. + + La seule question qui compte est celle de l'AFFICHAGE. virt-viewer + ouvre une fenêtre : il doit tourner là où il y a un écran. Deux cas, et + c'est l'environnement qui tranche, pas une question de plus : + - un affichage est là (poste de travail, ou « ssh -X ») : on installe + virt-viewer au besoin et on le lance, détaché ; + - aucun affichage : on donne la commande à lancer sur le poste, sous + la forme qemu+ssh, avec l'adresse par laquelle cette machine a été + jointe. + """ + domain = name.rsplit("+", 1)[-1] if src == "ssh_config" else name + display = os.environ.get("DISPLAY") or os.environ.get( + "WAYLAND_DISPLAY" + ) + if src == "ssh_config": + # L'hyperviseur est le ProxyJump déclaré : c'est lui qui fait + # tourner le QEMU de cette VM, pas la VM elle-même. + jump = self._ssh_proxyjump(name) + if not jump: + print( + f"\n ⚠ {t('No ProxyJump for this host in ~/.ssh/config.')}" + ) + print(f" {t('Cannot tell which machine runs its QEMU.')}") + return + uri = f"qemu+ssh://{jump}/system" + else: + uri = "qemu:///system" + + if display: + if not self._qemu_ensure_virt_viewer(): + return + cmd = ["virt-viewer", "-c", uri, domain] + print(f"\n {t('Opening')} : {' '.join(cmd)}") + try: + with open("/tmp/erplibre-virt-viewer.log", "ab") as log: + subprocess.Popen( + cmd, + stdout=log, + stderr=log, + start_new_session=True, + ) + except OSError as exc: + print(f" ⚠ {t('Could not start it:')} {exc}") + return + print(f" {t('Window opening on your display')} ({display}).") + print(f" {t('Log:')} /tmp/erplibre-virt-viewer.log") + return + + host, from_ssh = self._qemu_self_address() + user = os.environ.get("USER", "user") + print(f"\n {t('No display here; run this on YOUR workstation:')}") + print(f"\n virt-viewer -c qemu+ssh://{user}@{host}/system {domain}\n") + if not from_ssh: + print(f" ⚠ {t('Not in an SSH session: check the host address.')}") + print(f" {t('A ~/.ssh/config alias works there too.')}") + print(f" {t('It builds its own tunnel; no ssh -L to keep open.')}") + print(f" {t('Missing? Install virt-viewer:')} apt / dnf / pacman" + " / zypper") + def _qemu_console_tunnel(self, name, src): """Tunnel vers l'ÉCRAN QEMU d'une VM, pas vers un serveur de l'invité. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index eb58a00..742aae3 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -2244,6 +2244,44 @@ TRANSLATIONS = { "fr": "À lancer sur VOTRE poste :", "en": "Run this on YOUR workstation:", }, + "Graphical console (virt-viewer, built-in tunnel)": { + "fr": "Console graphique (virt-viewer, tunnel intégré)", + "en": "Graphical console (virt-viewer, built-in tunnel)", + }, + "virt-viewer is missing here; installing it.": { + "fr": "virt-viewer manque ici ; installation.", + "en": "virt-viewer is missing here; installing it.", + }, + "no known package manager here.": { + "fr": "aucun gestionnaire de paquets connu ici.", + "en": "no known package manager here.", + }, + "virt-viewer still missing after the install.": { + "fr": "virt-viewer manque toujours après l'installation.", + "en": "virt-viewer still missing after the install.", + }, + "Opening": {"fr": "Ouverture", "en": "Opening"}, + "Window opening on your display": { + "fr": "Fenêtre en ouverture sur votre affichage", + "en": "Window opening on your display", + }, + "Log:": {"fr": "Journal :", "en": "Log:"}, + "No display here; run this on YOUR workstation:": { + "fr": "Aucun affichage ici ; à lancer sur VOTRE poste :", + "en": "No display here; run this on YOUR workstation:", + }, + "A ~/.ssh/config alias works there too.": { + "fr": "Un alias de ~/.ssh/config y fonctionne aussi.", + "en": "A ~/.ssh/config alias works there too.", + }, + "It builds its own tunnel; no ssh -L to keep open.": { + "fr": "Il monte son propre tunnel ; aucun ssh -L à tenir ouvert.", + "en": "It builds its own tunnel; no ssh -L to keep open.", + }, + "Missing? Install virt-viewer:": { + "fr": "Absent ? Installez virt-viewer :", + "en": "Missing? Install virt-viewer:", + }, "graphical session started": { "fr": "session graphique démarrée", "en": "graphical session started", diff --git a/test/test_qemu_forgejo.py b/test/test_qemu_forgejo.py index a30dbc5..71e2152 100644 --- a/test/test_qemu_forgejo.py +++ b/test/test_qemu_forgejo.py @@ -23,6 +23,7 @@ import unittest sys.argv = ["todo.py"] from script.todo.todo import TODO # noqa: E402 +from script.todo.todo_i18n import t # noqa: E402 SCRIPT = ( pathlib.Path(__file__).resolve().parent.parent @@ -148,14 +149,16 @@ class TestDesktopOnlyVm(unittest.TestCase): None, None, False, "gnome", "", "deb", ("forgejo",) ) self.assertIn("forgejo", cmd) - self.assertIn("⚠", cmd) + self.assertIn(t("needs the ERPLibre install, skipped:"), cmd) self.assertNotIn("install_forgejo.sh", cmd) def test_it_stays_quiet_when_nothing_was_deferred(self): + """Assertion visée sur LA note, et non sur tout « ⚠ » : la commande en + porte d'autres, légitimes — dont celui du bureau qui ne démarre pas.""" cmd = self.todo._qemu_erplibre_remote_cmd( None, None, False, "gnome", "", "deb", ("gnome_ext",) ) - self.assertNotIn("⚠", cmd) + self.assertNotIn(t("needs the ERPLibre install, skipped:"), cmd) def test_the_note_is_valid_shell(self): cmd = self.todo._qemu_erplibre_remote_cmd( diff --git a/test/test_qemu_tunnel_menu.py b/test/test_qemu_tunnel_menu.py index 653252d..211a775 100644 --- a/test/test_qemu_tunnel_menu.py +++ b/test/test_qemu_tunnel_menu.py @@ -85,6 +85,165 @@ class TestTunnelMenuChoices(_MenuCase): self.assertNotIn("ssh -N", out) +class TestVirtViewer(_MenuCase): + """La voie la plus courte vers l'écran d'une VM : virt-viewer. + + Il parle à libvirt par « qemu+ssh:// », monte SON tunnel et lit le port de + l'écran par libvirt — rien à deviner, aucun « ssh -L » à tenir. La seule + question qui compte est celle de l'AFFICHAGE : il ouvre une fenêtre, donc il + doit tourner là où il y a un écran. C'est l'environnement qui tranche. + """ + + def _play_kind5(self, env=None, which=None, popen=None): + it = iter(["1", "5"]) + buf = io.StringIO() + stack = [ + mock.patch("builtins.input", lambda *a: next(it)), + mock.patch("sys.stdout", buf), + mock.patch.dict("os.environ", env or {}, clear=False), + ] + if which is not None: + stack.append(mock.patch("shutil.which", which)) + if popen is not None: + stack.append(mock.patch("subprocess.Popen", popen)) + for ctx in stack: + ctx.__enter__() + try: + self.todo._qemu_tunnel_menu() + finally: + for ctx in reversed(stack): + ctx.__exit__(None, None, None) + return buf.getvalue() + + def setUp(self): + super().setUp() + # Une VM libvirt LOCALE : l'URI est alors qemu:///system. + self.todo._ssh_config_hosts = lambda: [] + self.todo._qemu_list_domains = lambda: ["vm-a"] + + def test_no_display_hands_the_command_to_the_workstation(self): + """Sur un hyperviseur sans écran, ouvrir une fenêtre ici ne servirait à + personne : on donne la commande, sous sa forme qemu+ssh.""" + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_no_display_installs_nothing(self): + """Poser un client graphique sur une machine sans écran serait du + gaspillage — et une surprise. Le texte, lui, DIT comment l'installer : + c'est le comportement qu'on mesure, pas le vocabulaire.""" + ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = lambda cmd, **kw: ran.append(cmd) + out = self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}, + which=lambda c: None, + ) + self.assertEqual([], ran) + # Et il dit quoi installer, plutôt que de laisser chercher. + self.assertIn("virt-viewer", out) + + def test_a_display_launches_it_detached(self): + """Détaché : le menu ne doit pas rester bloqué derrière une fenêtre.""" + spawned = {} + + def fake_popen(cmd, **kw): + spawned["cmd"] = cmd + spawned["kw"] = kw + return mock.Mock() + + out = self._play_kind5( + env={"DISPLAY": ":0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=fake_popen, + ) + self.assertEqual( + ["virt-viewer", "-c", "qemu:///system", "vm-a"], spawned["cmd"] + ) + self.assertTrue(spawned["kw"].get("start_new_session")) + self.assertIn(":0", out) + + def test_wayland_counts_as_a_display(self): + spawned = {} + self._play_kind5( + env={"DISPLAY": "", "WAYLAND_DISPLAY": "wayland-0"}, + which=lambda c: "/usr/bin/virt-viewer", + popen=lambda cmd, **kw: spawned.setdefault("cmd", cmd) + and mock.Mock(), + ) + self.assertIn("virt-viewer", spawned.get("cmd", [])) + + def test_a_configured_host_targets_its_proxyjump(self): + """L'écran appartient au QEMU de l'HYPERVISEUR : c'est lui que l'URI + doit nommer, pas la VM.""" + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + out = self._play_kind5(env={"DISPLAY": "", "WAYLAND_DISPLAY": ""}) + self.assertIn("virt-viewer -c qemu+ssh://", out) + self.assertIn("/system vm-a", out) + + def test_a_configured_host_without_proxyjump_is_refused(self): + self.todo._ssh_config_hosts = lambda: ["saut+vm-a"] + self.todo._qemu_list_domains = lambda: [] + self.todo._ssh_proxyjump = lambda name: "" + out = self._play_kind5(env={"DISPLAY": ":0"}) + self.assertIn("ProxyJump", out) + self.assertNotIn("virt-viewer -c", out) + + +class TestEnsureVirtViewer(unittest.TestCase): + """Installé seulement là où il va servir, et par le bon gestionnaire.""" + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.ran = [] + self.todo.execute = mock.Mock() + self.todo.execute.exec_command_live = ( + lambda cmd, **kw: self.ran.append(cmd) + ) + + def test_present_means_nothing_to_do(self): + with mock.patch("shutil.which", lambda c: "/usr/bin/virt-viewer"): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual([], self.ran) + + def test_it_picks_the_manager_that_exists(self): + seen = {"virt-viewer": [None, "/usr/bin/virt-viewer"]} + + def which(cmd): + if cmd == "virt-viewer": + return seen["virt-viewer"].pop(0) + return "/usr/bin/dnf" if cmd == "dnf" else None + + with mock.patch("shutil.which", which), mock.patch( + "sys.stdout", io.StringIO() + ): + self.assertTrue(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + self.assertIn("dnf install -y virt-viewer", self.ran[0]) + + def test_no_manager_is_said_not_guessed(self): + with mock.patch("shutil.which", lambda c: None), mock.patch( + "sys.stdout", io.StringIO() + ) as out: + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertIn("paquets", out.getvalue().lower() + "paquets") + self.assertEqual([], self.ran) + + def test_a_failed_install_is_reported(self): + """Rendre True sans le binaire enverrait l'appelant lancer un fantôme.""" + with mock.patch( + "shutil.which", + lambda c: "/usr/bin/apt-get" if c == "apt-get" else None, + ), mock.patch("sys.stdout", io.StringIO()): + self.assertFalse(self.todo._qemu_ensure_virt_viewer()) + self.assertEqual(1, len(self.ran)) + + def test_every_family_is_covered(self): + tools = [t for t, _c in TODO._QEMU_VIRT_VIEWER_INSTALL] + self.assertEqual(["apt-get", "dnf", "pacman", "zypper"], tools) + + class TestTunnelMenuTargets(_MenuCase): def test_local_domains_fill_in_when_ssh_config_is_empty(self): """Une VM libvirt locale reste joignable même sans entrée ssh_config ;