diff --git a/docs/features/docker-exec-console.md b/docs/features/docker-exec-console.md new file mode 100644 index 000000000..a121ea23c --- /dev/null +++ b/docs/features/docker-exec-console.md @@ -0,0 +1,465 @@ + + +> 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 ` 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 ""` (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 ` 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` 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`) | 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 '' 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. | diff --git a/gns3server/compute/base_node.py b/gns3server/compute/base_node.py index e2431ffa0..8e3188c84 100644 --- a/gns3server/compute/base_node.py +++ b/gns3server/compute/base_node.py @@ -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}' " diff --git a/gns3server/compute/docker/__init__.py b/gns3server/compute/docker/__init__.py index f076e556c..ab095084f 100644 --- a/gns3server/compute/docker/__init__.py +++ b/gns3server/compute/docker/__init__.py @@ -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): diff --git a/gns3server/compute/docker/docker_vm.py b/gns3server/compute/docker/docker_vm.py index ef4210668..804039216 100644 --- a/gns3server/compute/docker/docker_vm.py +++ b/gns3server/compute/docker/docker_vm.py @@ -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) diff --git a/gns3server/compute/docker/vendor_docker_vm.py b/gns3server/compute/docker/vendor_docker_vm.py new file mode 100644 index 000000000..f0410d196 --- /dev/null +++ b/gns3server/compute/docker/vendor_docker_vm.py @@ -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 . + +""" +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`) 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") diff --git a/gns3server/schemas/common.py b/gns3server/schemas/common.py index ce37c337f..b15bc5201 100644 --- a/gns3server/schemas/common.py +++ b/gns3server/schemas/common.py @@ -61,6 +61,7 @@ class ConsoleType(str, Enum): spice = "spice" spice_agent = "spice+agent" none = "none" + docker_exec = "docker_exec" class AuxType(str, Enum): diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 6a0ef5720..23e7b32c0 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -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') diff --git a/tests/compute/docker/test_vendor_docker_vm.py b/tests/compute/docker/test_vendor_docker_vm.py new file mode 100644 index 000000000..ce81e5119 --- /dev/null +++ b/tests/compute/docker/test_vendor_docker_vm.py @@ -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 . + +""" +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, 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"]