mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
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:
commit
c588652768
465
docs/features/docker-exec-console.md
Normal file
465
docs/features/docker-exec-console.md
Normal 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 35–52) 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. |
|
||||
@ -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}' "
|
||||
|
||||
@ -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):
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
450
gns3server/compute/docker/vendor_docker_vm.py
Normal file
450
gns3server/compute/docker/vendor_docker_vm.py
Normal 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 35–52) for
|
||||
containers that skip init.sh (GNS3_SKIP_INIT=1).
|
||||
|
||||
On first start the container's original files are seeded into the
|
||||
persistent host directory; on subsequent starts the persisted data
|
||||
is bind-mounted over the in-container path so writes land on the host.
|
||||
Permission-changes recorded by _fix_permissions at the previous
|
||||
stop are restored (best-effort).
|
||||
"""
|
||||
for volume in self._volumes:
|
||||
vol_target = f"/gns3volumes{volume}"
|
||||
# fmt: off
|
||||
script = (
|
||||
f'mkdir -p "{volume}" && '
|
||||
f'if [ ! -f "{vol_target}/.gns3_perms" ]; then '
|
||||
f' /gns3/bin/busybox cp -a "{volume}/." "{vol_target}/" 2>/dev/null; '
|
||||
f' /gns3/bin/busybox touch "{vol_target}/.gns3_perms"; '
|
||||
f'fi && '
|
||||
f'/gns3/bin/busybox mount --bind "{vol_target}" "{volume}" && '
|
||||
f'while IFS=: read -r PERMS OWNER GROUP FILE; do '
|
||||
f' [ -L "$FILE" ] || /gns3/bin/busybox chmod "$PERMS" "$FILE" 2>/dev/null; '
|
||||
f' /gns3/bin/busybox chown -h "$OWNER:$GROUP" "$FILE" 2>/dev/null; '
|
||||
f'done < "{volume}/.gns3_perms"'
|
||||
)
|
||||
# fmt: on
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
"docker",
|
||||
"exec",
|
||||
self._cid,
|
||||
"sh",
|
||||
"-c",
|
||||
script,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
err = stderr.decode(errors="replace").strip()
|
||||
log.warning(
|
||||
"Volume setup for '%s' on container '%s' returned %d: %s",
|
||||
volume, self._name, process.returncode, err,
|
||||
)
|
||||
else:
|
||||
log.info("Volume '%s' bound to persistent storage for '%s'", volume, self._name)
|
||||
except OSError as e:
|
||||
log.warning(
|
||||
"Could not setup volume '%s' for container '%s': %s", volume, self._name, e
|
||||
)
|
||||
|
||||
async def _start_console_server(self):
|
||||
"""
|
||||
Override: add the ``docker_exec`` console type alongside the
|
||||
telnet/ssh/http types supported by the base class.
|
||||
"""
|
||||
if self.console_type == "docker_exec":
|
||||
await self._start_docker_exec_console()
|
||||
else:
|
||||
await super()._start_console_server()
|
||||
|
||||
# ---- docker_exec console implementation --------------------------------
|
||||
|
||||
async def _start_docker_exec_console(self):
|
||||
"""
|
||||
Start a console that runs a command inside the container via the Docker
|
||||
exec API, bridged to a telnet server. Intended for vendor NOS containers
|
||||
(e.g. Nokia SR Linux) whose CLI is a separate TUI process not exposed on
|
||||
PID 1's stdio.
|
||||
|
||||
The exec is created lazily on the first client connection (not when the
|
||||
node starts) so the command's startup terminal probe has a real xterm.js
|
||||
client to answer it (CPR / prompt_toolkit). The single exec is then
|
||||
shared (broadcast) by all clients, matching GNS3's console model.
|
||||
Command from GNS3_CONSOLE_CMD.
|
||||
"""
|
||||
|
||||
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")
|
||||
@ -61,6 +61,7 @@ class ConsoleType(str, Enum):
|
||||
spice = "spice"
|
||||
spice_agent = "spice+agent"
|
||||
none = "none"
|
||||
docker_exec = "docker_exec"
|
||||
|
||||
|
||||
class AuxType(str, Enum):
|
||||
|
||||
@ -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')
|
||||
|
||||
614
tests/compute/docker/test_vendor_docker_vm.py
Normal file
614
tests/compute/docker/test_vendor_docker_vm.py
Normal 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"]
|
||||
Loading…
x
Reference in New Issue
Block a user