Merge pull request #2852 from yueguobin/docker-srlinux-support

Support vendor NOS Docker containers: docker_exec console + SR Linux integration
This commit is contained in:
Jeremy Grossmann 2026-08-12 22:49:05 +02:00 committed by GitHub
commit c588652768
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1587 additions and 13 deletions

View File

@ -0,0 +1,465 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is organized by AI with reference to actual code. AI can make mistakes — please verify against the source code when in doubt.
# Docker exec Console (Vendor NOS Containers)
## Overview
GNS3 Docker nodes normally expose their console by attaching to the container's
PID 1 stdio. That works for CLIs that run as PID 1 (e.g. FRR's `vtysh`), but it
does **not** work for vendor NOS containers (Nokia SR Linux, Arista cEOS,
Juniper cRPD, …) whose CLI is a separate, full-screen TUI process that is *not*
on PID 1. For those, attaching to PID 1 only shows boot logs and never yields a
CLI prompt.
The `docker_exec` console type solves this. It runs a chosen command inside the
running container via the Docker exec API (with a pty) and bridges it to the
GNS3 console, so the vendor's native TUI CLI renders in the Web UI (xterm.js)
exactly as if you had run `docker exec -it <container> <cli>` in a real
terminal.
Two companion environment knobs (`GNS3_SKIP_INIT`, `GNS3_INTERFACE_NAMES`) make
the container itself boot and wire correctly for vendor NOS images. Together
they let a vendor NOS run as a first-class GNS3 Docker router node.
> Prototype status: the knobs are environment-driven and intentionally avoid
> schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected.
> `console_type: "docker_exec"` is added to the `ConsoleType` enum.
## The three environment knobs
All three are read from the node's `environment` field. Entries prefixed with
`GNS3_` are **not** forwarded into the container (existing GNS3 behaviour), so
they stay host-side configuration.
| Variable | Purpose |
|----------|---------|
| `GNS3_SKIP_INIT=1` | Do **not** prepend `/gns3/init.sh` to the entrypoint. Vendor NOS images must run their own entrypoint (e.g. SR Linux's `sr_linux`); GNS3's init script (busybox bootstrap, `ifup`, eth wait) interferes with them. |
| `GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3` | Rename the injected interfaces in adapter order instead of the default `eth{N}`. SR Linux expects `mgmt0` + `e1-N`; without this it does not recognise its datapath. |
| `GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli` | Command run by the `docker_exec` console inside the container. |
## Architecture: `VendorDockerVM` subclass
All vendor-specific logic lives in a `VendorDockerVM(DockerVM)` subclass in
`gns3server/compute/docker/vendor_docker_vm.py``docker_vm.py` itself stays
on its baseline behaviour and is never touched by this feature.
`DockerVM` exposes four small extension hooks (pure refactorings, zero
behaviour change for existing nodes):
| Hook | Baseline behaviour | `VendorDockerVM` override |
|------|--------------------|---------------------------|
| `_prepare_init_and_interface_env(params)` | prepend `/gns3/init.sh`, set `GNS3_MAX_ETHERNET=eth{N-1}` | conditional init.sh (`GNS3_SKIP_INIT`), `GNS3_MAX_ETHERNET` follows the interface rename |
| `_start_console_server()` | telnet/ssh/http console dispatch | adds the `docker_exec` branch |
| `_get_container_ifname(adapter_number)` | `eth{N}` | `GNS3_INTERFACE_NAMES` lookup, fallback `eth{N}` |
| `_cleanup_console_resources()` | no-op | closes the docker-exec pty socket before restart/stop |
### Class selection
The Docker manager picks the class per node in `Docker.create_node()`
(`gns3server/compute/docker/__init__.py`):
```python
def _select_node_class(self, **kwargs):
if kwargs.get("console_type") == "docker_exec":
return VendorDockerVM
return DockerVM
```
`console_type == "docker_exec"` is the **only** trigger — every other console
type (telnet, vnc, ssh, http, …) keeps using the unmodified `DockerVM`. All
vendor features are opt-in: without the `GNS3_*` environment variables a
`VendorDockerVM` instance behaves identically to `DockerVM` (init.sh still
runs, interfaces stay `eth{N}`, the exec command defaults to `/bin/sh`), so a
regular container can use `docker_exec` too.
## The `docker_exec` console type
Setting `console_type: "docker_exec"` makes the node's primary console port run
`_start_docker_exec_console()` instead of the attach-to-PID-1 path.
### Console architecture
```mermaid
graph LR
A[Web UI xterm.js] -->|console WS| B[GNS3 Compute telnet server]
B -->|binary pty stream| C[Docker exec API]
C -->|Tty:true pty| D[sr_cli / vendor CLI]
A -.->|NAWS size| B
B -.->|POST exec/.../resize| C
```
The console uses GNS3's **existing shared/broadcast telnet model**: a single
exec instance (one CLI session) is broadcast to every console client, exactly
like the primary console shares one PID 1. There is deliberately **no
per-client session isolation** — this matches how every other GNS3 console
behaves.
### Implementation
**File**: `gns3server/compute/docker/vendor_docker_vm.py`
`_start_docker_exec_console()`
A small subclass `_LazyExecTelnetServer(AsyncioTelnetServer)` implements the
console. Key points:
1. **Lazy exec creation.** The exec is created on the **first client
connection** (`client_connected_hook`), not when the node starts. This is
essential: vendor CLIs (e.g. `sr_cli` via `prompt_toolkit`) send a
cursor-position request (`\e[6n`, CPR) during startup and block waiting for
the terminal's answer. If the exec starts at node-start time there is no
xterm.js client to answer, the probe times out, and the TUI degrades (no
status bar, "Terminal doesn't support CPR" warning). Creating the exec on
first connect means the probe runs with a real xterm.js attached, which
answers CPR → full TUI. After creation the exec is shared by all clients.
2. **Exec API with a pty.** `POST containers/{cid}/exec` with
`Tty: true`, `User: "root"` (vendor CLIs reject the image's default
unprivileged user — SR Linux returns *"User 'user' is not authorized to use
CLI"* otherwise), and `Env: ["TERM=xterm"]` (the TUI library needs a
recognised terminal).
3. **No while-true wrapper.** The command runs as `sh -c "<cmd>"` (no
restart loop). When the CLI exits (`quit`, the NOS's own idle timeout, or a
crash) the exec pty closes, the broadcast task ends, and the next client
connection **recreates** the exec (see *Reconnection*). A `while true`
wrapper would restart the CLI mid-session with no client attached to
answer its startup CPR probe, producing a blank/degraded screen on
reconnect.
### Reconnection
The exec is created lazily and **recreated on reconnect if it has died**.
`client_connected_hook` checks `_upstream_alive()` (exec id set, writer open,
broadcast task not done) before each connect:
- **First connect / dead upstream** → (re)create the exec. Because a client is
now attached, the CLI's startup CPR probe is answered by xterm.js → full
TUI. A half-dead writer is closed first to avoid a socket leak.
- **Live upstream** → reuse the existing exec, just send `Ctrl-L` to redraw
for the new client.
This is what makes the console survive `quit`, idle timeout, and CLI
crashes: the death is detected (pty EOF ends the broadcast task) and the
next connection spins up a fresh exec with a terminal present. The
`_LazyExecTelnetServer` is extracted to module level specifically so this
reconnect logic is unit-tested.
4. **Hijacked raw-HTTP start.** The exec is started with
`POST exec/{eid}/start` sent as a raw HTTP upgrade over the Docker unix
socket (`asyncio.open_unix_connection`), the same approach docker-py uses.
This is required because aiohttp's websocket client (`ws_connect`) is
rejected by Docker's exec-start endpoint (HTTP 400), while a raw POST
upgrade succeeds (101). With `Tty:true` the response body is a raw,
non-multiplexed bidirectional pty byte stream — no frame demux needed.
5. **NAWS → exec resize.** The telnet server runs with `naws=True`; the
`window_size_changed_callback` calls `POST exec/{eid}/resize?h=&w=` so the
TUI lays out for the xterm.js window size.
6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach
xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect
a `Ctrl-L` (`\x0c`) is sent to the pty so a TUI that already drew its
screen for a previous client redraws for the new one (otherwise a
reconnect shows a blank screen until the next output).
**File**: `gns3server/compute/base_node.py` — the console WebSocket guard now
allows `docker_exec` (alongside `telnet`/`ssh`), since the WS bridge connects to
the console TCP port exactly as it does for telnet.
### Why earlier approaches failed (context)
- `script` + `docker exec -it`: the `script` pty had size 0 (no NAWS) → the TUI
could not lay out → blank.
- `docker exec -i` (no `-t`) + `sr_cli -d` (dumb mode): line-mode output was
block-buffered and visually messy.
- Direct pipe relay: telnet `CRLF` polluted line input.
The exec-API approach fixes all of these: a real pty (`Tty:true`), a real size
(NAWS resize), and a real terminal emulator (xterm.js answering CPR).
## Configuration
### SR Linux node example
```json
{
"name": "srlinux-1",
"node_type": "docker",
"image": "ghcr.io/nokia/srlinux:latest",
"adapters": 4,
"console_type": "docker_exec",
"start_command": "sudo -E bash -c 'touch /.dockerenv && /opt/srlinux/bin/sr_linux'",
"environment": "GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3\nGNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"
}
```
- `start_command` is the SR Linux launch line (as used by containerlab).
- Connect the node's ports as usual — links still use GNS3's UDP NIO datapath
(container-agnostic); the rename only affects the in-container interface name.
- For the Web UI port **labels** to match (display `mgmt0`/`e1-1` instead of
`Ethernet0..3`), set `custom_adapters` per port
(`{"adapter_number": 0, "port_name": "mgmt0"}`, …). Port labels are a
controller-side concept, independent of the compute-side interface rename.
### Appliance (`gns3a`) packaging
A SR Linux appliance lives in `gns3-registry/appliances/srlinux.gns3a`
(`registry_version: 6`). It sets the full chassis — **35 adapters**
(`mgmt0` + `e1-1`..`e1-34`) — with matching `GNS3_INTERFACE_NAMES` and 35
`custom_adapters` entries (`mgmt0`, `e1-1`..`e1-34`) so the canvas labels,
the kernel interface names and the `ethernet-1/N` CLI names all line up.
Three appliance-schema fixes are required for this appliance to load (all on
the gns3-server side; the registry JSON schema is unchanged because its docker
block allows `additionalProperties`):
1. **`DockerConsoleType`** (`schemas/controller/appliances.py`) must include
`docker_exec`, or the Pydantic appliance model rejects the file at import.
2. **`ApplianceV1_6.custom_adapters`** must be declared on the top-level
appliance model, or `GET /appliances` (response_model=`schemas.Appliance`)
strips `custom_adapters` from the API response even though the file and the
server-side template conversion handle it. (Node creation still worked
because `appliance_to_template._add_docker_config` reads it from the raw
dict; only the GET response was lossy.)
3. `extra_volumes` rides inside the `docker` block (passed through by
`new_config.update(appliance_config["docker"])`); no schema change needed.
> **Symbol theme caveat.** An appliance `symbol` that starts with
> `:/symbols/` is forcibly rewritten at load time
> (`appliance_manager._load_appliances`) to the current theme's default for the
> appliance category — so `:/symbols/affinity/circle/blue/router_cloud.svg` (or
> `router2.svg`) becomes `:/symbols/affinity/circle/blue/router.svg`, because
> the theme maps only the canonical name `"router"`. This is intentional: it
> lets theme switching re-skin every node consistently. To use a non-default
> icon (e.g. `router_cloud`), install it as a **custom symbol** under the
> configured `symbols_path` and reference it by filename (no `:/symbols/`
> prefix) — custom symbols do not participate in re-theming. The SR Linux
> appliance uses `router.svg`.
### Persistent state
For SR Linux, persist `/etc/opt/srlinux` (config / AAA users / TLS certs) and
`/var/log/srlinux` (logs, optional) by adding them to the node's
`extra_volumes`. The image also declares its own `VOLUME` directories
(e.g. `/opt/srlinux/appmgr`), which GNS3 persists automatically.
## Volume persistence with `GNS3_SKIP_INIT`
This is the one place where skipping init.sh changes behaviour beyond boot:
`/gns3/init.sh` normally performs the volume-persistence bridge, and without it
**nothing writes through to the host** — the container writes to its overlay
filesystem and the data is lost on stop.
The bridge (see init.sh lines 3552) has two parts:
```
host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always mounted)
│ init.sh: mount --bind
/etc/opt/srlinux (where the NOS writes)
```
`VendorDockerVM` replicates this for SKIP_INIT containers:
1. **`_setup_skip_init_volumes()`** — runs once per start, right after the
container is up (`VendorDockerVM.start()`). For each persistent volume it
`docker exec`s a busybox script that:
- seeds the host directory with the container's original files on first
start (`cp -a` + `.gns3_perms` marker), exactly like init.sh;
- `mount --bind /gns3volumes<path> <path>` to bridge persistent storage
back to the in-container path — on subsequent starts the persisted data
replaces the fresh overlay content;
- restores the permissions recorded in `.gns3_perms` at the previous stop
(best-effort).
2. **Container-side `_fix_permissions()` override targeting `/gns3volumes`**
`DockerVM._fix_permissions` operates on the in-container paths
(`/etc/opt/srlinux`, …), which only resolve to persistent storage while
the `mount --bind` bridge is up; after a container restart the bridge is
gone and it would chown the overlay copy instead of the host files. It
also restarts an exited container just to chown. The override instead
runs the same busybox record/chmod/chown script **inside the container
(as root) on the `/gns3volumes<path>` paths** — the Docker bind-mount
targets, which exist for the whole container lifetime and need no bridge.
A stopped/exited container is **not** restarted: the pass is skipped and
the next start fixes ownership. It runs at start (so the controller can
read project files while the node runs) and at stop (for files written
during runtime).
> The fix must run container-side: files written by the container are
> host-side root-owned, and an unprivileged GNS3 process cannot chown them
> from the host. Container-side root (with GNS3's `UsernsMode: host`) can.
With `GNS3_SKIP_INIT`, GNS3's hardcoded `/etc/network` volume (see
`docker_vm.py` `_mount_binds()`) is dropped entirely by
`VendorDockerVM._mount_binds()`: it holds GNS3's own network config for
init.sh's `ifup`, which never runs for SKIP_INIT containers — the NOS
manages its own interfaces. The override removes the bind, filters the
volume out of `self._volumes`, and deletes the host-side skeleton directory
the base class just created. Without `GNS3_SKIP_INIT` the mount is kept
(behaviour matches the base class).
### Lifecycle summary
| Phase | Normal Docker node | `VendorDockerVM` + `GNS3_SKIP_INIT` |
|-------|--------------------|--------------------------------------|
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | `docker exec` after start: seed + bind-mount + restore perms; then container-side chown on `/gns3volumes` |
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on `/gns3volumes` paths (skips dead containers, no restart) |
| volume config | identical `_mount_binds` (host → `/gns3volumes<path>`) | identical |
### Runtime ownership safety
The start-time fix pass chowns the volume files to the host user **while the
container is running** — a deliberate deviation from the standard model, where
init.sh restores container-native ownership at start and the container never
sees host-owned files during runtime. Verified harmless for SR Linux:
1. **Most processes run as root** (`sr_linux`, appmgr) — root ignores file
ownership entirely.
2. **Self-healing daemons.** SR Linux's `aaamgr` rewrites its managed files
with its own ownership at boot: after the start-time pass chowned
`etc/opt/srlinux/aaamgr_local_user.json` to the host user, the daemon
re-created it as `srlinux:srlinux` (uid 1002, mode 700) within seconds.
3. **ACL-based access.** The directory carries a default ACL
(`default:group:srlinux:rwx`, `default:other::rwx`), so named group ACL
entries grant access independently of the owner uid; the observed file ACL
(`group:srlinux:rwx`, owner `srlinux`) survives chown.
Caveat: a NOS that strictly validates ownership of its files (e.g. "SSH keys
must be root:root 600 or refuse to start") would not tolerate this. If that
ever matters, drop the start-time pass and keep only the stop-time one
(standard behaviour — the trade-off is mid-run `Permission denied` in the
file browser, identical to regular Docker nodes).
### Boot-ordering caveat
The volume bridge (`mount --bind`) is established **after** the vendor
entrypoint has started (there is no init.sh to do it before), so the NOS's
early boot reads the overlay copy of the volume paths — default image
content, not the persisted data. Whether the persisted config takes effect
depends on the NOS re-reading those files after the bridge is up (SR Linux's
daemons do re-read/write their managed files during boot, as observed).
Always verify the closed loop when adopting a new image: `save` a config →
stop the node → start it → confirm the config is actually applied, not just
present on the host.
## Troubleshooting
**1. Console shows only boot logs, no CLI**
- You are on the primary attach console. Set `console_type: "docker_exec"` and
use `GNS3_CONSOLE_CMD` to point at the vendor CLI.
**2. `User '...' is not authorized to use CLI`**
- The exec must run as root. The implementation sets `User: "root"`; if you
fork it, keep that.
**3. `Terminal doesn't support cursor position requests (CPR)`**
- This means the exec was started without an xterm.js client connected (the
startup probe had no one to answer). The lazy-start design avoids this; if you
see it, ensure the exec is created on first connect, not at node start.
**4. Reconnecting the Web console shows a blank screen**
- A `Ctrl-L` is sent on each connect to force a TUI redraw. If the TUI does not
redraw, verify the `client_connected_hook` still writes `\x0c` to the pty.
**5. `aiohttp WSServerHandshakeError: 400` on exec start**
- Do **not** use the websocket client to start an exec. Use the hijacked raw
HTTP upgrade over the unix socket (see Implementation).
**6. SR Linux data interfaces stay down**
- SR Linux defaults its data ports to `admin-state disable`; enable them in the
CLI (`interface ethernet-1/1 admin-state enable`) and bind the interface to a
network-instance before ping works. This is SR Linux behaviour, not a GNS3
issue.
**7. "Session has been idle, will logout in 300 seconds" → Connection closed**
- SR Linux's own CLI idle timeout logs the CLI out, the exec pty closes, and
the console disconnects. Reopening the console recreates the exec (see
*Reconnection*) and gives a fresh login. To keep a permanent session,
disable the timeout in the CLI: `enter candidate`
`/system cli idle-timeout disable``commit now`.
**8. Controller logs `Permission denied` reading files under the node's
project directory while the node runs**
- Root-written files inside a persistent volume. The container-side
`_fix_permissions` pass runs at start (fixes the seeded files) and at stop;
files created by the container *during* runtime become readable after the
next stop.
- Concrete example: SR Linux's `aaamgr` daemon rewrites
`etc/opt/srlinux/aaamgr_local_user.json` during boot, **after** the
start-time pass, as the image's `srlinux` user (uid 1002, mode 700) — so
the host-side file stays `1002:1002` until the stop-time pass chowns it.
- The log line comes from the file-browser API chain: Web UI *Show in file
manager* → `GET /v3/projects/{pid}/nodes/{nid}/files`
(`controller/nodes.py:538`) → `project.list_node_files`
(`compute/project.py:510`), where `magic.from_file()` cannot read the
file and the `file_type` field is left empty for that entry. The MCP
`list_node_files` tool uses the same code path. Size/modified-at fields
and everything else keep working; only the type sniff and one warning
line are affected — same behaviour as any regular Docker node writing
root-owned files at runtime.
**9. Persistent volume empty on the host after `save` + stop**
- Ensure `GNS3_SKIP_INIT=1` is set (so the host-side bridge path is taken) and
the volume path is in `extra_volumes`; check the compute log for
`Volume '<path>' bound to persistent storage`.
**10. Persisted config present on the host but not applied after restart**
- The volume bridge is established after the NOS has booted (see
*Boot-ordering caveat*); the NOS may have already loaded the overlay's
default config into memory. Verify with a visible change (hostname,
interface description): `save` → stop → start → check the change took
effect. If it does not, the image needs the bridge earlier (a
vendor-specific entrypoint wrapper, not covered by this prototype).
## Limitations
1. **Shared session (broadcast).** All console clients share one CLI session
and can see each other's input — identical to GNS3's existing primary
console model. There is no per-client independent session.
2. **`reset_console` not wired.** The console-reset action only handles
`telnet`/`ssh`; it is a no-op for `docker_exec` (non-blocking; reconnect
works fine).
3. **Prototype knobs.** `GNS3_SKIP_INIT` / `GNS3_INTERFACE_NAMES` /
`GNS3_CONSOLE_CMD` are environment-driven; they are not yet first-class node
schema fields and are not declared in the appliance (`gns3a`) schema.
4. **Rootful-Docker assumption** (`UsernsMode: host`, set for all GNS3
Docker nodes) so the container-side chown acts on the host files' real
uid/gid (see the volume-persistence section).
5. **Post-boot volume bridge.** The bind-mount bridge is established after the
vendor entrypoint has started (init.sh would do it before). A NOS that
strictly requires its persisted files at its very first read may need a
different boot arrangement (see *Boot-ordering caveat*).
## References
- `gns3server/compute/docker/vendor_docker_vm.py``VendorDockerVM`:
`_start_docker_exec_console`, `_LazyExecTelnetServer`,
`_setup_skip_init_volumes`, container-side `_fix_permissions` on
`/gns3volumes`, `start()`.
- `gns3server/compute/docker/docker_vm.py``DockerVM` extension hooks
(`_prepare_init_and_interface_env`, `_start_console_server`,
`_get_container_ifname`, `_cleanup_console_resources`).
- `gns3server/compute/docker/__init__.py``Docker._select_node_class` /
`create_node` factory.
- `gns3server/compute/base_node.py` — console WebSocket guard.
- `gns3server/schemas/common.py``ConsoleType.docker_exec`.
- containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and
interface naming.
## Version History
| Version | Date | Changes |
|---------|------|---------|
| 1.5 | 2026-08-13 | Add appliance (`gns3a`) packaging section: 35-adapter full-chassis design, the three server-side schema fixes (DockerConsoleType, ApplianceV1_6.custom_adapters, extra_volumes passthrough), and the symbol-theme caveat (any `:/symbols/` symbol is rewritten to the category default at load). |
| 1.4 | 2026-08-13 | Reconnect fix: drop the while-true wrapper (it restarted the CLI with no client to answer CPR → blank screen on reconnect); the exec is now recreated on connect when the upstream has died. `_LazyExecTelnetServer` extracted to module level and unit-tested. |
| 1.3 | 2026-08-12 | Document runtime ownership safety (root processes, self-healing daemons, ACL evidence for SR Linux), the boot-ordering caveat (bridge after boot → verify save/stop/start closed loop), and troubleshooting #10. |
| 1.2 | 2026-08-12 | `_fix_permissions` rewritten: container-side (as root) on the `/gns3volumes` bind-mount targets instead of host-side — host-side chown cannot touch root-owned files when GNS3 is unprivileged. Dead containers are skipped instead of restarted. |
| 1.1 | 2026-08-12 | Refactor: vendor logic extracted from `DockerVM` into `VendorDockerVM` subclass with 4 hook points + class-selection factory. Add SKIP_INIT volume persistence (`_setup_skip_init_volumes` + `_fix_permissions`) and lifecycle comparison. Add troubleshooting entries for idle timeout and permission-denied files. |
| 1.0 | 2026-08-12 | Initial documentation of the `docker_exec` console and vendor NOS knobs. |

View File

@ -520,7 +520,7 @@ class BaseNode:
log.warning(f"Cannot open console WebSocket: node {self.name} is not started")
return
if self._console_type not in ("telnet", "ssh"):
if self._console_type not in ("telnet", "ssh", "docker_exec"):
await websocket.close(code=1000)
log.warning(
f"Cannot open console WebSocket: node {self.name} console type '{self._console_type}' "

View File

@ -32,6 +32,7 @@ from gns3server.config import Config
from gns3server.utils.asyncio import locking
from gns3server.compute.base_manager import BaseManager
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM
from gns3server.compute.docker.docker_error import DockerError, DockerHttp304Error, DockerHttp404Error, DockerHttp409Error
log = logging.getLogger(__name__)
@ -59,6 +60,16 @@ class Docker(BaseManager):
self._session = None
self._api_version = DOCKER_MINIMUM_API_VERSION
def _select_node_class(self, **kwargs):
"""Select the node class based on console_type."""
if kwargs.get("console_type") == "docker_exec":
return VendorDockerVM
return DockerVM
async def create_node(self, name, project_id, node_id, *args, **kwargs):
self._NODE_CLASS = self._select_node_class(**kwargs)
return await super().create_node(name, project_id, node_id, *args, **kwargs)
@staticmethod
async def install_busybox(dst_dir):

View File

@ -433,6 +433,16 @@ class DockerVM(BaseNode):
""".format(adapter=adapter, hostname=self._name))
return path
def _prepare_init_and_interface_env(self, params):
"""
Prepare the init-script entrypoint and GNS3_MAX_ETHERNET env var.
May be overridden by subclasses (e.g. VendorDockerVM) to skip init.sh
or rename injected interfaces.
"""
params["Entrypoint"].insert(0, "/gns3/init.sh") # FIXME /gns3/init.sh is not found?
# Give the information to the container on how many interface should be inside
params["Env"].append(f"GNS3_MAX_ETHERNET=eth{self.adapters - 1}")
async def create(self):
"""
Creates the Docker container.
@ -494,10 +504,7 @@ class DockerVM(BaseNode):
params["Cmd"] = []
if len(params["Cmd"]) == 0 and len(params["Entrypoint"]) == 0:
params["Cmd"] = ["/bin/sh"]
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}")
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)))
@ -665,6 +672,7 @@ class DockerVM(BaseNode):
if self._console_websocket:
await self._console_websocket.close()
self._console_websocket = None
self._cleanup_console_resources()
await self._clean_servers()
await self.manager.query("POST", f"containers/{self._cid}/start")
@ -694,10 +702,7 @@ class DockerVM(BaseNode):
log.error(line)
raise DockerError(logdata)
if self.console_type in ("telnet", "ssh"):
await self._start_console()
elif self.console_type == "http" or self.console_type == "https":
await self._start_http()
await self._start_console_server()
if self.aux_type != "none":
await self._start_aux()
@ -710,6 +715,16 @@ class DockerVM(BaseNode):
)
)
async def _start_console_server(self):
"""
Dispatch the console server start based on console_type.
May be overridden to add extra console types (e.g. docker_exec).
"""
if self.console_type in ("telnet", "ssh"):
await self._start_console()
elif self.console_type == "http" or self.console_type == "https":
await self._start_http()
async def _start_aux(self):
"""
Start an auxiliary console
@ -1012,6 +1027,13 @@ class DockerVM(BaseNode):
await self.manager.query("POST", f"containers/{self._cid}/restart")
log.debug("Docker container '{name}' [{image}] restarted".format(name=self._name, image=self._image))
def _cleanup_console_resources(self):
"""
Clean up console resources before restart.
May be overridden (e.g. VendorDockerVM closes the exec pty socket).
"""
pass
async def _clean_servers(self):
"""
Clean the list of running console servers
@ -1032,6 +1054,7 @@ class DockerVM(BaseNode):
if self._console_websocket:
await self._console_websocket.close()
self._console_websocket = None
self._cleanup_console_resources()
await self._clean_servers()
await self._stop_ubridge()
@ -1146,6 +1169,13 @@ class DockerVM(BaseNode):
log.debug(f"Docker error when closing: {str(e)}")
return
def _get_container_ifname(self, adapter_number):
"""
Return the interface name used inside the container for *adapter_number*.
May be overridden to provide custom naming (e.g. mgmt0, e1-1).
"""
return f"eth{adapter_number}"
async def _add_ubridge_connection(self, nio, adapter_number):
"""
Creates a connection in uBridge.
@ -1194,12 +1224,11 @@ class DockerVM(BaseNode):
log.warning(f"Could not set MAC address {mac_address} on interface {adapter.host_ifc}")
log.debug(f"Move container {self.name} adapter {adapter.host_ifc} to namespace {self._namespace}")
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(
"docker move_to_ns {ifc} {ns} eth{adapter}".format(
ifc=adapter.host_ifc, ns=self._namespace, adapter=adapter_number
)
f"docker move_to_ns {adapter.host_ifc} {self._namespace} {ifname}"
)
except UbridgeError as e:
raise UbridgeNamespaceError(e)

View File

@ -0,0 +1,450 @@
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Vendor NOS Docker container subclass.
Provides support for vendor NOS containers (Nokia SR Linux, Arista cEOS,
Juniper cRPD, ) whose CLI is a separate TUI process not exposed on PID 1
stdio, and whose boot model requires skipping GNS3's init.sh bootstrapping.
The subclass is selected automatically when ``console_type == "docker_exec"``.
All vendor features are opt-in without GNS3_* environment variables the
container behaves identically to DockerVM.
"""
import asyncio
import contextlib
import json
import logging
import os
import shutil
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
log = logging.getLogger(__name__)
class VendorDockerVM(DockerVM):
"""
DockerVM subclass for vendor NOS containers.
Opt-in features, activated by GNS3_-prefixed environment entries
(host-side only GNS3_ entries are never forwarded into the container):
* ``GNS3_SKIP_INIT=1`` do not prepend /gns3/init.sh; the container runs
its own entrypoint (e.g. SR Linux's ``sr_linux``). Init.sh's volume
persistence (bind-mount /gns3volumes target) is replicated via
``docker exec`` after the container starts.
* ``GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2`` rename injected interfaces
(adapter order) instead of default ``eth{N}``.
* ``GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli`` command run inside the
container by the ``docker_exec`` console (defaults to ``/bin/sh``).
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Prototype knobs — parsed from GNS3_-prefixed entries in create().
# Parse eagerly so _get_container_ifname can return the right name.
self._gns3_init = True
self._interface_names = []
self._console_cmd = None
self._console_exec_writer = None
if self._environment:
for _line in self._environment.splitlines():
_line = _line.strip().rstrip(",")
if _line.startswith("GNS3_SKIP_INIT="):
self._gns3_init = _line.split("=", 1)[1].strip().lower() not in ("1", "true", "yes")
elif _line.startswith("GNS3_INTERFACE_NAMES="):
self._interface_names = [
n.strip() for n in _line.split("=", 1)[1].split(",") if n.strip()
]
elif _line.startswith("GNS3_CONSOLE_CMD="):
self._console_cmd = _line.split("=", 1)[1].strip()
# ---- hook overrides ---------------------------------------------------
def _mount_binds(self, image_info):
"""
Override: for SKIP_INIT containers, drop GNS3's hardcoded
/etc/network volume. It holds GNS3's own network config consumed by
init.sh's `ifup`; init.sh never runs for SKIP_INIT containers (the
NOS manages its own interfaces), so the mount would be dead weight.
Removes the bind, drops the volume from self._volumes (so
GNS3_VOLUMES and the vendor passes stay consistent) and deletes the
host-side skeleton directory the base class just created.
"""
binds = super()._mount_binds(image_info)
if self._gns3_init:
return binds
binds = [b for b in binds if b.get("Target") != "/gns3volumes/etc/network"]
self._volumes = [v for v in self._volumes if v != "/etc/network"]
shutil.rmtree(os.path.join(self.working_dir, "etc", "network"), ignore_errors=True)
with contextlib.suppress(OSError):
os.rmdir(os.path.join(self.working_dir, "etc"))
return binds
def _prepare_init_and_interface_env(self, params):
"""
Override: conditionally prepend init.sh, and honour
GNS3_INTERFACE_NAMES (if set) for GNS3_MAX_ETHERNET.
"""
if self._gns3_init:
params["Entrypoint"].insert(0, "/gns3/init.sh")
# Tell init.sh which last interface to wait for; honour the rename if any
# (no-op when init is skipped, but kept consistent).
if self._interface_names and self.adapters - 1 < len(self._interface_names):
last_ifname = self._interface_names[self.adapters - 1]
else:
last_ifname = f"eth{self.adapters - 1}"
params["Env"].append(f"GNS3_MAX_ETHERNET={last_ifname}")
def _get_container_ifname(self, adapter_number):
"""
Override: honour GNS3_INTERFACE_NAMES (e.g. mgmt0, e1-1) in adapter
order; fall back to eth{N} for unlisted ports.
"""
if self._interface_names and adapter_number < len(self._interface_names):
return self._interface_names[adapter_number]
return f"eth{adapter_number}"
def _cleanup_console_resources(self):
"""
Override: close the docker-exec pty socket, if any, so the next
restart or stop doesn't leak it.
"""
if self._console_exec_writer:
try:
self._console_exec_writer.close()
except Exception:
pass
self._console_exec_writer = None
async def start(self):
await super().start()
if self.status == "started" and not self._gns3_init:
await self._setup_skip_init_volumes()
# Fix host-side ownership of the seeded volume right away so the
# controller can read project files while the node runs. Reset the
# "fixed" flag afterwards: files written by the container during
# runtime still need the stop-time pass.
await self._fix_permissions()
self._permissions_fixed = False
async def _fix_permissions(self):
"""
Container-side override of DockerVM._fix_permissions for vendor NOS
containers. It targets the Docker bind-mount paths
(`/gns3volumes<volume>`) directly instead of the in-container paths:
the in-container paths only resolve to persistent storage while the
`mount --bind` bridge from _setup_skip_init_volumes is up, and after a
container restart the bridge is gone the base implementation would
then chown the overlay copy instead of the host files.
The busybox script runs inside the container as root (a host-side
GNS3 process may be unprivileged and cannot chown root-owned files).
Unlike the base implementation, a stopped/exited container is NOT
restarted just to fix permissions (vendor NOS images are heavy to
boot): the pass is skipped and the next start fixes ownership.
"""
try:
state = await self._get_container_state()
except DockerHttp404Error:
log.warning("Container '%s' does not exist, skipping permission fix", self._name)
return
if state == "stopped" or state == "exited":
log.info(
"Container '%s' is %s, skipping permission fix (next start will fix)",
self._name, state,
)
return
uid, gid = os.getuid(), os.getgid()
for volume in self._volumes:
target = f"/gns3volumes{volume}"
log.debug("Docker container '%s' fix ownership on %s", self._name, target)
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker",
"exec",
self._cid,
"/gns3/bin/busybox",
"sh",
"-c",
"("
f'/gns3/bin/busybox find "{target}" -depth -print0'
f" | /gns3/bin/busybox xargs -0 /gns3/bin/busybox stat -c '%a:%u:%g:%n' > \"{target}/.gns3_perms\""
")"
f' && /gns3/bin/busybox chmod -R u+rX "{target}"'
f' && /gns3/bin/busybox chown {uid}:{gid} -R "{target}"',
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 _setup_skip_init_volumes(self):
"""
Replicate the volume-persistence portion of init.sh (lines 3552) for
containers that skip init.sh (GNS3_SKIP_INIT=1).
On first start the container's original files are seeded into the
persistent host directory; on subsequent starts the persisted data
is bind-mounted over the in-container path so writes land on the host.
Permission-changes recorded by _fix_permissions at the previous
stop are restored (best-effort).
"""
for volume in self._volumes:
vol_target = f"/gns3volumes{volume}"
# fmt: off
script = (
f'mkdir -p "{volume}" && '
f'if [ ! -f "{vol_target}/.gns3_perms" ]; then '
f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; '
f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; '
f'fi && '
f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && '
f'while IFS=: read -r PERMS OWNER GROUP FILE; do '
f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; '
f' /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" 2>/dev/null; '
f'done < "{volume}/.gns3_perms"'
)
# fmt: on
try:
process = await asyncio.subprocess.create_subprocess_exec(
"docker",
"exec",
self._cid,
"sh",
"-c",
script,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
err = stderr.decode(errors="replace").strip()
log.warning(
"Volume setup for '%s' on container '%s' returned %d: %s",
volume, self._name, process.returncode, err,
)
else:
log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name)
except OSError as e:
log.warning(
"Could not setup volume '%s' for container '%s': %s", volume, self._name, e
)
async def _start_console_server(self):
"""
Override: add the ``docker_exec`` console type alongside the
telnet/ssh/http types supported by the base class.
"""
if self.console_type == "docker_exec":
await self._start_docker_exec_console()
else:
await super()._start_console_server()
# ---- docker_exec console implementation --------------------------------
async def _start_docker_exec_console(self):
"""
Start a console that runs a command inside the container via the Docker
exec API, bridged to a telnet server. Intended for vendor NOS containers
(e.g. Nokia SR Linux) whose CLI is a separate TUI process not exposed on
PID 1's stdio.
The exec is created lazily on the first client connection (not when the
node starts) so the command's startup terminal probe has a real xterm.js
client to answer it (CPR / prompt_toolkit). The single exec is then
shared (broadcast) by all clients, matching GNS3's console model.
Command from GNS3_CONSOLE_CMD.
"""
telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh")
try:
self._telnet_servers.append(
await telnet.start(self._manager.port_manager.console_host, self.console)
)
except OSError as e:
raise DockerError(
f"Could not start console server on socket {self._manager.port_manager.console_host}:{self.console}: {e}"
)
log.debug(f"Docker container '{self.name}' started docker_exec console (lazy) on {self.console}")
class _LazyExecTelnetServer(AsyncioTelnetServer):
"""Telnet console whose docker exec (pty + command) is created lazily on
the first client connection and recreated if the upstream dies.
Extracted to module level (rather than a closure inside
_start_docker_exec_console) so the reconnect/recreate logic is unit-testable.
Lifecycle: the exec is created on the first connect. When the CLI exits
(quit / idle timeout / crash) the exec pty closes, the broadcast task ends,
and the *next* client connection recreates the exec with a terminal
attached, so the CLI's startup CPR probe is answered. No ``while true``
wrapper: that would restart the CLI mid-session with no client to answer
CPR, producing a blank/degraded screen on reconnect.
"""
def __init__(self, vm, manager, cid, command):
super().__init__(
reader=None,
writer=None,
binary=True,
echo=False,
naws=True,
window_size_changed_callback=self._on_naws,
)
self._vm = vm
self._manager = manager
self._cid = cid
self._command = command
self._exec_id = None
self._broadcast_task = None
self._lock = asyncio.Lock()
self._log_name = f"docker_exec console '{vm.name}'"
def _upstream_alive(self):
"""True if the exec pty + broadcast task are still pumping."""
if self._exec_id is None or self._writer is None:
return False
if self._writer.is_closing():
return False
if self._broadcast_task is not None and self._broadcast_task.done():
return False
return True
async def _on_naws(self, columns, rows):
if self._exec_id:
try:
await self._manager.query(
"POST",
f"exec/{self._exec_id}/resize",
params={"h": str(rows), "w": str(columns)},
)
except DockerError:
pass
async def run(self, network_reader, network_writer):
"""Catch and log any exception that kills the client session."""
try:
await super().run(network_reader, network_writer)
except Exception as exc:
log.warning(f"{self._log_name}: client session terminated: {exc}", exc_info=True)
async def _create_exec(self):
# create exec with a pty; run as root (vendor CLIs reject the image's
# default unprivileged user) and export TERM=xterm.
result = await self._manager.query(
"POST",
f"containers/{self._cid}/exec",
data={
"AttachStdin": True,
"AttachStdout": True,
"AttachStderr": True,
"Tty": True,
"User": "root",
"Env": ["TERM=xterm"],
"Cmd": ["sh", "-c", self._command],
},
)
self._exec_id = result["Id"]
log.info(f"{self._log_name}: exec created ({self._exec_id})")
# start the exec via a hijacked raw HTTP request on the Docker unix
# socket; with Tty:true the response body is a raw bidirectional pty
# byte stream (no multiplexing).
reader, writer = await asyncio.open_unix_connection(self._manager._server_url)
body = json.dumps({"Detach": False, "Tty": True})
request = (
f"POST /v{self._manager._api_version}/exec/{self._exec_id}/start HTTP/1.1\r\n"
"Host: docker\r\n"
"Connection: Upgrade\r\n"
"Upgrade: tcp\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n\r\n{body}"
).encode()
writer.write(request)
await writer.drain()
try:
headers = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), timeout=5)
except (asyncio.IncompleteReadError, asyncio.TimeoutError) as e:
writer.close()
raise DockerError(f"Docker exec start failed: {e}")
status_line = headers.split(b"\r\n", 1)[0]
log.info(f"{self._log_name}: hijacked start -> {status_line.decode(errors='ignore')}")
if b" 101 " not in status_line and b" 200 " not in status_line:
writer.close()
raise DockerError(f"Docker exec start rejected: {status_line.decode(errors='ignore')}")
# wire the exec stream as this server's upstream and start the broadcast
# task. AsyncioTelnetServer.start() only starts the broadcast when a
# reader is set at construction time, so with a lazy upstream we start
# it manually here.
self._reader = reader
self._writer = writer
self._vm._console_exec_writer = writer # for stop() cleanup
self._broadcast_task = asyncio.create_task(self._broadcast_from_upstream())
log.info(f"{self._log_name}: broadcast task started, upstream wired, ready")
async def client_connected_hook(self):
await super().client_connected_hook()
async with self._lock:
# (Re)create the exec if it was never created or has died (CLI
# exited → pty EOF → broadcast task ended). Doing this with a
# client attached means the CLI's startup CPR probe is answered by
# a real terminal.
if not self._upstream_alive():
log.info(f"{self._log_name}: client connected, (re)creating exec")
# close a half-dead writer before replacing it
if self._writer is not None and not self._writer.is_closing():
with contextlib.suppress(Exception):
self._writer.close()
try:
await self._create_exec()
except Exception as exc:
log.warning(f"{self._log_name}: failed to create exec: {exc}", exc_info=True)
raise
try:
await self._on_naws(80, 24) # initial size before NAWS
except Exception:
pass
else:
log.info(f"{self._log_name}: client connected, reusing live exec")
# ask the TUI to (re)draw for the client that just connected.
if self._writer:
try:
self._writer.write(b"\x0c") # Ctrl-L -> TUI redraws
await self._writer.drain()
except Exception as exc:
log.warning(f"{self._log_name}: Ctrl-L write failed: {exc}")
log.info(f"{self._log_name}: client_connected_hook done")

View File

@ -61,6 +61,7 @@ class ConsoleType(str, Enum):
spice = "spice"
spice_agent = "spice+agent"
none = "none"
docker_exec = "docker_exec"
class AuxType(str, Enum):

View File

@ -285,6 +285,7 @@ class DockerConsoleType(str, Enum):
http = 'http'
https = 'https'
none = 'none'
docker_exec = 'docker_exec'
class ChecksumType(str, Enum):
@ -631,6 +632,9 @@ class ApplianceV1_6(BaseModel):
None,
title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2',
)
custom_adapters: Optional[List[CustomAdapterItem]] = Field(
None, title='Optional per-adapter overrides (port name, adapter type, MAC address)'
)
linked_clone: Optional[bool] = Field(None, title="False if you don't want to use a single image for all nodes")
docker: Optional[Docker] = Field(None, title='Docker specific options')
iou: Optional[Iou] = Field(None, title='IOU specific options')

View File

@ -0,0 +1,614 @@
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for the VendorDockerVM subclass (vendor NOS containers, e.g. Nokia SR
Linux) and the Docker manager's class-selection factory.
These tests cover:
* the factory selecting VendorDockerVM iff console_type == "docker_exec";
* GNS3_* env parsing (SKIP_INIT, INTERFACE_NAMES, CONSOLE_CMD);
* init.sh prepend being skipped with GNS3_SKIP_INIT;
* GNS3_INTERFACE_NAMES renaming injected interfaces (move_to_ns target);
* the hardcoded /etc/network mount being dropped for SKIP_INIT containers;
* the docker_exec console dispatch in start();
* the SKIP_INIT volume bridge and container-side _fix_permissions passes.
"""
import uuid
import os
import pytest
import pytest_asyncio
from unittest.mock import patch, MagicMock, call
from tests.utils import asyncio_patch, AsyncioMagicMock
from gns3server.compute.docker import Docker
from gns3server.compute.docker.docker_vm import DockerVM
from gns3server.compute.docker.vendor_docker_vm import VendorDockerVM, _LazyExecTelnetServer
from gns3server.compute.docker.docker_error import DockerError, DockerHttp404Error
# ---------------------------------------------------------------------------
# Helpers / fixtures
# ---------------------------------------------------------------------------
def _create_response(vm, entrypoint=None, volumes=None):
"""Build the Docker /containers/create response (with image info merged)."""
return {
"Id": "e90e34656806",
"Warnings": [],
"Config": {
"Entrypoint": entrypoint,
"Cmd": [],
"Volumes": volumes,
},
}
@pytest_asyncio.fixture
async def manager(port_manager):
m = Docker.instance()
m.port_manager = port_manager
return m
def _make_vm(compute_project, manager, environment=None, console_type="docker_exec",
extra_volumes=None, adapters=4):
"""Build a VendorDockerVM with a fake cid (no create() called)."""
vm = VendorDockerVM(
"srlinux-1", str(uuid.uuid4()), compute_project, manager, "srlinux:latest",
console_type=console_type, environment=environment,
extra_volumes=extra_volumes or [], adapters=adapters,
)
vm._cid = "e90e34656842"
return vm
# ---------------------------------------------------------------------------
# Factory selection
# ---------------------------------------------------------------------------
def test_factory_selects_vendor_when_docker_exec(manager):
assert manager._select_node_class(console_type="docker_exec") is VendorDockerVM
def test_factory_selects_base_for_other_console_types(manager):
for ct in ("telnet", "ssh", "vnc", "http", "https", "none", "spice"):
assert manager._select_node_class(console_type=ct) is DockerVM, ct
def test_factory_default_is_base(manager):
assert manager._select_node_class() is DockerVM
@pytest.mark.asyncio
async def test_create_node_sets_node_class(manager, compute_project, monkeypatch):
"""create_node() must switch _NODE_CLASS based on console_type."""
captured = {}
async def fake_super_create_node(name, project_id, node_id, *args, **kwargs):
# record which class create_node selected before delegating
captured["cls"] = manager._NODE_CLASS
return None
monkeypatch.setattr(
"gns3server.compute.base_manager.BaseManager.create_node",
fake_super_create_node,
)
await manager.create_node("v", compute_project.id, str(uuid.uuid4()),
"srlinux:latest", console_type="docker_exec")
assert captured["cls"] is VendorDockerVM
await manager.create_node("v", compute_project.id, str(uuid.uuid4()),
"ubuntu:latest", console_type="telnet")
assert captured["cls"] is DockerVM
# ---------------------------------------------------------------------------
# GNS3_* env parsing
# ---------------------------------------------------------------------------
def test_env_skip_init_true(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
assert vm._gns3_init is False
def test_env_skip_init_absent_defaults_true(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="FOO=bar")
assert vm._gns3_init is True
def test_env_interface_names(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment="GNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3")
assert vm._interface_names == ["mgmt0", "e1-1", "e1-2", "e1-3"]
def test_env_console_cmd(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli")
assert vm._console_cmd == "/opt/srlinux/bin/sr_cli"
def test_env_multiple_lines(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment=("GNS3_SKIP_INIT=1\n"
"GNS3_INTERFACE_NAMES=mgmt0,e1-1\n"
"GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli\n"))
assert vm._gns3_init is False
assert vm._interface_names == ["mgmt0", "e1-1"]
assert vm._console_cmd == "/opt/srlinux/bin/sr_cli"
def test_env_console_cmd_default_none(compute_project, manager):
vm = _make_vm(compute_project, manager)
assert vm._console_cmd is None
# ---------------------------------------------------------------------------
# create() — init.sh skip, GNS3_MAX_ETHERNET, /etc/network drop
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_skip_init_omits_init_sh(compute_project, manager):
response = _create_response(None, entrypoint=["/init"])
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1")
await vm.create()
# the Entrypoint must NOT contain /gns3/init.sh
sent = mock.call_args.kwargs["data"]
assert "/gns3/init.sh" not in sent["Entrypoint"]
assert sent["Entrypoint"] == ["/init"]
@pytest.mark.asyncio
async def test_create_without_skip_init_prepends_init_sh(compute_project, manager):
response = _create_response(None, entrypoint=["/init"])
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec")
await vm.create()
sent = mock.call_args.kwargs["data"]
# init.sh IS prepended when not skipping
assert sent["Entrypoint"][0] == "/gns3/init.sh"
@pytest.mark.asyncio
async def test_create_interface_names_sets_max_ethernet(compute_project, manager):
response = _create_response(None)
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest", adapters=4,
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3")
await vm.create()
sent = mock.call_args.kwargs["data"]
# last interface (adapter index 3) should be e1-3, not eth3
assert any(v == "GNS3_MAX_ETHERNET=e1-3" for v in sent["Env"])
@pytest.mark.asyncio
async def test_create_drops_etc_network_for_skip_init(compute_project, manager):
response = _create_response(None, volumes={"/opt/srlinux/appmgr": None})
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec",
environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
await vm.create()
sent = mock.call_args.kwargs["data"]
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
# /etc/network must NOT be mounted
assert "/gns3volumes/etc/network" not in targets
# but the declared volumes ARE mounted
assert "/gns3volumes/opt/srlinux/appmgr" in targets
assert "/gns3volumes/etc/opt/srlinux" in targets
# GNS3_VOLUMES env must also exclude /etc/network
vol_env = [v for v in sent["Env"] if v.startswith("GNS3_VOLUMES=")][0]
assert "/etc/network" not in vol_env
# host skeleton dir removed
assert not os.path.exists(os.path.join(vm.working_dir, "etc", "network"))
@pytest.mark.asyncio
async def test_create_keeps_etc_network_without_skip_init(compute_project, manager):
response = _create_response(None)
with asyncio_patch("gns3server.compute.docker.Docker.list_images",
return_value=[{"image": "srlinux"}]):
with asyncio_patch("gns3server.compute.docker.Docker.query",
return_value=response) as mock:
vm = VendorDockerVM("srlinux-1", str(uuid.uuid4()), compute_project,
manager, "srlinux:latest",
console_type="docker_exec")
await vm.create()
sent = mock.call_args.kwargs["data"]
targets = [m["Target"] for m in sent["HostConfig"]["Mounts"]]
assert "/gns3volumes/etc/network" in targets
# ---------------------------------------------------------------------------
# Interface renaming (_get_container_ifname / move_to_ns)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_move_to_ns_uses_renamed_interface(compute_project, manager):
vm = _make_vm(compute_project, manager,
environment="GNS3_SKIP_INIT=1\nGNS3_INTERFACE_NAMES=mgmt0,e1-1,e1-2,e1-3")
vm._ubridge_hypervisor = MagicMock()
vm._namespace = 42
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 0)
# adapter 0 should be renamed to mgmt0
move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)]
assert move_calls, "move_to_ns was not sent"
assert call.send("docker move_to_ns tap-gns3-e0 42 mgmt0") in move_calls
@pytest.mark.asyncio
async def test_move_to_ns_falls_back_to_eth(compute_project, manager):
vm = _make_vm(compute_project, manager) # no INTERFACE_NAMES
vm._ubridge_hypervisor = MagicMock()
vm._namespace = 42
nio = manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
await vm._add_ubridge_connection(nio, 1)
move_calls = [c for c in vm._ubridge_hypervisor.method_calls if "move_to_ns" in str(c)]
assert call.send("docker move_to_ns tap-gns3-e0 42 eth1") in move_calls
# ---------------------------------------------------------------------------
# start() — docker_exec console dispatch + volume bridge + permission fix
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_docker_exec_dispatches_console(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
vm.adapters = 1
vm._get_container_state = AsyncioMagicMock(return_value="stopped")
vm._start_ubridge = AsyncioMagicMock()
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_docker_exec_console = AsyncioMagicMock()
vm._setup_skip_init_volumes = AsyncioMagicMock()
vm._fix_permissions = AsyncioMagicMock()
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
vm._start_docker_exec_console.assert_called_once()
assert vm.status == "started"
# SKIP_INIT path runs the volume bridge + permission fix
vm._setup_skip_init_volumes.assert_called_once()
vm._fix_permissions.assert_called_once()
@pytest.mark.asyncio
async def test_start_without_skip_init_skips_vendor_passes(compute_project, manager):
vm = _make_vm(compute_project, manager) # no SKIP_INIT
vm.adapters = 1
vm.console_type = "docker_exec"
vm._get_container_state = AsyncioMagicMock(return_value="stopped")
vm._start_ubridge = AsyncioMagicMock()
vm._get_namespace = AsyncioMagicMock(return_value=42)
vm._add_ubridge_connection = AsyncioMagicMock()
vm._start_docker_exec_console = AsyncioMagicMock()
vm._setup_skip_init_volumes = AsyncioMagicMock()
vm._fix_permissions = AsyncioMagicMock()
with patch("gns3server.compute.docker.Docker.install_busybox"):
with asyncio_patch("gns3server.compute.docker.Docker.query"):
await vm.start()
# init.sh runs (no SKIP_INIT) → no vendor bridge/fix passes
vm._setup_skip_init_volumes.assert_not_called()
# ---------------------------------------------------------------------------
# _fix_permissions — container-side, skips dead containers, targets /gns3volumes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fix_permissions_skips_dead_container(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
vm._volumes = ["/etc/opt/srlinux"]
vm._get_container_state = AsyncioMagicMock(return_value="exited")
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
await vm._fix_permissions()
# must NOT exec into a dead container
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_fix_permissions_skips_missing_container(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
vm._volumes = ["/etc/opt/srlinux"]
vm._get_container_state = AsyncioMagicMock(side_effect=DockerHttp404Error("nope"))
with patch("asyncio.subprocess.create_subprocess_exec") as mock_exec:
await vm._fix_permissions()
mock_exec.assert_not_called()
@pytest.mark.asyncio
async def test_fix_permissions_targets_gns3volumes(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
vm._volumes = ["/etc/opt/srlinux", "/var/log/srlinux"]
vm._get_container_state = AsyncioMagicMock(return_value="running")
proc = MagicMock()
proc.wait = AsyncioMagicMock(return_value=0)
proc.returncode = 0
proc.stderr = MagicMock()
proc.stderr.read = AsyncioMagicMock(return_value=b"")
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=proc) as mock_exec:
await vm._fix_permissions()
# one exec per volume
assert mock_exec.call_count == 2
# each script must target /gns3volumes<volume>, not the raw path
for call_obj in mock_exec.call_args_list:
script = call_obj.args[-1] # last positional arg is the sh -c script
assert "/gns3volumes" in script
# must NOT chown the in-container path directly
assert 'chown' in script and '"/gns3volumes' in script
# ---------------------------------------------------------------------------
# _setup_skip_init_volumes — bridge via docker exec
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_setup_skip_init_volumes_runs_exec(compute_project, manager):
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
extra_volumes=["/etc/opt/srlinux"])
vm._volumes = ["/etc/opt/srlinux"]
proc = MagicMock()
proc.communicate = AsyncioMagicMock(return_value=(b"", b""))
proc.returncode = 0
with patch("asyncio.subprocess.create_subprocess_exec",
return_value=proc) as mock_exec:
await vm._setup_skip_init_volumes()
assert mock_exec.call_count == 1
script = mock_exec.call_args.args[-1]
# must do the bind mount
assert "mount --bind" in script
assert "/gns3volumes/etc/opt/srlinux" in script
# ---------------------------------------------------------------------------
# _cleanup_console_resources
# ---------------------------------------------------------------------------
def test_cleanup_console_resources_closes_writer(compute_project, manager):
vm = _make_vm(compute_project, manager)
writer = MagicMock()
vm._console_exec_writer = writer
vm._cleanup_console_resources()
writer.close.assert_called_once()
assert vm._console_exec_writer is None
def test_cleanup_console_resources_no_writer(compute_project, manager):
vm = _make_vm(compute_project, manager)
vm._console_exec_writer = None
# must not raise
vm._cleanup_console_resources()
# ---------------------------------------------------------------------------
# _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic
# ---------------------------------------------------------------------------
def _make_lazy_server(compute_project, manager):
"""Build a _LazyExecTelnetServer with _create_exec mocked out (no docker)."""
vm = _make_vm(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli")
srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli")
srv._create_exec = AsyncioMagicMock()
srv._on_naws = AsyncioMagicMock()
return srv
def _live_writer():
"""A writer mock that reports as open (not closing)."""
w = MagicMock()
w.is_closing.return_value = False
return w
def _dead_writer():
"""A writer mock that reports as closing (pty closed)."""
w = MagicMock()
w.is_closing.return_value = True
return w
def test_upstream_alive_never_created(compute_project, manager):
srv = _make_lazy_server(compute_project, manager)
assert srv._upstream_alive() is False
def test_upstream_alive_writer_closing(compute_project, manager):
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
srv._writer = _dead_writer()
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = False
assert srv._upstream_alive() is False
def test_upstream_alive_broadcast_done(compute_project, manager):
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
srv._writer = _live_writer()
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = True # CLI exited → EOF → task ended
assert srv._upstream_alive() is False
def test_upstream_alive_live(compute_project, manager):
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
srv._writer = _live_writer()
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = False
assert srv._upstream_alive() is True
@pytest.mark.asyncio
async def test_first_connect_creates_exec(compute_project, manager):
srv = _make_lazy_server(compute_project, manager)
# never created → must create
await srv.client_connected_hook()
srv._create_exec.assert_called_once()
@pytest.mark.asyncio
async def test_reconnect_live_exec_not_recreated(compute_project, manager):
"""Reconnecting while the exec is alive must NOT recreate it."""
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "abc"
srv._writer = _live_writer()
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = False
await srv.client_connected_hook()
srv._create_exec.assert_not_called()
# Ctrl-L redraw is still sent to the live writer
srv._writer.write.assert_any_call(b"\x0c")
@pytest.mark.asyncio
async def test_reconnect_after_death_recreates_exec(compute_project, manager):
"""The core reconnect fix: after the CLI exits (broadcast task done),
the next client connection recreates the exec so CPR gets answered."""
srv = _make_lazy_server(compute_project, manager)
# simulate a dead upstream: exec existed, but the pty closed / task ended
srv._exec_id = "old-exec"
srv._writer = _dead_writer()
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = True
await srv.client_connected_hook()
srv._create_exec.assert_called_once()
@pytest.mark.asyncio
async def test_reconnect_closes_half_dead_writer(compute_project, manager):
"""If the writer is still open but the broadcast task died, the old writer
must be closed before a new exec is created (no socket leak)."""
srv = _make_lazy_server(compute_project, manager)
srv._exec_id = "old-exec"
srv._writer = _live_writer() # still open, but...
srv._broadcast_task = MagicMock()
srv._broadcast_task.done.return_value = True # ...task ended
await srv.client_connected_hook()
srv._writer.close.assert_called_once()
srv._create_exec.assert_called_once()
@pytest.mark.asyncio
async def test_create_exec_cmd_has_no_while_true(compute_project, manager):
"""The command must NOT be wrapped in a while-true loop (regression guard:
while-true restarts the CLI with no client to answer CPR blank screen)."""
vm = _make_vm(compute_project, manager)
manager._server_url = "/var/run/docker.sock"
manager._api_version = "1.40"
srv = _LazyExecTelnetServer(vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli")
captured = {}
async def fake_query(method, path, data=None, **kw):
captured["data"] = data
return {"Id": "exec123"}
manager.query = fake_query
with patch("asyncio.open_unix_connection") as mock_open:
reader = MagicMock()
reader.readuntil = AsyncioMagicMock(return_value=b"HTTP/1.1 101 Upgraded\r\n\r\n")
writer = MagicMock()
writer.is_closing.return_value = False
mock_open.return_value = (reader, writer)
await srv._create_exec()
cmd = captured["data"]["Cmd"]
assert cmd == ["sh", "-c", "/opt/srlinux/bin/sr_cli"]
assert "while true" not in cmd[2]
# must run as root with a pty and TERM
assert captured["data"]["User"] == "root"
assert captured["data"]["Tty"] is True
assert "TERM=xterm" in captured["data"]["Env"]