YueGuobin 89d7f866cb
docker: replace vendor SKIP_INIT exec volume bridge with create-time direct binds
The SKIP_INIT volume bridge replicated init.sh's seed + mount --bind script
via docker exec *after* the container started. That copied the mechanism but
not the invariant that makes init.sh safe — the entrypoint position, which
guarantees the volume is in place before the application runs. The exec runs
concurrently with the NOS boot, so whether the NOS loaded its persisted
config or the overlay's factory copy was a timing race:

- single node stop/start on an idle system won it (exec ~1s, SR Linux reads
  its startup config at ~2-4s) — the save/stop/start round-trip passed;
- a server restart + project reload lost it (concurrent node starts queue on
  the Docker API, delaying the exec by seconds) — SR Linux booted factory
  while the persisted config.json sat intact on the host;
- XRd was immune (systemd boots tens of seconds before XR touches
  /xr-storage), which is why the race was never observed on it.

Replace the bridge entirely:

- new DockerVM._prepare_volumes hook (no-op in the base class) runs in
  create() after the image is present, before the container is created;
  VendorDockerVM overrides it to seed each volume's host directory from the
  image (throwaway docker create container + docker cp -a, nothing
  executes). The .gns3_perms marker gates the seeding: a volume that ever
  started is never re-seeded, so saved configuration is never overwritten
  with factory content (also the upgrade path for existing nodes).
- VendorDockerVM._mount_binds now binds the volumes directly at their real
  in-container paths (/etc/opt/srlinux) instead of /gns3volumes aliases, so
  the persisted config is visible to the NOS from the very first process.
- _setup_skip_init_volumes and its start() call are gone; the container-side
  _fix_permissions targets the volume paths directly (the direct binds
  exist for the whole container lifetime, unlike the old bridge).

The volume-list computation (validation + overlap de-duplication) moves
into DockerVM._persistent_volume_list so create-time seeding and _mount_binds
cannot drift apart.
2026-08-22 00:09:13 +08:00

