mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-07 02:25:21 +03:00
feat: IOU-style startup-config for IOL Docker nodes
Templates reference a config file with the GNS3_IOL_STARTUP_CONFIG environment knob; the controller materializes the file content into startup_config_content on node creation (sent once, knob consumed — the same pattern as the IOU startup_config mapping). The compute builds the content into the node's nvram_<app id> at the next start using the IOU nvram_import utility (IOL and IOU share the nvram container format, verified against iol-xe 17.18.02): valid config at boot, no setup dialog, %h hostname substitution, hostname rewrite on rename. Semantics verified against the runner: IOL boots from NVRAM whenever it holds a config, so a plain stop/start never re-applies the startup config and 'write memory' survives restarts; an explicit content edit (PUT) is re-applied on the next start and wins over the saved config, like IOU.
This commit is contained in:
parent
9147deccaa
commit
8b3aafbbdc
@ -98,7 +98,7 @@ API docs for the auth flow), or in the Web UI under
|
|||||||
|
|
||||||
| Field | Value | Why |
|
| Field | Value | Why |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048). |
|
| `environment` | `GNS3_IOL_RUNNER=1` | The switch that selects `IOLDockerVM` (skip-init, unix-socket NIO, auto volumes). Optional: `GNS3_IOL_MEMORY=<MB>` (default 2048), `GNS3_IOL_STARTUP_CONFIG=<file>` (initial configuration, see below). |
|
||||||
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
|
| `extra_volumes` | `["/config"]` | `/tmp/run` is auto-added. **Never add `/tmp`** — it would persist the socket directory into the projects tree and uBridge would reject the too-long AF_UNIX path. |
|
||||||
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
|
| `adapters` | number of 4-port units | The IOU convention: one adapter = `Ethernet0/0`–`Ethernet0/3`, two adapters add `Ethernet1/0`–`1/3`, … (8 units / 32 ports max). Ports are addressed as (adapter, port 0–3) and shown grouped in the UI. |
|
||||||
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
|
| `memory` | optional; `0` (default) = no cap | Unset works — Docker applies no limit. When you do set a cap, keep it at IOL memory + ~512 MB, or the cgroup OOM-killer shoots the router. |
|
||||||
@ -113,9 +113,42 @@ API docs for the auth flow), or in the Web UI under
|
|||||||
`Linux Unix (i686)` banner within seconds.
|
`Linux Unix (i686)` banner within seconds.
|
||||||
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
|
3. `$XDG_RUNTIME_DIR/gns3/unixio/<node-id>/` contains `s00.sock`… (one
|
||||||
pair per port).
|
pair per port).
|
||||||
4. The startup-config lives at
|
4. A node created with a startup-config boots straight to the configured
|
||||||
`project-files/docker/<node>/tmp/run/config` (interface names
|
hostname — no initial configuration dialog (interface names in the
|
||||||
`Ethernet0/0`, not `GigabitEthernet0/0`).
|
config are `Ethernet0/0`, not `GigabitEthernet0/0`).
|
||||||
|
|
||||||
|
## Startup configuration
|
||||||
|
|
||||||
|
IOL Docker nodes load an initial configuration exactly like native IOU
|
||||||
|
nodes: the **template references a config file**, and every node created
|
||||||
|
from it boots with that configuration as its personal starting point.
|
||||||
|
|
||||||
|
```json
|
||||||
|
"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=iol-xe-base.txt"
|
||||||
|
```
|
||||||
|
|
||||||
|
* The file lives in the controller's configs directory (the `configs_path`
|
||||||
|
server setting, e.g. `~/.config/GNS3/3.1/configs`) — the same place IOU
|
||||||
|
and VPCS base configs live. `%h` in the content is replaced with the node
|
||||||
|
name at start.
|
||||||
|
* **Creation materializes it once**: the controller reads the file and
|
||||||
|
sends its content with the node; afterwards the knob is consumed and the
|
||||||
|
template file is never referenced again — editing it does not affect
|
||||||
|
existing nodes.
|
||||||
|
* **NVRAM is the source of truth at boot** (verified on the runner): the
|
||||||
|
content is built into the node's `tmp/run/nvram_<app id>` — IOL and IOU
|
||||||
|
share the nvram container format, so the server-side `nvram_import`
|
||||||
|
utility produces a file IOL boots from directly. Consequences:
|
||||||
|
* `write memory` survives stop/start and container recreation (a plain
|
||||||
|
restart never re-applies the startup-config over it).
|
||||||
|
* Editing a node's `startup_config_content` property (PUT) re-applies it
|
||||||
|
on the next start, overwriting what `write memory` had saved — the
|
||||||
|
explicit edit wins, as with IOU.
|
||||||
|
* Renaming a node rewrites the `hostname` line in its NVRAM, so a
|
||||||
|
duplicated/renamed node boots under its new name.
|
||||||
|
* **Resetting a node to its template config**: stop the node, delete
|
||||||
|
`project-files/docker/<node>/tmp/run/nvram_*`, then re-apply the content
|
||||||
|
(or recreate the node).
|
||||||
|
|
||||||
## Server mechanisms
|
## Server mechanisms
|
||||||
|
|
||||||
@ -125,6 +158,7 @@ API docs for the auth flow), or in the Web UI under
|
|||||||
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
|
| `GNS3_UNIX_SOCKET_DIR=<dir>` | `VendorDockerVM` | In-container socket directory (default `/tmp`). Any image whose agent exposes the `s%02d`/`c%02d` datagram pairs can use this without the IOL specifics. |
|
||||||
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`tmp/run` is never touched). |
|
| `GNS3_IOL_RUNNER=1` | `IOLDockerVM` (selected in the manager) | Forces skip-init + unix-socket NIO + the `/config` and `/tmp/run` volumes; on every start writes `<node>/config/iol-config.json` (`num-eth` = adapter count, `num-serial` = 0, memory from `GNS3_IOL_MEMORY`, default 2048), creates `<node>/tmp/run/` (the IOL process dies without it) and removes stale sockets/netio dirs from the socket directory (`tmp/run` is never touched). |
|
||||||
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
|
| `restart()` hardening | `IOLDockerVM` | The base `docker restart` would boot the runner on a stale config and leave uBridge wired to the previous run's sockets; reload becomes graceful stop (SIGTERM → NVRAM flush) + full start. |
|
||||||
|
| `GNS3_IOL_STARTUP_CONFIG=<file>` | controller `Node._node_data` + `IOLDockerVM` | Startup-config plumbing (see above): the controller translates the file into `startup_config_content` on node creation (sent once); the compute builds it into `nvram_<app id>` at the next start via the IOU `nvram_import` utility and rewrites the hostname line on rename. |
|
||||||
|
|
||||||
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
|
`GNS3_STOP_TIMEOUT` (default 60) controls the SIGTERM grace period on stop.
|
||||||
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
|
Extra iol-runner flags can be passed via `start_command`, e.g. `-keep`
|
||||||
|
|||||||
@ -140,6 +140,7 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
|
|||||||
"extra_hosts",
|
"extra_hosts",
|
||||||
"extra_volumes",
|
"extra_volumes",
|
||||||
"extra_configs",
|
"extra_configs",
|
||||||
|
"startup_config_content",
|
||||||
"memory",
|
"memory",
|
||||||
"cpus",
|
"cpus",
|
||||||
]
|
]
|
||||||
@ -147,7 +148,8 @@ async def update_docker_node(node_data: schemas.DockerUpdate, node: DockerVM = D
|
|||||||
changed = False
|
changed = False
|
||||||
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
node_data = jsonable_encoder(node_data, exclude_unset=True)
|
||||||
for prop in props:
|
for prop in props:
|
||||||
if prop in node_data and node_data[prop] != getattr(node, prop):
|
# hasattr: startup_config_content only exists on IOLDockerVM
|
||||||
|
if prop in node_data and hasattr(node, prop) and node_data[prop] != getattr(node, prop):
|
||||||
setattr(node, prop, node_data[prop])
|
setattr(node, prop, node_data[prop])
|
||||||
changed = True
|
changed = True
|
||||||
# We don't call container.update for nothing because it will restart the container
|
# We don't call container.update for nothing because it will restart the container
|
||||||
|
|||||||
@ -42,12 +42,15 @@ import glob
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
|
|
||||||
from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter
|
from gns3server.compute.adapters.ethernet_adapter import EthernetAdapter
|
||||||
from gns3server.compute.docker.docker_error import DockerHttp404Error
|
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
|
||||||
from gns3server.compute.docker.docker_vm import DockerVM
|
from gns3server.compute.docker.docker_vm import DockerVM
|
||||||
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
||||||
|
from gns3server.compute.iou.utils.iou_export import nvram_export
|
||||||
|
from gns3server.compute.iou.utils.iou_import import nvram_import
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -66,10 +69,20 @@ class IOLDockerVM(VendorDockerVM):
|
|||||||
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring,
|
The marker itself forces ``GNS3_SKIP_INIT`` and the unix-socket NIO wiring,
|
||||||
and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a
|
and auto-adds the ``/config`` and ``/tmp/run`` persistent volumes, so a
|
||||||
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
|
template containing only ``GNS3_IOL_RUNNER=1`` is fully configured.
|
||||||
|
|
||||||
|
Startup configuration follows the IOU model: a template may reference a
|
||||||
|
config file with the ``GNS3_IOL_STARTUP_CONFIG`` environment knob; the
|
||||||
|
controller materializes the file content into ``startup_config_content``
|
||||||
|
when the node is created. The content is built into the node's NVRAM
|
||||||
|
(``tmp/run/nvram_<app id>``) on the next start — IOL boots straight from
|
||||||
|
NVRAM, so a plain stop/start never re-applies it and ``write memory``
|
||||||
|
survives restarts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_IOL_CONFIG_DIR = "/config"
|
_IOL_CONFIG_DIR = "/config"
|
||||||
_IOL_RUN_DIR = "/tmp/run"
|
_IOL_RUN_DIR = "/tmp/run"
|
||||||
|
# The runner launches IOL with a fixed 256KB nvram (-n 256)
|
||||||
|
_IOL_NVRAM_SIZE_KB = 256
|
||||||
|
|
||||||
def _parse_vendor_environment(self):
|
def _parse_vendor_environment(self):
|
||||||
|
|
||||||
@ -97,6 +110,13 @@ class IOLDockerVM(VendorDockerVM):
|
|||||||
# space, disjoint from IOU's); None until the create payload carries it.
|
# space, disjoint from IOU's); None until the create payload carries it.
|
||||||
self._application_id = None
|
self._application_id = None
|
||||||
|
|
||||||
|
# Startup-config content materialized by the controller from the file
|
||||||
|
# referenced by GNS3_IOL_STARTUP_CONFIG. Applied to the NVRAM at start
|
||||||
|
# time (not in the setter: the application id may not be final yet
|
||||||
|
# when the create payload is applied field by field).
|
||||||
|
self._startup_config_content = None
|
||||||
|
self._startup_config_dirty = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def application_id(self) -> int:
|
def application_id(self) -> int:
|
||||||
"""
|
"""
|
||||||
@ -114,6 +134,99 @@ class IOLDockerVM(VendorDockerVM):
|
|||||||
def application_id(self, value) -> None:
|
def application_id(self, value) -> None:
|
||||||
self._application_id = int(value)
|
self._application_id = int(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def startup_config_content(self):
|
||||||
|
"""
|
||||||
|
Startup-config content, delivered by the controller when the node is
|
||||||
|
created from a template carrying GNS3_IOL_STARTUP_CONFIG (or updated
|
||||||
|
with new content).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return self._startup_config_content
|
||||||
|
|
||||||
|
@startup_config_content.setter
|
||||||
|
def startup_config_content(self, content):
|
||||||
|
"""
|
||||||
|
Record new startup-config content; it is built into the node's NVRAM
|
||||||
|
on the next start. IOL boots from NVRAM whenever it holds a config, so
|
||||||
|
the content is only pushed when it actually changes — a plain
|
||||||
|
stop/start never re-applies it and `write memory` survives restarts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not content or content == self._startup_config_content:
|
||||||
|
# An empty value is ignored: erasing the config is not supported
|
||||||
|
# (mirrors the IOU setter) and Web clients PUT "" for unset fields.
|
||||||
|
return
|
||||||
|
self._startup_config_content = content
|
||||||
|
self._startup_config_dirty = True
|
||||||
|
|
||||||
|
def _iol_nvram_file(self) -> str:
|
||||||
|
"""
|
||||||
|
The IOL NVRAM file inside the persistent /tmp/run volume. The name
|
||||||
|
embeds the application id (nvram_00772-style, like CML).
|
||||||
|
"""
|
||||||
|
|
||||||
|
return os.path.join(self.working_dir, "tmp", "run", f"nvram_{self.application_id:05d}")
|
||||||
|
|
||||||
|
def _apply_pending_startup_config(self) -> None:
|
||||||
|
"""
|
||||||
|
Build the node's NVRAM from the recorded startup-config content. IOL
|
||||||
|
and IOU share the same NVRAM container format (startup-config stored
|
||||||
|
as text inside the nvram file system), so the IOU nvram_import
|
||||||
|
utility produces a file IOL boots from directly — valid config, no
|
||||||
|
initial configuration dialog.
|
||||||
|
"""
|
||||||
|
|
||||||
|
content = self._startup_config_content.replace("%h", self._name)
|
||||||
|
nvram_file = self._iol_nvram_file()
|
||||||
|
os.makedirs(os.path.dirname(nvram_file), exist_ok=True)
|
||||||
|
try:
|
||||||
|
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
|
||||||
|
with open(nvram_file, "wb") as f:
|
||||||
|
f.write(nvram)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
raise DockerError(f"Could not write IOL startup-config to NVRAM of container '{self._name}': {e}")
|
||||||
|
log.debug("IOL container '%s': startup-config written to %s", self._name, nvram_file)
|
||||||
|
|
||||||
|
@DockerVM.name.setter
|
||||||
|
def name(self, new_name):
|
||||||
|
"""
|
||||||
|
Override: keep the hostname line inside the NVRAM in sync with the
|
||||||
|
node name (IOU parity), so a renamed or duplicated node boots under
|
||||||
|
its new name. Skipped while a content change is pending — the next
|
||||||
|
start pushes the new content with the already-updated name.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not self._startup_config_dirty:
|
||||||
|
nvram_file = self._iol_nvram_file()
|
||||||
|
if os.path.exists(nvram_file):
|
||||||
|
try:
|
||||||
|
with open(nvram_file, "rb") as f:
|
||||||
|
startup_config, _ = nvram_export(f.read())
|
||||||
|
if startup_config:
|
||||||
|
content = re.sub(
|
||||||
|
r"hostname .+$",
|
||||||
|
"hostname " + new_name,
|
||||||
|
startup_config.decode("utf-8", errors="replace"),
|
||||||
|
flags=re.MULTILINE,
|
||||||
|
)
|
||||||
|
nvram = nvram_import(None, content.encode("utf-8"), None, self._IOL_NVRAM_SIZE_KB)
|
||||||
|
with open(nvram_file, "wb") as f:
|
||||||
|
f.write(nvram)
|
||||||
|
except (OSError, ValueError) as e:
|
||||||
|
log.warning(f"Could not update hostname in NVRAM of IOL container '{self._name}': {e}")
|
||||||
|
super(IOLDockerVM, IOLDockerVM).name.__set__(self, new_name)
|
||||||
|
|
||||||
|
def asdict(self):
|
||||||
|
"""
|
||||||
|
Override: expose the recorded startup-config content (empty for nodes
|
||||||
|
created before the knob existed or reloaded from a topology).
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = super().asdict()
|
||||||
|
result["startup_config_content"] = self._startup_config_content
|
||||||
|
return result
|
||||||
|
|
||||||
@DockerVM.adapters.setter
|
@DockerVM.adapters.setter
|
||||||
def adapters(self, adapters):
|
def adapters(self, adapters):
|
||||||
"""
|
"""
|
||||||
@ -195,6 +308,14 @@ class IOLDockerVM(VendorDockerVM):
|
|||||||
if state == "running":
|
if state == "running":
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Pending startup-config is materialized here rather than in the
|
||||||
|
# property setter: at create-payload time the application id may not
|
||||||
|
# be final yet (the create route applies fields in schema order) and
|
||||||
|
# a running container must not have its NVRAM swapped mid-flight.
|
||||||
|
if self._startup_config_dirty:
|
||||||
|
self._apply_pending_startup_config()
|
||||||
|
self._startup_config_dirty = False
|
||||||
|
|
||||||
wiring_dir = self._unix_socket_wiring_dir()
|
wiring_dir = self._unix_socket_wiring_dir()
|
||||||
for pattern in ("s??.sock", "c??.sock"):
|
for pattern in ("s??.sock", "c??.sock"):
|
||||||
for stale in glob.glob(os.path.join(wiring_dir, pattern)):
|
for stale in glob.glob(os.path.join(wiring_dir, pattern)):
|
||||||
|
|||||||
@ -40,6 +40,30 @@ import logging
|
|||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_iol_startup_config_knob(environment):
|
||||||
|
"""
|
||||||
|
Split the GNS3_IOL_STARTUP_CONFIG knob out of a Docker environment
|
||||||
|
(iol-runner images): the value is a config file name, resolved by the
|
||||||
|
controller in its configs directory.
|
||||||
|
|
||||||
|
:returns: (filename, environment without the knob line); filename is None
|
||||||
|
when the knob is absent or empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not environment or "GNS3_IOL_STARTUP_CONFIG=" not in environment:
|
||||||
|
return None, environment
|
||||||
|
filename = None
|
||||||
|
kept = []
|
||||||
|
for line in environment.splitlines():
|
||||||
|
stripped = line.strip().rstrip(",")
|
||||||
|
if stripped.startswith("GNS3_IOL_STARTUP_CONFIG="):
|
||||||
|
if filename is None:
|
||||||
|
filename = stripped.split("=", 1)[1].strip()
|
||||||
|
else:
|
||||||
|
kept.append(line)
|
||||||
|
return filename or None, "\n".join(kept)
|
||||||
|
|
||||||
|
|
||||||
class Node:
|
class Node:
|
||||||
# These properties are used only on controller and are not forwarded to the compute
|
# These properties are used only on controller and are not forwarded to the compute
|
||||||
CONTROLLER_ONLY_PROPERTIES = [
|
CONTROLLER_ONLY_PROPERTIES = [
|
||||||
@ -573,6 +597,25 @@ class Node:
|
|||||||
data[v] = self._base_config_file_content(self._properties[k])
|
data[v] = self._base_config_file_content(self._properties[k])
|
||||||
del data[k]
|
del data[k]
|
||||||
del self._properties[k] # We send the file only one time
|
del self._properties[k] # We send the file only one time
|
||||||
|
|
||||||
|
# IOL runner (Docker) startup-config: translate the config file
|
||||||
|
# referenced by the GNS3_IOL_STARTUP_CONFIG environment knob into
|
||||||
|
# content, like the mappings above. Sent only once — afterwards
|
||||||
|
# the node's configuration lives in its NVRAM on the compute (a
|
||||||
|
# reloaded node is recreated without the knob and boots from the
|
||||||
|
# persistent NVRAM, preserving `write memory`).
|
||||||
|
if self._node_type == "docker":
|
||||||
|
filename, environment = _extract_iol_startup_config_knob(self._properties.get("environment"))
|
||||||
|
if filename:
|
||||||
|
content = self._base_config_file_content(filename)
|
||||||
|
if content:
|
||||||
|
data["startup_config_content"] = content
|
||||||
|
data["environment"] = environment
|
||||||
|
self._properties["environment"] = environment
|
||||||
|
else:
|
||||||
|
log.warning(
|
||||||
|
f"Cannot load IOL startup-config file '{filename}' for node '{self._name}': file not found"
|
||||||
|
)
|
||||||
data["name"] = self._name
|
data["name"] = self._name
|
||||||
|
|
||||||
# For remote computes, convert absolute image paths to relative paths
|
# For remote computes, convert absolute image paths to relative paths
|
||||||
|
|||||||
@ -26,7 +26,7 @@ class DockerBase(BaseModel):
|
|||||||
Common Docker node properties.
|
Common Docker node properties.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@field_validator("start_command", "environment", "extra_hosts", mode="before")
|
@field_validator("start_command", "environment", "extra_hosts", "startup_config_content", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _empty_string_to_none(cls, value):
|
def _empty_string_to_none(cls, value):
|
||||||
# Web clients serialize empty form fields as "" while unset values are
|
# Web clients serialize empty form fields as "" while unset values are
|
||||||
@ -59,6 +59,9 @@ class DockerBase(BaseModel):
|
|||||||
extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)")
|
extra_hosts: Optional[str] = Field(None, description="Docker extra hosts (added to /etc/hosts)")
|
||||||
extra_volumes: Optional[List[str]] = Field(None, description="Additional directories to make persistent")
|
extra_volumes: Optional[List[str]] = Field(None, description="Additional directories to make persistent")
|
||||||
extra_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)")
|
extra_configs: Optional[List[ExtraConfig]] = Field(None, description="Configuration files injected into the container (bind-mounted read-only)")
|
||||||
|
startup_config_content: Optional[str] = Field(
|
||||||
|
None, description="Startup-config content (IOL runner images: materialized into the node's NVRAM at start)"
|
||||||
|
)
|
||||||
memory: Optional[int] = Field(None, ge=0, description="Maximum amount of memory the container can use in MB")
|
memory: Optional[int] = Field(None, ge=0, description="Maximum amount of memory the container can use in MB")
|
||||||
cpus: Optional[float] = Field(None, ge=0, description="Maximum amount of CPU resources the container can use")
|
cpus: Optional[float] = Field(None, ge=0, description="Maximum amount of CPU resources the container can use")
|
||||||
custom_adapters: Optional[List[CustomAdapter]] = Field(None, description="Custom adapters")
|
custom_adapters: Optional[List[CustomAdapter]] = Field(None, description="Custom adapters")
|
||||||
|
|||||||
@ -20,9 +20,11 @@ import pytest_asyncio
|
|||||||
|
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
from tests.utils import asyncio_patch
|
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch, MagicMock
|
||||||
|
|
||||||
|
from gns3server.compute.docker import Docker
|
||||||
|
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
|
||||||
from gns3server.compute.project import Project
|
from gns3server.compute.project import Project
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
@ -98,6 +100,46 @@ class TestDockerNodesRoutes:
|
|||||||
assert response.json()["extra_hosts"] == "test:127.0.0.1"
|
assert response.json()["extra_hosts"] == "test:127.0.0.1"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_docker_create_iol_startup_config(
|
||||||
|
self, app: FastAPI,
|
||||||
|
compute_client: AsyncClient,
|
||||||
|
compute_project: Project,
|
||||||
|
base_params: dict
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
The controller materializes the GNS3_IOL_STARTUP_CONFIG knob into
|
||||||
|
startup_config_content; the create route must carry it onto the
|
||||||
|
IOLDockerVM (plain Docker nodes have no such property and skip it).
|
||||||
|
"""
|
||||||
|
|
||||||
|
params = dict(base_params)
|
||||||
|
params["image"] = "iol-xe/iol-xe:17-18-02"
|
||||||
|
params["environment"] = "GNS3_IOL_RUNNER=1"
|
||||||
|
params["startup_config_content"] = "hostname %h\n"
|
||||||
|
create_response = {
|
||||||
|
"Id": "8bd8153ea8f5",
|
||||||
|
"Warnings": [],
|
||||||
|
"Config": {
|
||||||
|
"Entrypoint": ["/iol-runner", "-config", "/config/iol-config.json", "-stdio"],
|
||||||
|
"Cmd": [],
|
||||||
|
"Volumes": {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
seed_proc = MagicMock()
|
||||||
|
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid\n", b""))
|
||||||
|
seed_proc.returncode = 0
|
||||||
|
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "iol-xe/iol-xe"}]):
|
||||||
|
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=create_response):
|
||||||
|
with patch("asyncio.subprocess.create_subprocess_exec", return_value=seed_proc):
|
||||||
|
response = await compute_client.post(
|
||||||
|
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
|
||||||
|
)
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
assert response.json()["startup_config_content"] == "hostname %h\n"
|
||||||
|
vm = Docker.instance().get_node(response.json()["node_id"], project_id=str(compute_project.id))
|
||||||
|
assert isinstance(vm, IOLDockerVM)
|
||||||
|
assert vm.startup_config_content == "hostname %h\n"
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"name, status_code",
|
"name, status_code",
|
||||||
(
|
(
|
||||||
|
|||||||
@ -40,6 +40,8 @@ from gns3server.compute.docker.docker_vm import DockerVM
|
|||||||
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
|
||||||
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
|
from gns3server.compute.docker.iol_docker_vm import IOLDockerVM
|
||||||
from gns3server.compute.docker.docker_error import DockerError
|
from gns3server.compute.docker.docker_error import DockerError
|
||||||
|
from gns3server.compute.iou.utils.iou_export import nvram_export
|
||||||
|
from gns3server.compute.iou.utils.iou_import import nvram_import
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@ -601,3 +603,134 @@ async def test_generic_unix_socket_dir_honored_in_wiring(compute_project, manage
|
|||||||
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
|
flat = "\n".join(str(c) for c in vm._ubridge_hypervisor.method_calls)
|
||||||
assert f'"{os.path.join(wiring_dir, "s00.sock")}"' in flat
|
assert f'"{os.path.join(wiring_dir, "s00.sock")}"' in flat
|
||||||
assert "add_nio_tap" not in flat
|
assert "add_nio_tap" not in flat
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Startup configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _nvram_path(vm):
|
||||||
|
return os.path.join(vm.working_dir, "tmp", "run", f"nvram_{vm.application_id:05d}")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_nvram(vm):
|
||||||
|
with open(_nvram_path(vm), "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
async def _start(vm, state="stopped"):
|
||||||
|
_mock_start(vm, state=state)
|
||||||
|
with patch("gns3server.compute.docker.Docker.install_busybox"):
|
||||||
|
with asyncio_patch("gns3server.compute.docker.Docker.query"):
|
||||||
|
await vm.start()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_startup_config_built_into_nvram_at_start(compute_project, manager):
|
||||||
|
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname %h\ninterface Ethernet0/0\n ip address dhcp\n no shutdown"
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
startup, _ = nvram_export(_read_nvram(vm))
|
||||||
|
content = startup.decode("utf-8")
|
||||||
|
# %h is substituted with the node name, like the IOU startup-config
|
||||||
|
assert f"hostname {vm.name}" in content
|
||||||
|
assert "ip address dhcp" in content
|
||||||
|
assert vm._startup_config_dirty is False # consumed
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_plain_restart_never_reapplies_startup_config(compute_project, manager):
|
||||||
|
# IOL boots from NVRAM whenever it holds a config: a plain stop/start must
|
||||||
|
# not rebuild it or `write memory` done in the router would be lost.
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname RouterOne"
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
# simulate `write memory`: IOS rewrites the NVRAM behind our back
|
||||||
|
with open(_nvram_path(vm), "wb") as f:
|
||||||
|
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
|
||||||
|
|
||||||
|
await _start(vm) # plain restart, no new content delivered
|
||||||
|
|
||||||
|
startup, _ = nvram_export(_read_nvram(vm))
|
||||||
|
assert b"hostname RouterSaved" in startup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_changed_content_reapplied_at_next_start(compute_project, manager):
|
||||||
|
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname RouterOne"
|
||||||
|
await _start(vm)
|
||||||
|
vm.startup_config_content = "hostname RouterTwo"
|
||||||
|
assert vm._startup_config_dirty is True
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
startup, _ = nvram_export(_read_nvram(vm))
|
||||||
|
assert b"hostname RouterTwo" in startup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_empty_content_never_builds_nvram(compute_project, manager):
|
||||||
|
# Web clients PUT "" for unset fields: must be ignored, not treated as a
|
||||||
|
# config change (mirrors the IOU setter's erase protection).
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = ""
|
||||||
|
vm.startup_config_content = None
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
assert not os.path.exists(_nvram_path(vm))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reput_unchanged_content_keeps_written_nvram(compute_project, manager):
|
||||||
|
# A full PUT that carries the same content again (WebUI round-trips what
|
||||||
|
# the GET returned) must not re-apply it over `write memory`.
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname RouterOne"
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
with open(_nvram_path(vm), "wb") as f: # simulate `write memory`
|
||||||
|
f.write(nvram_import(None, b"hostname RouterSaved\n", None, 256))
|
||||||
|
|
||||||
|
vm.startup_config_content = "hostname RouterOne" # unchanged re-PUT
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
startup, _ = nvram_export(_read_nvram(vm))
|
||||||
|
assert b"hostname RouterSaved" in startup
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_running_container_keeps_pending_config(compute_project, manager):
|
||||||
|
# The NVRAM is only (re)built on a genuine start of a stopped container:
|
||||||
|
# swapping it mid-flight would be lost to the next `write memory`.
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname RouterOne"
|
||||||
|
await _start(vm, state="running")
|
||||||
|
|
||||||
|
assert not os.path.exists(_nvram_path(vm))
|
||||||
|
assert vm._startup_config_dirty is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_rewrites_hostname_in_nvram(compute_project, manager):
|
||||||
|
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
vm.startup_config_content = "hostname %h"
|
||||||
|
await _start(vm)
|
||||||
|
|
||||||
|
vm.name = "renamed-1" # IOU parity: the node boots under its new name
|
||||||
|
|
||||||
|
startup, _ = nvram_export(_read_nvram(vm))
|
||||||
|
assert b"hostname renamed-1" in startup
|
||||||
|
|
||||||
|
|
||||||
|
def test_asdict_exposes_startup_config_content(compute_project, manager):
|
||||||
|
|
||||||
|
vm = _make_vm(compute_project, manager)
|
||||||
|
assert vm.asdict()["startup_config_content"] is None
|
||||||
|
vm.startup_config_content = "hostname RouterOne"
|
||||||
|
assert vm.asdict()["startup_config_content"] == "hostname RouterOne"
|
||||||
|
|||||||
@ -72,6 +72,59 @@ def test_docker_iol_ports_grouped_in_four_port_units(compute, project):
|
|||||||
assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"]
|
assert [p.asdict()["name"] for p in node.ports] == ["eth0", "eth1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_iol_startup_config_materialized_once(compute, project, monkeypatch):
|
||||||
|
"""
|
||||||
|
The GNS3_IOL_STARTUP_CONFIG environment knob references a config file in
|
||||||
|
the controller's configs directory: like the IOU startup_config mapping,
|
||||||
|
its content is sent to the compute exactly once (the knob is consumed) —
|
||||||
|
afterwards the node's configuration lives in its NVRAM on the compute.
|
||||||
|
"""
|
||||||
|
|
||||||
|
node = Node(project, compute, "iol",
|
||||||
|
node_id=str(uuid.uuid4()),
|
||||||
|
node_type="docker",
|
||||||
|
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=my-iol-config.txt"})
|
||||||
|
monkeypatch.setattr(node, "_base_config_file_content", lambda path: "hostname %h\n")
|
||||||
|
|
||||||
|
data = node._node_data()
|
||||||
|
assert data["startup_config_content"] == "hostname %h\n"
|
||||||
|
assert data["environment"] == "GNS3_IOL_RUNNER=1"
|
||||||
|
assert "GNS3_IOL_STARTUP_CONFIG" not in node.properties["environment"]
|
||||||
|
|
||||||
|
# sent only once: a later sync (e.g. project reload) carries neither
|
||||||
|
data = node._node_data()
|
||||||
|
assert "startup_config_content" not in data
|
||||||
|
|
||||||
|
|
||||||
|
def test_docker_iol_startup_config_missing_file_keeps_knob(compute, project, monkeypatch):
|
||||||
|
|
||||||
|
node = Node(project, compute, "iol",
|
||||||
|
node_id=str(uuid.uuid4()),
|
||||||
|
node_type="docker",
|
||||||
|
properties={"environment": "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=gone.txt"})
|
||||||
|
monkeypatch.setattr(node, "_base_config_file_content", lambda path: None)
|
||||||
|
|
||||||
|
data = node._node_data()
|
||||||
|
assert "startup_config_content" not in data
|
||||||
|
# the knob is kept so the config still applies once the file shows up
|
||||||
|
assert "GNS3_IOL_STARTUP_CONFIG" in node.properties["environment"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_iol_startup_config_knob_variants():
|
||||||
|
|
||||||
|
from gns3server.controller.node import _extract_iol_startup_config_knob
|
||||||
|
|
||||||
|
assert _extract_iol_startup_config_knob(None) == (None, None)
|
||||||
|
assert _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1") == (None, "GNS3_IOL_RUNNER=1")
|
||||||
|
# whitespace and trailing commas are tolerated, like the compute env parsing
|
||||||
|
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_RUNNER=1,\n GNS3_IOL_STARTUP_CONFIG=cfg.txt ,")
|
||||||
|
assert filename == "cfg.txt"
|
||||||
|
assert environment == "GNS3_IOL_RUNNER=1,"
|
||||||
|
# an empty value is treated as absent
|
||||||
|
filename, environment = _extract_iol_startup_config_knob("GNS3_IOL_STARTUP_CONFIG=")
|
||||||
|
assert filename is None
|
||||||
|
|
||||||
|
|
||||||
def test_name(compute, project):
|
def test_name(compute, project):
|
||||||
"""
|
"""
|
||||||
If node use a name template generate names
|
If node use a name template generate names
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user