[ADD] script todo: ouvrir l'écran d'une VM avec virt-viewer

C'est la voie la plus courte : virt-viewer parle à libvirt par « qemu+ssh », lit
le port de l'écran par libvirt et monte SON tunnel — aucun « ssh -L » à tenir
ouvert, rien à deviner. Le menu tunnel le propose donc en cinquième choix.

La seule question qui compte est celle de l'affichage, et c'est l'environnement
qui tranche, pas une question de plus. Un affichage présent (poste, ou ssh -X) :
virt-viewer est installé s'il manque — apt, dnf, pacman ou zypper — puis lancé
détaché. Aucun affichage : la commande est donnée pour le poste, et rien n'est
installé sur une machine sans écran.

--- EN ---

The shortest path: virt-viewer talks to libvirt over "qemu+ssh", reads the
display port from libvirt and builds its OWN tunnel — no "ssh -L" to keep open,
nothing to guess. The tunnel menu offers it as a fifth choice.

The only question that matters is the display, and the environment answers it
rather than one more prompt. A display present (workstation, or ssh -X):
virt-viewer gets installed if missing — apt, dnf, pacman or zypper — then
launched detached. No display: the command is handed over for the workstation,
and nothing is installed on a screenless machine.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-20 03:33:11 -04:00
parent 75dbeb3258
commit 7452c581db
4 changed files with 303 additions and 2 deletions

View file

@ -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é.

View file

@ -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",

View file

@ -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(

View file

@ -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 ;