mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
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:
parent
511c52330b
commit
5388fd3796
@ -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):
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
330
gns3server/compute/docker/vendor_docker_vm.py
Normal file
330
gns3server/compute/docker/vendor_docker_vm.py
Normal 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 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}")
|
||||
Loading…
x
Reference in New Issue
Block a user