From 5388fd3796041c0402ec0ffd2e7eb352453b2e47 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Wed, 12 Aug 2026 22:51:26 +0800 Subject: [PATCH] refactor: move vendor NOS Docker support into VendorDockerVM subclass Extract the docker_exec console and GNS3_* prototype knobs (SKIP_INIT, INTERFACE_NAMES, CONSOLE_CMD) from DockerVM into a VendorDockerVM subclass. DockerVM is restored to its 3.1 baseline plus four small extension hooks (_prepare_init_and_interface_env, _start_console_server, _get_container_ifname, _cleanup_console_resources) that are pure refactorings with zero behaviour change for existing nodes. VendorDockerVM additionally replicates init.sh's volume persistence (bind-mount /gns3volumes over the in-container path) via docker exec for containers that skip init.sh, so vendor NOS config (e.g. /etc/opt/srlinux) survives node stop/start. The Docker manager selects VendorDockerVM when console_type == docker_exec; all other nodes keep using DockerVM unchanged. --- gns3server/compute/docker/__init__.py | 11 + gns3server/compute/docker/docker_vm.py | 246 +++---------- gns3server/compute/docker/vendor_docker_vm.py | 330 ++++++++++++++++++ 3 files changed, 380 insertions(+), 207 deletions(-) create mode 100644 gns3server/compute/docker/vendor_docker_vm.py diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index f076e556c..ab095084f 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -32,6 +32,7 @@ from gns3server.config import Config from gns3server.utils.asyncio import locking from gns3server.compute.base_manager import BaseManager from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error log = logging.getLogger(__name__) @@ -59,6 +60,16 @@ class Docker(BaseManager): self._session = None self._api_version = DOCKER_MINIMUM_API_VERSION + def _select_node_class(self, **kwargs): + """Select the node class based on console_type.""" + if kwargs.get("console_type") == "docker_exec": + return VendorDockerVM + return DockerVM + + async def create_node(self, name, project_id, node_id, *args, **kwargs): + self._NODE_CLASS = self._select_node_class(**kwargs) + return await super().create_node(name, project_id, node_id, *args, **kwargs) + @staticmethod async def install_busybox(dst_dir): diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index 5bfd3811f..804039216 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -20,7 +20,6 @@ Docker container instance. import sys import asyncio -import json import shutil import psutil import shlex @@ -119,13 +118,6 @@ class DockerVM(BaseNode): self._console_websocket = None self._extra_hosts = extra_hosts self._extra_volumes = extra_volumes or [] - # Prototype knobs for vendor NOS containers (e.g. Nokia SR Linux), parsed - # from GNS3_-prefixed entries in the node's environment. GNS3_-prefixed vars - # are not forwarded to the container, so this stays host-side only. - self._gns3_init = True # GNS3_SKIP_INIT=1 -> False (use image entrypoint) - self._interface_names = [] # GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,... - self._console_cmd = None # GNS3_CONSOLE_CMD=... (command for the docker_exec console) - self._console_exec_writer = None # raw socket to the docker exec console (for cleanup) self._memory = memory self._cpus = cpus self._permissions_fixed = True @@ -441,6 +433,16 @@ class DockerVM(BaseNode): """.format(adapter=adapter, hostname=self._name)) return path + def _prepare_init_and_interface_env(self, params): + """ + Prepare the init-script entrypoint and GNS3_MAX_ETHERNET env var. + May be overridden by subclasses (e.g. VendorDockerVM) to skip init.sh + or rename injected interfaces. + """ + params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found? + # Give the information to the container on how many interface should be inside + params["Env"].append(f"GNS3_MAX_ETHERNET=eth{self.adapters - 1}") + async def create(self): """ Creates the Docker container. @@ -502,33 +504,7 @@ class DockerVM(BaseNode): params["Cmd"] = [] if len(params["Cmd"]) == 0 and len(params["Entrypoint"]) == 0: params["Cmd"] = ["/bin/sh"] - # Prototype: parse GNS3_-prefixed env overrides for vendor NOS containers. - # GNS3_SKIP_INIT=1 -> don't prepend /gns3/init.sh (image runs its own) - # GNS3_INTERFACE_NAMES=a,b,.. -> rename injected interfaces, in adapter order - # GNS3_CONSOLE_CMD= -> command run via the "docker_exec" console type - if self._environment: - for _line in self._environment.splitlines(): - _line = _line.strip().rstrip(",") - if _line.startswith("GNS3_SKIP_INIT="): - # GNS3_SKIP_INIT=1 means do NOT prepend /gns3/init.sh - self._gns3_init = _line.split("=", 1)[1].strip().lower() not in ("1", "true", "yes") - elif _line.startswith("GNS3_INTERFACE_NAMES="): - self._interface_names = [ - n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip() - ] - elif _line.startswith("GNS3_CONSOLE_CMD="): - self._console_cmd = _line.split("=", 1)[1].strip() - - if self._gns3_init: - params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found? - - # Tell init.sh which last interface to wait for; honour the rename if any - # (no-op when init is skipped, but kept consistent). - if self._interface_names and self.adapters - 1 < len(self._interface_names): - last_ifname = self._interface_names[self.adapters - 1] - else: - last_ifname = f"eth{self.adapters - 1}" - params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}") + self._prepare_init_and_interface_env(params) # Give the information to the container the list of volume path mounted params["Env"].append("GNS3_VOLUMES={}".format(":".join(self._volumes))) @@ -696,9 +672,7 @@ class DockerVM(BaseNode): if self._console_websocket: await self._console_websocket.close() self._console_websocket = None - if self._console_exec_writer: - self._console_exec_writer.close() - self._console_exec_writer = None + self._cleanup_console_resources() await self._clean_servers() await self.manager.query("POST", f"containers/{self._cid}/start") @@ -728,12 +702,7 @@ class DockerVM(BaseNode): log.error(line) raise DockerError(logdata) - if self.console_type in ("telnet", "ssh"): - await self._start_console() - elif self.console_type == "http" or self.console_type == "https": - await self._start_http() - elif self.console_type == "docker_exec": - await self._start_docker_exec_console() + await self._start_console_server() if self.aux_type != "none": await self._start_aux() @@ -746,6 +715,16 @@ class DockerVM(BaseNode): ) ) + async def _start_console_server(self): + """ + Dispatch the console server start based on console_type. + May be overridden to add extra console types (e.g. docker_exec). + """ + if self.console_type in ("telnet", "ssh"): + await self._start_console() + elif self.console_type == "http" or self.console_type == "https": + await self._start_http() + async def _start_aux(self): """ Start an auxiliary console @@ -946,162 +925,6 @@ class DockerVM(BaseNode): except DockerError as e: log.warning(f"Could not resize the container TTY: {e}") - async def _start_docker_exec_console(self): - """ - Start a console that runs a command inside the container via the Docker - exec API, bridged to a telnet server. Intended for vendor NOS containers - (e.g. Nokia SR Linux) whose CLI is a separate TUI process not exposed on - PID 1's stdio. - - The exec is created with a pty (Tty:true) and started with a hijacked - raw HTTP request on the Docker unix socket (aiohttp's websocket client - cannot start a Docker exec; docker-py uses the same hijacked-HTTP - approach). The bidirectional byte stream is bridged to the telnet server - in binary mode so TUI escape sequences reach xterm.js intact, and the - client's terminal size (NAWS) is propagated to the exec pty via - `POST exec/{id}/resize`. - - The exec is created lazily on the first client connection (not when the - node starts) so the command's startup terminal probe — e.g. sr_cli / - prompt_toolkit cursor-position requests (CPR) — has a real client to - answer it; otherwise the probe runs before any xterm.js is attached and - the TUI degrades. The single exec is then shared (broadcast) by all - clients, matching GNS3's console model. Command from GNS3_CONSOLE_CMD. - """ - - command = self._console_cmd or "/bin/sh" - vm = self - manager = self.manager - cid = self._cid - - class _LazyExecTelnetServer(AsyncioTelnetServer): - """Telnet console whose docker exec (pty + command) is created on the - first client connection and then broadcast to all clients.""" - - def __init__(srv): - super().__init__( - reader=None, - writer=None, - binary=True, - echo=False, - naws=True, - window_size_changed_callback=srv._on_naws, - ) - srv._exec_id = None - srv._started = False - srv._lock = asyncio.Lock() - srv._log_name = f"docker_exec console '{vm.name}'" - - async def _on_naws(srv, columns, rows): - # propagate the client's terminal size to the exec pty (no-op - # until the exec has been created on first connect). - if srv._exec_id: - try: - await manager.query( - "POST", - f"exec/{srv._exec_id}/resize", - params={"h": str(rows), "w": str(columns)}, - ) - except DockerError: - pass - - async def run(srv, network_reader, network_writer): - """Catch and log any exception that kills the client session.""" - try: - await super().run(network_reader, network_writer) - except Exception as exc: - log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) - - async def _create_exec(srv): - # create exec with a pty; run as root (vendor CLIs reject the - # image's default unprivileged user) and export TERM=xterm. - result = await manager.query( - "POST", - f"containers/{cid}/exec", - data={ - "AttachStdin": True, - "AttachStdout": True, - "AttachStderr": True, - "Tty": True, - "User": "root", - "Env": ["TERM=xterm"], - "Cmd": ["sh", "-c", f"while true; do {command}; done"], - }, - ) - srv._exec_id = result["Id"] - log.info(f"{srv._log_name}: exec created ({srv._exec_id})") - - # start the exec via a hijacked raw HTTP request on the Docker - # unix socket; with Tty:true the response body is a raw - # bidirectional pty byte stream (no multiplexing). - reader, writer = await asyncio.open_unix_connection(manager._server_url) - body = json.dumps({"Detach": False, "Tty": True}) - request = ( - f"POST /v{manager._api_version}/exec/{srv._exec_id}/start HTTP/1.1\r\n" - "Host: docker\r\n" - "Connection: Upgrade\r\n" - "Upgrade: tcp\r\n" - "Content-Type: application/json\r\n" - f"Content-Length: {len(body)}\r\n\r\n{body}" - ).encode() - writer.write(request) - await writer.drain() - try: - headers = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5) - except (asyncio.IncompleteReadError, asyncio.TimeoutError) as e: - writer.close() - raise DockerError(f"Docker exec start failed: {e}") - status_line = headers.split(b"\r\n", 1)[0] - log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") - if b" 101 " not in status_line and b" 200 " not in status_line: - writer.close() - raise DockerError(f"Docker exec start rejected: {status_line.decode(errors='ignore')}") - - # wire the exec stream as this server's upstream and start the - # broadcast task. AsyncioTelnetServer.start() only starts the - # broadcast when a reader is set at construction time, so with a - # lazy upstream we start it manually here. - srv._reader = reader - srv._writer = writer - vm._console_exec_writer = writer # for stop() cleanup - srv._broadcast_task = asyncio.create_task(srv._broadcast_from_upstream()) - log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") - - async def client_connected_hook(srv): - await super().client_connected_hook() - log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") - async with srv._lock: - if not srv._started: - try: - await srv._create_exec() - except Exception as exc: - log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) - raise - srv._started = True - try: - await srv._on_naws(80, 24) # initial size before NAWS - except Exception: - pass - # ask the TUI to (re)draw for the client that just connected. - if srv._writer: - try: - srv._writer.write(b"\x0c") # Ctrl-L -> TUI redraws - await srv._writer.drain() - except Exception as exc: - log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") - log.info(f"{srv._log_name}: client_connected_hook done") - - telnet = _LazyExecTelnetServer() - try: - self._telnet_servers.append( - await telnet.start(self._manager.port_manager.console_host, self.console) - ) - except OSError as e: - raise DockerError( - f"Could not start console server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" - ) - log.debug(f"Docker container '{self.name}' started docker_exec console (lazy) on {self.console}") - async def _start_console(self): """ Starts streaming the console via telnet or ssh @@ -1204,6 +1027,13 @@ class DockerVM(BaseNode): await self.manager.query("POST", f"containers/{self._cid}/restart") log.debug("Docker container '{name}' [{image}] restarted".format(name=self._name, image=self._image)) + def _cleanup_console_resources(self): + """ + Clean up console resources before restart. + May be overridden (e.g. VendorDockerVM closes the exec pty socket). + """ + pass + async def _clean_servers(self): """ Clean the list of running console servers @@ -1224,6 +1054,7 @@ class DockerVM(BaseNode): if self._console_websocket: await self._console_websocket.close() self._console_websocket = None + self._cleanup_console_resources() await self._clean_servers() await self._stop_ubridge() @@ -1338,6 +1169,13 @@ class DockerVM(BaseNode): log.debug(f"Docker error when closing: {str(e)}") return + def _get_container_ifname(self, adapter_number): + """ + Return the interface name used inside the container for *adapter_number*. + May be overridden to provide custom naming (e.g. mgmt0, e1-1). + """ + return f"eth{adapter_number}" + async def _add_ubridge_connection(self, nio, adapter_number): """ Creates a connection in uBridge. @@ -1386,13 +1224,7 @@ class DockerVM(BaseNode): log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}") - # Interface name inside the container netns: honour GNS3_INTERFACE_NAMES - # (e.g. mgmt0, e1-1 for vendor NOS containers) else default eth{N}. - if self._interface_names and adapter_number < len(self._interface_names): - ifname = self._interface_names[adapter_number] - else: - ifname = f"eth{adapter_number}" - + ifname = self._get_container_ifname(adapter_number) log.debug(f"Move container {self.name} adapter {adapter.host_ifc} -> {ifname} in ns {self._namespace}") try: await self._ubridge_send( diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py new file mode 100644 index 000000000..cd03fa0fe --- /dev/null +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -0,0 +1,330 @@ +# +# Copyright (C) 2025 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +""" +Vendor NOS Docker container subclass. + +Provides support for vendor NOS containers (Nokia SR Linux, Arista cEOS, +Juniper cRPD, …) whose CLI is a separate TUI process not exposed on PID 1 +stdio, and whose boot model requires skipping GNS3's init.sh bootstrapping. + +The subclass is selected automatically when ``console_type == "docker_exec"``. +All vendor features are opt-in — without GNS3_* environment variables the +container behaves identically to DockerVM. +""" + +import asyncio +import json +import logging + +from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer +from gns3server.compute.docker.docker_vm import DockerVM +from gns3server.compute.docker.docker_error import DockerError + +log = logging.getLogger(__name__) + + +class VendorDockerVM(DockerVM): + """ + DockerVM subclass for vendor NOS containers. + + Opt-in features, activated by GNS3_-prefixed environment entries + (host-side only — GNS3_ entries are never forwarded into the container): + + * ``GNS3_SKIP_INIT=1`` — do not prepend /gns3/init.sh; the container runs + its own entrypoint (e.g. SR Linux's ``sr_linux``). Init.sh's volume + persistence (bind-mount /gns3volumes → target) is replicated via + ``docker exec`` after the container starts. + * ``GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2`` — rename injected interfaces + (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``). + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Prototype knobs — parsed from GNS3_-prefixed entries in create(). + # Parse eagerly so _get_container_ifname can return the right name. + self._gns3_init = True + self._interface_names = [] + self._console_cmd = None + self._console_exec_writer = None + + if self._environment: + for _line in self._environment.splitlines(): + _line = _line.strip().rstrip(",") + if _line.startswith("GNS3_SKIP_INIT="): + self._gns3_init = _line.split("=", 1)[1].strip().lower() not in ("1", "true", "yes") + elif _line.startswith("GNS3_INTERFACE_NAMES="): + self._interface_names = [ + n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip() + ] + elif _line.startswith("GNS3_CONSOLE_CMD="): + self._console_cmd = _line.split("=", 1)[1].strip() + + # ---- hook overrides --------------------------------------------------- + + def _prepare_init_and_interface_env(self, params): + """ + Override: conditionally prepend init.sh, and honour + GNS3_INTERFACE_NAMES (if set) for GNS3_MAX_ETHERNET. + """ + if self._gns3_init: + params["Entrypoint"].insert(0, "/gns3/init.sh") + + # Tell init.sh which last interface to wait for; honour the rename if any + # (no-op when init is skipped, but kept consistent). + if self._interface_names and self.adapters - 1 < len(self._interface_names): + last_ifname = self._interface_names[self.adapters - 1] + else: + last_ifname = f"eth{self.adapters - 1}" + params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}") + + def _get_container_ifname(self, adapter_number): + """ + Override: honour GNS3_INTERFACE_NAMES (e.g. mgmt0, e1-1) in adapter + order; fall back to eth{N} for unlisted ports. + """ + if self._interface_names and adapter_number < len(self._interface_names): + return self._interface_names[adapter_number] + return f"eth{adapter_number}" + + def _cleanup_console_resources(self): + """ + Override: close the docker-exec pty socket, if any, so the next + restart or stop doesn't leak it. + """ + if self._console_exec_writer: + try: + self._console_exec_writer.close() + except Exception: + pass + self._console_exec_writer = None + + async def start(self): + await super().start() + if self.status == "started" and not self._gns3_init: + await self._setup_skip_init_volumes() + + async def _setup_skip_init_volumes(self): + """ + Replicate the volume-persistence portion of init.sh (lines 35–52) for + containers that skip init.sh (GNS3_SKIP_INIT=1). + + On first start the container's original files are seeded into the + persistent host directory; on subsequent starts the persisted data + is bind-mounted over the in-container path so writes land on the host. + Permission-changes recorded by _fix_permissions at the previous + stop are restored (best-effort). + """ + for volume in self._volumes: + vol_target = f"/gns3volumes{volume}" + # fmt: off + script = ( + f'mkdir -p "{volume}" && ' + f'if [ ! -f "{vol_target}/.gns3_perms" ]; then ' + f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; ' + f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; ' + f'fi && ' + f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && ' + f'while IFS=: read -r PERMS OWNER GROUP FILE; do ' + f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; ' + f' /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" 2>/dev/null; ' + f'done < "{volume}/.gns3_perms"' + ) + # fmt: on + try: + process = await asyncio.subprocess.create_subprocess_exec( + "docker", + "exec", + self._cid, + "sh", + "-c", + script, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await process.communicate() + if process.returncode != 0: + err = stderr.decode(errors="replace").strip() + log.warning( + "Volume setup for '%s' on container '%s' returned %d: %s", + volume, self._name, process.returncode, err, + ) + else: + log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name) + except OSError as e: + log.warning( + "Could not setup volume '%s' for container '%s': %s", volume, self._name, e + ) + + async def _start_console_server(self): + """ + Override: add the ``docker_exec`` console type alongside the + telnet/ssh/http types supported by the base class. + """ + if self.console_type == "docker_exec": + await self._start_docker_exec_console() + else: + await super()._start_console_server() + + # ---- docker_exec console implementation -------------------------------- + + async def _start_docker_exec_console(self): + """ + Start a console that runs a command inside the container via the Docker + exec API, bridged to a telnet server. Intended for vendor NOS containers + (e.g. Nokia SR Linux) whose CLI is a separate TUI process not exposed on + PID 1's stdio. + + The exec is created lazily on the first client connection (not when the + node starts) so the command's startup terminal probe has a real xterm.js + client to answer it (CPR / prompt_toolkit). The single exec is then + shared (broadcast) by all clients, matching GNS3's console model. + Command from GNS3_CONSOLE_CMD. + """ + + command = self._console_cmd or "/bin/sh" + vm = self + manager = self.manager + cid = self._cid + + class _LazyExecTelnetServer(AsyncioTelnetServer): + """Telnet console whose docker exec (pty + command) is created on the + first client connection and then broadcast to all clients.""" + + def __init__(srv): + super().__init__( + reader=None, + writer=None, + binary=True, + echo=False, + naws=True, + window_size_changed_callback=srv._on_naws, + ) + srv._exec_id = None + srv._started = False + srv._lock = asyncio.Lock() + srv._log_name = f"docker_exec console '{vm.name}'" + + async def _on_naws(srv, columns, rows): + if srv._exec_id: + try: + await manager.query( + "POST", + f"exec/{srv._exec_id}/resize", + params={"h": str(rows), "w": str(columns)}, + ) + except DockerError: + pass + + async def run(srv, network_reader, network_writer): + """Catch and log any exception that kills the client session.""" + try: + await super().run(network_reader, network_writer) + except Exception as exc: + log.warning(f"{srv._log_name}: client session terminated: {exc}", exc_info=True) + + async def _create_exec(srv): + # create exec with a pty; run as root (vendor CLIs reject the + # image's default unprivileged user) and export TERM=xterm. + result = await manager.query( + "POST", + f"containers/{cid}/exec", + data={ + "AttachStdin": True, + "AttachStdout": True, + "AttachStderr": True, + "Tty": True, + "User": "root", + "Env": ["TERM=xterm"], + "Cmd": ["sh", "-c", f"while true; do {command}; done"], + }, + ) + srv._exec_id = result["Id"] + log.info(f"{srv._log_name}: exec created ({srv._exec_id})") + + # start the exec via a hijacked raw HTTP request on the Docker + # unix socket; with Tty:true the response body is a raw + # bidirectional pty byte stream (no multiplexing). + reader, writer = await asyncio.open_unix_connection(manager._server_url) + body = json.dumps({"Detach": False, "Tty": True}) + request = ( + f"POST /v{manager._api_version}/exec/{srv._exec_id}/start HTTP/1.1\r\n" + "Host: docker\r\n" + "Connection: Upgrade\r\n" + "Upgrade: tcp\r\n" + "Content-Type: application/json\r\n" + f"Content-Length: {len(body)}\r\n\r\n{body}" + ).encode() + writer.write(request) + await writer.drain() + try: + headers = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5) + except (asyncio.IncompleteReadError, asyncio.TimeoutError) as e: + writer.close() + raise DockerError(f"Docker exec start failed: {e}") + status_line = headers.split(b"\r\n", 1)[0] + log.info(f"{srv._log_name}: hijacked start -> {status_line.decode(errors='ignore')}") + if b" 101 " not in status_line and b" 200 " not in status_line: + writer.close() + raise DockerError(f"Docker exec start rejected: {status_line.decode(errors='ignore')}") + + # wire the exec stream as this server's upstream and start the + # broadcast task. AsyncioTelnetServer.start() only starts the + # broadcast when a reader is set at construction time, so with a + # lazy upstream we start it manually here. + srv._reader = reader + srv._writer = writer + vm._console_exec_writer = writer # for stop() cleanup + srv._broadcast_task = asyncio.create_task(srv._broadcast_from_upstream()) + log.info(f"{srv._log_name}: broadcast task started, upstream wired, ready") + + async def client_connected_hook(srv): + await super().client_connected_hook() + log.info(f"{srv._log_name}: client connected, lazy_started={srv._started}") + async with srv._lock: + if not srv._started: + try: + await srv._create_exec() + except Exception as exc: + log.warning(f"{srv._log_name}: failed to create exec: {exc}", exc_info=True) + raise + srv._started = True + try: + await srv._on_naws(80, 24) # initial size before NAWS + except Exception: + pass + # ask the TUI to (re)draw for the client that just connected. + if srv._writer: + try: + srv._writer.write(b"\x0c") # Ctrl-L -> TUI redraws + await srv._writer.drain() + except Exception as exc: + log.warning(f"{srv._log_name}: Ctrl-L write failed: {exc}") + log.info(f"{srv._log_name}: client_connected_hook done") + + telnet = _LazyExecTelnetServer() + try: + self._telnet_servers.append( + await telnet.start(self._manager.port_manager.console_host, self.console) + ) + except OSError as e: + raise DockerError( + f"Could not start console server on socket {self._manager.port_manager.console_host}:{self.console}: {e}" + ) + log.debug(f"Docker container '{self.name}' started docker_exec console (lazy) on {self.console}")