docker: add GNS3_CONSOLE_RESIZE knob for paging CLIs

The exec behind a docker_exec console is shared by every console client,
so a browser's terminal-size resize (WS control frames -> NAWS) also
changes the geometry concurrent netmiko sessions see. SR Linux doesn't
care (no pager, no hard wrapping), but CLIs that page on the PTY window
size (IOS-XR) would park at --More-- again the moment a browser is
connected.

Split the client-driven NAWS path (_on_naws) from the internal resize
(_resize_exec): GNS3_CONSOLE_RESIZE=0 makes the console ignore client
resizes entirely and keep the tall 511x10000 no-paging default, while
the creation-time default and the restore-on-last-disconnect still go
through the internal path. XRd appliance templates should set it.
This commit is contained in:
YueGuobin 2026-08-20 13:36:55 +08:00
parent abd0b8e274
commit 5741e85b65
No known key found for this signature in database
2 changed files with 76 additions and 10 deletions

View File

@ -55,6 +55,11 @@ class VendorDockerVM(DockerVM):
(adapter order) instead of default ``eth{N}``.
* ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` command run inside the
container by the ``docker_exec`` console (defaults to ``/bin/sh``).
* ``GNS3_CONSOLE_RESIZE=0`` ignore client-driven console resizes
(WS terminal-size frames / telnet NAWS). Set for CLIs that page on the
PTY window size (IOS-XR): the exec PTY must stay at the tall
no-NAWS default for every client, including concurrent netmiko
sessions on the shared exec.
* ``GNS3_STOP_TIMEOUT=60`` SIGTERM grace period in seconds when stopping
the container (default 60; Docker SIGKILLs once it expires).
"""
@ -77,6 +82,7 @@ class VendorDockerVM(DockerVM):
self._gns3_init = True
self._interface_names = []
self._console_cmd = None
self._console_resize = True
self._stop_timeout = 60
if self._environment:
for _line in self._environment.splitlines():
@ -89,6 +95,8 @@ class VendorDockerVM(DockerVM):
]
elif _line.startswith("GNS3_CONSOLE_CMD="):
self._console_cmd = _line.split("=", 1)[1].strip()
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
elif _line.startswith("GNS3_STOP_TIMEOUT="):
try:
timeout = int(_line.split("=", 1)[1].strip())
@ -357,7 +365,13 @@ class VendorDockerVM(DockerVM):
Command from GNS3_CONSOLE_CMD.
"""
telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh")
telnet = _LazyExecTelnetServer(
self,
self.manager,
self._cid,
self._console_cmd or "/bin/sh",
allow_resize=self._console_resize,
)
try:
self._telnet_servers.append(
await telnet.start(self._manager.port_manager.console_host, self.console)
@ -384,7 +398,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
CPR, producing a blank/degraded screen on reconnect.
"""
def __init__(self, vm, manager, cid, command):
def __init__(self, vm, manager, cid, command, allow_resize=True):
super().__init__(
reader=None,
writer=None,
@ -397,6 +411,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
self._manager = manager
self._cid = cid
self._command = command
self._allow_resize = allow_resize
self._exec_id = None
self._broadcast_task = None
self._lock = asyncio.Lock()
@ -421,9 +436,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
# inherit it and hit PTY-window paging (the IOS-XR --More-- trap).
if self._exec_id and not await self._get_connections_snapshot():
with contextlib.suppress(Exception):
await self._on_naws(511, 10000)
await self._resize_exec(511, 10000)
async def _on_naws(self, columns, rows):
async def _resize_exec(self, columns, rows):
if self._exec_id:
try:
await self._manager.query(
@ -434,6 +449,15 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
except DockerError:
pass
async def _on_naws(self, columns, rows):
# Client-driven resize (WS terminal-size control frames, telnet NAWS).
# Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared
# by all clients, one browser resize would break concurrent netmiko
# sessions that rely on the tall no-paging geometry.
if not self._allow_resize:
return
await self._resize_exec(columns, rows)
async def run(self, network_reader, network_writer):
"""Catch and log any exception that kills the client session."""
try:
@ -523,7 +547,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
# WebUI clients resize to their real geometry right after
# connecting, via WS terminal-size control frames turned
# into NAWS by start_websocket_console.
await self._on_naws(511, 10000)
await self._resize_exec(511, 10000)
except Exception:
pass
else:

View File

@ -463,12 +463,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager):
# _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic
# ---------------------------------------------------------------------------
def _make_lazy_server(compute_project, manager):
def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"):
"""Build a _LazyExecTelnetServer with _create_exec mocked out (no docker)."""
vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli")
srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli")
vm = _make_vm(compute_project, manager, environment=environment)
srv = _LazyExecTelnetServer(
vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli",
allow_resize=vm._console_resize,
)
srv._create_exec = AsyncioMagicMock()
srv._on_naws = AsyncioMagicMock()
srv._resize_exec = AsyncioMagicMock()
return srv
@ -539,7 +542,46 @@ async def test_first_connect_sets_tall_default_pty_geometry(compute_project, man
srv = _make_lazy_server(compute_project, manager)
await srv.client_connected_hook()
srv._on_naws.assert_called_once_with(511, 10000)
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio
async def test_client_naws_resizes_exec_by_default(compute_project, manager):
"""Client-driven NAWS (WS terminal-size frames) reaches the exec resize."""
srv = _make_lazy_server(compute_project, manager)
await srv._on_naws(120, 40)
srv._resize_exec.assert_called_once_with(120, 40)
@pytest.mark.asyncio
async def test_client_naws_ignored_when_resize_disabled(compute_project, manager):
"""GNS3_CONSOLE_RESIZE=0: client resizes must not change the shared exec
geometry (paging CLIs need the tall default for concurrent netmiko)."""
srv = _make_lazy_server(
compute_project, manager,
environment="GNS3_CONSOLE_RESIZE=0",
)
assert srv._allow_resize is False
await srv._on_naws(120, 40)
srv._resize_exec.assert_not_called()
# the tall default is still applied at exec creation (internal path)
await srv.client_connected_hook()
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio
async def test_last_client_disconnect_restores_tall_default(compute_project, manager):
"""When the last console client leaves, the exec goes back to the tall
no-NAWS default so a later non-NAWS client (netmiko) doesn't inherit a
browser geometry and hit PTY-window paging."""
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
writer = AsyncioMagicMock()
await srv._disconnect_client(writer)
srv._resize_exec.assert_called_once_with(511, 10000)
@pytest.mark.asyncio