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.
This commit is contained in:
YueGuobin 2026-08-12 22:51:26 +08:00
parent 511c52330b
commit 5388fd3796
No known key found for this signature in database
3 changed files with 380 additions and 207 deletions

View File

@ -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):

View File

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

View File

@ -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 <http://www.gnu.org/licenses/>.
"""
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 3552) 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}")