docker: harden the shm/devices/extra_configs/masking work (code review)

Nine fixes from a review of the docker-shm-devices diff:

* GNS3_STOP_TIMEOUT >300 s aborted at the manager's default HTTP timeout
  before Docker finished the stop — the stop query now gets a timeout
  with a margin over the grace period.
* Overlapping bind targets (GNS3_MASK_UDEV + GNS3_MASK_SYSTEMD on the
  same unit, a unit named twice, an extra_configs target equal to a
  masked unit) made Docker reject the create with 'Duplicate mount
  point' — Mounts are deduplicated by target.
* ExtraConfig.target now carries a pydantic validator (absolute file
  path, no '..'), so bad targets 422 at template-save time instead of
  failing at node-create time after a multi-GB image pull; directory
  forms ('/', '/etc/') are also rejected by the runtime guard instead
  of raising IsADirectoryError (raw 500).
* _check_host_readiness skipped every remaining check when one
  /proc/sys key was unreadable (mid-loop return) — now continues.
* The base-class GNS3_* env parser strips trailing commas like the
  vendor parser, so 'GNS3_MASK_UDEV=1,' composed from a list still
  activates.
* Vendor env knobs are re-parsed on every create(), so a PUT to the
  node's environment takes effect on the next (re)create.
* The graceful SIGTERM stop is now limited to the explicit user stop
  route; delete/update/close/crash-cleanup keep the immediate kill
  (those paths force-delete or recreate the container right after).
* An extra_configs target beneath a persisted volume is shadowed by the
  volume bind — warn at create time.
This commit is contained in:
YueGuobin committed 2026-08-15 00:52:55 +08:00
1 parent 1124d7a539
commit 9604c85fda
9 files changed
+297 -43

No files matched your search

@@ -177,10 +177,12 @@ async def start_docker_node(node: DockerVM = Depends(dep_node)) -> None:
)
async def stop_docker_node(node: DockerVM = Depends(dep_node)) -> None:
"""
Stop a Docker node.
Stop a Docker node. This is the explicit user stop — the only path that
asks for a graceful SIGTERM shutdown (vendor NOS override); internal
paths (delete/update/close) keep the immediate kill.
"""
await node.stop()
await node.stop(graceful=True)
@router.post(
+3 -1
View File
@@ -190,7 +190,9 @@ class Docker(BaseManager):
with open(f"/proc/sys/{key.replace('.', '/')}") as f:
current = int(f.read().strip())
except (OSError, ValueError):
return # Not Linux or unreadable -- nothing to check.
# One unreadable key must not discard the warnings already
# collected nor skip the FUSE check — skip just this key.
continue
if current < minimum:
low.append((key, current, minimum))
+41 -7
View File
@@ -433,10 +433,21 @@ class DockerVM(BaseNode):
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 ".." in target.split("/"):
if not target.startswith("/") or target.endswith("/") or ".." in target.split("/"):
raise DockerError(
f"Extra config target '{target}' must be an absolute path and not contain '..'."
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:
@@ -556,7 +567,10 @@ class DockerVM(BaseNode):
# when set, so ordinary nodes keep the default Docker behaviour.
if self._environment:
for line in self._environment.splitlines():
line = line.strip()
# 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)
@@ -594,6 +608,19 @@ class DockerVM(BaseNode):
"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:
@@ -1179,9 +1206,14 @@ class DockerVM(BaseNode):
await telnet_server.wait_closed()
self._telnet_servers = []
async def stop(self):
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:
@@ -1207,7 +1239,7 @@ class DockerVM(BaseNode):
state = await self._get_container_state()
if state != "stopped" and state != "exited":
try:
await self._terminate_container()
await self._terminate_container(graceful=graceful)
log.debug(f"Docker container '{self._name}' [{self._image}] stopped")
except DockerHttp409Error:
# Container is already stopped
@@ -1218,14 +1250,16 @@ class DockerVM(BaseNode):
return
self.status = "stopped"
async def _terminate_container(self):
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 (see VendorDockerVM).
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")
+42 -11
View File
@@ -62,14 +62,22 @@ class VendorDockerVM(DockerVM):
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._console_exec_writer = None
# Parsed eagerly so _get_container_ifname can return the right name,
# and re-parsed on every create() so a PUT to the node's environment
# takes effect on the next (re)create instead of the next reload.
self._parse_vendor_environment()
def _parse_vendor_environment(self):
"""
(Re)parse the GNS3_* knobs from the current ``environment`` value,
resetting to defaults first so removed entries stop applying.
"""
self._gns3_init = True
self._interface_names = []
self._console_cmd = None
self._console_exec_writer = None
self._stop_timeout = 60
if self._environment:
for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",")
@@ -89,6 +97,12 @@ class VendorDockerVM(DockerVM):
except ValueError:
pass
async def create(self):
# The environment may have changed since __init__ (PUT on the node) —
# re-parse the knobs so the recreated container picks them up.
self._parse_vendor_environment()
return await super().create()
# ---- hook overrides ---------------------------------------------------
def _mount_binds(self, image_info):
@@ -148,18 +162,35 @@ class VendorDockerVM(DockerVM):
pass
self._console_exec_writer = None
async def _terminate_container(self):
async def _terminate_container(self, graceful: bool = False):
"""
Override: vendor NOS containers run systemd and require a graceful
shutdown (e.g. Cisco XRd treats an abrupt SIGKILL as an unclean
shutdown). Send SIGTERM and wait up to ``GNS3_STOP_TIMEOUT`` seconds
(default 60) for the services to stop; Docker SIGKILLs the container
itself once the grace period expires, so no fallback kill is needed.
The blocking stop call sits well inside the manager's default 300 s
query timeout.
shutdown).
With ``graceful`` (explicit user stop), send SIGTERM and wait up to
``GNS3_STOP_TIMEOUT`` seconds (default 60, 1-600) for the services to
stop; Docker SIGKILLs the container itself once the grace period
expires, so no fallback kill is needed. The stop query gets an HTTP
timeout with a margin over the grace period — the manager's default
300 s would abort first for values above it.
Without ``graceful`` (delete/update/close/crash cleanup), fall back to
the base immediate kill: those paths force-delete or recreate the
container right after anyway, so a grace period buys nothing but
latency.
"""
if not graceful:
await super()._terminate_container(graceful=False)
return
try:
await self.manager.query("POST", f"containers/{self._cid}/stop", params={"t": self._stop_timeout})
response = await self.manager.http_query(
"POST",
f"containers/{self._cid}/stop",
params={"t": self._stop_timeout},
timeout=self._stop_timeout + 30,
)
response.close()
except DockerHttp304Error:
pass # already stopped
+17 -1
View File
@@ -14,7 +14,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, field_validator
from typing import Optional
from enum import Enum
@@ -60,6 +60,22 @@ class ExtraConfig(BaseModel):
target: str = Field(..., description="Absolute path inside the container where the file is mounted")
content: str = Field("", description="File content written by GNS3 and bind-mounted read-only into the container")
@field_validator("target")
@classmethod
def target_is_an_absolute_file_path(cls, v):
"""
Reject at save time (template/appliance/node PUT) what would only
blow up at node-create time — after a potentially multi-GB image
pull: relative paths, '..' components and directory forms ('/',
'/etc/').
"""
if not v.startswith("/") or v.endswith("/") or ".." in v.split("/"):
raise ValueError(
"target must be an absolute file path inside the container "
"(start with '/', name a file, no '..' components)"
)
return v
class ConsoleType(str, Enum):
"""