1704 lines
65 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#
# Copyright (C) 2015 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/>.
"""
Docker container instance.
"""
import sys
import asyncio
import shutil
import psutil
import shlex
import aiohttp
import subprocess
import os
import re
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer
from gns3server.utils.asyncio import wait_for_file_creation
from gns3server.utils.asyncio import monitor_process
from gns3server.utils.get_resource import get_resource
from gns3server.utils.hostname import is_rfc1123_hostname_valid
from gns3server.utils import macaddress_to_int, int_to_macaddress
from gns3server.compute.ubridge.ubridge_error import UbridgeError, UbridgeNamespaceError
from ..base_node import BaseNode
from ..adapters.ethernet_adapter import EthernetAdapter
from ..nios.nio_udp import NIOUDP
from .docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error
import logging
log = logging.getLogger(__name__)
class DockerVM(BaseNode):
"""
Docker container implementation.
:param name: Docker container name
:param node_id: Node identifier
:param project: Project instance
:param manager: Manager instance
:param image: Docker image
:param console: TCP console port
:param console_type: console type
:param aux: TCP aux console port
:param aux_type: auxiliary console type
:param console_resolution: Resolution of the VNC display
:param console_http_port: Port to redirect HTTP queries
:param console_http_path: Url part with the path of the web interface
:param extra_hosts: Hosts which will be written into /etc/hosts into docker conainer
:param extra_volumes: Additional directories to make persistent
"""
# systemd units masked by GNS3_MASK_UDEV=1: the udev daemon, its activation
# sockets and the coldplug/settle triggers. Masking them stops a privileged
# systemd container from replaying device events on the host.
_UDEV_UNITS = (
"systemd-udevd.service",
"systemd-udevd-control.socket",
"systemd-udevd-kernel.socket",
"systemd-udev-trigger.service",
"systemd-udev-settle.service",
)
# udevadm binary paths also null-bound by GNS3_MASK_UDEV=1. NOS startup
# scripts call udevadm directly -- Cisco XRd's xr_startup.sh runs
# `udevadm trigger --action=add --parent-match=<usb device>` (USB license
# dongle probing), which synthesizes uevents into the host kernel from a
# privileged container and reconnects host USB devices. Masking the units
# alone does not stop this; the binary must be neutralized too. XRd boots
# fine without udevadm (interfaces are pre-created by GNS3).
_UDEVADM_PATHS = (
"/bin/udevadm",
"/sbin/udevadm",
"/usr/bin/udevadm",
)
def __init__(
self,
name,
node_id,
project,
manager,
image,
console=None,
aux=None,
start_command=None,
adapters=None,
mac_address="",
environment=None,
console_type="telnet",
aux_type="none",
console_resolution="1024x768",
console_http_port=80,
console_http_path="/",
extra_hosts=None,
extra_volumes=[],
extra_configs=None,
memory=0,
cpus=0,
):
if not is_rfc1123_hostname_valid(name):
raise DockerError(f"'{name}' is an invalid name to create a Docker node")
super().__init__(
name, node_id, project, manager, console=console, console_type=console_type, aux=aux, aux_type=aux_type
)
# force the latest image if no version is specified
if ":" not in image:
image = f"{image}:latest"
self._image = image
# assign through the property setters so creation and updates apply
# the same value normalization (e.g. "" -> None)
self.start_command = start_command
self.environment = environment
self._cid = None
self._ethernet_adapters = []
self._temporary_directory = None
self._telnet_servers = []
self._vnc_process = None
self._vncconfig_process = None
self._console_resolution = console_resolution
self.console_http_path = console_http_path
self._console_http_port = console_http_port
self._console_websocket = None
self.extra_hosts = extra_hosts
self._extra_volumes = extra_volumes or []
self._extra_configs = extra_configs or []
self._memory = memory
self._cpus = cpus
self._permissions_fixed = True
self._display = None
self._closing = False
self._volumes = []
# Keep a list of created bridge
self._bridges = set()
if adapters is None:
self.adapters = 1
else:
self.adapters = adapters
self.mac_address = mac_address
log.debug(
"{module}: {name} [{image}] initialized.".format(
module=self.manager.module_name, name=self.name, image=self._image
)
)
def asdict(self):
return {
"name": self._name,
"usage": self.usage,
"node_id": self._id,
"container_id": self._cid,
"project_id": self._project.id,
"image": self._image,
"adapters": self.adapters,
"mac_address": self.mac_address,
"console": self.console,
"console_type": self.console_type,
"console_resolution": self.console_resolution,
"console_http_port": self.console_http_port,
"console_http_path": self.console_http_path,
"aux": self.aux,
"aux_type": self.aux_type,
"start_command": self.start_command,
"status": self.status,
"environment": self.environment,
"node_directory": self.working_path,
"extra_hosts": self.extra_hosts,
"extra_volumes": self.extra_volumes,
"extra_configs": self.extra_configs,
"memory": self.memory,
"cpus": self.cpus,
}
def _get_free_display_port(self):
"""
Search a free display port
"""
display = 100
if not os.path.exists("/tmp/.X11-unix/"):
return display
while True:
if not os.path.exists(f"/tmp/.X11-unix/X{display}"):
return display
display += 1
@BaseNode.name.setter
def name(self, new_name):
"""
Sets the name of this Qemu VM.
:param new_name: name
"""
if not is_rfc1123_hostname_valid(new_name):
raise DockerError(f"'{new_name}' is an invalid name to rename Docker container '{self._name}'. Allowed characters: letters (a-z, A-Z), digits (0-9), and hyphens (-). The name cannot start or end with a hyphen.")
super(DockerVM, DockerVM).name.__set__(self, new_name)
@property
def ethernet_adapters(self):
return self._ethernet_adapters
@property
def docker_name(self):
"""
Container name in Docker
"""
return "GNS3.{}.{}".format(self.name, self._project.id)
@property
def mac_address(self):
"""
Returns the MAC address for this Docker container.
:returns: adapter type (string)
"""
return self._mac_address
@mac_address.setter
def mac_address(self, mac_address):
"""
Sets the MAC address for this Docker container.
:param mac_address: MAC address
"""
if not mac_address:
# use the node UUID to generate a random MAC address
self._mac_address = "02:42:%s:%s:%s:00" % (self.id[2:4], self.id[4:6], self.id[6:8])
else:
self._mac_address = mac_address
log.debug('Docker container "{name}" [{id}]: MAC address changed to {mac_addr}'.format(
name=self._name,
id=self._id,
mac_addr=self._mac_address)
)
@property
def start_command(self):
return self._start_command
@start_command.setter
def start_command(self, command):
if command:
command = command.strip()
if command is None or len(command) == 0:
self._start_command = None
else:
self._start_command = command
@property
def console_resolution(self):
return self._console_resolution
@console_resolution.setter
def console_resolution(self, resolution):
self._console_resolution = resolution
@property
def console_http_path(self):
return self._console_http_path
@console_http_path.setter
def console_http_path(self, path):
# the canonical "no path" value is "/" so that "", None and "/"
# all compare equal in the update diff
self._console_http_path = path or "/"
@property
def console_http_port(self):
return self._console_http_port
@console_http_port.setter
def console_http_port(self, port):
self._console_http_port = port
@property
def environment(self):
return self._environment
@environment.setter
def environment(self, command):
# "" and None are the same "no environment variables" value
self._environment = command or None
@property
def extra_hosts(self):
return self._extra_hosts
@extra_hosts.setter
def extra_hosts(self, extra_hosts):
# "" and None are the same "no extra hosts" value
self._extra_hosts = extra_hosts or None
@property
def extra_volumes(self):
return self._extra_volumes
@extra_volumes.setter
def extra_volumes(self, extra_volumes):
self._extra_volumes = extra_volumes
@property
def extra_configs(self):
return self._extra_configs
@extra_configs.setter
def extra_configs(self, extra_configs):
self._extra_configs = extra_configs or []
@property
def memory(self):
return self._memory
@memory.setter
def memory(self, memory):
self._memory = memory
@property
def cpus(self):
return self._cpus
@cpus.setter
def cpus(self, cpus):
self._cpus = cpus
async def _get_container_state(self):
"""
Returns the container state (e.g. running, paused etc.)
:returns: state
:rtype: str
"""
try:
result = await self.manager.query("GET", f"containers/{self._cid}/json")
except DockerError:
return "exited"
if result["State"]["Paused"]:
return "paused"
if result["State"]["Running"]:
return "running"
return "exited"
async def _get_image_information(self):
"""
:returns: Dictionary information about the container image
"""
result = await self.manager.query("GET", f"images/{self._image}/json")
return result
def _persistent_volume_list(self, image_info, include_network_config=True):
"""
The in-container paths that get a persistent volume mount: GNS3's
/etc/network, every VOLUME declared by the image and the node's
extra_volumes. Overlapping paths are de-duplicated so that a path
covered by a more general volume is not mounted twice.
:param include_network_config: include GNS3's hardcoded /etc/network
volume (consumed by init.sh; subclasses that skip init.sh pass
False so the list matches the mounts they actually create).
"""
for volume in self._extra_volumes:
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
raise DockerError(
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
)
volumes = []
if include_network_config:
volumes.append("/etc/network")
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
volumes.extend(self._extra_volumes)
deduped = []
# define lambdas for validation checks
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
for volume in volumes:
# remove any mount that is equal or more specific, then append this one
deduped = list(filter(lambda v: not generalises(volume, v), deduped))
# if there is nothing more general, append this mount
if not [v for v in deduped if generalises(v, volume)]:
deduped.append(volume)
return deduped
async def _prepare_volumes(self, image_info):
"""
Hook: prepare persistent volumes before the container (and its
mounts) are created. The default implementation does nothing —
init.sh performs the first-copy seeding inside the container at
boot. Subclasses that skip init.sh override this to seed the host
directories from the image instead, so their mounts can be bound
directly at the real in-container paths from the very first process.
"""
def _mount_binds(self, image_info):
"""
:returns: Return the path that we need to map to local folders
"""
try:
resources_path = self.manager.resources_path()
except OSError as e:
raise DockerError(f"Cannot access resources: {e}")
log.debug(f'Mount resources from "{resources_path}"')
binds = [{
"Type": "bind",
"Source": resources_path,
"Target": "/gns3",
"ReadOnly": True
}]
# We mount our own etc/network
try:
self._create_network_config()
except OSError as e:
raise DockerError(f"Could not create network config in the container: {e}")
self._volumes = self._persistent_volume_list(image_info)
for volume in self._volumes:
source = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
os.makedirs(source, exist_ok=True)
binds.append({
"Type": "bind",
"Source": source,
"Target": "/gns3volumes{}".format(volume)
})
# Inject extra config files: write each to the node working directory and
# bind-mount it read-only at its target path. Single-file binds are applied
# at create time, so this works for the generic init.sh path AND for vendor
# nodes that skip init.sh (the NOS reads its startup config from the mount).
for cfg in self._extra_configs:
target = cfg["target"] if isinstance(cfg, dict) else cfg.target
content = cfg["content"] if isinstance(cfg, dict) else cfg.content
if not target.startswith("/") or target.endswith("/") or ".." in target.split("/"):
raise DockerError(
f"Extra config target '{target}' must be an absolute file path and not contain '..'."
)
for volume in self._volumes:
# A single-file bind gets covered by the volume's bind mount at
# start (init.sh or the vendor volume bridge), so the injected
# content would never be seen — or worse, frozen at whatever
# the first-start seed copied.
if target == volume or target.startswith(volume.rstrip("/") + "/"):
log.warning(
"Extra config target '%s' on container '%s' is shadowed by persisted volume '%s' "
"and will not take effect; pick a target outside persisted volumes.",
target, self._name, volume,
)
host_path = os.path.join(self.working_dir, "configs", target.lstrip("/"))
os.makedirs(os.path.dirname(host_path), exist_ok=True)
with open(host_path, "w") as f:
f.write(content)
binds.append({
"Type": "bind",
"Source": host_path,
"Target": target,
"ReadOnly": True,
})
return binds
def _create_network_config(self):
"""
If network config is empty we create a sample config
"""
path = os.path.join(self.working_dir, "etc", "network")
os.makedirs(path, exist_ok=True)
open(os.path.join(path, ".gns3_perms"), "a").close()
os.makedirs(os.path.join(path, "if-up.d"), exist_ok=True)
os.makedirs(os.path.join(path, "if-down.d"), exist_ok=True)
os.makedirs(os.path.join(path, "if-pre-up.d"), exist_ok=True)
os.makedirs(os.path.join(path, "if-post-down.d"), exist_ok=True)
os.makedirs(os.path.join(path, "interfaces.d"), exist_ok=True)
if not os.path.exists(os.path.join(path, "interfaces")):
with open(os.path.join(path, "interfaces"), "w+") as f:
f.write("""#
# This is a sample network config, please uncomment lines to configure the network
#
# Uncomment this line to load custom interface files
# source /etc/network/interfaces.d/*
""")
for adapter in range(0, self.adapters):
f.write(
"""
# Static config for eth{adapter}
#auto eth{adapter}
#iface eth{adapter} inet static
#\taddress 192.168.{adapter}.2
#\tnetmask 255.255.255.0
#\tgateway 192.168.{adapter}.1
#\tup echo nameserver 192.168.{adapter}.1 > /etc/resolv.conf
# DHCP config for eth{adapter}
#auto eth{adapter}
#iface eth{adapter} inet dhcp
#\thostname {hostname}
""".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.
"""
if ":" in os.path.splitdrive(self.working_dir)[1]:
raise DockerError("Cannot create a Docker container with a project directory containing a colon character (':')")
#await self.manager.install_resources()
try:
image_infos = await self._get_image_information()
except DockerHttp404Error:
log.info("Image '{}' is missing, pulling it from Docker repository...".format(self._image))
await self.pull_image(self._image)
image_infos = await self._get_image_information()
if image_infos is None:
raise DockerError(f"Cannot get information for image '{self._image}', please try again.")
available_cpus = psutil.cpu_count(logical=True)
if self._cpus > available_cpus:
raise DockerError(
f"You have allocated too many CPUs for the Docker container "
f"(max available is {available_cpus} CPUs)"
)
# Prepare persistent volume content before the container and its
# mounts are created (no-op for the init.sh path).
await self._prepare_volumes(image_infos)
params = {
"Hostname": self._name,
"Image": self._image,
"NetworkDisabled": True,
"Tty": True,
"OpenStdin": True,
"StdinOnce": False,
"HostConfig": {
"CapAdd": ["ALL"],
"Privileged": True,
"Mounts": self._mount_binds(image_infos),
"Memory": self._memory * (1024 * 1024), # convert memory to bytes
"NanoCpus": int(self._cpus * 1e9), # convert cpus to nano cpus
"UsernsMode": "host"
},
"Volumes": {},
"Env": ["container=docker"], # Systemd compliant: https://github.com/GNS3/gns3-server/issues/573
"Cmd": [],
"Entrypoint": image_infos.get("Config", {"Entrypoint": []}).get("Entrypoint"),
}
# Optional /dev/shm size and host device mappings requested through the
# environment (GNS3_SHM_SIZE in MB, GNS3_DEVICES). These are native Docker
# HostConfig keys applied at create time, so they work whether or not
# init.sh runs -- heavy NOS containers such as Cisco XRd (which skips
# init.sh via the vendor/docker_exec path) rely on them. Only injected
# when set, so ordinary nodes keep the default Docker behaviour.
if self._environment:
for line in self._environment.splitlines():
# Strip a trailing comma like the vendor-class parser does, so
# "GNS3_MASK_UDEV=1," composed from a comma-separated list
# still activates (values are never comma-separated here).
line = line.strip().rstrip(",")
if line.startswith("GNS3_SHM_SIZE="):
try:
params["HostConfig"]["ShmSize"] = int(line.split("=", 1)[1].strip()) * (1024 * 1024)
except ValueError:
pass
elif line.startswith("GNS3_DEVICES="):
devices = self._format_devices(line.split("=", 1)[1])
if devices:
params["HostConfig"]["Devices"] = devices
elif line.startswith("GNS3_MASK_UDEV=") and \
line.split("=", 1)[1].strip().lower() in ("1", "true", "yes"):
# A privileged systemd-based NOS container (e.g. Cisco XRd)
# runs systemd-udevd, which coldplugs every device it can see
# -- and in privileged mode that includes the HOST's USB/input/
# audio/disk devices, reconnecting/muting them on every start.
# XRd doesn't need udev (interfaces are pre-created by GNS3), so
# bind /dev/null over the udev units to keep it from running.
for target in [f"/etc/systemd/system/{u}" for u in self._UDEV_UNITS] + list(self._UDEVADM_PATHS):
params["HostConfig"]["Mounts"].append({
"Type": "bind",
"Source": "/dev/null",
"Target": target,
"ReadOnly": True,
})
elif line.startswith("GNS3_MASK_SYSTEMD="):
# Generic form: comma/semicolon-separated unit names to mask
# the same way (bind /dev/null over /etc/systemd/system/<unit>).
for unit in line.split("=", 1)[1].replace(";", ",").split(","):
unit = unit.strip()
if unit and "/" not in unit and ".." not in unit:
params["HostConfig"]["Mounts"].append({
"Type": "bind",
"Source": "/dev/null",
"Target": f"/etc/systemd/system/{unit}",
"ReadOnly": True,
})
# Overlapping bind targets (GNS3_MASK_UDEV together with a
# GNS3_MASK_SYSTEMD entry for the same unit, an extra_configs target
# equal to a masked unit, a unit named twice in the list) make Docker
# reject the create outright ("Duplicate mount point") — keep only
# the first occurrence of each target.
seen_targets = set()
deduped_mounts = []
for mount in params["HostConfig"]["Mounts"]:
if mount["Target"] not in seen_targets:
seen_targets.add(mount["Target"])
deduped_mounts.append(mount)
params["HostConfig"]["Mounts"] = deduped_mounts
if params["Entrypoint"] is None:
params["Entrypoint"] = []
if self._start_command:
try:
params["Cmd"] = shlex.split(self._start_command)
except ValueError as e:
raise DockerError(f"Invalid start command '{self._start_command}': {e}")
if len(params["Cmd"]) == 0:
params["Cmd"] = image_infos.get("Config", {"Cmd": []}).get("Cmd")
if params["Cmd"] is None:
params["Cmd"] = []
if len(params["Cmd"]) == 0 and len(params["Entrypoint"]) == 0:
params["Cmd"] = ["/bin/sh"]
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)))
# Pass user configured for image to init script
if image_infos.get("Config", {"User": ""}).get("User"):
params["User"] = "root"
params["Env"].append("GNS3_USER={}".format(image_infos.get("Config", {"User": ""})["User"]))
variables = self.project.variables
if not variables:
variables = []
for var in variables:
# Handle both Pydantic Variable objects and dictionaries
if hasattr(var, "name"):
# Pydantic Variable object
var_name = var.name
var_value = getattr(var, "value", "")
else:
# Dictionary format
var_name = var.get("name", "")
var_value = var.get("value", "")
formatted = self._format_env(variables, var_value)
params["Env"].append("{}={}".format(var_name, formatted))
if self._environment:
for e in self._environment.strip().split("\n"):
e = e.strip()
if e.split("=")[0] == "":
self.project.emit("log.warning", {"message": f"{self.name} has invalid environment variable: {e}"})
continue
if not e.startswith("GNS3_"):
formatted = self._format_env(variables, e)
vm_name = self._name.replace(",", ",,")
project_path = self.project.path.replace(",", ",,")
formatted = formatted.replace("%vm-name%", '"' + vm_name.replace('"', '\\"') + '"')
formatted = formatted.replace("%vm-id%", self._id)
formatted = formatted.replace("%project-id%", self.project.id)
formatted = formatted.replace("%project-path%", '"' + project_path.replace('"', '\\"') + '"')
params["Env"].append(formatted)
if self._console_type == "vnc":
await self._start_vnc()
params["Env"].append(
"QT_GRAPHICSSYSTEM=native"
) # To fix a Qt issue: https://github.com/GNS3/gns3-server/issues/556
params["Env"].append(f"DISPLAY=:{self._display}")
params["HostConfig"]["Mounts"].append({
"Type": "bind",
"Source": f"/tmp/.X11-unix/X{self._display}",
"Target": f"/tmp/.X11-unix/X{self._display}",
"ReadOnly": True
})
if self._extra_hosts:
extra_hosts = self._format_extra_hosts(self._extra_hosts)
if extra_hosts:
params["Env"].append(f"GNS3_EXTRA_HOSTS={extra_hosts}")
# Support name in Doker: [a-zA-Z0-9][a-zA-Z0-9_.-]
try:
result = await self.manager.query("POST", f"containers/create?name={self.docker_name}", data=params)
except DockerHttp409Error:
# Container name already exists. This can happen when the server crashes
# and leaves containers behind. Try to remove the conflicting container.
log.warning(f"Container name '{self.docker_name}' is already in use, attempting to clean up the stale container...")
try:
# Try to get and remove the conflicting container
try:
container_info = await self.manager.query("GET", f"containers/{self.docker_name}/json")
container_id = container_info["Id"]
# Force remove the container
await self.manager.query("DELETE", f"containers/{container_id}", params={"force": 1, "v": 1})
log.info(f"Removed stale container '{self.docker_name}' ({container_id})")
except DockerHttp404Error:
# Container doesn't exist anymore, race condition - just continue
pass
# Retry creating the container
result = await self.manager.query("POST", f"containers/create?name={self.docker_name}", data=params)
except DockerError as e:
log.error(f"Failed to clean up conflicting container '{self.docker_name}': {e}")
raise
self._cid = result["Id"]
log.debug(f"Docker container '{self._name}' [{self._id}] created")
if self._cpus > 0:
log.debug(f"CPU limit set to {self._cpus} CPUs")
if self._memory > 0:
log.debug(f"Memory limit set to {self._memory} MB")
return True
def _format_env(self, variables, env):
for variable in variables:
# Handle both Pydantic Variable objects and dictionaries
if hasattr(variable, "name"):
# Pydantic Variable object
var_name = variable.name
var_value = getattr(variable, "value", "")
else:
# Dictionary format
var_name = variable.get("name", "")
var_value = variable.get("value", "")
env = env.replace("${" + var_name + "}", var_value)
return env
def _format_extra_hosts(self, extra_hosts):
lines = [h.strip() for h in self._extra_hosts.split("\n") if h.strip() != ""]
hosts = []
try:
for host in lines:
hostname, ip = host.split(":")
hostname = hostname.strip()
ip = ip.strip()
if hostname and ip:
hosts.append((hostname, ip))
except ValueError:
raise DockerError(f"Can't apply `ExtraHosts`, wrong format: {extra_hosts}")
return "\n".join([f"{h[1]}\t{h[0]}" for h in hosts])
def _format_devices(self, devices_value):
"""
Parse a GNS3_DEVICES value into Docker HostConfig Devices entries.
Mirrors `docker run --device`: items are whitespace/comma-separated and
each is ``host[:container[:permissions]]`` (e.g. /dev/fuse,
/dev/fuse:/dev/fuse:rwm). Docker resolves type/major/minor from the host
node itself, so the device must exist on the host -- the host-readiness
check warns when /dev/fuse is missing (load the fuse module).
"""
formatted = []
for raw in devices_value.replace(",", " ").split():
parts = raw.split(":")
if len(parts) == 1:
on_host = in_container = parts[0]
permissions = "rwm"
elif len(parts) == 2:
on_host, in_container = parts
permissions = "rwm"
elif len(parts) == 3:
on_host, in_container, permissions = parts
else:
continue
formatted.append({
"PathOnHost": on_host,
"PathInContainer": in_container,
"CgroupPermissions": permissions,
})
return formatted
async def update(self):
"""
Destroy and recreate the container with the new settings
"""
# We need to save the console and state and restore it
console = self.console
aux = self.aux
state = await self._get_container_state()
# reset the docker container, but don't release the NIO UDP ports
await self.reset(False)
await self.create()
self.console = console
self.aux = aux
if state == "running":
await self.start()
async def start(self):
"""
Starts this Docker container.
"""
await self.manager.install_resources()
try:
state = await self._get_container_state()
except DockerHttp404Error:
raise DockerError(
"Docker container '{name}' with ID {cid} does not exist or is not ready yet. Please try again in a few seconds.".format(
name=self.name, cid=self._cid
)
)
if state == "paused":
await self.unpause()
elif state == "running":
return
else:
if self._console_type == "vnc" and not self._vnc_process:
# restart the vnc process in case it had previously crashed
await self._start_vnc_process(restart=True)
monitor_process(self._vnc_process, self._vnc_callback)
if self._console_websocket:
await self._console_websocket.close()
self._console_websocket = None
self._cleanup_console_resources()
await self._clean_servers()
await self.manager.query("POST", f"containers/{self._cid}/start")
await asyncio.sleep(0.5) # give the Docker container some time to start
# Fix host-side directory ownership after Docker (re)creates
# volume mount points as root (rootful Docker only).
# This allows the GNS3 process to write files into node directories
# while the container is running. Permissions are recorded and
# restored inside the container by init.sh on next startup.
# await self._fix_permissions()
self._namespace = await self._get_namespace()
await self._start_ubridge(require_privileged_access=True)
for adapter_number in range(0, self.adapters):
nio = self._ethernet_adapters[adapter_number].get_nio(0)
async with self.manager.ubridge_lock:
try:
await self._add_ubridge_connection(nio, adapter_number)
except UbridgeNamespaceError:
log.error("Container %s failed to start", self.name)
await self.stop()
# The container can crash soon after the start, this means we can not move the interface to the container namespace
logdata = await self._get_log()
for line in logdata.split("\n"):
log.error(line)
raise DockerError(logdata)
await self._start_console_server()
if self.aux_type != "none":
await self._start_aux()
self._permissions_fixed = False
self.status = "started"
log.debug(
"Docker container '{name}' [{image}] started listen for {console_type} on {console}".format(
name=self._name, image=self._image, console=self.console, console_type=self.console_type
)
)
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
"""
# We can not use the API because docker doesn't expose a websocket api for exec
# https://github.com/GNS3/gns3-gui/issues/1039
try:
process = await asyncio.subprocess.create_subprocess_exec(
"script",
"-qfc",
f"docker exec -i -t {self._cid} /gns3/bin/busybox sh -c 'while true; do TERM=vt100 /gns3/bin/busybox sh; done'",
"/dev/null",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stdin=asyncio.subprocess.PIPE,
)
except OSError as e:
raise DockerError(f"Could not start auxiliary console process: {e}")
if self.aux_type == "telnet":
server = AsyncioTelnetServer(reader=process.stdout, writer=process.stdin, binary=True, echo=True)
transport = "Telnet"
else:
server = AsyncioSSHServer(reader=process.stdout, writer=process.stdin)
transport = "SSH"
try:
self._telnet_servers.append(await server.start(self._manager.port_manager.console_host, self.aux))
except OSError as e:
raise DockerError(
f"Could not start {transport} server on socket {self._manager.port_manager.console_host}:{self.aux}: {e}"
)
log.debug(f"Docker container '{self.name}' started listening for auxiliary {self.aux_type} on {self.aux}")
async def _fix_permissions(self):
"""
Because docker run as root we need to fix permission and ownership to allow user to interact
with it from their filesystem and do operation like file delete
"""
state = await self._get_container_state()
log.debug(f"Docker container '{self._name}' fix ownership, state = {state}")
if state == "stopped" or state == "exited":
# We need to restart it to fix permissions
await self.manager.query("POST", f"containers/{self._cid}/start")
for volume in self._volumes:
log.debug(
"Docker container '{name}' [{image}] fix ownership on {path}".format(
name=self._name, image=self._image, path=volume
)
)
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker",
"exec",
self._cid,
"/gns3/bin/busybox",
"sh",
"-c",
"("
'/gns3/bin/busybox find "{path}" -depth -print0'
" | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c '%a:%u:%g:%n' > \"{path}/.gns3_perms\""
")"
' && /gns3/bin/busybox chmod -R u+rX "{path}"'
' && /gns3/bin/busybox chown {uid}:{gid} -R "{path}"'.format(
uid=os.getuid(), gid=os.getgid(), path=volume
),
stderr=asyncio.subprocess.PIPE,
)
except OSError as e:
raise DockerError(f"Could not fix permissions for {volume}: {e}")
await process.wait()
if process.returncode != 0:
stderr = (await process.stderr.read()).decode(errors="replace").strip()
log.error(
"Failed to fix permissions on '%s' for container '%s': %s",
volume, self._name, stderr or f"exit code {process.returncode}"
)
else:
self._permissions_fixed = True
async def _start_vnc_process(self, restart=False):
"""
Starts the VNC process.
"""
self._display = self._get_free_display_port()
tigervnc_path = shutil.which("Xtigervnc") or shutil.which("Xvnc")
if not tigervnc_path:
raise DockerError("Please install TigerVNC server before using VNC support")
if tigervnc_path:
with open(os.path.join(self.working_dir, "vnc.log"), "w") as fd:
self._vnc_process = await asyncio.create_subprocess_exec(tigervnc_path,
"-extension", "MIT-SHM",
"-geometry", self._console_resolution,
"-depth", "16",
"-interface", self._manager.port_manager.console_host,
"-rfbport", str(self.console),
"-AlwaysShared",
"-SecurityTypes", "None",
"-desktop", self.name,
":{}".format(self._display),
stdout=fd, stderr=subprocess.STDOUT)
async def _start_vnc(self):
"""
Starts a VNC server for this container
"""
self._display = self._get_free_display_port()
tigervnc_path = shutil.which("Xtigervnc") or shutil.which("Xvnc")
if not tigervnc_path:
raise DockerError("Please install TigerVNC server before using VNC support")
await self._start_vnc_process()
x11_socket = os.path.join("/tmp/.X11-unix/", f"X{self._display}")
try:
await wait_for_file_creation(x11_socket)
except asyncio.TimeoutError:
raise DockerError(f'x11 socket file "{x11_socket}" does not exist')
if not hasattr(sys, "_called_from_test") or not sys._called_from_test:
# Start vncconfig for tigervnc clipboard support, connection available only after socket creation.
tigervncconfig_path = shutil.which("vncconfig")
if tigervnc_path and tigervncconfig_path:
self._vncconfig_process = await asyncio.create_subprocess_exec(
tigervncconfig_path, "-display", f":{self._display}", "-nowin"
)
# sometimes the VNC process can crash
monitor_process(self._vnc_process, self._vnc_callback)
def _vnc_callback(self, returncode):
"""
Called when the process has stopped.
:param returncode: Process returncode
"""
if returncode != 0 and self._closing is False:
self.project.emit(
"log.error",
{
"message": f"The vnc process has stopped with return code {returncode} for node '{self.name}'. Please restart this node."
},
)
self._vnc_process = None
async def _start_http(self):
"""
Starts an HTTP tunnel to container localhost. It's not perfect
but the only way we have to inject network packet is using nc.
"""
log.debug("Forward HTTP for %s to %d", self.name, self._console_http_port)
command = [
"docker",
"exec",
"-i",
self._cid,
"/gns3/bin/busybox",
"nc",
"127.0.0.1",
str(self._console_http_port),
]
# We replace host and port in the server answer otherwise some link could be broken
server = AsyncioRawCommandServer(
command,
replaces=[
(
b"://127.0.0.1", # {{HOST}} mean client host
b"://{{HOST}}",
),
(
f":{self._console_http_port}".encode(),
f":{self.console}".encode(),
),
],
)
self._telnet_servers.append(
await asyncio.start_server(server.run, self._manager.port_manager.console_host, self.console)
)
async def _window_size_changed_callback(self, columns, rows):
"""
Called when the console window size has been changed.
(when naws is enabled in the Telnet server)
:param columns: number of columns
:param rows: number of rows
"""
# resize the container TTY.
try:
await self._manager.query("POST", f"containers/{self._cid}/resize?h={rows}&w={columns}")
except DockerError as e:
log.warning(f"Could not resize the container TTY: {e}")
async def _start_console(self):
"""
Starts streaming the console via telnet or ssh
"""
class InputStream:
def __init__(self):
self._data = b""
def write(self, data):
self._data += data
async def drain(self):
if not self.ws.closed:
await self.ws.send_bytes(self._data)
self._data = b""
output_stream = asyncio.StreamReader()
input_stream = InputStream()
if self.console_type == "telnet":
telnet = AsyncioTelnetServer(
reader=output_stream,
writer=input_stream,
echo=True,
naws=True,
window_size_changed_callback=self._window_size_changed_callback,
)
transport = "Telnet"
else:
telnet = AsyncioSSHServer(reader=output_stream, writer=input_stream)
transport = "SSH"
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 {transport} server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
)
self._console_websocket = await self.manager.websocket_query(
f"containers/{self._cid}/attach/ws?stream=1&stdin=1&stdout=1&stderr=1"
)
input_stream.ws = self._console_websocket
output_stream.feed_data(self.name.encode() + b" console is now available... Press RETURN to get started.\r\n")
asyncio.ensure_future(self._read_console_output(self._console_websocket, output_stream))
async def _read_console_output(self, ws, out):
"""
Reads Websocket and forward it to the telnet
:param ws: Websocket connection
:param out: Output stream
"""
while True:
msg = await ws.receive()
if msg.type == aiohttp.WSMsgType.TEXT:
out.feed_data(msg.data.encode())
elif msg.type == aiohttp.WSMsgType.BINARY:
out.feed_data(msg.data)
elif msg.type == aiohttp.WSMsgType.ERROR:
log.critical(f"Docker WebSocket Error: {ws.exception()}")
else:
out.feed_eof()
await ws.close()
break
async def reset_console(self):
"""
Reset the console.
"""
if self.console_type not in ("telnet", "ssh"):
return
if self._console_websocket:
await self._console_websocket.close()
await self._clean_servers()
await self._start_console()
async def is_running(self):
"""
Checks if the container is running.
:returns: True or False
:rtype: bool
"""
state = await self._get_container_state()
if state == "running":
return True
if self.status == "started": # The container crashed we need to clean
await self.stop()
return False
async def restart(self):
"""
Restart this Docker container.
"""
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
"""
if len(self._telnet_servers) > 0:
for telnet_server in self._telnet_servers:
telnet_server.close()
await telnet_server.wait_closed()
self._telnet_servers = []
async def stop(self, graceful: bool = False):
"""
Stops this Docker container.
:param graceful: request a graceful SIGTERM shutdown (honoured by the
vendor NOS override). The default immediate kill is used on the
internal paths (delete, update, close, crash cleanup), where the
container is force-deleted or recreated right after anyway.
"""
try:
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()
try:
state = await self._get_container_state()
except DockerHttp404Error:
self.status = "stopped"
return
if state == "paused":
await self.unpause()
if not self._permissions_fixed:
await self._fix_permissions()
state = await self._get_container_state()
if state != "stopped" and state != "exited":
try:
await self._terminate_container(graceful=graceful)
log.debug(f"Docker container '{self._name}' [{self._image}] stopped")
except DockerHttp409Error:
# Container is already stopped
pass
# Ignore runtime error because when closing the server
except RuntimeError as e:
log.debug(f"Docker runtime error when closing: {str(e)}")
return
self.status = "stopped"
async def _terminate_container(self, graceful: bool = False):
"""
Final termination of a still-running container: immediate SIGKILL.
GNS3 has already persisted container state (permissions via
_fix_permissions, /gns3volumes) before this point, and the business
process (often an interactive shell) ignores SIGTERM — a stop grace
period buys nothing but latency. Vendor NOS containers override this
with a graceful SIGTERM shutdown when asked (see VendorDockerVM);
the ``graceful`` flag is accepted here only for signature
compatibility.
"""
await self.manager.query("POST", f"containers/{self._cid}/kill")
async def pause(self):
"""
Pauses this Docker container.
"""
await self.manager.query("POST", f"containers/{self._cid}/pause")
self.status = "suspended"
log.debug(f"Docker container '{self._name}' [{self._image}] paused")
async def unpause(self):
"""
Unpauses this Docker container.
"""
await self.manager.query("POST", f"containers/{self._cid}/unpause")
self.status = "started"
log.debug(f"Docker container '{self._name}' [{self._image}] unpaused")
async def close(self):
"""
Closes this Docker container.
"""
self._closing = True
if not (await super().close()):
return False
await self.reset()
async def reset(self, release_nio_udp_ports=True):
try:
state = await self._get_container_state()
if state == "paused" or state == "running":
await self.stop()
if self.console_type == "vnc":
if self._vncconfig_process:
try:
self._vncconfig_process.terminate()
await self._vncconfig_process.wait()
except ProcessLookupError:
pass
if self._vnc_process:
try:
self._vnc_process.terminate()
await self._vnc_process.wait()
except ProcessLookupError:
pass
if self._display:
display = f"/tmp/.X11-unix/X{self._display}"
try:
if os.path.exists(display):
os.remove(display)
except OSError as e:
log.warning(f"Could not remove display {display}: {e}")
# v 1/True/true or 0/False/false, Remove the volumes associated to the container. Default false.
# force - 1/True/true or 0/False/false, Kill then remove the container. Default false.
try:
await self.manager.query("DELETE", f"containers/{self._cid}", params={"force": 1, "v": 1})
except DockerHttp404Error:
# Container already removed (normal case)
pass
except DockerError as e:
# Container deletion failed - log warning but don't block project close
# The stale container will be cleaned up when the project is opened again
log.warning(f"Failed to delete Docker container '{self.docker_name}': {e}")
log.debug("Docker container '{name}' [{image}] removed".format(name=self._name, image=self._image))
if release_nio_udp_ports:
for adapter in self._ethernet_adapters:
if adapter is not None:
for nio in adapter.ports.values():
if nio and isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
# Ignore runtime error because when closing the server
except (DockerHttp404Error, RuntimeError) as e:
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.
:param nio: NIO instance or None if it's a dummy interface (if an interface is missing in ubridge you can't see it via ifconfig in the container)
:param adapter_number: adapter number
"""
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
raise DockerError(
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
name=self.name, adapter_number=adapter_number
)
)
for index in range(4096):
if f"tap-gns3-e{index}" not in psutil.net_if_addrs():
adapter.host_ifc = f"tap-gns3-e{str(index)}"
break
if adapter.host_ifc is None:
raise DockerError(
"Adapter {adapter_number} couldn't allocate interface on Docker container '{name}'. Too many Docker interfaces already exists".format(
name=self.name, adapter_number=adapter_number
)
)
bridge_name = f"bridge{adapter_number}"
await self._ubridge_send(f"bridge create {bridge_name}")
self._bridges.add(bridge_name)
await self._ubridge_send(
"bridge add_nio_tap bridge{adapter_number} {hostif}".format(
adapter_number=adapter_number, hostif=adapter.host_ifc
)
)
mac_address = int_to_macaddress(macaddress_to_int(self._mac_address) + adapter_number)
custom_adapter = self._get_custom_adapter_settings(adapter_number)
custom_mac_address = custom_adapter.get("mac_address")
if custom_mac_address:
mac_address = custom_mac_address
try:
await self._ubridge_send('docker set_mac_addr {ifc} {mac}'.format(ifc=adapter.host_ifc, mac=mac_address))
except UbridgeError:
log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}")
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(
f"docker move_to_ns {adapter.host_ifc} {self._namespace} {ifname}"
)
except UbridgeError as e:
raise UbridgeNamespaceError(e)
else:
log.debug(f"Created adapter {adapter_number} with MAC address {mac_address} in namespace {self._namespace}")
if nio:
await self._connect_nio(adapter_number, nio)
async def _get_namespace(self):
result = await self.manager.query("GET", f"containers/{self._cid}/json")
return int(result["State"]["Pid"])
async def _connect_nio(self, adapter_number, nio):
bridge_name = f"bridge{adapter_number}"
await self._ubridge_send(
"bridge add_nio_udp {bridge_name} {lport} {rhost} {rport}".format(
bridge_name=bridge_name, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
if nio.capturing:
await self._ubridge_send(
'bridge start_capture {bridge_name} "{pcap_file}"'.format(
bridge_name=bridge_name, pcap_file=nio.pcap_output_file
)
)
await self._ubridge_send(f"bridge start {bridge_name}")
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_add_nio_binding(self, adapter_number, nio):
"""
Adds an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to add to the slot/port
"""
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
raise DockerError(
"Adapter {adapter_number} doesn't exist on Docker container '{name}'".format(
name=self.name, adapter_number=adapter_number
)
)
if self.status == "started" and self.ubridge:
await self._connect_nio(adapter_number, nio)
adapter.add_nio(0, nio)
log.debug(
"Docker container '{name}' [{id}]: {nio} added to adapter {adapter_number}".format(
name=self.name, id=self._id, nio=nio, adapter_number=adapter_number
)
)
async def adapter_update_nio_binding(self, adapter_number, nio):
"""
Update an adapter NIO binding.
:param adapter_number: adapter number
:param nio: NIO instance to update the adapter
"""
if self.ubridge:
bridge_name = f"bridge{adapter_number}"
if bridge_name in self._bridges:
await self._ubridge_apply_filters(bridge_name, nio.filters)
await self._ubridge_apply_markers(bridge_name, nio)
async def adapter_remove_nio_binding(self, adapter_number):
"""
Removes an adapter NIO binding.
:param adapter_number: adapter number
:returns: NIO instance
"""
try:
adapter = self._ethernet_adapters[adapter_number]
except IndexError:
raise DockerError(
"Adapter {adapter_number} doesn't exist on Docker VM '{name}'".format(
name=self.name, adapter_number=adapter_number
)
)
await self.stop_capture(adapter_number)
if self.ubridge:
nio = adapter.get_nio(0)
bridge_name = f"bridge{adapter_number}"
await self._ubridge_send(f"bridge stop {bridge_name}")
await self._ubridge_send(
"bridge remove_nio_udp bridge{adapter} {lport} {rhost} {rport}".format(
adapter=adapter_number, lport=nio.lport, rhost=nio.rhost, rport=nio.rport
)
)
adapter.remove_nio(0)
log.debug(
"Docker VM '{name}' [{id}]: {nio} removed from adapter {adapter_number}".format(
name=self.name, id=self.id, nio=adapter.host_ifc, adapter_number=adapter_number
)
)
def get_nio(self, adapter_number):
"""
Gets an adapter NIO binding.
:param adapter_number: adapter number
:returns: NIO instance
"""
try:
adapter = self._ethernet_adapters[adapter_number]
except KeyError:
raise DockerError(
"Adapter {adapter_number} doesn't exist on Docker VM '{name}'".format(
name=self.name, adapter_number=adapter_number
)
)
nio = adapter.get_nio(0)
if not nio:
raise DockerError(f"Adapter {adapter_number} is not connected")
return nio
@property
def adapters(self):
"""
Returns the number of Ethernet adapters for this Docker VM.
:returns: number of adapters
:rtype: int
"""
return len(self._ethernet_adapters)
@adapters.setter
def adapters(self, adapters):
"""
Sets the number of Ethernet adapters for this Docker container.
:param adapters: number of adapters
"""
if len(self._ethernet_adapters) == adapters:
return
self._ethernet_adapters.clear()
for adapter_number in range(0, adapters):
self._ethernet_adapters.append(EthernetAdapter())
log.debug(
'Docker container "{name}" [{id}]: number of Ethernet adapters changed to {adapters}'.format(
name=self._name, id=self._id, adapters=adapters
)
)
async def pull_image(self, image):
"""
Pulls an image from Docker repository
"""
def callback(msg):
self.project.emit("log.info", {"message": msg})
await self.manager.pull_image(image, progress_callback=callback)
async def _start_ubridge_capture(self, adapter_number, output_file):
"""
Starts a packet capture in uBridge.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
"""
adapter = f"bridge{adapter_number}"
if not self.ubridge:
raise DockerError("Cannot start the packet capture: uBridge is not running")
await self._ubridge_send(f'bridge start_capture {adapter} "{output_file}"')
async def _stop_ubridge_capture(self, adapter_number):
"""
Stops a packet capture in uBridge.
:param adapter_number: adapter number
"""
adapter = f"bridge{adapter_number}"
if not self.ubridge:
raise DockerError("Cannot stop the packet capture: uBridge is not running")
await self._ubridge_send(f"bridge stop_capture {adapter}")
async def start_capture(self, adapter_number, output_file):
"""
Starts a packet capture.
:param adapter_number: adapter number
:param output_file: PCAP destination file for the capture
"""
nio = self.get_nio(adapter_number)
if nio.capturing:
raise DockerError(f"Packet capture is already activated on adapter {adapter_number}")
nio.start_packet_capture(output_file)
if self.status == "started" and self.ubridge:
await self._start_ubridge_capture(adapter_number, output_file)
log.debug(
"Docker VM '{name}' [{id}]: starting packet capture on adapter {adapter_number}".format(
name=self.name, id=self.id, adapter_number=adapter_number
)
)
async def stop_capture(self, adapter_number):
"""
Stops a packet capture.
:param adapter_number: adapter number
"""
nio = self.get_nio(adapter_number)
if not nio.capturing:
return
nio.stop_packet_capture()
if self.status == "started" and self.ubridge:
await self._stop_ubridge_capture(adapter_number)
log.debug(
"Docker VM '{name}' [{id}]: stopping packet capture on adapter {adapter_number}".format(
name=self.name, id=self.id, adapter_number=adapter_number
)
)
async def _get_log(self):
"""
Returns the log from the container
:returns: string
"""
result = await self.manager.query("GET", f"containers/{self._cid}/logs", params={"stderr": 1, "stdout": 1})
return result
async def delete(self):
"""
Deletes the VM (including all its files).
"""
await self.close()
await super().delete()