mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
Merge pull request #2857 from yueguobin/feat/skills-device-topics
feat: appliance v8 install, copilot device integration, console terminal size and misc fixes
This commit is contained in:
commit
770c898ec1
@ -29,6 +29,9 @@
|
||||
### Docker Container Stop Delay
|
||||
- **[Docker Container Stop Delay](./docker-container-stop-delay.md)** - Some containers take ~5s to stop because they don't handle SIGTERM (AlpiNet, OstinatoWireshark)
|
||||
|
||||
### Device Console / Copilot Known Bugs
|
||||
- **[XRd Console --More-- Pager Bug](./xrd-console-more-pager-bug.md)** - FIXED: root cause was our own 80x24 initial PTY geometry for the docker_exec console (the XR pager reads PTY rows, not `terminal length`); initial geometry is now 511x10000. Copilot reconnect-before-retry + session_log still open; `--More--` auto-answer kept as fallback design
|
||||
|
||||
### MCP Service
|
||||
- **[MCP Service Design](./mcp-service-design.md)** - MCP (Model Context Protocol) service architecture using FastMCP with SSE transport, JWT auth, 29 tools across 5 domains
|
||||
- **[MCP Tool Description Location](./mcp-tool-description-guide.md)** - Where to define MCP tool descriptions: in `@mcp.tool()` functions in `__init__.py`, not in `*_TOOLS` arrays
|
||||
|
||||
72
.claude/memory/xrd-console-more-pager-bug.md
Normal file
72
.claude/memory/xrd-console-more-pager-bug.md
Normal file
@ -0,0 +1,72 @@
|
||||
# XRd Console --More-- Pager Bug (PTY window size paging vs terminal length 0)
|
||||
|
||||
## Background
|
||||
|
||||
Copilot commands with long output consistently failed against XRd (IOS XRv 9000 container) nodes: `device_show_run` running `show ipv4 interface brief` always reported `netmiko_multiline (failed)` (even as a single command); short-output commands like `show ipv4 interface <iface>` worked fine.
|
||||
|
||||
Failure sequence (from the 2026-08-18 logs):
|
||||
|
||||
1. First failure: `ReadTimeout: Pattern not detected: 'RP/0/RP0/CPU0:ios\#'` — the command echo matched, but the prompt never appeared within 60s
|
||||
2. The copilot's single-retry reused the same netmiko session (nornir caches it on the host object) with half-consumed output in the buffer → the second failure died earlier in `command_echo_read` — a follow-on effect of the dirty session, not an independent fault
|
||||
|
||||
## Root Cause (fully traced)
|
||||
|
||||
The XRd node uses the `docker_exec` console type (`gns3server/compute/docker/vendor_docker_vm.py`, `_LazyExecTelnetServer`): a telnet TCP server whose backend is a `docker exec` PTY. Three facts combine:
|
||||
|
||||
1. The XR pager (at least for table-engine/TABLAST-style commands) pages on the **PTY window size (TIOCGWINSZ)**, not the CLI-level `terminal length` — `show terminal` happily reports `Length: 0 lines` while output still pages
|
||||
2. `_LazyExecTelnetServer.client_connected_hook` explicitly resized the exec PTY to **80×24** "before NAWS" (`await self._on_naws(80, 24)`) — the 24 rows were **our own initial geometry, not a Docker default**. Live test: `show ipv4 interface brief` paged after exactly 24 lines
|
||||
3. netmiko's telnetlib never negotiates **NAWS**, so the initial geometry is never corrected for copilot/bare-telnet clients. Real NAWS clients resize to their own geometry via the existing `_on_naws` → `POST /exec/{id}/resize` wiring
|
||||
|
||||
### Dead ends (do not retry)
|
||||
|
||||
- `show ipv4 interface brief | no-more` → `% Invalid input detected`. `| no-more` is a **Junos** pipe modifier; IOS-XR does not have it
|
||||
- `terminal length 0`: takes effect at the CLI layer, but this pager ignores it
|
||||
- The XR CLI has no command to change PTY rows
|
||||
|
||||
### Related fact: paramiko vs docker_exec
|
||||
|
||||
paramiko (SSH) cannot connect to a `docker_exec` console — the client-side endpoint is a plain telnet server (`AsyncioTelnetServer`); only `console_type: ssh` (standard attach path, `AsyncioSSHServer`) speaks SSH. The copilot correctly uses netmiko `*_telnet` drivers (netmiko's vendored `_telnetlib`, stdlib-free on Python 3.13).
|
||||
|
||||
## Decision/Implementation
|
||||
|
||||
### Fix (implemented 2026-08-18, branch `feat/docker-exec-default-pty-geometry`)
|
||||
|
||||
Change the initial exec geometry in `vendor_docker_vm.py` `client_connected_hook` from 80×24 to **511×10000** (`await self._on_naws(511, 10000)`):
|
||||
|
||||
- Tall/wide default so CLIs that page on PTY rows never hit `--More--` for clients that never send NAWS (netmiko, bare telnet)
|
||||
- Width 511 matches netmiko's `terminal width 511` convention
|
||||
- Real NAWS clients still resize to their actual geometry right after connecting (existing `_on_naws` path unchanged)
|
||||
- Test: `test_first_connect_sets_tall_default_pty_geometry` in `tests/compute/docker/test_vendor_docker_vm.py`
|
||||
|
||||
### Fallback design (NOT implemented — keep if the pager ever resurfaces on another console type)
|
||||
|
||||
Channel-level loop answering `--More--` in the copilot display tool for `cisco_xr*`: prompt regex breaks; **tail-anchored** `re.search(r"--More--\s*$", buf)` with a ~150ms quiet double-confirmation before `write_channel(" ")`. Never put `--More--` into netmiko's `expect_string` — expect `re.search`es accumulated output, so content containing "More" would false-trigger.
|
||||
|
||||
### Still-open copilot improvements (agreed, not yet implemented)
|
||||
|
||||
1. Reconnect before retry: `_run_all_device_configs_with_single_retry` (display tool) and the config tool's retry should `task.host.close_connection("netmiko")` first — retrying on a dirty session is doomed
|
||||
2. `session_log_file` in hosts_data netmiko extras — the copilot has no session_log anywhere, which made this bug a guessing game
|
||||
|
||||
## Rationale
|
||||
|
||||
Resizing the PTY at exec creation attacks the root (geometry is fixed before the CLI outputs anything); it covers every consumer of the docker_exec console (copilot, MCP, bare telnet) with a one-line change, while the `--More--` auto-answer design remains as a generic fallback for consoles without a resize path.
|
||||
|
||||
## Related Files
|
||||
|
||||
- `gns3server/compute/docker/vendor_docker_vm.py` — `_LazyExecTelnetServer`: `_on_naws` (exec resize), `_create_exec` (Tty=True, TERM=xterm), `client_connected_hook` (initial geometry — the fix)
|
||||
- `gns3server/compute/docker/docker_vm.py:1074-1087` — standard console path: NAWS → `containers/{cid}/resize`
|
||||
- `gns3server/agent/gns3_copilot/tools_v2/display_tools_nornir.py:284-341` — dirty-session retry (open item)
|
||||
- `gns3server/agent/gns3_copilot/utils/get_gns3_device_port.py` — hosts_data (session_log extras insertion point)
|
||||
|
||||
## Examples
|
||||
|
||||
Live console transcript (2026-08-17):
|
||||
|
||||
```
|
||||
RP/0/RP0/CPU0:ios#show terminal
|
||||
Length: 0 lines, Width: 511 columns <- CLI-layer setting in effect
|
||||
|
||||
RP/0/RP0/CPU0:ios#show ipv4 interface brief
|
||||
(exactly 24 lines: timestamp + blank + header + 21 interface rows)
|
||||
--More-- <- the 80x24 initial exec geometry was paging
|
||||
```
|
||||
@ -85,7 +85,11 @@ bridge start {node_id}-{port}
|
||||
```
|
||||
|
||||
Captures and marker signals are applied via the existing `_ubridge_apply_filters`
|
||||
and `_ubridge_apply_markers` helpers from `BaseNode`.
|
||||
and `_ubridge_apply_markers` helpers from `BaseNode`. The controller therefore allows
|
||||
packet filters and traffic-insight markers on switch links (including switch-to-switch):
|
||||
`ethernet_switch` is a marker/filter-capable node type, and the compute API exposes the
|
||||
matching NIO-update and per-marker endpoints. The `ethernet_hub` — still Dynamips-hosted,
|
||||
no uBridge — remains excluded.
|
||||
|
||||
### `remove_nio(port_number)`
|
||||
|
||||
|
||||
@ -31,17 +31,20 @@ they let a vendor NOS run as a first-class GNS3 Docker router node.
|
||||
> schema changes, so existing Docker nodes (FRR, ipterm, …) are unaffected.
|
||||
> `console_type: "docker_exec"` is added to the `ConsoleType` enum.
|
||||
|
||||
## The three environment knobs
|
||||
## Environment knobs
|
||||
|
||||
All three are read from the node's `environment` field. Entries prefixed with
|
||||
All 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.
|
||||
they stay host-side configuration. The console-relevant ones (the full vendor
|
||||
set, incl. `GNS3_SHM_SIZE` / `GNS3_DEVICES` / `GNS3_MASK_UDEV` /
|
||||
`GNS3_STOP_TIMEOUT`, is documented in [vendor-nos-xrd.md](./vendor-nos-xrd.md)):
|
||||
|
||||
| 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. |
|
||||
| `GNS3_CONSOLE_RESIZE=0` | Ignore client-driven console resizes (WS terminal-size control frames / telnet NAWS) and keep the tall no-paging PTY geometry. Set for CLIs that page on the PTY window size (IOS-XR) — see [Terminal geometry](#terminal-geometry-and-size-forwarding). |
|
||||
|
||||
## Architecture: `VendorDockerVM` subclass
|
||||
|
||||
@ -87,18 +90,21 @@ Setting `console_type: "docker_exec"` makes the node's primary console port run
|
||||
|
||||
```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
|
||||
A[Web UI xterm.js] -->|console WS: text frames| B[Controller forward]
|
||||
B -->|WS: text + binary| C[GNS3 Compute telnet server]
|
||||
C -->|binary pty stream| D[Docker exec API]
|
||||
D -->|Tty:true pty| E[sr_cli / vendor CLI]
|
||||
A -.->|binary control frame {"cols","rows"}| B
|
||||
B -.-> C
|
||||
C -.->|POST exec/.../resize| D
|
||||
```
|
||||
|
||||
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.
|
||||
behaves. The PTY geometry is likewise shared: the last client resize wins
|
||||
(see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
|
||||
### Implementation
|
||||
|
||||
@ -159,8 +165,11 @@ reconnect logic is unit-tested.
|
||||
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.
|
||||
`window_size_changed_callback` (`_on_naws`, gated by
|
||||
`GNS3_CONSOLE_RESIZE`) calls `POST exec/{eid}/resize?h=&w=` so the TUI
|
||||
lays out for the client's window size. The internal `_resize_exec` path
|
||||
(creation-time default, restore-on-idle) is not gated. See
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding).
|
||||
|
||||
6. **Binary passthrough + redraw.** `binary=True` so TUI escape sequences reach
|
||||
xterm.js intact; `echo=False` (the pty echoes). On every client (re)connect
|
||||
@ -168,9 +177,66 @@ reconnect logic is unit-tested.
|
||||
screen for a previous client redraws for the new one (otherwise a
|
||||
reconnect shows a blank screen until the next output).
|
||||
|
||||
### Terminal geometry and size forwarding
|
||||
|
||||
The exec PTY geometry is a shared resource with three consumers that want
|
||||
different things:
|
||||
|
||||
- **Browser clients (xterm.js)** need the PTY to match their real window, or
|
||||
TUI CLIs misrender and over-render (below).
|
||||
- **Non-NAWS clients** (netmiko, bare telnet — no terminal-size negotiation)
|
||||
need the PTY *tall*: CLIs that page on the PTY window size (the IOS-XR
|
||||
pager ignores `terminal length 0`) park at `--More--` on a 24-row PTY.
|
||||
- **Concurrent sessions share one exec** — one browser resize changes what
|
||||
every attached client sees.
|
||||
|
||||
Resolution:
|
||||
|
||||
1. **Tall default.** The exec is created at 511×10000 (width 511 matches
|
||||
netmiko's `terminal width 511` convention). Non-NAWS clients get no paging
|
||||
and no hard wrapping.
|
||||
2. **WS terminal-size forwarding.** Console WebSocket clients may send
|
||||
**binary control frames** — UTF-8 JSON `{"cols": N, "rows": N}` —
|
||||
alongside text frames carrying terminal data (xterm.js's AttachAddon only
|
||||
sends text, so binary is an unambiguous side channel; valid ranges are
|
||||
cols 2–5000, rows 2–100000, anything else is silently ignored). The
|
||||
controller forwards binary frames (previously only text was forwarded —
|
||||
and a binary frame would have crashed the old `receive_text` loop), and
|
||||
the compute side turns them into a telnet NAWS subnegotiation for
|
||||
telnet-based consoles (docker_exec included) or an asyncssh
|
||||
`change_terminal_size` for SSH consoles
|
||||
(`base_node.py` `start_websocket_console`).
|
||||
3. **Races.** A size frame that arrives before/during the exec creation is
|
||||
remembered and applied right after creation — it is **not** overwritten by
|
||||
the tall default. When the **last** client disconnects the exec goes back
|
||||
to 511×10000, so a later non-NAWS client attaching to the still-live exec
|
||||
doesn't inherit a browser geometry and hit PTY-window paging.
|
||||
4. **`GNS3_CONSOLE_RESIZE=0`** makes the console ignore client resizes
|
||||
entirely (the tall default is then permanent). Set it for paging CLIs
|
||||
where a browser resize would break concurrent netmiko sessions on the
|
||||
shared exec — XRd, which is line-oriented and doesn't need browser
|
||||
resizing at all.
|
||||
|
||||
**Why the browser must send its size — the SR Linux flicker.** `sr_cli` is a
|
||||
prompt_toolkit TUI that anchors its layout with cursor-position requests
|
||||
(CPR), which xterm.js answers. On a 10000-row PTY canvas the CPR-anchored
|
||||
model conflicts with the winsize model, and every incremental render re-emits
|
||||
the accumulated output: measured with a CPR-answering client, one `info`
|
||||
command produces **~145 KB instead of ~60 KB** (~7× duplicated lines either
|
||||
way — the CLI re-renders its output region as a scroll-append stream; that
|
||||
part is inherent to `sr_cli` and identical outside GNS3, verified via manual
|
||||
`docker exec`). The inflation is driven by **rows** (24/32 → normal, 10000 →
|
||||
pathological, at any width) and is invisible without CPR answers — which is
|
||||
why plain-telnet probes and real xterm.js sessions behaved so differently.
|
||||
In the Web UI the excess renders as frequent full-screen clear/redraw — the
|
||||
"flicker". With the browser's real size forwarded (rows ≈ 30), output volume
|
||||
and rendering return to normal.
|
||||
|
||||
**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.
|
||||
the console TCP port exactly as it does for telnet. The same WS handler also
|
||||
intercepts binary control frames and propagates client terminal sizes (see
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
|
||||
### Why earlier approaches failed (context)
|
||||
|
||||
@ -256,41 +322,46 @@ This is the one place where skipping init.sh changes behaviour beyond boot:
|
||||
**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:
|
||||
init.sh (as the entrypoint) is safe because it runs **before** the
|
||||
application: for each volume it seeds the host directory with the image's
|
||||
original files on first start, then `mount --bind /gns3volumes<path> <path>`
|
||||
bridges persistent storage into place.
|
||||
|
||||
`VendorDockerVM` cannot use that position (the NOS must own its entrypoint),
|
||||
so the same persistence is established entirely **outside the container and
|
||||
before it exists**:
|
||||
|
||||
```
|
||||
host ──Docker bind mount──▶ /gns3volumes/etc/opt/srlinux (always mounted)
|
||||
│ init.sh: mount --bind
|
||||
▼
|
||||
/etc/opt/srlinux (where the NOS writes)
|
||||
create() 之前: host dir seeded from the image (docker create + docker cp, first time only)
|
||||
create() 时: host ──Docker bind mount──▶ /etc/opt/srlinux (direct, at the real path)
|
||||
启动: NOS native entrypoint — the persisted config is visible from the first process
|
||||
```
|
||||
|
||||
`VendorDockerVM` replicates this for SKIP_INIT containers:
|
||||
1. **`_prepare_volumes()`** — host-side, at `create()` time (after the image
|
||||
is present, before the container is created). For each persistent volume
|
||||
whose host directory lacks the `.gns3_perms` marker, a throwaway
|
||||
`docker create` container (nothing executes) is used as a `docker cp -a`
|
||||
source to seed the host directory with the image's original content. The
|
||||
marker is written after the copy attempt — a volume that has it (every
|
||||
node that ever started, on any GNS3 version) is **never re-seeded**, so
|
||||
saved configuration is never overwritten with factory content.
|
||||
|
||||
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. **`_mount_binds()` override** — the volume binds target the **real
|
||||
in-container paths** (`/etc/opt/srlinux`) instead of `/gns3volumes<volume>`.
|
||||
With the content seeded first, the image's files are never shadowed by an
|
||||
empty mount, and the NOS sees its persisted configuration from the very
|
||||
first process — no post-start mount pass that could race the NOS reading
|
||||
its startup config (see "History: the exec-bridge race" below).
|
||||
|
||||
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).
|
||||
3. **Container-side `_fix_permissions()` override** — runs the same busybox
|
||||
record/chmod/chown script **inside the container (as root) on the volume
|
||||
paths**. Because the volumes are Docker bind mounts created with the
|
||||
container, the in-container paths resolve to the host files for the whole
|
||||
container lifetime. A stopped/exited container is **not** restarted (the
|
||||
base class would, just to chown; vendor NOS images are heavy to boot):
|
||||
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
|
||||
@ -309,9 +380,10 @@ the base class just created. Without `GNS3_SKIP_INIT` the mount is kept
|
||||
|
||||
| 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 |
|
||||
| create | — | `_prepare_volumes()` seeds host dirs from the image (first create only); volumes bound **directly** at their real paths |
|
||||
| start | init.sh seeds + bind-mounts + restores perms (in-container, before the app starts) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
|
||||
| stop | container-side `_fix_permissions` on in-container paths (restarts an exited container) | container-side `_fix_permissions` on the volume paths (skips dead containers, no restart) |
|
||||
| volume config | `_mount_binds`: host → `/gns3volumes<path>` | `_mount_binds` override: host → `<path>` directly |
|
||||
|
||||
### Runtime ownership safety
|
||||
|
||||
@ -337,17 +409,28 @@ 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
|
||||
### History: the exec-bridge race (fixed)
|
||||
|
||||
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.
|
||||
The first SKIP_INIT implementation replicated init.sh's script **via
|
||||
`docker exec` after the container started** instead of binding directly at
|
||||
create time. That copied the mechanism but not the invariant that makes
|
||||
init.sh safe — the entrypoint position, which guarantees the volume is in
|
||||
place *before* the application runs. An exec-based bind runs **concurrently**
|
||||
with the NOS boot, so whether the NOS reads its persisted config or the
|
||||
overlay's factory copy was a timing race:
|
||||
|
||||
- a single node stop/start on an idle system won it (the exec landed ~1 s
|
||||
in, SR Linux reads its startup config at ~2–4 s) — which is why the
|
||||
round-trip "save → stop → start → config still there" passed;
|
||||
- a server restart + project reload lost it (all nodes start concurrently,
|
||||
the Docker API queue delays the execs by several seconds) — SR Linux
|
||||
booted factory while the persisted `config.json` sat intact on the host;
|
||||
- XRd was immune either way (systemd boots for tens of seconds before any
|
||||
XR process touches `/xr-storage`), which is why the race was never seen
|
||||
on it.
|
||||
|
||||
The direct-bind-at-create design removes the window entirely; there is no
|
||||
ordering requirement left to verify when adopting a new NOS image.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@ -406,49 +489,70 @@ present on the host.
|
||||
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`.
|
||||
- Ensure `GNS3_SKIP_INIT=1` is set (so the direct-bind path is taken) and
|
||||
the volume path is in `extra_volumes`; check that the host directory
|
||||
carries the `.gns3_perms` marker (written at create-time seeding) and the
|
||||
compute log for `Seeded persistent volume`.
|
||||
|
||||
**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).
|
||||
- On builds since the direct-bind rework this should not happen: the volume
|
||||
is in place before the first process. If you see it, confirm the server
|
||||
build includes the rework (older builds established the bind via a
|
||||
post-start `docker exec` that could lose the race against the NOS reading
|
||||
its startup config — see *History: the exec-bridge race*).
|
||||
|
||||
**11. Web console flickers (full-screen clear/redraw) on every command**
|
||||
- The PTY is stuck at the tall 511×10000 default while a CPR-answering client
|
||||
is attached — see
|
||||
[Terminal geometry](#terminal-geometry-and-size-forwarding). Check that the
|
||||
Web UI actually sends the binary size control frames on connect/resize
|
||||
(F12 → the console WS should show outgoing binary frames), and that the
|
||||
server is new enough to forward them (the controller used to forward text
|
||||
frames only). A client that never negotiates/forwards size (old Web UI,
|
||||
bare telnet without NAWS) cannot trigger the fix — but also never answers
|
||||
CPR, so it doesn't flicker either.
|
||||
- The much milder per-keystroke/5 s cursor toggles (`\e[?25l…\e[?25h`) from
|
||||
the TUI are normal and not this bug.
|
||||
|
||||
## 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.
|
||||
console model. There is no per-client independent session. The PTY
|
||||
geometry is shared too (last resize wins): two browsers of different sizes
|
||||
disagree harmlessly, but a browser on a *paging* CLI needs
|
||||
`GNS3_CONSOLE_RESIZE=0` to stop resizing on behalf of concurrent netmiko
|
||||
sessions (see [Terminal geometry](#terminal-geometry-and-size-forwarding)).
|
||||
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.
|
||||
`GNS3_CONSOLE_CMD` / `GNS3_CONSOLE_RESIZE` 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*).
|
||||
5. **Docker CLI dependency.** Volume seeding shells out to the `docker`
|
||||
binary (`docker create` + `docker cp` + `docker rm`) at create time —
|
||||
the same dependency the permission passes already have.
|
||||
|
||||
## 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()`.
|
||||
`_prepare_volumes` (host-side seeding), direct volume binds in
|
||||
`_mount_binds`, container-side `_fix_permissions`, `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/compute/base_node.py` — console WebSocket guard; binary
|
||||
terminal-size control frames → NAWS / asyncssh resize
|
||||
(`start_websocket_console`).
|
||||
- `gns3server/api/routes/controller/nodes.py` — console WS forwarding
|
||||
(text and binary frames).
|
||||
- `gns3server/schemas/common.py` — `ConsoleType.docker_exec`.
|
||||
- containerlab `nodes/srl/srl.go` — reference for SR Linux launch command and
|
||||
interface naming.
|
||||
@ -457,6 +561,8 @@ present on the host.
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.7 | 2026-08-22 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp -a`, marker-gated so saved config is never overwritten) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge. Root cause: the exec bridge raced the NOS reading its startup config — SR Linux read `config.json` at ~2–4 s and booted factory whenever concurrent node starts (server restart + project reload) delayed the exec past that point, while single-node stop/start and XRd (systemd touches `/xr-storage` tens of seconds in) never lost the race. New `_prepare_volumes` hook on `DockerVM`; `_fix_permissions` now targets the volume paths directly. |
|
||||
| 1.6 | 2026-08-20 | Terminal geometry and size forwarding: WS binary control frames `{"cols","rows"}` → NAWS / asyncssh resize (controller now forwards binary frames; compute intercepts them); tall 511×10000 default kept for non-NAWS clients, applied post-creation and restored on last disconnect (client size racing exec creation wins over the default); new `GNS3_CONSOLE_RESIZE=0` knob for paging CLIs (XRd) where a browser resize would break concurrent netmiko sessions on the shared exec; documented the SR Linux flicker root cause (tall rows × CPR-answering client → ~2.4× re-emitted output; rows-driven, width-independent). |
|
||||
| 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. |
|
||||
|
||||
@ -97,8 +97,8 @@ IOU runs a single `IOL-BRIDGE` per node shared by every interface, so `bridge`+`
|
||||
identical across that node's links. uBridge keeps a separate filter list **per port
|
||||
(bay/unit)** within the bridge, so each interface gets its own `global-{name}` filter, its own
|
||||
pcap file, and its own `link=`. The shared bridge name is irrelevant to attribution. Other
|
||||
capable node types (`qemu`, `docker`, `vpcs`, `cloud`) already use one bridge per link; `link`
|
||||
applies uniformly to all of them.
|
||||
capable node types (`qemu`, `docker`, `vpcs`, `cloud`, `ethernet_switch`) already use one
|
||||
bridge per link (the switch's per-port relay); `link` applies uniformly to all of them.
|
||||
|
||||
## Direction
|
||||
|
||||
@ -137,7 +137,7 @@ pass `capture_node_id` on marker **create**:
|
||||
```
|
||||
|
||||
The value must be one of the link's two endpoints and a marker-capable type (`vpcs`, `qemu`,
|
||||
`docker`, `iou`, `dynamips`, `cloud`); any other id is rejected with `409`. Omit it to keep
|
||||
`docker`, `iou`, `dynamips`, `cloud`, `ethernet_switch`); any other id is rejected with `409`. Omit it to keep
|
||||
the auto-pick. The chosen id is echoed back as `capture_node_id` in the marker entry and in
|
||||
each `MARK` signal's `node=<id>`, so the Web UI always knows the observer regardless of who
|
||||
picked it.
|
||||
@ -357,7 +357,9 @@ direction relative to the capture node; see [Direction](#direction).
|
||||
runs one `tcpdump -d` rather than *N*. (uBridge still runs `pcap_compile` itself at
|
||||
install time, so an invalid expression can never slip through.)
|
||||
- **Supported node types.** A marker needs a uBridge bridge: `vpcs`, `qemu`, `docker`,
|
||||
`iou`, `dynamips`, `cloud` (one capable endpoint suffices). Types without a uBridge are
|
||||
`iou`, `dynamips`, `cloud`, `ethernet_switch` (one capable endpoint suffices). The
|
||||
`ethernet_switch` hosts markers on its per-port uBridge relays (brctl backend); the
|
||||
`ethernet_hub` is still Dynamips-hosted and has no uBridge. Types without a uBridge are
|
||||
silently skipped by the inheritance fan-out. IOU uses one shared `IOL-BRIDGE` per node but
|
||||
keeps filters, pcap files, and `link=` ids per port, so multi-interface nodes are handled
|
||||
(see [Per-link attribution](#per-link-attribution)).
|
||||
|
||||
@ -41,7 +41,7 @@ graph TB
|
||||
MASK["GNS3_MASK_UDEV → /dev/null binds"]
|
||||
HOSTCFG["ShmSize / Devices"]
|
||||
CFGINJ["extra_configs → RO single-file bind"]
|
||||
VBRIDGE["VendorDockerVM volume bridge"]
|
||||
VBRIDGE["VendorDockerVM volume seeding + direct binds"]
|
||||
HOSTCHK["host-readiness check (read-only)"]
|
||||
end
|
||||
subgraph Container["XRd container"]
|
||||
@ -100,6 +100,7 @@ Note: "journal corrupted" messages with varying machine-IDs come from the
|
||||
```
|
||||
GNS3_SKIP_INIT=1
|
||||
GNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh
|
||||
GNS3_CONSOLE_RESIZE=0
|
||||
GNS3_MASK_UDEV=1
|
||||
GNS3_SHM_SIZE=1024
|
||||
GNS3_DEVICES=/dev/fuse
|
||||
@ -109,6 +110,15 @@ XR_MGMT_INTERFACES=linux:eth0,xr_name=Mg0/RP0/CPU0/0,chksum,snoop_v4,snoop_v6
|
||||
XR_INTERFACES=linux:eth1,xr_name=Gi0/0/0/0;linux:eth2,xr_name=Gi0/0/0/1;...
|
||||
```
|
||||
|
||||
`GNS3_CONSOLE_RESIZE=0` (console geometry lock): the XR pager pages on the
|
||||
PTY window size and ignores `terminal length 0`, so the exec PTY must stay at
|
||||
the tall no-paging default for **every** client. The docker_exec console is a
|
||||
single shared exec — one browser's terminal-size resize (WS control frames →
|
||||
NAWS) would change the geometry concurrent netmiko/copilot sessions see and
|
||||
bring `--More--` back. XRd's CLI is line-oriented, so browsers lose nothing
|
||||
by not resizing. See
|
||||
[docker-exec-console.md](./docker-exec-console.md#terminal-geometry-and-size-forwarding).
|
||||
|
||||
XRd-specific gotchas (image-side, not GNS3):
|
||||
|
||||
- Management interface xr_name is **`Mg0/RP0/CPU0/0`** (short prefix, `CPU0`
|
||||
@ -148,11 +158,12 @@ sequenceDiagram
|
||||
participant X as XRd container
|
||||
U->>S: create node from template
|
||||
S->>S: parse GNS3_* env host-side
|
||||
S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind)
|
||||
S->>D: seed volume host dirs from image (docker create + cp, first time only)
|
||||
S->>D: container create (ShmSize, Devices, /dev/null binds, firstboot.cfg RO bind, volumes bound directly at /xr-storage*)
|
||||
U->>S: start
|
||||
S->>X: container start (native entrypoint /usr/sbin/init)
|
||||
Note over X: systemd boots; udevd + udevadm masked → host untouched
|
||||
S->>X: docker exec volume bridge (container's own chown)
|
||||
S->>X: docker exec permission fix (container's own chown)
|
||||
U->>S: open console
|
||||
S->>X: docker exec pty: /pkg/bin/xr_cli.sh
|
||||
X-->>U: IOS XR CLI (first boot: apply /firstboot.cfg, save to /xr-storage-shadow)
|
||||
@ -188,7 +199,7 @@ sequenceDiagram
|
||||
- `gns3server/compute/docker/docker_vm.py` — HostConfig env injection,
|
||||
`_UDEV_UNITS`/`_UDEVADM_PATHS`, `extra_configs` binds, `_format_devices()`
|
||||
- `gns3server/compute/docker/vendor_docker_vm.py` — vendor path, volume
|
||||
bridge, container-chown
|
||||
seeding + direct binds, container-chown
|
||||
- `gns3server/compute/docker/__init__.py` — `_check_host_readiness()`
|
||||
- `gns3server/schemas/common.py` — `ExtraConfig`
|
||||
- `gns3server/db/models/templates.py` + `db_migrations/` — persistence
|
||||
@ -201,6 +212,8 @@ sequenceDiagram
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.6 | 2026-08-21 | SKIP_INIT volume persistence rebuilt: host-side seeding at create time (`docker create` + `docker cp`) and direct bind mounts at the real in-container paths replace the post-start `docker exec` bridge, which raced the NOS reading its startup config (visible on SR Linux: factory boot after a server restart + project reload; XRd was immune only because systemd touches `/xr-storage` tens of seconds in). No behaviour change for XRd beyond the race removal. |
|
||||
| 1.5 | 2026-08-20 | Appliance env gains `GNS3_CONSOLE_RESIZE=0`: client-driven console resizes are ignored so the shared exec PTY stays at the tall no-paging geometry for concurrent netmiko/copilot sessions (browsers included). |
|
||||
| 1.4 | 2026-08-15 | Code-review hardening: stop-query HTTP timeout scales with `GNS3_STOP_TIMEOUT` (values >300 s no longer abort); overlapping mask/config bind targets deduplicated (Docker "Duplicate mount point"); `ExtraConfig.target` validated at save time and directory forms rejected; host-readiness check no longer aborts on one unreadable `/proc/sys` key; base env parser strips trailing commas; vendor env knobs re-parsed on create (PUT environment takes effect); graceful stop limited to explicit user stop (delete/update/close keep the immediate kill); extra_configs under a persisted volume warns. |
|
||||
| 1.3 | 2026-08-14 | Persistence corrected: XR's live data layer is `/xr-storage` (the image's symlink farm is materialized into real directories at bootstrap; `/xr-storage-shadow` is a pristine spare) — the appliance persists both. Vendor containers now stop gracefully (SIGTERM + `GNS3_STOP_TIMEOUT` grace, default 60 s) instead of being SIGKILLed on the spot. |
|
||||
| 1.2 | 2026-08-14 | Link UDP self-loop root-caused to a port-allocation race and fixed — see [link-udp-self-loop](../bugs/link-udp-self-loop.md) (now marked Fixed). |
|
||||
|
||||
@ -14,7 +14,7 @@ GNS3 Copilot loads all skills, prompts, and security configurations from an exte
|
||||
|
||||
The repository provides:
|
||||
- **Injection skills** (39 categories): Network fault scenarios for troubleshooting practice
|
||||
- **Device skills**: Device-specific command knowledge (VPCS, etc.)
|
||||
- **Device skills**: Device-specific command knowledge (VPCS, etc.) — large devices split into per-protocol **topics**
|
||||
- **Feature skills**: Topology planning, network design
|
||||
- **System prompts**: Agent personality and behavior definitions
|
||||
- **Forbidden commands**: Security rules for command filtering
|
||||
@ -24,7 +24,7 @@ The repository provides:
|
||||
```mermaid
|
||||
graph TD
|
||||
subgraph "GNS3-Skills Repository"
|
||||
YAML[injection/*.yaml<br/>device/*.yaml<br/>feature/*.yaml]
|
||||
YAML[injection/*.yaml<br/>device/*.yaml + device/*/*.yaml<br/>feature/*.yaml]
|
||||
MD[prompts/*.md]
|
||||
CFG[config/forbidden_commands.txt]
|
||||
end
|
||||
@ -56,7 +56,11 @@ GNS3-Skills/
|
||||
│ ├── vlan_issues.yaml
|
||||
│ └── ...
|
||||
├── device/ # Device-specific skills
|
||||
│ └── vpcs.yaml
|
||||
│ ├── vpcs.yaml # small devices: one single file
|
||||
│ └── frr/ # large devices: split per protocol topic
|
||||
│ ├── _base.yaml # device-level skill (console model, notes, aliases)
|
||||
│ ├── ospf.yaml # topic file (merged under "topics" at load time)
|
||||
│ └── bgp.yaml
|
||||
├── feature/ # Feature skills
|
||||
│ └── topology_planner.yaml
|
||||
├── prompts/ # System prompts (Markdown)
|
||||
@ -68,6 +72,26 @@ GNS3-Skills/
|
||||
└── forbidden_commands.txt
|
||||
```
|
||||
|
||||
## Device Topics
|
||||
|
||||
A device with knowledge for many protocols would grow one YAML file indefinitely. Such devices use a split layout instead: `device/<device>/_base.yaml` holds the device-level skill, and one file per protocol topic (`ospf.yaml`, `bgp.yaml`, ...) holds its commands and troubleshooting entries. The loader merges them into a single `SKILLS_REGISTRY` entry:
|
||||
|
||||
```
|
||||
SKILLS_REGISTRY["frr_vtysh"] = { ..._base.yaml..., "topics": { "ospf": {...}, "bgp": {...} } }
|
||||
```
|
||||
|
||||
Topic files must declare `device_type` (matching their `_base.yaml`), `topic` and `name`; the CI validator in the skills repository enforces this.
|
||||
|
||||
The `device_skills` tool exposes a three-step drill-down (mirroring `injection_skills`'s list → index → issue pattern):
|
||||
|
||||
```json
|
||||
{"action": "list"}
|
||||
{"device_type": "frr_vtysh", "detail": "index"}
|
||||
{"device_type": "frr_vtysh", "topic": "bgp"}
|
||||
```
|
||||
|
||||
Topic bodies are only served on an explicit `topic` request — every other detail level returns a topic index — so adding topics to a device does not grow the token cost of device-level lookups.
|
||||
|
||||
## Configuration
|
||||
|
||||
Skills repository settings are configured in `gns3_server.conf` under the `[Server]` section:
|
||||
|
||||
@ -1,32 +1,32 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>GNS3 controller API - ReDoc</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link type="text/css" rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
|
||||
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
|
||||
<!--
|
||||
ReDoc doesn't change outer page styles
|
||||
-->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
<title>GNS3 controller API - Swagger UI</title>
|
||||
</head>
|
||||
<body>
|
||||
<redoc spec-url="openapi.json"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
|
||||
<div id="swagger-ui">
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
|
||||
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: 'openapi.json',
|
||||
"dom_id": "#swagger-ui",
|
||||
"layout": "BaseLayout",
|
||||
"deepLinking": true,
|
||||
"showExtensions": true,
|
||||
"showCommonExtensions": true,
|
||||
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,35 +1,31 @@
|
||||
<!--
|
||||
SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
See LICENSE file for licensing information.
|
||||
-->
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<link type="text/css" rel="stylesheet" href="/static/swagger-ui.css">
|
||||
<title>GNS3 controller API - ReDoc</title>
|
||||
<!-- needed for adaptive design -->
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||
|
||||
<link rel="shortcut icon" href="https://fastapi.tiangolo.com/img/favicon.png">
|
||||
<title>GNS3 controller API - Swagger UI</title>
|
||||
<!--
|
||||
ReDoc doesn't change outer page styles
|
||||
-->
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui">
|
||||
</div>
|
||||
<script src="/static/swagger-ui-bundle.js"></script>
|
||||
<!-- `SwaggerUIBundle` is now available on the page -->
|
||||
<script>
|
||||
const ui = SwaggerUIBundle({
|
||||
url: 'openapi.json',
|
||||
oauth2RedirectUrl: window.location.origin + '/docs/oauth2-redirect',
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout",
|
||||
deepLinking: true,
|
||||
showExtensions: true,
|
||||
showCommonExtensions: true
|
||||
})
|
||||
</script>
|
||||
<noscript>
|
||||
ReDoc requires Javascript to function. Please enable it to browse the documentation.
|
||||
</noscript>
|
||||
<redoc spec-url="openapi.json"></redoc>
|
||||
<script src="https://cdn.jsdelivr.net/npm/redoc@2/bundles/redoc.standalone.js"> </script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -535,6 +535,8 @@ class Gns3Connector:
|
||||
|
||||
- `tags` (list): List of tags for the template (e.g.,
|
||||
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
|
||||
- `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet");
|
||||
preferred over the device_type:<type> tag
|
||||
- Any other template attributes supported by GNS3 API
|
||||
"""
|
||||
# Get existing template
|
||||
@ -571,6 +573,8 @@ class Gns3Connector:
|
||||
|
||||
- `tags` (list): List of tags for the template (e.g.,
|
||||
["device_type:cisco_ios_telnet", "platform:cisco_ios"])
|
||||
- `netmiko_device_type` (str): Netmiko device type (e.g. "cisco_ios_telnet");
|
||||
preferred over the device_type:<type> tag
|
||||
- Any other template attributes supported by GNS3 API
|
||||
|
||||
**Example:**
|
||||
@ -579,7 +583,8 @@ class Gns3Connector:
|
||||
>>> connector.create_template(
|
||||
... name="cisco_router",
|
||||
... template_type="dynamips",
|
||||
... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"]
|
||||
... tags=["device_type:cisco_ios_telnet", "platform:cisco_ios"],
|
||||
... netmiko_device_type="cisco_ios_telnet"
|
||||
... )
|
||||
```
|
||||
"""
|
||||
@ -1366,6 +1371,9 @@ class Node:
|
||||
template_id: str | None = None
|
||||
properties: Any | None = None
|
||||
tags: list[str] | None = None
|
||||
netmiko_device_type: str | None = None
|
||||
default_username: str | None = None
|
||||
default_password: str | None = None
|
||||
|
||||
template: str | None = None
|
||||
links: list[Link] = field(default_factory=list, repr=False)
|
||||
@ -2482,6 +2490,9 @@ class Project:
|
||||
"x": _n.x,
|
||||
"y": _n.y,
|
||||
"tags": _n.tags if _n.tags else [],
|
||||
"netmiko_device_type": _n.netmiko_device_type,
|
||||
"default_username": _n.default_username,
|
||||
"default_password": _n.default_password,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -100,6 +100,12 @@ class SkillsLoader:
|
||||
"""
|
||||
Load all device skills from YAML files.
|
||||
|
||||
Supports two layouts:
|
||||
- Single file: device/<device>.yaml
|
||||
- Split directory: device/<device>/_base.yaml + device/<device>/<topic>.yaml
|
||||
(topic files are merged into the base skill under "topics",
|
||||
keyed by their "topic" field)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping skill keys to skill definitions
|
||||
"""
|
||||
@ -107,33 +113,110 @@ class SkillsLoader:
|
||||
logger.error("PyYAML is not installed. Cannot load skills from YAML.")
|
||||
return {}
|
||||
|
||||
skills = {}
|
||||
skills: Dict[str, Dict[str, Any]] = {}
|
||||
device_dir = self.skills_dir / "device"
|
||||
|
||||
if not device_dir.exists():
|
||||
logger.warning(f"Device skills directory not found: {device_dir}")
|
||||
return {}
|
||||
|
||||
for yaml_file in device_dir.glob("*.yaml"):
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type") if isinstance(skill_data, dict) else None
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
for entry in sorted(device_dir.iterdir()):
|
||||
if entry.is_file() and entry.suffix == ".yaml":
|
||||
self._load_single_device_skill(skills, entry)
|
||||
elif entry.is_dir():
|
||||
self._load_split_device_skill(skills, entry)
|
||||
|
||||
logger.debug(f"Loaded {len(skills)} device skills from device directory")
|
||||
return skills
|
||||
|
||||
def _load_single_device_skill(self, skills: Dict[str, Dict[str, Any]], yaml_file: Path) -> None:
|
||||
"""
|
||||
Load a single-file device skill into the skills dictionary.
|
||||
"""
|
||||
try:
|
||||
skill_data = self._load_yaml(yaml_file)
|
||||
if not skill_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
return
|
||||
# Use device_type from YAML content as the key
|
||||
# Fallback to filename stem if device_type not present
|
||||
skill_key = skill_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = yaml_file.stem
|
||||
logger.warning(f"No device_type in {yaml_file}, using filename '{skill_key}' as key")
|
||||
skills[skill_key] = skill_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {yaml_file}: {e}")
|
||||
|
||||
def _load_split_device_skill(self, skills: Dict[str, Dict[str, Any]], device_path: Path) -> None:
|
||||
"""
|
||||
Load a split device skill (directory with _base.yaml + topic files).
|
||||
|
||||
The base file provides the device-level skill; every other YAML file
|
||||
in the directory is a protocol topic merged under "topics".
|
||||
"""
|
||||
base_file = device_path / "_base.yaml"
|
||||
if not base_file.exists():
|
||||
logger.error(f"No _base.yaml in device directory: {device_path}, skipping")
|
||||
return
|
||||
|
||||
try:
|
||||
base_data = self._load_yaml(base_file)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load skill from {base_file}: {e}")
|
||||
return
|
||||
if not base_data:
|
||||
logger.warning(f"Skipping empty YAML file: {base_file}")
|
||||
return
|
||||
|
||||
skill_key = base_data.get("device_type")
|
||||
if not skill_key:
|
||||
skill_key = device_path.name
|
||||
logger.warning(f"No device_type in {base_file}, using directory name '{skill_key}' as key")
|
||||
|
||||
# Seed topics from the base file (if any), then merge topic files
|
||||
base_topics = base_data.get("topics")
|
||||
topics: Dict[str, Any] = dict(base_topics) if isinstance(base_topics, dict) else {}
|
||||
|
||||
for yaml_file in sorted(device_path.glob("*.yaml")):
|
||||
if yaml_file.name == "_base.yaml":
|
||||
continue
|
||||
try:
|
||||
topic_data = self._load_yaml(yaml_file)
|
||||
if not topic_data:
|
||||
logger.warning(f"Skipping empty YAML file: {yaml_file}")
|
||||
continue
|
||||
|
||||
topic_device_type = topic_data.pop("device_type", None)
|
||||
if topic_device_type is not None and topic_device_type != skill_key:
|
||||
logger.error(
|
||||
f"device_type mismatch in {yaml_file}: '{topic_device_type}' "
|
||||
f"!= base '{skill_key}', skipping topic"
|
||||
)
|
||||
continue
|
||||
|
||||
topic_key = topic_data.pop("topic", None)
|
||||
if not topic_key:
|
||||
topic_key = yaml_file.stem
|
||||
logger.warning(f"No 'topic' field in {yaml_file}, using filename '{topic_key}'")
|
||||
if topic_key in topics:
|
||||
logger.warning(f"Duplicate topic '{topic_key}' in {device_path.name} (from {yaml_file}), overwriting")
|
||||
|
||||
# category/topics belong to the base skill only
|
||||
topic_data.pop("category", None)
|
||||
topic_data.pop("topics", None)
|
||||
|
||||
topics[topic_key] = topic_data
|
||||
logger.debug(f"Loaded device topic: {skill_key}/{topic_key} from {yaml_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load topic from {yaml_file}: {e}")
|
||||
|
||||
if topics:
|
||||
base_data["topics"] = topics
|
||||
skills[skill_key] = base_data
|
||||
logger.debug(f"Loaded device skill: {skill_key} from {device_path} ({len(topics)} topics)")
|
||||
|
||||
def load_feature_skills(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Load all feature skills from YAML files.
|
||||
|
||||
@ -221,11 +221,18 @@ class SkillsManager:
|
||||
logger.warning("No injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
|
||||
# Validate injection skills
|
||||
# Validate injection skills (drop invalid ones before merging)
|
||||
valid_injection_skills = {}
|
||||
for skill_key, skill_data in new_injection_skills.items():
|
||||
if not self.loader.validate_skill_format(skill_data):
|
||||
if self.loader.validate_skill_format(skill_data):
|
||||
valid_injection_skills[skill_key] = skill_data
|
||||
else:
|
||||
logger.error(f"Invalid skill format for {skill_key}, skipping")
|
||||
continue
|
||||
|
||||
if not valid_injection_skills:
|
||||
logger.warning("No valid injection skills loaded, keeping existing skills")
|
||||
return False
|
||||
new_injection_skills = valid_injection_skills
|
||||
|
||||
# Load new device skills from YAML files
|
||||
new_device_skills = self.loader.load_device_skills()
|
||||
|
||||
@ -373,6 +373,7 @@ def get_skill(
|
||||
category: str | None = None,
|
||||
detail: str = "full",
|
||||
issue: str | None = None,
|
||||
topic: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Get skill by device_type, with configurable detail level.
|
||||
@ -382,6 +383,9 @@ def get_skill(
|
||||
category: Optional category filter
|
||||
detail: Detail level - "index" (names only), "summary" (+desc/sev/diff), "full" (all)
|
||||
issue: Optional specific issue key to retrieve
|
||||
topic: Optional protocol topic to retrieve (split devices only).
|
||||
Topic bodies are NEVER included without an explicit topic
|
||||
request - all other detail levels return a topic index.
|
||||
|
||||
Returns:
|
||||
Skill dictionary (detail varies by level), or error dict
|
||||
@ -412,6 +416,28 @@ def get_skill(
|
||||
],
|
||||
}
|
||||
|
||||
topics = skill.get("topics", {})
|
||||
|
||||
# Single topic lookup (topic bodies stay out of every other response)
|
||||
if topic:
|
||||
topic_data = topics.get(topic)
|
||||
if not topic_data:
|
||||
for key, data in topics.items():
|
||||
if key.lower() == topic.lower():
|
||||
topic_data = data
|
||||
topic = key
|
||||
break
|
||||
if not topic_data:
|
||||
return {
|
||||
"error": f"Unknown topic '{topic}' in {device_type}",
|
||||
"available_topics": list(topics.keys()),
|
||||
}
|
||||
return {
|
||||
"device_type": device_type,
|
||||
"skill_name": skill.get("name"),
|
||||
"topic": {topic: topic_data},
|
||||
}
|
||||
|
||||
issues = skill.get("issues", {})
|
||||
|
||||
# Single issue lookup (most token-efficient)
|
||||
@ -429,17 +455,20 @@ def get_skill(
|
||||
}
|
||||
|
||||
if detail == "index":
|
||||
# Minimal: only issue keys and names (90%+ token savings)
|
||||
return {
|
||||
# Minimal: only issue/topic keys and names (90%+ token savings)
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
"issues": {k: v["name"] for k, v in issues.items()},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
if detail == "summary":
|
||||
# Moderate: names + description + severity + difficulty
|
||||
return {
|
||||
result = {
|
||||
"device_type": device_type,
|
||||
"name": skill.get("name"),
|
||||
"description": skill.get("description"),
|
||||
@ -453,14 +482,22 @@ def get_skill(
|
||||
for k, v in issues.items()
|
||||
},
|
||||
}
|
||||
if topics:
|
||||
result["topics"] = {
|
||||
k: {"name": v.get("name", k), "description": v.get("description", "")}
|
||||
for k, v in topics.items()
|
||||
}
|
||||
return result
|
||||
|
||||
# Full detail (original behavior)
|
||||
result = dict(skill)
|
||||
# Full detail: topic bodies are replaced by the topic index
|
||||
result = {k: v for k, v in skill.items() if k != "topics"}
|
||||
result["device_type"] = device_type
|
||||
if topics:
|
||||
result["topics"] = {k: v.get("name", k) for k, v in topics.items()}
|
||||
return result
|
||||
|
||||
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
def list_available_skills(category: str | None = None) -> list[dict[str, Any]]:
|
||||
"""List all available device/feature skills, optionally filtered by category."""
|
||||
skills = []
|
||||
for did, skill in SKILLS_REGISTRY.items():
|
||||
@ -470,12 +507,14 @@ def list_available_skills(category: str | None = None) -> list[dict[str, str]]:
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
else:
|
||||
skills.append({
|
||||
"device_type": did,
|
||||
"name": skill.get("name", did),
|
||||
"category": skill.get("category"),
|
||||
"topic_count": len(skill.get("topics", {})),
|
||||
})
|
||||
return skills
|
||||
|
||||
@ -642,15 +681,21 @@ class DeviceSkillsTool(BaseTool):
|
||||
Provides access to device command knowledge (VPCS), topology planning, etc.
|
||||
For fault injection skills, use the injection_skills tool.
|
||||
|
||||
INPUT FORMAT (JSON string):
|
||||
{
|
||||
"action": "get", # "get" (default) or "list"
|
||||
"device_type": "gns3_vpcs_telnet", # Required for action="get"
|
||||
"detail": "full" # "full" (default) for complete skill information
|
||||
}
|
||||
TOKEN-EFFICIENT USAGE:
|
||||
1. List devices: {"action": "list"}
|
||||
2. List topics of a device: {"device_type": "frr_vtysh", "detail": "index"}
|
||||
3. Get ONE protocol topic (devices with topics): {"device_type": "frr_vtysh", "topic": "bgp"}
|
||||
4. Devices without topics: {"device_type": "gns3_vpcs_telnet"}
|
||||
|
||||
For action="list":
|
||||
{"action": "list"} # Lists all available device/feature skills
|
||||
Topic bodies are NEVER returned without an explicit "topic" - fetching a
|
||||
device without one only returns its base skill plus the topic index, so
|
||||
always request the specific protocol topic before configuring it.
|
||||
|
||||
PARAMETERS:
|
||||
- action: "list" or "get" (default "get")
|
||||
- device_type: Required for action="get" (e.g., "frr_vtysh")
|
||||
- topic: Protocol topic key from the topic index (e.g., "ospf", "bgp")
|
||||
- detail: "index" | "summary" | "full" (default "full")
|
||||
"""
|
||||
|
||||
def _run(
|
||||
@ -693,8 +738,9 @@ class DeviceSkillsTool(BaseTool):
|
||||
category = params.get("category")
|
||||
detail = params.get("detail", "full")
|
||||
issue = params.get("issue")
|
||||
topic = params.get("topic")
|
||||
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue)
|
||||
skill = get_skill(device_type, category, detail=detail, issue=issue, topic=topic)
|
||||
|
||||
return json.dumps(skill, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
@ -62,7 +62,7 @@ def get_device_ports_from_topology(
|
||||
"groups": ["network_devices"], # For inheriting shared settings
|
||||
"connection_options": {
|
||||
"netmiko": {
|
||||
"extras": {"device_type": "huawei_telnet"} # Extracted from tags
|
||||
"extras": {"device_type": "huawei_telnet"} # netmiko_device_type field, tag fallback
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -102,18 +102,21 @@ def get_device_ports_from_topology(
|
||||
logger.warning("Device '%s' missing console_port", device_name)
|
||||
continue
|
||||
|
||||
# Extract device_type and platform from tags
|
||||
device_type = None
|
||||
# Extract device_type and platform.
|
||||
# Precedence: the netmiko_device_type field (node/template/appliance
|
||||
# level, set in GNS3 server >= 3.x) wins over the device_type:<type>
|
||||
# tag, which remains as a fallback.
|
||||
device_type = node_info.get("netmiko_device_type")
|
||||
platform = None
|
||||
tags = node_info.get("tags", [])
|
||||
|
||||
for tag in tags:
|
||||
if tag.startswith("device_type:"):
|
||||
if tag.startswith("device_type:") and device_type is None:
|
||||
device_type = tag.split(":", 1)[1].strip()
|
||||
elif tag.startswith("platform:"):
|
||||
platform = tag.split(":", 1)[1].strip()
|
||||
|
||||
# Return error if device_type not found in tags
|
||||
# Return error if device_type not found anywhere
|
||||
# Using a default would cause command execution errors
|
||||
if device_type is None:
|
||||
tested_device_types = (
|
||||
@ -121,8 +124,9 @@ def get_device_ports_from_topology(
|
||||
"gns3_ruijie_telnet (custom Ruijie)"
|
||||
)
|
||||
error_msg = (
|
||||
f"Device '{device_name}': device_type tag not found. "
|
||||
f"Please add 'device_type:<type>' tag to this device in GNS3. "
|
||||
f"Device '{device_name}': no device type found. "
|
||||
f"Set the template/node 'netmiko_device_type' field (e.g. 'cisco_ios_telnet'), "
|
||||
f"or add a 'device_type:<type>' tag to this device in GNS3. "
|
||||
f"To configure via Web UI: right-click the device -> Configure -> Tags -> add 'device_type:<type>'. "
|
||||
f"Tested types: {tested_device_types}. "
|
||||
f"Current tags: {tags}"
|
||||
@ -134,7 +138,7 @@ def get_device_ports_from_topology(
|
||||
continue
|
||||
|
||||
logger.debug(
|
||||
"Device '%s': extracted device_type=%s from tags",
|
||||
"Device '%s': device_type=%s",
|
||||
device_name,
|
||||
device_type,
|
||||
)
|
||||
@ -156,7 +160,7 @@ def get_device_ports_from_topology(
|
||||
# This is the Nornir best practice - each host has its own
|
||||
# connection configuration (device_type), while sharing common
|
||||
# settings (hostname, timeout) via group inheritance.
|
||||
hosts_data[device_name] = {
|
||||
host_entry = {
|
||||
"port": node_info["console_port"],
|
||||
"platform": platform,
|
||||
"groups": ["network_devices"], # For inheriting hostname, timeout, etc.
|
||||
@ -167,6 +171,16 @@ def get_device_ports_from_topology(
|
||||
},
|
||||
}
|
||||
|
||||
# Per-node default credentials (seeded from the template appliance
|
||||
# metadata) override the group's empty fallback. Only inject when
|
||||
# set, so credential-less devices keep inheriting the group values.
|
||||
if node_info.get("default_username"):
|
||||
host_entry["username"] = node_info["default_username"]
|
||||
if node_info.get("default_password"):
|
||||
host_entry["password"] = node_info["default_password"]
|
||||
|
||||
hosts_data[device_name] = host_entry
|
||||
|
||||
logger.info("Returning %d device port mappings", len(hosts_data))
|
||||
|
||||
return hosts_data
|
||||
|
||||
@ -192,6 +192,33 @@ async def create_ethernet_switch_nio(
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
response_model=schemas.UDPNIO,
|
||||
)
|
||||
async def update_ethernet_switch_nio(
|
||||
*,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
nio_data: schemas.UDPNIO,
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> schemas.UDPNIO:
|
||||
"""
|
||||
Update a NIO (Network Input/Output) on the node: re-apply the packet
|
||||
filters and traffic-insight markers carried by the NIO onto the port's
|
||||
uBridge relay. The adapter number on the switch is always 0.
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
nio.filters.clear()
|
||||
if nio_data.filters:
|
||||
nio.filters = nio_data.filters
|
||||
nio.markers = nio_data.markers or {}
|
||||
await node.update_nio(port_number, nio)
|
||||
return nio.asdict()
|
||||
|
||||
|
||||
@router.delete("/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ethernet_switch_nio(
|
||||
*,
|
||||
@ -257,3 +284,75 @@ async def stream_pcap_file(
|
||||
nio = node.get_nio(port_number)
|
||||
stream = Builtin.instance().stream_pcap_file(nio, node.project.id)
|
||||
return StreamingResponse(stream, media_type="application/vnd.tcpdump.pcap")
|
||||
|
||||
|
||||
@router.put("/{node_id}/markers/{marker_name}")
|
||||
async def toggle_ethernet_switch_marker(
|
||||
marker_name: str,
|
||||
toggle_data: schemas.MarkerToggle,
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Toggle a marker filter on/off without an NIO rebuild (ubridge contract §3.2).
|
||||
"""
|
||||
|
||||
if not any(n == marker_name for (n, lid) in node._marker_filter_bridges):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Marker '{marker_name}' is not installed on this node",
|
||||
)
|
||||
await node._ubridge_set_marker_filter_state(marker_name, toggle_data.enabled)
|
||||
return {"marker_name": marker_name, "enabled": toggle_data.enabled}
|
||||
|
||||
|
||||
@router.post("/{node_id}/markers/pause", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def pause_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_pause()
|
||||
|
||||
|
||||
@router.post("/{node_id}/markers/resume", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def resume_ethernet_switch_markers(node: EthernetSwitch = Depends(dep_node)) -> None:
|
||||
|
||||
await node._ubridge_marker_resume()
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/markers/{marker_name}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
async def delete_ethernet_switch_marker_capture(
|
||||
*,
|
||||
marker_name: str,
|
||||
adapter_number: int = Path(..., ge=0, le=0),
|
||||
port_number: int,
|
||||
link_id: str = "",
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> None:
|
||||
"""
|
||||
Delete a marker's capture pcap (called by the controller when the marker is
|
||||
removed) so the file is cleaned up even with the switch stopped. Also drops
|
||||
the marker from the port NIO's cached spec so a switch restart won't
|
||||
reinstall it (and recreate an empty pcap). The adapter number is always 0.
|
||||
"""
|
||||
|
||||
nio = node.get_nio(port_number)
|
||||
await node.delete_marker_capture(marker_name, link_id, nio)
|
||||
|
||||
|
||||
@router.put("/{node_id}/markers/{marker_name}/rebuild")
|
||||
async def rebuild_ethernet_switch_marker(
|
||||
marker_name: str,
|
||||
rebuild_data: schemas.MarkerRebuild,
|
||||
node: EthernetSwitch = Depends(dep_node)
|
||||
) -> dict:
|
||||
"""
|
||||
Re-install a single marker filter with new BPF/tag/direction (delete + add,
|
||||
no bridge reset) so sibling markers' pcaps stay open.
|
||||
"""
|
||||
|
||||
await node.rebuild_marker_filter(
|
||||
marker_name, rebuild_data.link_id, rebuild_data.bpf,
|
||||
rebuild_data.tag, rebuild_data.direction, rebuild_data.enabled,
|
||||
)
|
||||
return {"marker_name": marker_name}
|
||||
|
||||
@ -61,6 +61,7 @@ from . import acl
|
||||
from . import pools
|
||||
from . import privileges
|
||||
from . import api_keys
|
||||
from . import netmiko
|
||||
|
||||
from .dependencies.authentication import get_current_active_user
|
||||
|
||||
@ -159,6 +160,12 @@ router.include_router(
|
||||
tags=["Appliances"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
netmiko.router,
|
||||
prefix="/netmiko",
|
||||
tags=["Netmiko"]
|
||||
)
|
||||
|
||||
router.include_router(
|
||||
pools.router,
|
||||
prefix="/pools",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import bcrypt
|
||||
|
||||
@ -176,7 +177,14 @@ async def get_current_active_user_from_websocket(
|
||||
return user
|
||||
|
||||
except HTTPException as e:
|
||||
err_msg = f"Could not authenticate while connecting to controller WebSocket: {e.detail}"
|
||||
# Fingerprint the received token so clients can compare it against the fingerprint
|
||||
# returned when the token was issued (e.g. token_sha256_prefix from the
|
||||
# node_console_info MCP tool) and detect copy corruption on their side.
|
||||
token_sha256_prefix = hashlib.sha256(token.encode()).hexdigest()[:8]
|
||||
err_msg = (
|
||||
f"Could not authenticate while connecting to controller WebSocket: {e.detail} "
|
||||
f"(received token sha256 prefix: {token_sha256_prefix})"
|
||||
)
|
||||
websocket_error = {"action": "log.error", "event": {"message": err_msg}}
|
||||
await websocket.send_json(websocket_error)
|
||||
log.error(err_msg)
|
||||
|
||||
102
gns3server/api/routes/controller/netmiko.py
Normal file
102
gns3server/api/routes/controller/netmiko.py
Normal file
@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2020 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/>.
|
||||
|
||||
"""
|
||||
API routes for Netmiko metadata.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from gns3server import schemas
|
||||
|
||||
from .dependencies.authentication import get_current_active_user
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Computed once per process: the list only changes if the installed
|
||||
# Netmiko library changes, which requires a server restart anyway.
|
||||
_device_types_cache: Optional[schemas.NetmikoDeviceTypeList] = None
|
||||
|
||||
|
||||
def _load_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
|
||||
"""
|
||||
Build the list of device types supported by the installed Netmiko library.
|
||||
|
||||
Imports Netmiko and the GNS3-copilot custom drivers (which register
|
||||
additional 'gns3_*' device types into Netmiko's CLASS_MAPPER on import),
|
||||
then filters out the '<type>_ssh' aliases and the 'autodetect'
|
||||
pseudo device type.
|
||||
|
||||
Raises:
|
||||
ImportError: If Netmiko is not installed (ai-features extra).
|
||||
"""
|
||||
|
||||
import importlib
|
||||
|
||||
import netmiko
|
||||
# "from netmiko import ssh_dispatcher" is shadowed by a function of the same
|
||||
# name in netmiko's __init__, so import the module through importlib
|
||||
sd = importlib.import_module("netmiko.ssh_dispatcher")
|
||||
|
||||
# Importing the package auto-registers all custom drivers (in case nothing
|
||||
# imported them yet); failures are logged by the package itself, do not
|
||||
# fail the whole endpoint.
|
||||
try:
|
||||
from gns3server.agent.gns3_copilot.utils import custom_netmiko # noqa: F401
|
||||
except Exception as e:
|
||||
log.warning(f"Could not register GNS3-copilot custom Netmiko drivers: {e}")
|
||||
|
||||
# Custom drivers all use the 'gns3_' prefix by convention, which is more
|
||||
# reliable than diffing CLASS_MAPPER around the import: the drivers may
|
||||
# already be registered when the copilot package got imported at startup.
|
||||
device_types = [
|
||||
schemas.NetmikoDeviceType(name=name, telnet="_telnet" in name, custom=name.startswith("gns3_"))
|
||||
for name in sorted(sd.CLASS_MAPPER.keys())
|
||||
if not name.endswith("_ssh") and name != "autodetect"
|
||||
]
|
||||
return schemas.NetmikoDeviceTypeList(netmiko_version=netmiko.__version__, device_types=device_types)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device_types",
|
||||
response_model=schemas.NetmikoDeviceTypeList,
|
||||
dependencies=[Depends(get_current_active_user)]
|
||||
)
|
||||
def get_netmiko_device_types() -> schemas.NetmikoDeviceTypeList:
|
||||
"""
|
||||
Return the device types supported by the Netmiko library installed on this server.
|
||||
|
||||
Required privilege: None (authenticated users only)
|
||||
"""
|
||||
|
||||
global _device_types_cache
|
||||
if _device_types_cache is None:
|
||||
try:
|
||||
_device_types_cache = _load_netmiko_device_types()
|
||||
except ImportError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_501_NOT_IMPLEMENTED,
|
||||
detail="Netmiko is not available. Install AI dependencies with: pip install gns3-server[ai-features]"
|
||||
)
|
||||
return _device_types_cache
|
||||
@ -694,13 +694,19 @@ async def ws_console(
|
||||
async def ws_receive(ws_console_compute):
|
||||
"""
|
||||
Receive WebSocket data from client and forward to compute console WebSocket.
|
||||
Text frames carry terminal data; binary frames carry client control
|
||||
messages (e.g. terminal size), forwarded as-is.
|
||||
"""
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
if data:
|
||||
await ws_console_compute.send_str(data)
|
||||
msg = await websocket.receive()
|
||||
if msg["type"] == "websocket.disconnect":
|
||||
break
|
||||
if "text" in msg and msg["text"]:
|
||||
await ws_console_compute.send_str(msg["text"])
|
||||
elif "bytes" in msg and msg["bytes"]:
|
||||
await ws_console_compute.send_bytes(msg["bytes"])
|
||||
except WebSocketDisconnect:
|
||||
await ws_console_compute.close()
|
||||
log.info(
|
||||
|
||||
@ -213,14 +213,16 @@ async def _resolve_token(token: str) -> str | None:
|
||||
|
||||
Returns None if the token is invalid.
|
||||
"""
|
||||
# Try JWT first
|
||||
try:
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_jwt_username_var.set(token_data.username)
|
||||
_jwt_token_version_var.set(token_data.token_version)
|
||||
return token
|
||||
except Exception:
|
||||
pass
|
||||
# API keys (gns3_...) are never valid JWTs — skip the JWT attempt for them
|
||||
# so it doesn't log a spurious "JWT rejected" line on every API-key connection.
|
||||
if not token.startswith("gns3_"):
|
||||
try:
|
||||
token_data = auth_service.get_token_data(token)
|
||||
_jwt_username_var.set(token_data.username)
|
||||
_jwt_token_version_var.set(token_data.token_version)
|
||||
return token
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try API key — format: gns3_<api_key_id>_<random_secret> → O(1) lookup
|
||||
if token.startswith("gns3_") and _app is not None:
|
||||
|
||||
@ -25,6 +25,7 @@ via Gns3Connector (from custom_gns3fy).
|
||||
from typing import Any
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from gns3server.services import auth_service
|
||||
@ -312,6 +313,12 @@ def get_node_console_info_handler(params: dict[str, Any], gns3_ctx: dict[str, An
|
||||
"ws_url": ws_url,
|
||||
"command": f"websocat -t --no-close {ws_url}",
|
||||
}
|
||||
if ws_token:
|
||||
# Fingerprint of the minted token: compare it against what actually reached the
|
||||
# server (logged on WebSocket auth rejection) to detect copy corruption, and
|
||||
# re-request the URL once token_ttl_seconds has elapsed.
|
||||
result["token_sha256_prefix"] = hashlib.sha256(ws_token.encode()).hexdigest()[:8]
|
||||
result["token_ttl_seconds"] = 600
|
||||
if console_type in ("vnc",):
|
||||
result["vnc_url"] = f"/v3/projects/{project_id}/nodes/{node_id}/console/vnc?token={gns3_ctx['jwt_token']}"
|
||||
return result
|
||||
|
||||
@ -19,6 +19,9 @@ import os
|
||||
import stat
|
||||
import shutil
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import struct
|
||||
import tempfile
|
||||
import psutil
|
||||
import platform
|
||||
@ -561,6 +564,51 @@ class BaseNode:
|
||||
log.warning(f"Cannot connect to node {self.name} console server: {e}")
|
||||
return
|
||||
|
||||
def _parse_terminal_size_message(data: bytes):
|
||||
"""
|
||||
Binary control frames sent by WebSocket console clients to propagate
|
||||
their terminal geometry: {"cols": int, "rows": int}. Terminal data
|
||||
travels as text frames (xterm.js AttachAddon), so binary frames are
|
||||
an unambiguous side channel. Returns (cols, rows) or None.
|
||||
"""
|
||||
|
||||
try:
|
||||
message = json.loads(data.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return None
|
||||
if not isinstance(message, dict):
|
||||
return None
|
||||
cols, rows = message.get("cols"), message.get("rows")
|
||||
if (
|
||||
isinstance(cols, int) and not isinstance(cols, bool)
|
||||
and isinstance(rows, int) and not isinstance(rows, bool)
|
||||
and 2 <= cols <= 5000
|
||||
and 2 <= rows <= 100000
|
||||
):
|
||||
return cols, rows
|
||||
return None
|
||||
|
||||
async def resize_console(cols: int, rows: int) -> None:
|
||||
"""
|
||||
Propagate a client terminal resize to the node console stream:
|
||||
SSH channels use a pty request update, telnet-based consoles
|
||||
(including docker_exec) speak a NAWS subnegotiation to the console
|
||||
telnet server, which resizes the underlying stream (e.g. the
|
||||
docker exec pty).
|
||||
"""
|
||||
|
||||
if self._console_type == "ssh":
|
||||
with contextlib.suppress(AttributeError):
|
||||
ssh_process.change_terminal_size(cols, rows)
|
||||
else:
|
||||
telnet_writer.write(
|
||||
bytes([255, 251, 31]) # IAC WILL NAWS
|
||||
+ bytes([255, 250, 31]) # IAC SB NAWS
|
||||
+ struct.pack("!HH", cols, rows).replace(b"\xff", b"\xff\xff")
|
||||
+ bytes([255, 240]) # IAC SE
|
||||
)
|
||||
await telnet_writer.drain()
|
||||
|
||||
async def ws_forward(telnet_writer):
|
||||
|
||||
try:
|
||||
@ -571,6 +619,14 @@ class BaseNode:
|
||||
if "text" in msg and msg["text"]:
|
||||
data = msg["text"].encode()
|
||||
elif "bytes" in msg and msg["bytes"]:
|
||||
size = _parse_terminal_size_message(msg["bytes"])
|
||||
if size is not None:
|
||||
log.debug(
|
||||
f"Console WebSocket client {websocket.client.host}:{websocket.client.port}"
|
||||
f" resized terminal to {size[0]}x{size[1]}"
|
||||
)
|
||||
await resize_console(*size)
|
||||
continue
|
||||
data = msg["bytes"]
|
||||
else:
|
||||
continue
|
||||
|
||||
@ -129,8 +129,10 @@ class DockerVM(BaseNode):
|
||||
if ":" not in image:
|
||||
image = f"{image}:latest"
|
||||
self._image = image
|
||||
self._start_command = start_command
|
||||
self._environment = environment
|
||||
# assign through the property setters so creation and updates apply
|
||||
# the same value normalization (e.g. "" -> None)
|
||||
self.start_command = start_command
|
||||
self.environment = environment
|
||||
self._cid = None
|
||||
self._ethernet_adapters = []
|
||||
self._temporary_directory = None
|
||||
@ -138,10 +140,10 @@ class DockerVM(BaseNode):
|
||||
self._vnc_process = None
|
||||
self._vncconfig_process = None
|
||||
self._console_resolution = console_resolution
|
||||
self._console_http_path = console_http_path
|
||||
self.console_http_path = console_http_path
|
||||
self._console_http_port = console_http_port
|
||||
self._console_websocket = None
|
||||
self._extra_hosts = extra_hosts
|
||||
self.extra_hosts = extra_hosts
|
||||
self._extra_volumes = extra_volumes or []
|
||||
self._extra_configs = extra_configs or []
|
||||
self._memory = memory
|
||||
@ -288,7 +290,9 @@ class DockerVM(BaseNode):
|
||||
|
||||
@console_http_path.setter
|
||||
def console_http_path(self, path):
|
||||
self._console_http_path = path
|
||||
# the canonical "no path" value is "/" so that "", None and "/"
|
||||
# all compare equal in the update diff
|
||||
self._console_http_path = path or "/"
|
||||
|
||||
@property
|
||||
def console_http_port(self):
|
||||
@ -304,7 +308,8 @@ class DockerVM(BaseNode):
|
||||
|
||||
@environment.setter
|
||||
def environment(self, command):
|
||||
self._environment = command
|
||||
# "" and None are the same "no environment variables" value
|
||||
self._environment = command or None
|
||||
|
||||
@property
|
||||
def extra_hosts(self):
|
||||
@ -312,7 +317,8 @@ class DockerVM(BaseNode):
|
||||
|
||||
@extra_hosts.setter
|
||||
def extra_hosts(self, extra_hosts):
|
||||
self._extra_hosts = extra_hosts
|
||||
# "" and None are the same "no extra hosts" value
|
||||
self._extra_hosts = extra_hosts or None
|
||||
|
||||
@property
|
||||
def extra_volumes(self):
|
||||
@ -373,6 +379,51 @@ class DockerVM(BaseNode):
|
||||
result = await self.manager.query("GET", f"images/{self._image}/json")
|
||||
return result
|
||||
|
||||
def _persistent_volume_list(self, image_info, include_network_config=True):
|
||||
"""
|
||||
The in-container paths that get a persistent volume mount: GNS3's
|
||||
/etc/network, every VOLUME declared by the image and the node's
|
||||
extra_volumes. Overlapping paths are de-duplicated so that a path
|
||||
covered by a more general volume is not mounted twice.
|
||||
|
||||
:param include_network_config: include GNS3's hardcoded /etc/network
|
||||
volume (consumed by init.sh; subclasses that skip init.sh pass
|
||||
False so the list matches the mounts they actually create).
|
||||
"""
|
||||
|
||||
for volume in self._extra_volumes:
|
||||
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
|
||||
raise DockerError(
|
||||
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
|
||||
)
|
||||
volumes = []
|
||||
if include_network_config:
|
||||
volumes.append("/etc/network")
|
||||
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
|
||||
volumes.extend(self._extra_volumes)
|
||||
|
||||
deduped = []
|
||||
# define lambdas for validation checks
|
||||
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
|
||||
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
|
||||
for volume in volumes:
|
||||
# remove any mount that is equal or more specific, then append this one
|
||||
deduped = list(filter(lambda v: not generalises(volume, v), deduped))
|
||||
# if there is nothing more general, append this mount
|
||||
if not [v for v in deduped if generalises(v, volume)]:
|
||||
deduped.append(volume)
|
||||
return deduped
|
||||
|
||||
async def _prepare_volumes(self, image_info):
|
||||
"""
|
||||
Hook: prepare persistent volumes before the container (and its
|
||||
mounts) are created. The default implementation does nothing —
|
||||
init.sh performs the first-copy seeding inside the container at
|
||||
boot. Subclasses that skip init.sh override this to seed the host
|
||||
directories from the image instead, so their mounts can be bound
|
||||
directly at the real in-container paths from the very first process.
|
||||
"""
|
||||
|
||||
def _mount_binds(self, image_info):
|
||||
"""
|
||||
:returns: Return the path that we need to map to local folders
|
||||
@ -396,26 +447,7 @@ class DockerVM(BaseNode):
|
||||
self._create_network_config()
|
||||
except OSError as e:
|
||||
raise DockerError(f"Could not create network config in the container: {e}")
|
||||
volumes = ["/etc/network"]
|
||||
|
||||
volumes.extend((image_info.get("Config", {}).get("Volumes") or {}).keys())
|
||||
for volume in self._extra_volumes:
|
||||
if not volume.strip() or volume[0] != "/" or volume.find("..") >= 0:
|
||||
raise DockerError(
|
||||
f"Persistent volume '{volume}' has invalid format. It must start with a '/' and not contain '..'."
|
||||
)
|
||||
volumes.extend(self._extra_volumes)
|
||||
|
||||
self._volumes = []
|
||||
# define lambdas for validation checks
|
||||
nf = lambda x: re.sub(r"//+", "/", (x if x.endswith("/") else x + "/"))
|
||||
generalises = lambda v1, v2: nf(v2).startswith(nf(v1))
|
||||
for volume in volumes:
|
||||
# remove any mount that is equal or more specific, then append this one
|
||||
self._volumes = list(filter(lambda v: not generalises(volume, v), self._volumes))
|
||||
# if there is nothing more general, append this mount
|
||||
if not [v for v in self._volumes if generalises(v, volume)]:
|
||||
self._volumes.append(volume)
|
||||
self._volumes = self._persistent_volume_list(image_info)
|
||||
|
||||
for volume in self._volumes:
|
||||
source = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
|
||||
@ -538,6 +570,10 @@ class DockerVM(BaseNode):
|
||||
f"(max available is {available_cpus} CPUs)"
|
||||
)
|
||||
|
||||
# Prepare persistent volume content before the container and its
|
||||
# mounts are created (no-op for the init.sh path).
|
||||
await self._prepare_volumes(image_infos)
|
||||
|
||||
params = {
|
||||
"Hostname": self._name,
|
||||
"Image": self._image,
|
||||
|
||||
@ -48,13 +48,20 @@ class VendorDockerVM(DockerVM):
|
||||
(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.
|
||||
its own entrypoint (e.g. SR Linux's ``sr_linux``). Persistent volumes
|
||||
are seeded host-side and bound directly at their real in-container
|
||||
paths at create time (see ``_prepare_volumes`` / ``_mount_binds``), so
|
||||
the NOS sees its saved configuration from the very first process —
|
||||
no post-start mount pass that could race the NOS reading its config.
|
||||
* ``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``).
|
||||
* ``GNS3_CONSOLE_RESIZE=0`` — ignore client-driven console resizes
|
||||
(WS terminal-size frames / telnet NAWS). Set for CLIs that page on the
|
||||
PTY window size (IOS-XR): the exec PTY must stay at the tall
|
||||
no-NAWS default for every client, including concurrent netmiko
|
||||
sessions on the shared exec.
|
||||
* ``GNS3_STOP_TIMEOUT=60`` — SIGTERM grace period in seconds when stopping
|
||||
the container (default 60; Docker SIGKILLs once it expires).
|
||||
"""
|
||||
@ -77,6 +84,7 @@ class VendorDockerVM(DockerVM):
|
||||
self._gns3_init = True
|
||||
self._interface_names = []
|
||||
self._console_cmd = None
|
||||
self._console_resize = True
|
||||
self._stop_timeout = 60
|
||||
if self._environment:
|
||||
for _line in self._environment.splitlines():
|
||||
@ -89,6 +97,8 @@ class VendorDockerVM(DockerVM):
|
||||
]
|
||||
elif _line.startswith("GNS3_CONSOLE_CMD="):
|
||||
self._console_cmd = _line.split("=", 1)[1].strip()
|
||||
elif _line.startswith("GNS3_CONSOLE_RESIZE="):
|
||||
self._console_resize = _line.split("=", 1)[1].strip().lower() not in ("0", "false", "no")
|
||||
elif _line.startswith("GNS3_STOP_TIMEOUT="):
|
||||
try:
|
||||
timeout = int(_line.split("=", 1)[1].strip())
|
||||
@ -119,6 +129,17 @@ class VendorDockerVM(DockerVM):
|
||||
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.
|
||||
|
||||
Additionally, the persistent volumes are bound directly at their
|
||||
real in-container paths instead of /gns3volumes<volume>. With
|
||||
init.sh skipped there is no in-container mount pass, so a volume
|
||||
bound at /gns3volumes would only be moved into place by a post-start
|
||||
``docker exec`` — racing the NOS reading its startup configuration
|
||||
(an SR Linux node booted factory whenever the exec lost that race,
|
||||
e.g. on the concurrent node starts of a project reload). Binding at
|
||||
the real path is safe because the content is seeded host-side before
|
||||
the container is created (see _prepare_volumes): the image's files
|
||||
are never shadowed by an empty mount.
|
||||
"""
|
||||
binds = super()._mount_binds(image_info)
|
||||
if self._gns3_init:
|
||||
@ -128,7 +149,115 @@ class VendorDockerVM(DockerVM):
|
||||
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
|
||||
|
||||
# Re-target the volume binds from /gns3volumes<volume> to <volume>.
|
||||
retargeted = []
|
||||
for bind in binds:
|
||||
target = bind.get("Target", "")
|
||||
if target.startswith("/gns3volumes"):
|
||||
volume = target[len("/gns3volumes"):]
|
||||
if volume in self._volumes:
|
||||
bind = {**bind, "Target": volume}
|
||||
retargeted.append(bind)
|
||||
return retargeted
|
||||
|
||||
async def _prepare_volumes(self, image_info):
|
||||
"""
|
||||
Override: for SKIP_INIT containers, seed every persistent volume's
|
||||
host directory with the image's original content *before* the
|
||||
container is created. This is the host-side replacement of init.sh's
|
||||
first-copy: because the volume is then bound directly at its real
|
||||
in-container path (see _mount_binds), the seed must exist first or
|
||||
the NOS would boot with an empty config directory.
|
||||
|
||||
``.gns3_perms`` doubles as the seeded marker: a volume that has it
|
||||
(every node that ever started, on any GNS3 version) is never
|
||||
re-seeded — a re-seed would overwrite the node's saved
|
||||
configuration with the factory image content.
|
||||
"""
|
||||
if self._gns3_init:
|
||||
return
|
||||
volumes = self._persistent_volume_list(image_info, include_network_config=False)
|
||||
to_seed = []
|
||||
for volume in volumes:
|
||||
host_dir = os.path.join(self.working_dir, os.path.relpath(volume, "/"))
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
if not os.path.exists(os.path.join(host_dir, ".gns3_perms")):
|
||||
to_seed.append((volume, host_dir))
|
||||
if not to_seed:
|
||||
return
|
||||
seed_cid = await self._create_seed_container()
|
||||
try:
|
||||
for volume, host_dir in to_seed:
|
||||
await self._seed_volume_from_container(seed_cid, volume, host_dir)
|
||||
# Write the marker only after the copy attempt, mirroring
|
||||
# init.sh: a volume without it is (re)seeded on the next
|
||||
# create(), so a partial seed self-heals.
|
||||
open(os.path.join(host_dir, ".gns3_perms"), "a").close()
|
||||
finally:
|
||||
await self._remove_seed_container(seed_cid)
|
||||
|
||||
async def _create_seed_container(self):
|
||||
"""
|
||||
A throwaway ``docker create`` container (nothing executes) used as
|
||||
the copy source for seeding persistent volumes with the image's
|
||||
original content.
|
||||
"""
|
||||
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
"docker", "create", self._image,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as e:
|
||||
raise DockerError(f"Could not seed persistent volumes for '{self._name}': {e}")
|
||||
stdout, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
raise DockerError(
|
||||
f"Could not create a seeding container for image '{self._image}': "
|
||||
f"{stderr.decode(errors='replace').strip()}"
|
||||
)
|
||||
return stdout.decode().strip()
|
||||
|
||||
async def _seed_volume_from_container(self, seed_cid, volume, host_dir):
|
||||
"""
|
||||
Copy one volume's original content from the seeding container to its
|
||||
host directory with ``docker cp -a`` (preserves modes/ownership; no
|
||||
dependency on tools inside the image).
|
||||
"""
|
||||
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
"docker", "cp", "-a", f"{seed_cid}:{volume}/.", host_dir + "/",
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError as e:
|
||||
raise DockerError(f"Could not seed persistent volume '{volume}' for '{self._name}': {e}")
|
||||
_, stderr = await process.communicate()
|
||||
if process.returncode != 0:
|
||||
# A path the image does not contain (e.g. XRd's /xr-storage-shadow)
|
||||
# is not an error: the volume starts empty. Same tolerance as
|
||||
# init.sh's first copy (cp -a ... 2>/dev/null).
|
||||
log.info(
|
||||
"Persistent volume '%s' on '%s' not seedable from image '%s' (%s); starting empty",
|
||||
volume, self._name, self._image, stderr.decode(errors="replace").strip(),
|
||||
)
|
||||
return
|
||||
log.info("Seeded persistent volume '%s' for '%s' from image '%s'", volume, self._name, self._image)
|
||||
|
||||
async def _remove_seed_container(self, seed_cid):
|
||||
"""
|
||||
Best-effort removal of the seeding container.
|
||||
"""
|
||||
|
||||
try:
|
||||
process = await asyncio.subprocess.create_subprocess_exec(
|
||||
"docker", "rm", "-f", seed_cid,
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
await process.communicate()
|
||||
|
||||
def _prepare_init_and_interface_env(self, params):
|
||||
"""
|
||||
@ -201,23 +330,22 @@ class VendorDockerVM(DockerVM):
|
||||
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.
|
||||
# Persistent volumes are seeded and bound directly at create time
|
||||
# (see _prepare_volumes / _mount_binds), so there is no post-start
|
||||
# bridge to run. Fix host-side ownership right away so the
|
||||
# controller can read project files while the node runs, and reset
|
||||
# the "fixed" flag: 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.
|
||||
containers. The persistent volumes are Docker bind mounts created
|
||||
with the container (see _mount_binds), so the in-container paths
|
||||
resolve to the host-side files for the container's whole lifetime —
|
||||
no /gns3volumes aliasing is needed.
|
||||
|
||||
The busybox script runs inside the container as root (a host-side
|
||||
GNS3 process may be unprivileged and cannot chown root-owned files).
|
||||
@ -240,7 +368,7 @@ class VendorDockerVM(DockerVM):
|
||||
|
||||
uid, gid = os.getuid(), os.getgid()
|
||||
for volume in self._volumes:
|
||||
target = f"/gns3volumes{volume}"
|
||||
target = volume
|
||||
log.debug("Docker container '%s' fix ownership on %s", self._name, target)
|
||||
try:
|
||||
# chown prefers the container's own coreutils over /gns3/bin/busybox:
|
||||
@ -276,61 +404,6 @@ class VendorDockerVM(DockerVM):
|
||||
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; '
|
||||
# chown: prefer the container's coreutils, fall back to busybox
|
||||
# (see _fix_permissions -- static busybox chown aborts on
|
||||
# mismatched-glibc NOS images like XRd).
|
||||
f' ( command -v chown >/dev/null 2>&1 && chown -h "$OWNER:$GROUP" "$FILE" || /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
|
||||
@ -357,7 +430,13 @@ class VendorDockerVM(DockerVM):
|
||||
Command from GNS3_CONSOLE_CMD.
|
||||
"""
|
||||
|
||||
telnet = _LazyExecTelnetServer(self, self.manager, self._cid, self._console_cmd or "/bin/sh")
|
||||
telnet = _LazyExecTelnetServer(
|
||||
self,
|
||||
self.manager,
|
||||
self._cid,
|
||||
self._console_cmd or "/bin/sh",
|
||||
allow_resize=self._console_resize,
|
||||
)
|
||||
try:
|
||||
self._telnet_servers.append(
|
||||
await telnet.start(self._manager.port_manager.console_host, self.console)
|
||||
@ -384,7 +463,7 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
||||
CPR, producing a blank/degraded screen on reconnect.
|
||||
"""
|
||||
|
||||
def __init__(self, vm, manager, cid, command):
|
||||
def __init__(self, vm, manager, cid, command, allow_resize=True):
|
||||
super().__init__(
|
||||
reader=None,
|
||||
writer=None,
|
||||
@ -397,7 +476,9 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
||||
self._manager = manager
|
||||
self._cid = cid
|
||||
self._command = command
|
||||
self._allow_resize = allow_resize
|
||||
self._exec_id = None
|
||||
self._client_size = None # size received while no exec existed yet
|
||||
self._broadcast_task = None
|
||||
self._lock = asyncio.Lock()
|
||||
self._log_name = f"docker_exec console '{vm.name}'"
|
||||
@ -412,16 +493,42 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
||||
return False
|
||||
return True
|
||||
|
||||
async def _disconnect_client(self, network_writer):
|
||||
await super()._disconnect_client(network_writer)
|
||||
# When the last client leaves, restore the tall no-NAWS default: a
|
||||
# browser client resizes the exec to its own geometry (WS terminal
|
||||
# size control frames -> NAWS), and the next non-NAWS client (netmiko,
|
||||
# bare telnet) connecting to the still-live exec would otherwise
|
||||
# inherit it and hit PTY-window paging (the IOS-XR --More-- trap).
|
||||
if self._exec_id and not await self._get_connections_snapshot():
|
||||
with contextlib.suppress(Exception):
|
||||
self._client_size = None
|
||||
await self._resize_exec(511, 10000)
|
||||
|
||||
async def _resize_exec(self, columns, rows):
|
||||
if not self._exec_id:
|
||||
# No exec yet (first client still inside client_connected_hook):
|
||||
# remember the size — the hook applies it right after creation
|
||||
# instead of the tall default, so it doesn't get overwritten.
|
||||
self._client_size = (columns, rows)
|
||||
return
|
||||
try:
|
||||
await self._manager.query(
|
||||
"POST",
|
||||
f"exec/{self._exec_id}/resize",
|
||||
params={"h": str(rows), "w": str(columns)},
|
||||
)
|
||||
except DockerError:
|
||||
pass
|
||||
|
||||
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
|
||||
# Client-driven resize (WS terminal-size control frames, telnet NAWS).
|
||||
# Ignored for paging CLIs (GNS3_CONSOLE_RESIZE=0): with the exec shared
|
||||
# by all clients, one browser resize would break concurrent netmiko
|
||||
# sessions that rely on the tall no-paging geometry.
|
||||
if not self._allow_resize:
|
||||
return
|
||||
await self._resize_exec(columns, rows)
|
||||
|
||||
async def run(self, network_reader, network_writer):
|
||||
"""Catch and log any exception that kills the client session."""
|
||||
@ -504,7 +611,18 @@ class _LazyExecTelnetServer(AsyncioTelnetServer):
|
||||
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
|
||||
# Tall/wide default geometry before any NAWS arrives: a
|
||||
# 24-row PTY makes CLIs that page on the PTY window size
|
||||
# (e.g. the IOS-XR pager) park at --More-- for clients
|
||||
# that never negotiate NAWS (netmiko, bare telnet).
|
||||
# Width 511 matches netmiko's 'terminal width 511'.
|
||||
# A size already pushed by this client (WS terminal-size
|
||||
# control frames -> NAWS, racing the exec creation) wins
|
||||
# over the default.
|
||||
if self._client_size:
|
||||
await self._resize_exec(*self._client_size)
|
||||
else:
|
||||
await self._resize_exec(511, 10000)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
|
||||
@ -71,6 +71,17 @@ class Appliance:
|
||||
@property
|
||||
def type(self):
|
||||
|
||||
if self._data.get("registry_version", 0) >= 8:
|
||||
# registry version 8: the node type comes from the settings template_type,
|
||||
# the default settings take precedence over the other sets
|
||||
settings_list = self._data.get("settings") or []
|
||||
for settings in settings_list:
|
||||
if settings.get("default"):
|
||||
return settings.get("template_type", "qemu")
|
||||
if settings_list:
|
||||
return settings_list[0].get("template_type", "qemu")
|
||||
return "qemu"
|
||||
|
||||
if "iou" in self._data:
|
||||
return "iou"
|
||||
elif "dynamips" in self._data:
|
||||
|
||||
@ -174,7 +174,7 @@ class ApplianceManager:
|
||||
version_images = version.get("images")
|
||||
if version_images:
|
||||
for appliance_key, appliance_file in version_images.items():
|
||||
for image in appliance.images:
|
||||
for image in appliance.images or []:
|
||||
if appliance_file == image.get("filename"):
|
||||
image_checksum = image.get("md5sum")
|
||||
image_in_db = await images_repo.get_image_by_checksum(image_checksum)
|
||||
@ -225,12 +225,15 @@ class ApplianceManager:
|
||||
|
||||
from . import Controller
|
||||
|
||||
# downloading missing custom symbol for this appliance
|
||||
if appliance.symbol and not appliance.symbol.startswith(":/symbols/"):
|
||||
destination_path = os.path.join(Controller.instance().symbols.symbols_path(), appliance.symbol)
|
||||
template_data = ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
|
||||
# download the custom symbol used by the template if it is missing;
|
||||
# the symbol can be defined at the appliance, version or settings level
|
||||
symbol = template_data.get("symbol")
|
||||
if symbol and not symbol.startswith(":/symbols/"):
|
||||
destination_path = os.path.join(Controller.instance().symbols.symbols_path(), symbol)
|
||||
if not os.path.exists(destination_path):
|
||||
await self._download_symbol(appliance.symbol, destination_path)
|
||||
return ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
|
||||
await self._download_symbol(symbol, destination_path)
|
||||
return template_data
|
||||
|
||||
async def install_appliances_from_image(
|
||||
self,
|
||||
@ -290,11 +293,14 @@ class ApplianceManager:
|
||||
if not appliance.versions:
|
||||
raise ControllerBadRequestError(message=f"Appliance '{appliance_id}' do not have versions")
|
||||
|
||||
image_dir = default_images_directory(appliance.type)
|
||||
for appliance_version_info in appliance.versions:
|
||||
if appliance_version_info.get("name") == version:
|
||||
try:
|
||||
await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
|
||||
template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info)
|
||||
if template_type != "docker":
|
||||
# docker appliances have no image files to find or download
|
||||
image_dir = default_images_directory(template_type)
|
||||
await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
|
||||
except InvalidImageError as e:
|
||||
raise ControllerError(message=f"Image error: {e}")
|
||||
template_data = await self._appliance_to_template(appliance, appliance_version_info)
|
||||
@ -362,7 +368,16 @@ class ApplianceManager:
|
||||
symbol_theme = controller.symbols.theme
|
||||
category = appliance["category"]
|
||||
if category == "guest":
|
||||
if "docker" in appliance:
|
||||
if appliance.get("registry_version", 0) >= 8:
|
||||
# registry version 8: the emulator type comes from the default
|
||||
# settings set (or the only one present), not a top-level block
|
||||
settings = appliance.get("settings") or []
|
||||
selected = next((s for s in settings if s.get("default")), settings[0] if settings else None)
|
||||
if selected and selected.get("template_type") == "docker":
|
||||
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
|
||||
if selected:
|
||||
return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)
|
||||
elif "docker" in appliance:
|
||||
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
|
||||
elif "qemu" in appliance:
|
||||
return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)
|
||||
|
||||
@ -18,9 +18,32 @@
|
||||
|
||||
|
||||
import logging
|
||||
from .controller_error import ControllerError
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# appliance fields that describe the appliance (vendor information, default
|
||||
# credentials...) and are kept on the template as metadata instead of being
|
||||
# dropped at installation time
|
||||
_APPLIANCE_METADATA_FIELDS = (
|
||||
"description",
|
||||
"vendor_name",
|
||||
"vendor_url",
|
||||
"vendor_logo_url",
|
||||
"documentation_url",
|
||||
"product_name",
|
||||
"product_url",
|
||||
"status",
|
||||
"availability",
|
||||
"maintainer",
|
||||
"maintainer_email",
|
||||
"installation_instructions",
|
||||
"default_username",
|
||||
"default_password",
|
||||
)
|
||||
|
||||
|
||||
class ApplianceToTemplate:
|
||||
"""
|
||||
Appliance installation.
|
||||
@ -31,6 +54,9 @@ class ApplianceToTemplate:
|
||||
Creates a new template from an appliance.
|
||||
"""
|
||||
|
||||
if appliance_config.get("registry_version", 0) >= 8:
|
||||
return self._new_template_v8(appliance_config, version, server)
|
||||
|
||||
new_template = {
|
||||
"compute_id": server,
|
||||
"name": appliance_config["name"],
|
||||
@ -53,6 +79,13 @@ class ApplianceToTemplate:
|
||||
if "tags" in appliance_config:
|
||||
new_template["tags"] = appliance_config.get("tags")
|
||||
|
||||
if appliance_config.get("netmiko_device_type"):
|
||||
new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"]
|
||||
|
||||
appliance_metadata = self._build_appliance_metadata(appliance_config, version)
|
||||
if appliance_metadata:
|
||||
new_template["appliance_metadata"] = appliance_metadata
|
||||
|
||||
if new_template.get("symbol") is None:
|
||||
if appliance_config["category"] == "guest":
|
||||
if "docker" in appliance_config:
|
||||
@ -133,3 +166,172 @@ class ApplianceToTemplate:
|
||||
|
||||
new_config.update(appliance_config["iou"])
|
||||
new_config["path"] = version.get("images").get("image")
|
||||
|
||||
def _new_template_v8(self, appliance_config, version, server):
|
||||
"""
|
||||
Creates a new template from an appliance using the registry version 8 format.
|
||||
"""
|
||||
|
||||
settings = self._select_v8_settings(appliance_config, version)
|
||||
properties = self._merge_v8_properties(settings, appliance_config)
|
||||
|
||||
new_template = {
|
||||
"compute_id": server,
|
||||
"template_type": settings["template_type"],
|
||||
"name": appliance_config["name"],
|
||||
}
|
||||
|
||||
if version:
|
||||
new_template["version"] = version.get("name")
|
||||
|
||||
# category/usage/symbol can be defined in template_properties (already merged above),
|
||||
# otherwise at the version level, otherwise at the appliance level
|
||||
for prop in ("category", "usage", "symbol"):
|
||||
if prop not in properties:
|
||||
if version and version.get(prop) is not None:
|
||||
properties[prop] = version[prop]
|
||||
elif appliance_config.get(prop) is not None:
|
||||
properties[prop] = appliance_config[prop]
|
||||
|
||||
category_before_remap = properties.get("category")
|
||||
if category_before_remap == "multilayer_switch":
|
||||
properties["category"] = "switch"
|
||||
|
||||
if settings["template_type"] == "qemu":
|
||||
# kvm is not a valid template property: convert it to the
|
||||
# equivalent qemu options like for registry versions 1-6
|
||||
kvm = properties.pop("kvm", None) or "allow"
|
||||
options = properties.get("options") or ""
|
||||
if kvm == "disable" and "-machine accel=tcg" not in options:
|
||||
options += " -machine accel=tcg"
|
||||
properties["options"] = options.strip()
|
||||
|
||||
# template_properties must not override the structural fields
|
||||
for reserved in ("template_type", "compute_id", "version"):
|
||||
properties.pop(reserved, None)
|
||||
|
||||
new_template.update(properties)
|
||||
if "tags" in appliance_config:
|
||||
new_template["tags"] = appliance_config.get("tags")
|
||||
|
||||
if appliance_config.get("netmiko_device_type"):
|
||||
new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"]
|
||||
|
||||
appliance_metadata = self._build_appliance_metadata(appliance_config, version)
|
||||
if appliance_metadata:
|
||||
new_template["appliance_metadata"] = appliance_metadata
|
||||
|
||||
if not new_template.get("symbol"):
|
||||
# apply a default symbol based on the effective category and template type
|
||||
if category_before_remap == "guest":
|
||||
if settings["template_type"] == "docker":
|
||||
new_template["symbol"] = ":/symbols/docker_guest.svg"
|
||||
else:
|
||||
new_template["symbol"] = ":/symbols/qemu_guest.svg"
|
||||
else:
|
||||
symbols = {
|
||||
"router": ":/symbols/router.svg",
|
||||
"switch": ":/symbols/ethernet_switch.svg",
|
||||
"multilayer_switch": ":/symbols/multilayer_switch.svg",
|
||||
"firewall": ":/symbols/firewall.svg",
|
||||
}
|
||||
new_template["symbol"] = symbols.get(category_before_remap)
|
||||
|
||||
if version and version.get("images"):
|
||||
if settings["template_type"] == "iou":
|
||||
# IOU templates take the image path, not an image name
|
||||
new_template["path"] = version["images"].get("image")
|
||||
else:
|
||||
new_template.update(version["images"])
|
||||
|
||||
if version and settings["template_type"] == "dynamips" and version.get("idlepc"):
|
||||
# settings level idlepc takes precedence over the version level
|
||||
new_template.setdefault("idlepc", version["idlepc"])
|
||||
|
||||
return new_template
|
||||
|
||||
def _build_appliance_metadata(self, appliance_config, version):
|
||||
"""
|
||||
Builds the appliance metadata kept on the template: the fields that
|
||||
describe the appliance, with version level values (e.g. credentials
|
||||
specific to the installed version) overriding the appliance level ones.
|
||||
"""
|
||||
|
||||
version = version or {}
|
||||
metadata = {}
|
||||
for field in _APPLIANCE_METADATA_FIELDS:
|
||||
value = version.get(field)
|
||||
if value is None:
|
||||
value = appliance_config.get(field)
|
||||
if value is not None:
|
||||
metadata[field] = value
|
||||
appliance_id = appliance_config.get("appliance_id")
|
||||
if appliance_id:
|
||||
metadata["appliance_id"] = str(appliance_id)
|
||||
return metadata or None
|
||||
|
||||
def get_template_type(self, appliance_config, version):
|
||||
"""
|
||||
Returns the template type of the settings set used to install the given
|
||||
version: for registry versions 1-6 it comes from the emulator block, for
|
||||
version 8 from the settings set selected for the version.
|
||||
"""
|
||||
|
||||
if appliance_config.get("registry_version", 0) >= 8:
|
||||
return self._select_v8_settings(appliance_config, version)["template_type"]
|
||||
if "iou" in appliance_config:
|
||||
return "iou"
|
||||
if "dynamips" in appliance_config:
|
||||
return "dynamips"
|
||||
if "docker" in appliance_config:
|
||||
return "docker"
|
||||
return "qemu"
|
||||
|
||||
def _select_v8_settings(self, appliance_config, version):
|
||||
"""
|
||||
Selects the settings set to use: the one referenced by the version,
|
||||
otherwise the default set, otherwise the only set present.
|
||||
"""
|
||||
|
||||
settings_list = appliance_config.get("settings") or []
|
||||
if not settings_list:
|
||||
raise ControllerError(f"Appliance '{appliance_config['name']}' has no settings")
|
||||
|
||||
if version and version.get("settings"):
|
||||
settings_name = version["settings"]
|
||||
for settings in settings_list:
|
||||
if settings.get("name") == settings_name:
|
||||
return settings
|
||||
raise ControllerError(
|
||||
f"Could not find settings '{settings_name}' referenced by "
|
||||
f"version '{version.get('name')}' in appliance '{appliance_config['name']}'"
|
||||
)
|
||||
|
||||
for settings in settings_list:
|
||||
if settings.get("default"):
|
||||
return settings
|
||||
|
||||
if len(settings_list) == 1:
|
||||
return settings_list[0]
|
||||
|
||||
raise ControllerError(
|
||||
f"Appliance '{appliance_config['name']}' has multiple settings "
|
||||
f"but none is marked as default"
|
||||
)
|
||||
|
||||
def _merge_v8_properties(self, settings, appliance_config):
|
||||
"""
|
||||
Merges the template properties of the selected settings with the default
|
||||
settings properties, unless inheritance is disabled or the default set
|
||||
is selected. Only a default set of the same emulator type is inherited
|
||||
from, so properties of a different type never pollute the template.
|
||||
"""
|
||||
|
||||
properties = {}
|
||||
if not settings.get("default") and settings.get("inherit_default_properties", True):
|
||||
for other_settings in appliance_config.get("settings") or []:
|
||||
if other_settings.get("default") and other_settings.get("template_type") == settings["template_type"]:
|
||||
properties.update(other_settings.get("template_properties") or {})
|
||||
break
|
||||
properties.update(settings.get("template_properties") or {})
|
||||
return properties
|
||||
|
||||
@ -24,7 +24,6 @@ import sys
|
||||
import io
|
||||
|
||||
from fastapi import HTTPException
|
||||
from aiohttp import web
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
from asyncio import timeout as asynctimeout
|
||||
@ -373,6 +372,27 @@ class Compute:
|
||||
except ControllerError:
|
||||
pass
|
||||
|
||||
async def _report_connection_failure(self, error):
|
||||
"""
|
||||
Update the connection state after a failure, notify clients and
|
||||
schedule a reconnection attempt with exponential backoff.
|
||||
"""
|
||||
|
||||
self._connected = False
|
||||
self._last_error = str(error)
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
# Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb)
|
||||
if hasattr(sys, "_called_from_test") and sys._called_from_test:
|
||||
return
|
||||
self._connection_failure += 1
|
||||
# After 10 failures we close the project using the compute to avoid sync issues
|
||||
if self._connection_failure == 10:
|
||||
log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {error}")
|
||||
await self._controller.close_compute_projects(self)
|
||||
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s
|
||||
delay = min(5 * (2 ** (self._connection_failure - 1)), 300)
|
||||
asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect()))
|
||||
|
||||
@locking
|
||||
async def connect(self, report_failed_connection=False):
|
||||
"""
|
||||
@ -385,32 +405,20 @@ class Compute:
|
||||
response = await self._run_http_query("GET", "/capabilities")
|
||||
except ComputeError as e:
|
||||
# Update connection status and notify UI
|
||||
self._connected = False
|
||||
self._last_error = str(e)
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
|
||||
await self._report_connection_failure(e)
|
||||
if report_failed_connection:
|
||||
raise
|
||||
log.warning(f"Cannot connect to compute '{self._id}': {e}")
|
||||
# Try to reconnect if server unavailable only if not during tests (otherwise we create a ressource usage bomb)
|
||||
if not hasattr(sys, "_called_from_test") or not sys._called_from_test:
|
||||
self._connection_failure += 1
|
||||
# After 10 failures we close the project using the compute to avoid sync issues
|
||||
if self._connection_failure == 10:
|
||||
log.error(f"Could not connect to compute '{self._id}' after multiple attempts: {e}")
|
||||
await self._controller.close_compute_projects(self)
|
||||
# Exponential backoff: 5s, 10s, 20s, 40s, 80s, then cap at 300s
|
||||
delay = min(5 * (2 ** (self._connection_failure - 1)), 300)
|
||||
asyncio.get_event_loop().call_later(delay, lambda: asyncio.ensure_future(self._try_reconnect()))
|
||||
return
|
||||
except web.HTTPNotFound:
|
||||
raise ControllerNotFoundError(f"The server {self._id} is not a GNS3 server or it's a 1.X server")
|
||||
except web.HTTPUnauthorized:
|
||||
raise ControllerUnauthorizedError(f"Invalid auth for server {self._id}")
|
||||
except web.HTTPServiceUnavailable:
|
||||
raise ControllerNotFoundError(f"The server {self._id} is unavailable")
|
||||
except ValueError:
|
||||
raise ComputeError(f"Invalid server url for server {self._id}")
|
||||
except (ControllerError, HTTPException) as e:
|
||||
# _run_http_query translates HTTP status errors into ControllerError
|
||||
# subclasses (or a raw HTTPException for unexpected status codes).
|
||||
# They used to escape this method and silently kill the fire-and-forget
|
||||
# connect() task started at controller startup: no notification, no retry.
|
||||
# Schedule the retry, then re-raise so explicit callers still get the error.
|
||||
await self._report_connection_failure(e)
|
||||
log.warning(f"Cannot connect to compute '{self._id}': {e}")
|
||||
raise
|
||||
|
||||
if "version" not in response.json:
|
||||
msg = f"The server {self._id} is not a GNS3 server"
|
||||
@ -488,22 +496,27 @@ class Compute:
|
||||
elif response.type == aiohttp.WSMsgType.CLOSED:
|
||||
pass
|
||||
break
|
||||
except aiohttp.ClientError as e:
|
||||
log.error(f"Client response error received on compute '{self._id}' WebSocket '{ws_url}': {e}")
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
# A malformed frame or an error raised while dispatching a compute event
|
||||
# used to escape this task (only aiohttp.ClientError was caught) and
|
||||
# permanently killed the notification stream: no more compute.updated
|
||||
# events and no reconnection until the server was restarted. Log the
|
||||
# error with its traceback and reconnect below.
|
||||
log.error(f"Error on compute '{self._id}' notification stream '{ws_url}': {e!r}", exc_info=True)
|
||||
finally:
|
||||
self._connected = False
|
||||
self._cpu_usage_percent = None
|
||||
self._memory_usage_percent = None
|
||||
self._disk_usage_percent = None
|
||||
log.info(f"Connection closed to compute '{self._id}' WebSocket '{ws_url}'")
|
||||
|
||||
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb)
|
||||
from gns3server.api.server import app
|
||||
if not app.state.exiting and not hasattr(sys, "_called_from_test"):
|
||||
log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'")
|
||||
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
|
||||
|
||||
self._cpu_usage_percent = None
|
||||
self._memory_usage_percent = None
|
||||
self._disk_usage_percent = None
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
self._controller.notification.controller_emit("compute.updated", self.asdict())
|
||||
# Try to reconnect after 1 second if server unavailable only if not during tests (otherwise we create a resources usage bomb)
|
||||
from gns3server.api.server import app
|
||||
if not app.state.exiting and not hasattr(sys, "_called_from_test"):
|
||||
log.info(f"Reconnecting to compute '{self._id}' WebSocket '{ws_url}'")
|
||||
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self.connect()))
|
||||
|
||||
def _getUrl(self, path):
|
||||
host = self._host
|
||||
|
||||
@ -624,6 +624,8 @@ class Link:
|
||||
"nat",
|
||||
"virtualbox",
|
||||
"docker",
|
||||
# the brctl Ethernet switch applies filters on its per-port uBridge relays
|
||||
"ethernet_switch",
|
||||
):
|
||||
return node["node"]
|
||||
return None
|
||||
|
||||
@ -57,6 +57,9 @@ class Node:
|
||||
"ports",
|
||||
"category",
|
||||
"console_auto_start",
|
||||
"netmiko_device_type",
|
||||
"default_username",
|
||||
"default_password",
|
||||
]
|
||||
|
||||
def __init__(self, project, compute, name, node_id=None, node_type=None, template_id=None, **kwargs):
|
||||
@ -112,6 +115,9 @@ class Node:
|
||||
self._port_segment_size = 0
|
||||
self._first_port_name = None
|
||||
self._console_auto_start = False
|
||||
self._netmiko_device_type = None
|
||||
self._default_username = None
|
||||
self._default_password = None
|
||||
|
||||
# This properties will be recomputed
|
||||
ignore_properties = ("width", "height", "hover_symbol")
|
||||
@ -212,6 +218,30 @@ class Node:
|
||||
def console_auto_start(self, val):
|
||||
self._console_auto_start = val
|
||||
|
||||
@property
|
||||
def netmiko_device_type(self):
|
||||
return self._netmiko_device_type
|
||||
|
||||
@netmiko_device_type.setter
|
||||
def netmiko_device_type(self, val):
|
||||
self._netmiko_device_type = val
|
||||
|
||||
@property
|
||||
def default_username(self):
|
||||
return self._default_username
|
||||
|
||||
@default_username.setter
|
||||
def default_username(self, val):
|
||||
self._default_username = val
|
||||
|
||||
@property
|
||||
def default_password(self):
|
||||
return self._default_password
|
||||
|
||||
@default_password.setter
|
||||
def default_password(self, val):
|
||||
self._default_password = val
|
||||
|
||||
@property
|
||||
def properties(self):
|
||||
return self._properties
|
||||
@ -833,6 +863,9 @@ class Node:
|
||||
"console": self._console,
|
||||
"console_type": self._console_type,
|
||||
"console_auto_start": self._console_auto_start,
|
||||
"netmiko_device_type": self._netmiko_device_type,
|
||||
"default_username": self._default_username,
|
||||
"default_password": self._default_password,
|
||||
"aux": self._aux,
|
||||
"aux_type": self._aux_type,
|
||||
"properties": self._properties,
|
||||
|
||||
@ -584,6 +584,12 @@ class Project:
|
||||
default_name_format = template.pop("default_name_format", "{name}-{0}")
|
||||
if name is None:
|
||||
name = default_name_format.replace("{name}", template_name)
|
||||
# the appliance metadata stays template level: only the default
|
||||
# credentials are seeded on the node (where they can be overridden)
|
||||
appliance_metadata = template.pop("appliance_metadata", None) or {}
|
||||
for field in ("default_username", "default_password"):
|
||||
if appliance_metadata.get(field):
|
||||
template[field] = appliance_metadata[field]
|
||||
node_id = str(uuid.uuid4())
|
||||
node = await self.add_node(compute, name, node_id, node_type=node_type, **template)
|
||||
return node
|
||||
|
||||
@ -24,12 +24,13 @@ from .link import Link, _UNSET
|
||||
from .node_types import BUILTIN_NODE_TYPES
|
||||
from gns3server.utils.packet_filter_validation import validate_bpf_syntax, FilterValidationError
|
||||
|
||||
# Node types without a uBridge bridge — a marker filter has nothing to attach to.
|
||||
# Node types that can host a marker (have a uBridge bridge to attach the
|
||||
# `mark` filter to). Mirrors _get_filter_node in link.py, minus "nat"
|
||||
# (which has no uBridge).
|
||||
# (which has no uBridge) and "ethernet_hub" (still Dynamips-hosted, no
|
||||
# uBridge of its own). "ethernet_switch" hosts markers on the per-port
|
||||
# uBridge relays of its brctl kernel-bridge backend.
|
||||
_MARKER_CAPABLE_TYPES = frozenset({
|
||||
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud",
|
||||
"vpcs", "qemu", "docker", "iou", "dynamips", "cloud", "ethernet_switch",
|
||||
})
|
||||
|
||||
|
||||
@ -234,7 +235,10 @@ class UDPLink(Link):
|
||||
self._link_data[0]["filters"] = node1_filters
|
||||
self._link_data[0]["markers"] = node1_markers
|
||||
self._link_data[0]["suspend"] = self._suspended
|
||||
if node1.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
# The Ethernet hub is still Dynamips-hosted (no uBridge of its own and
|
||||
# no PUT NIO route) — keep skipping its side. Every other node type,
|
||||
# including the brctl Ethernet switch, re-applies via the NIO update.
|
||||
if node1.node_type != "ethernet_hub":
|
||||
await node1.put(
|
||||
f"/adapters/{adapter_number1}/ports/{port_number1}/nio", data=self._link_data[0], timeout=120
|
||||
)
|
||||
@ -244,7 +248,7 @@ class UDPLink(Link):
|
||||
self._link_data[1]["filters"] = node2_filters
|
||||
self._link_data[1]["markers"] = node2_markers
|
||||
self._link_data[1]["suspend"] = self._suspended
|
||||
if node2.node_type not in ("ethernet_switch", "ethernet_hub"):
|
||||
if node2.node_type != "ethernet_hub":
|
||||
await node2.put(
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221
|
||||
)
|
||||
|
||||
@ -35,6 +35,8 @@ class Template(BaseTable):
|
||||
symbol = Column(String)
|
||||
builtin = Column(Boolean, default=False)
|
||||
usage = Column(String)
|
||||
netmiko_device_type = Column(String)
|
||||
appliance_metadata = Column(JSON)
|
||||
template_type = Column(String)
|
||||
tags = Column(JSON)
|
||||
compute_id = Column(String)
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
"""add netmiko_device_type to templates table
|
||||
|
||||
Revision ID: b3c7e2a91d4f
|
||||
Revises: 8f2a1c4e9d3b
|
||||
Create Date: 2026-08-16 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'b3c7e2a91d4f'
|
||||
down_revision = '8f2a1c4e9d3b'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
op.add_column('templates', sa.Column('netmiko_device_type', sa.String()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
op.drop_column('templates', 'netmiko_device_type')
|
||||
@ -0,0 +1,26 @@
|
||||
"""add appliance_metadata to templates table
|
||||
|
||||
Revision ID: c7e4a9f1d2b6
|
||||
Revises: b3c7e2a91d4f
|
||||
Create Date: 2026-08-16 12:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'c7e4a9f1d2b6'
|
||||
down_revision = 'b3c7e2a91d4f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
|
||||
op.add_column('templates', sa.Column('appliance_metadata', sa.JSON()))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
|
||||
op.drop_column('templates', 'appliance_metadata')
|
||||
@ -61,6 +61,7 @@ from .controller.tokens import Token, ApiKeyCreate, RefreshTokenRequest
|
||||
from .controller.snapshots import SnapshotCreate, Snapshot
|
||||
from .controller.iou_license import IOULicense
|
||||
from .controller.capabilities import Capabilities
|
||||
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
|
||||
|
||||
# Controller template schemas
|
||||
from .controller.templates.vpcs_templates import VPCSTemplate, VPCSTemplateUpdate
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from typing import Optional, List
|
||||
from uuid import UUID
|
||||
|
||||
@ -26,6 +26,21 @@ class DockerBase(BaseModel):
|
||||
Common Docker node properties.
|
||||
"""
|
||||
|
||||
@field_validator("start_command", "environment", "extra_hosts", mode="before")
|
||||
@classmethod
|
||||
def _empty_string_to_none(cls, value):
|
||||
# Web clients serialize empty form fields as "" while unset values are
|
||||
# stored as None on the node: normalize before the update diff runs,
|
||||
# otherwise every full PUT would see a phantom change and recreate
|
||||
# the container for nothing.
|
||||
return value or None
|
||||
|
||||
@field_validator("console_http_path", mode="before")
|
||||
@classmethod
|
||||
def _empty_string_to_root_path(cls, value):
|
||||
# the canonical "no path" value is "/" (the creation default)
|
||||
return value or "/"
|
||||
|
||||
name: str
|
||||
image: str = Field(..., description="Docker image name")
|
||||
node_id: Optional[UUID] = None
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
from enum import Enum
|
||||
from typing import Annotated, List, Literal, Optional, Union
|
||||
from uuid import UUID
|
||||
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag
|
||||
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag, model_validator
|
||||
from ..common import ExtraConfig
|
||||
|
||||
|
||||
@ -481,6 +481,7 @@ class DockerPropertiesV8(BaseModel):
|
||||
extra_volumes: Optional[List[str]] = Field(
|
||||
None, title='Additional directories to make persistent'
|
||||
)
|
||||
custom_adapters: Optional[List[CustomAdapterItem]] = Field(None, title='Custom adapters')
|
||||
extra_configs: Optional[List[ExtraConfig]] = Field(
|
||||
None, title='Configuration files injected into the container (bind-mounted read-only)'
|
||||
)
|
||||
@ -566,14 +567,23 @@ class QemuPropertiesV8(BaseModel):
|
||||
title='Optional define the disk boot priory. Refer to -boot option in qemu manual for more details.',
|
||||
)
|
||||
kernel_command_line: Optional[str] = Field(None, title='Command line parameters send to the kernel')
|
||||
kvm: Optional[Kvm] = Field(None, title='KVM requirements')
|
||||
options: Optional[str] = Field(None, title='Optional additional qemu command line options')
|
||||
cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU')
|
||||
cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = Field(None, title='Throttle the CPU')
|
||||
tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)')
|
||||
uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode')
|
||||
on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed')
|
||||
process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU')
|
||||
|
||||
|
||||
_V8_PROPERTIES_MODELS = {
|
||||
TemplateType.qemu: QemuPropertiesV8,
|
||||
TemplateType.dynamips: DynamipsPropertiesV8,
|
||||
TemplateType.iou: IouPropertiesV8,
|
||||
TemplateType.docker: DockerPropertiesV8,
|
||||
}
|
||||
|
||||
|
||||
class TemplateSetting(BaseModel):
|
||||
"""Emulator settings configuration (v8)"""
|
||||
|
||||
@ -586,12 +596,34 @@ class TemplateSetting(BaseModel):
|
||||
title='Properties for the template'
|
||||
)
|
||||
|
||||
@model_validator(mode='before')
|
||||
@classmethod
|
||||
def _validate_template_properties(cls, data):
|
||||
"""
|
||||
Validate template_properties against the model matching template_type.
|
||||
The template_type discriminator lives at the settings level (not inside
|
||||
template_properties), so the union cannot be discriminated by pydantic
|
||||
alone and would misroute properties between the per-type models.
|
||||
"""
|
||||
|
||||
if isinstance(data, dict):
|
||||
# work on a copy: replacing template_properties with the validated
|
||||
# model must not mutate the caller's data
|
||||
data = data.copy()
|
||||
template_type = data.get("template_type")
|
||||
template_properties = data.get("template_properties")
|
||||
model = _V8_PROPERTIES_MODELS.get(template_type)
|
||||
if model is not None and isinstance(template_properties, dict):
|
||||
data["template_properties"] = model.model_validate(template_properties)
|
||||
return data
|
||||
|
||||
|
||||
class ApplianceVersionV8(BaseModel):
|
||||
"""Appliance version definition (v8)"""
|
||||
|
||||
name: str = Field(..., title='Name of the version')
|
||||
settings: Optional[str] = Field(None, title='Template settings to use to run the version')
|
||||
idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}')
|
||||
category: Optional[Category] = Field(None, title='Category of the version')
|
||||
installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version')
|
||||
usage: Optional[str] = Field(None, title='Optional instructions about using the version')
|
||||
@ -631,6 +663,9 @@ class ApplianceV1_6(BaseModel):
|
||||
maintainer_email: Optional[Union[EmailStr, Annotated[str, Field(max_length=0)]]] = Field(None, title='Maintainer email')
|
||||
usage: Optional[str] = Field(None, title='How to use the appliance')
|
||||
symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
|
||||
netmiko_device_type: Optional[str] = Field(
|
||||
None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
|
||||
)
|
||||
first_port_name: Optional[str] = Field(None, title='Optional name of the first networking port example: eth0')
|
||||
port_name_format: Optional[str] = Field(None, title='Optional formating of the networking port example: eth{0}')
|
||||
port_segment_size: Optional[int] = Field(
|
||||
@ -684,6 +719,9 @@ class ApplianceV8(BaseModel):
|
||||
default_username: Optional[str] = Field(None, title='Default username for the appliance')
|
||||
default_password: Optional[str] = Field(None, title='Default password for the appliance')
|
||||
symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
|
||||
netmiko_device_type: Optional[str] = Field(
|
||||
None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
|
||||
)
|
||||
tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance')
|
||||
settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance')
|
||||
images: Optional[List[ApplianceImage]] = Field(None, title='Images for this appliance')
|
||||
|
||||
38
gns3server/schemas/controller/netmiko.py
Normal file
38
gns3server/schemas/controller/netmiko.py
Normal file
@ -0,0 +1,38 @@
|
||||
#
|
||||
# Copyright (C) 2020 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/>.
|
||||
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List
|
||||
|
||||
|
||||
class NetmikoDeviceType(BaseModel):
|
||||
"""
|
||||
A Netmiko device type supported by the installed Netmiko library.
|
||||
"""
|
||||
|
||||
name: str = Field(..., description="Device type name to store in the netmiko_device_type field")
|
||||
telnet: bool = Field(False, description="Whether the device type connects over Telnet")
|
||||
custom: bool = Field(False, description="Whether the device type is a GNS3-copilot custom driver (gns3_ prefix)")
|
||||
|
||||
|
||||
class NetmikoDeviceTypeList(BaseModel):
|
||||
"""
|
||||
List of Netmiko device types supported by the installed Netmiko library.
|
||||
"""
|
||||
|
||||
netmiko_version: str = Field(..., description="Version of the installed Netmiko library")
|
||||
device_types: List[NetmikoDeviceType] = Field(..., description="Supported device types, sorted by name")
|
||||
@ -116,6 +116,19 @@ class NodeBase(BaseModel):
|
||||
console_auto_start: Optional[bool] = Field(
|
||||
False, description="Automatically start the console when the node has started"
|
||||
)
|
||||
netmiko_device_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Device type for Netmiko-based automation tools, overrides the template value",
|
||||
pattern=r"^[a-z0-9_]+$|^$",
|
||||
)
|
||||
default_username: Optional[str] = Field(
|
||||
None,
|
||||
description="Default username to log into the node, seeded from the template appliance metadata",
|
||||
)
|
||||
default_password: Optional[str] = Field(
|
||||
None,
|
||||
description="Default password to log into the node, seeded from the template appliance metadata",
|
||||
)
|
||||
aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port")
|
||||
aux_type: Optional[ConsoleType] = None
|
||||
properties: Optional[dict] = Field(default_factory=dict, description="Properties specific to an emulator")
|
||||
|
||||
@ -34,6 +34,34 @@ class Category(str, Enum):
|
||||
firewall = "firewall"
|
||||
|
||||
|
||||
class ApplianceMetadata(BaseModel):
|
||||
"""
|
||||
Metadata kept on a template installed from an appliance: vendor
|
||||
information, default credentials and other fields that describe
|
||||
the appliance but are not node properties.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="allow")
|
||||
|
||||
appliance_id: Optional[str] = Field(
|
||||
None, description="ID of the appliance the template was installed from"
|
||||
)
|
||||
description: Optional[str] = None
|
||||
vendor_name: Optional[str] = None
|
||||
vendor_url: Optional[str] = None
|
||||
vendor_logo_url: Optional[str] = None
|
||||
documentation_url: Optional[str] = None
|
||||
product_name: Optional[str] = None
|
||||
product_url: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
availability: Optional[str] = None
|
||||
maintainer: Optional[str] = None
|
||||
maintainer_email: Optional[str] = None
|
||||
installation_instructions: Optional[str] = None
|
||||
default_username: Optional[str] = None
|
||||
default_password: Optional[str] = None
|
||||
|
||||
|
||||
class TemplateBase(BaseModel):
|
||||
"""
|
||||
Common template properties.
|
||||
@ -48,10 +76,19 @@ class TemplateBase(BaseModel):
|
||||
template_type: Optional[NodeType] = None
|
||||
compute_id: Optional[str] = None
|
||||
usage: Optional[str] = ""
|
||||
netmiko_device_type: Optional[str] = Field(
|
||||
None,
|
||||
description="Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')",
|
||||
pattern=r"^[a-z0-9_]+$|^$",
|
||||
)
|
||||
tags: Optional[List[str]] = Field(
|
||||
default_factory=list,
|
||||
description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')"
|
||||
)
|
||||
appliance_metadata: Optional[ApplianceMetadata] = Field(
|
||||
None,
|
||||
description="Metadata inherited from the appliance the template was installed from"
|
||||
)
|
||||
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
|
||||
@ -16,7 +16,9 @@
|
||||
|
||||
from joserfc import jwt
|
||||
from joserfc.jwk import OctKey
|
||||
from joserfc.errors import JoseError
|
||||
from joserfc.errors import JoseError, BadSignatureError
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import bcrypt
|
||||
@ -34,6 +36,17 @@ log = logging.getLogger(__name__)
|
||||
DEFAULT_JWT_SECRET_KEY = "efd08eccec3bd0a1be2e086670e5efa90969c68d07e072d7354a76cea5e33d4e"
|
||||
|
||||
|
||||
def _extract_alg(token: str) -> str:
|
||||
"""Best-effort extraction of the unverified JWT header "alg" value — for logging only."""
|
||||
|
||||
try:
|
||||
header_segment = token.split(".", 1)[0]
|
||||
header = json.loads(base64.urlsafe_b64decode(header_segment + "=" * (-len(header_segment) % 4)))
|
||||
return str(header.get("alg", "<missing>"))
|
||||
except Exception:
|
||||
return "<undecodable>"
|
||||
|
||||
|
||||
class AuthService:
|
||||
|
||||
def hash_password(self, password: str) -> str:
|
||||
@ -75,32 +88,38 @@ class AuthService:
|
||||
|
||||
def get_token_data(self, token: str, secret_key: str = None) -> TokenData:
|
||||
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
def auth_error(detail: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail=detail,
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
if secret_key is None:
|
||||
secret_key = Config.instance().settings.Controller.jwt_secret_key
|
||||
if secret_key is None:
|
||||
secret_key = DEFAULT_JWT_SECRET_KEY
|
||||
log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
|
||||
algorithm = Config.instance().settings.Controller.jwt_algorithm
|
||||
key = OctKey.import_key(secret_key)
|
||||
try:
|
||||
if secret_key is None:
|
||||
secret_key = Config.instance().settings.Controller.jwt_secret_key
|
||||
if secret_key is None:
|
||||
secret_key = DEFAULT_JWT_SECRET_KEY
|
||||
log.error("A JWT secret key must be configured to secure the server, using an unsecured default key!")
|
||||
algorithm = Config.instance().settings.Controller.jwt_algorithm
|
||||
key = OctKey.import_key(secret_key)
|
||||
payload = jwt.decode(token, key, algorithms=[algorithm])
|
||||
username: str = payload.claims.get("sub")
|
||||
if username is None:
|
||||
raise credentials_exception
|
||||
raise auth_error("Invalid token: missing subject claim")
|
||||
# Validate the exp claim — joserfc does not validate time-based claims by default
|
||||
token_exp: int = payload.claims.get("exp", 0)
|
||||
if token_exp and time.time() > token_exp:
|
||||
raise credentials_exception
|
||||
raise auth_error("Token has expired")
|
||||
token_version: int = payload.claims.get("ver", 0)
|
||||
token_use: str = payload.claims.get("type", "access")
|
||||
token_data = TokenData(username=username, token_version=token_version, token_use=token_use)
|
||||
except (JoseError, ValidationError, ValueError):
|
||||
raise credentials_exception
|
||||
except BadSignatureError as e:
|
||||
log.warning("JWT rejected: bad signature (header alg: '%s', error: %s)", _extract_alg(token), e)
|
||||
raise auth_error("Invalid token signature")
|
||||
except (JoseError, ValidationError, ValueError) as e:
|
||||
log.warning("JWT rejected: %s: %s (header alg: '%s')", type(e).__name__, e, _extract_alg(token))
|
||||
raise auth_error(f"Invalid token ({type(e).__name__})")
|
||||
return token_data
|
||||
|
||||
def get_username_from_token(self, token: str, secret_key: str = None) -> Optional[str]:
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import psutil
|
||||
|
||||
from gns3server.utils.cpu_percent import CpuPercent
|
||||
@ -35,22 +36,39 @@ class NotificationQueue(asyncio.Queue):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._first = True
|
||||
self._last_ping = None
|
||||
|
||||
async def get(self, timeout):
|
||||
"""
|
||||
When timeout is expire we send a ping notification with server information
|
||||
Return a notification, or a ping notification with server information
|
||||
at least every `timeout` seconds. The ping used to be generated only
|
||||
when the queue was idle for the full timeout, which starved it under
|
||||
sustained event load (e.g. high marker.match rates): clients stopped
|
||||
receiving compute statistics until the event flow paused.
|
||||
"""
|
||||
|
||||
# At first get we return a ping so the client immediately receives data
|
||||
if self._first:
|
||||
self._first = False
|
||||
return ("ping", self._getPing(), {})
|
||||
return self._ping()
|
||||
|
||||
try:
|
||||
(action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return ("ping", self._getPing(), {})
|
||||
return (action, msg, kwargs)
|
||||
while True:
|
||||
now = time.monotonic()
|
||||
if self._last_ping is None or now - self._last_ping >= timeout:
|
||||
return self._ping()
|
||||
try:
|
||||
(action, msg, kwargs) = await asyncio.wait_for(super().get(), timeout - (now - self._last_ping))
|
||||
return (action, msg, kwargs)
|
||||
except asyncio.TimeoutError:
|
||||
continue # the ping deadline has been reached
|
||||
|
||||
def _ping(self):
|
||||
"""
|
||||
Build a ping notification and stamp the ping deadline.
|
||||
"""
|
||||
|
||||
self._last_ping = time.monotonic()
|
||||
return ("ping", self._getPing(), {})
|
||||
|
||||
def _getPing(self):
|
||||
"""
|
||||
|
||||
@ -69,3 +69,187 @@ def test_node_accepts_docker_exec_console():
|
||||
status="started",
|
||||
)
|
||||
assert node.console_type == "docker_exec"
|
||||
|
||||
|
||||
def test_node_accepts_netmiko_device_type():
|
||||
"""
|
||||
The vendored Node model must keep the netmiko_device_type field so the
|
||||
device-port tools can prefer it over the device_type:<type> tag.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="SR1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="docker_exec",
|
||||
status="started",
|
||||
netmiko_device_type="nokia_srl",
|
||||
)
|
||||
assert node.netmiko_device_type == "nokia_srl"
|
||||
|
||||
|
||||
def test_device_ports_prefer_netmiko_field_over_tag(monkeypatch):
|
||||
"""
|
||||
netmiko_device_type on the node wins over the device_type:<type> tag;
|
||||
the tag stays as fallback when the field is missing.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
||||
from gns3server.agent.gns3_copilot import gns3_client
|
||||
|
||||
class _FakeTopology:
|
||||
def _run(self, project_id=None, jwt_token=None, url=None):
|
||||
return {
|
||||
"nodes": {
|
||||
"SR1": {
|
||||
"console_port": 5000,
|
||||
"tags": ["device_type:cisco_ios_telnet"],
|
||||
"netmiko_device_type": "nokia_srl",
|
||||
},
|
||||
"R1": {
|
||||
"console_port": 5001,
|
||||
"tags": ["device_type:cisco_ios_telnet", "platform:cisco_ios"],
|
||||
"netmiko_device_type": None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# the function does a lazy from-import inside the body
|
||||
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
||||
hosts = get_gns3_device_port.get_device_ports_from_topology(["SR1", "R1"])
|
||||
|
||||
# field wins over tag
|
||||
assert hosts["SR1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "nokia_srl"
|
||||
# tag fallback when the field is absent
|
||||
assert hosts["R1"]["connection_options"]["netmiko"]["extras"]["device_type"] == "cisco_ios_telnet"
|
||||
assert hosts["R1"]["platform"] == "cisco_ios"
|
||||
|
||||
|
||||
def test_device_ports_error_without_any_device_type(monkeypatch):
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
||||
from gns3server.agent.gns3_copilot import gns3_client
|
||||
|
||||
class _FakeTopology:
|
||||
def _run(self, project_id=None, jwt_token=None, url=None):
|
||||
return {
|
||||
"nodes": {
|
||||
"R2": {
|
||||
"console_port": 5002,
|
||||
"tags": ["platform:cisco_ios"],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
# the function does a lazy from-import inside the body
|
||||
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
||||
hosts = get_gns3_device_port.get_device_ports_from_topology(["R2"])
|
||||
|
||||
assert "error" in hosts["R2"]
|
||||
assert "netmiko_device_type" in hosts["R2"]["error"]
|
||||
|
||||
|
||||
def test_node_accepts_default_credentials():
|
||||
"""
|
||||
The vendored Node model must keep the default credentials so the
|
||||
device-port tools can log into devices that require authentication.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node
|
||||
|
||||
node = Node(
|
||||
name="R1",
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="docker",
|
||||
console_type="telnet",
|
||||
status="started",
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
)
|
||||
assert node.default_username == "admin"
|
||||
assert node.default_password == "admin123"
|
||||
|
||||
|
||||
def test_nodes_inventory_emits_default_credentials():
|
||||
"""
|
||||
The inventory dict consumed by get_device_ports_from_topology must
|
||||
carry the per-node default credentials.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from types import SimpleNamespace
|
||||
from gns3server.agent.gns3_copilot.gns3_client.custom_gns3fy import Node, Project
|
||||
|
||||
project = Project(
|
||||
project_id="5f517ce3-1bc6-4245-b866-1a2fbd0ee5a7",
|
||||
connector=SimpleNamespace(base_url="http://127.0.0.1:3080"),
|
||||
)
|
||||
project.nodes = [
|
||||
Node(
|
||||
name="R1",
|
||||
project_id=project.project_id,
|
||||
node_id="0d15c2e6-8f83-4b79-8875-9dbc3e5f2f1e",
|
||||
node_type="dynamips",
|
||||
console=5000,
|
||||
default_username="admin",
|
||||
default_password="admin123",
|
||||
),
|
||||
]
|
||||
|
||||
inventory = project.nodes_inventory()
|
||||
assert inventory["R1"]["default_username"] == "admin"
|
||||
assert inventory["R1"]["default_password"] == "admin123"
|
||||
|
||||
|
||||
def test_device_ports_inject_default_credentials(monkeypatch):
|
||||
"""
|
||||
Per-node default credentials become host-level nornir values (which
|
||||
override the group's empty fallback); missing or cleared ("") values
|
||||
keep inheriting from the group.
|
||||
"""
|
||||
pytest.importorskip("jwt", reason="ai-features extras not installed")
|
||||
from gns3server.agent.gns3_copilot.utils import get_gns3_device_port
|
||||
from gns3server.agent.gns3_copilot import gns3_client
|
||||
|
||||
class _FakeTopology:
|
||||
def _run(self, project_id=None, jwt_token=None, url=None):
|
||||
return {
|
||||
"nodes": {
|
||||
"R1": {
|
||||
"console_port": 5000,
|
||||
"tags": [],
|
||||
"netmiko_device_type": "cisco_ios_telnet",
|
||||
"default_username": "admin",
|
||||
"default_password": "admin123",
|
||||
},
|
||||
# credentials cleared via PUT arrive as empty strings
|
||||
"R2": {
|
||||
"console_port": 5001,
|
||||
"tags": [],
|
||||
"netmiko_device_type": "cisco_ios_telnet",
|
||||
"default_username": "",
|
||||
"default_password": "",
|
||||
},
|
||||
# never seeded
|
||||
"R3": {
|
||||
"console_port": 5002,
|
||||
"tags": [],
|
||||
"netmiko_device_type": "cisco_ios_telnet",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gns3_client, "GNS3TopologyTool", _FakeTopology)
|
||||
hosts = get_gns3_device_port.get_device_ports_from_topology(["R1", "R2", "R3"])
|
||||
|
||||
# set credentials land at host level
|
||||
assert hosts["R1"]["username"] == "admin"
|
||||
assert hosts["R1"]["password"] == "admin123"
|
||||
# cleared ("") and absent credentials do not override the group fallback
|
||||
assert "username" not in hosts["R2"]
|
||||
assert "password" not in hosts["R2"]
|
||||
assert "username" not in hosts["R3"]
|
||||
assert "password" not in hosts["R3"]
|
||||
|
||||
348
tests/agent/test_skills_device_topics.py
Normal file
348
tests/agent/test_skills_device_topics.py
Normal file
@ -0,0 +1,348 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 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 device skill topic splitting: directory layout loading
|
||||
(_base.yaml + topic files) and topic-level retrieval via get_skill /
|
||||
DeviceSkillsTool.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from gns3server.agent.gns3_copilot.skills.loader import SkillsLoader
|
||||
from gns3server.agent.gns3_copilot.skills.registry import (
|
||||
DeviceSkillsTool,
|
||||
get_skill,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def skills_dir(tmp_path):
|
||||
"""
|
||||
Build a skills directory with both device layouts:
|
||||
|
||||
- vpcs.yaml: single-file device (no topics)
|
||||
- frr/: split device (_base.yaml + ospf/bgp topic files,
|
||||
one mismatched topic file, one file without a topic field)
|
||||
- orphan/: directory without _base.yaml (skipped)
|
||||
"""
|
||||
device_dir = tmp_path / "device"
|
||||
device_dir.mkdir()
|
||||
|
||||
(device_dir / "vpcs.yaml").write_text(
|
||||
"""
|
||||
name: "VPCS"
|
||||
description: "VPCS test device"
|
||||
device_type: "gns3_vpcs_telnet"
|
||||
category: "device"
|
||||
config_commands:
|
||||
ip_config:
|
||||
syntax: "ip <address>/<mask> <gateway>"
|
||||
description: "Set PC address"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
frr_dir = device_dir / "frr"
|
||||
frr_dir.mkdir()
|
||||
(frr_dir / "_base.yaml").write_text(
|
||||
"""
|
||||
name: "FRR (Free Range Routing)"
|
||||
description: "FRR test device"
|
||||
device_type: "frr_vtysh"
|
||||
category: "device"
|
||||
config_commands:
|
||||
write_memory:
|
||||
syntax: "write memory"
|
||||
description: "Save config"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(frr_dir / "ospf.yaml").write_text(
|
||||
"""
|
||||
device_type: "frr_vtysh"
|
||||
topic: ospf
|
||||
name: "OSPF (FRR 10.x)"
|
||||
description: "OSPF topic"
|
||||
config_commands:
|
||||
ospfv2:
|
||||
syntax: "router ospf"
|
||||
description: "OSPFv2 process"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(frr_dir / "bgp.yaml").write_text(
|
||||
"""
|
||||
device_type: "frr_vtysh"
|
||||
topic: bgp
|
||||
name: "BGP (FRR 10.x)"
|
||||
description: "BGP topic"
|
||||
config_commands:
|
||||
bgp_base:
|
||||
syntax: "router bgp <asn>"
|
||||
description: "BGP base"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# device_type mismatch with the base -> topic must be skipped
|
||||
(frr_dir / "mpls.yaml").write_text(
|
||||
"""
|
||||
device_type: "other_device_type"
|
||||
topic: mpls
|
||||
name: "MPLS"
|
||||
config_commands:
|
||||
mpls_base:
|
||||
syntax: "router mpls"
|
||||
description: "..."
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# no topic field -> falls back to the filename stem
|
||||
(frr_dir / "static.yaml").write_text(
|
||||
"""
|
||||
device_type: "frr_vtysh"
|
||||
name: "Static routing"
|
||||
config_commands:
|
||||
static_routes:
|
||||
syntax: "ip route <prefix>/<len> <nexthop>"
|
||||
description: "..."
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# directory without _base.yaml -> whole device skipped
|
||||
orphan_dir = device_dir / "orphan"
|
||||
orphan_dir.mkdir()
|
||||
(orphan_dir / "some_topic.yaml").write_text(
|
||||
"""
|
||||
device_type: "orphan_device"
|
||||
topic: anything
|
||||
name: "Orphan"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
class TestDeviceSkillsLoading:
|
||||
"""
|
||||
SkillsLoader.load_device_skills() with single-file and split layouts.
|
||||
"""
|
||||
|
||||
def test_both_layouts_are_loaded(self, skills_dir):
|
||||
skills = SkillsLoader(str(skills_dir)).load_device_skills()
|
||||
|
||||
assert "gns3_vpcs_telnet" in skills
|
||||
assert "frr_vtysh" in skills
|
||||
# the orphan directory (no _base.yaml) must not produce an entry
|
||||
assert "orphan_device" not in skills
|
||||
assert len(skills) == 2
|
||||
|
||||
def test_topics_are_merged_under_topics(self, skills_dir):
|
||||
skills = SkillsLoader(str(skills_dir)).load_device_skills()
|
||||
frr = skills["frr_vtysh"]
|
||||
|
||||
# base-level content stays at the top level
|
||||
assert frr["config_commands"]["write_memory"]["syntax"] == "write memory"
|
||||
assert frr["category"] == "device"
|
||||
|
||||
topics = frr["topics"]
|
||||
assert topics["ospf"]["name"] == "OSPF (FRR 10.x)"
|
||||
assert topics["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp <asn>"
|
||||
# file without a topic field falls back to its filename stem
|
||||
assert "static" in topics
|
||||
# mismatched device_type is skipped
|
||||
assert "mpls" not in topics
|
||||
|
||||
def test_topic_metadata_is_stripped(self, skills_dir):
|
||||
skills = SkillsLoader(str(skills_dir)).load_device_skills()
|
||||
for topic_data in skills["frr_vtysh"]["topics"].values():
|
||||
assert "device_type" not in topic_data
|
||||
assert "topic" not in topic_data
|
||||
assert "category" not in topic_data
|
||||
assert "topics" not in topic_data
|
||||
|
||||
def test_single_file_device_has_no_topics_key(self, skills_dir):
|
||||
skills = SkillsLoader(str(skills_dir)).load_device_skills()
|
||||
assert "topics" not in skills["gns3_vpcs_telnet"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_registry():
|
||||
"""
|
||||
Populate SKILLS_REGISTRY with a split device and a single-file device,
|
||||
restoring the previous content afterwards.
|
||||
"""
|
||||
from gns3server.agent.gns3_copilot.skills import registry
|
||||
|
||||
saved = dict(registry.SKILLS_REGISTRY)
|
||||
registry.SKILLS_REGISTRY.clear()
|
||||
registry.SKILLS_REGISTRY.update(
|
||||
{
|
||||
"frr_vtysh": {
|
||||
"name": "FRR (Free Range Routing)",
|
||||
"description": "FRR test device",
|
||||
"category": "device",
|
||||
"config_commands": {"write_memory": {"syntax": "write memory"}},
|
||||
"topics": {
|
||||
"ospf": {
|
||||
"name": "OSPF (FRR 10.x)",
|
||||
"description": "OSPF topic",
|
||||
"config_commands": {"ospfv2": {"syntax": "router ospf"}},
|
||||
},
|
||||
"bgp": {
|
||||
"name": "BGP (FRR 10.x)",
|
||||
"description": "BGP topic",
|
||||
"config_commands": {"bgp_base": {"syntax": "router bgp <asn>"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
"gns3_vpcs_telnet": {
|
||||
"name": "VPCS",
|
||||
"description": "VPCS test device",
|
||||
"category": "device",
|
||||
"config_commands": {"ip_config": {"syntax": "ip <address>/<mask>"}},
|
||||
},
|
||||
}
|
||||
)
|
||||
yield registry
|
||||
registry.SKILLS_REGISTRY.clear()
|
||||
registry.SKILLS_REGISTRY.update(saved)
|
||||
|
||||
|
||||
class TestGetSkillTopics:
|
||||
"""
|
||||
Topic-level retrieval and topic index behavior in get_skill().
|
||||
"""
|
||||
|
||||
def test_topic_lookup_returns_topic_body(self, device_registry):
|
||||
result = get_skill("frr_vtysh", topic="bgp")
|
||||
assert result["device_type"] == "frr_vtysh"
|
||||
assert result["skill_name"] == "FRR (Free Range Routing)"
|
||||
assert result["topic"]["bgp"]["config_commands"]["bgp_base"]["syntax"] == "router bgp <asn>"
|
||||
|
||||
def test_topic_lookup_is_case_insensitive(self, device_registry):
|
||||
result = get_skill("frr_vtysh", topic="BGP")
|
||||
assert "bgp" in result["topic"]
|
||||
|
||||
def test_unknown_topic_lists_available_topics(self, device_registry):
|
||||
result = get_skill("frr_vtysh", topic="mpls")
|
||||
assert "error" in result
|
||||
assert sorted(result["available_topics"]) == ["bgp", "ospf"]
|
||||
|
||||
def test_full_without_topic_returns_index_not_bodies(self, device_registry):
|
||||
result = get_skill("frr_vtysh", detail="full")
|
||||
# base-level content is included...
|
||||
assert result["config_commands"]["write_memory"]["syntax"] == "write memory"
|
||||
# ...but topic bodies are never included without an explicit topic
|
||||
assert result["topics"] == {
|
||||
"ospf": "OSPF (FRR 10.x)",
|
||||
"bgp": "BGP (FRR 10.x)",
|
||||
}
|
||||
assert "config_commands" not in result["topics"]["ospf"]
|
||||
|
||||
def test_index_includes_topic_index(self, device_registry):
|
||||
result = get_skill("frr_vtysh", detail="index")
|
||||
assert result["topics"]["bgp"] == "BGP (FRR 10.x)"
|
||||
|
||||
def test_summary_includes_topic_descriptions(self, device_registry):
|
||||
result = get_skill("frr_vtysh", detail="summary")
|
||||
assert result["topics"]["ospf"] == {
|
||||
"name": "OSPF (FRR 10.x)",
|
||||
"description": "OSPF topic",
|
||||
}
|
||||
|
||||
def test_single_file_device_still_works(self, device_registry):
|
||||
result = get_skill("gns3_vpcs_telnet")
|
||||
assert result["config_commands"]["ip_config"]["syntax"] == "ip <address>/<mask>"
|
||||
assert "topics" not in result
|
||||
|
||||
|
||||
class TestDeviceSkillsToolTopics:
|
||||
"""
|
||||
DeviceSkillsTool passes the topic parameter through to get_skill().
|
||||
"""
|
||||
|
||||
def test_tool_topic_request(self, device_registry):
|
||||
import json
|
||||
|
||||
tool = DeviceSkillsTool()
|
||||
result = json.loads(tool._run('{"device_type": "frr_vtysh", "topic": "ospf"}'))
|
||||
assert result["topic"]["ospf"]["config_commands"]["ospfv2"]["syntax"] == "router ospf"
|
||||
|
||||
def test_tool_list_shows_topic_counts(self, device_registry):
|
||||
import json
|
||||
|
||||
tool = DeviceSkillsTool()
|
||||
result = json.loads(tool._run('{"action": "list"}'))
|
||||
by_type = {s["device_type"]: s for s in result["skills"]}
|
||||
assert by_type["frr_vtysh"]["topic_count"] == 2
|
||||
assert by_type["gns3_vpcs_telnet"]["topic_count"] == 0
|
||||
|
||||
|
||||
class TestReloadSkillsValidation:
|
||||
"""
|
||||
Invalid injection skills are dropped instead of merged into the registry.
|
||||
"""
|
||||
|
||||
def test_invalid_injection_skill_is_dropped(self, tmp_path, monkeypatch):
|
||||
from gns3server.config import Config
|
||||
from gns3server.agent.gns3_copilot.skills import registry
|
||||
from gns3server.agent.gns3_copilot.skills.manager import SkillsManager
|
||||
|
||||
# SkillsManager derives its local path from <config_dir>/skills
|
||||
injection_dir = tmp_path / "skills" / "injection"
|
||||
injection_dir.mkdir(parents=True)
|
||||
(injection_dir / "valid.yaml").write_text(
|
||||
"""
|
||||
name: "OSPF Issues Injection"
|
||||
description: "OSPF faults"
|
||||
category: "injection"
|
||||
issues:
|
||||
ospf_area_mismatch:
|
||||
name: "OSPF Area Mismatch"
|
||||
description: "Areas differ"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# missing the required "issues" field -> invalid, must be dropped
|
||||
(injection_dir / "broken.yaml").write_text(
|
||||
"""
|
||||
name: "Broken Injection"
|
||||
description: "No issues field"
|
||||
""",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(Config, "config_dir", property(lambda self: str(tmp_path)))
|
||||
|
||||
saved_injection = dict(registry.INJECTION_SKILLS_REGISTRY)
|
||||
saved_skills = dict(registry.SKILLS_REGISTRY)
|
||||
registry.INJECTION_SKILLS_REGISTRY.clear()
|
||||
registry.SKILLS_REGISTRY.clear()
|
||||
try:
|
||||
manager = SkillsManager(repo_url="https://example.invalid/gns3-skills.git")
|
||||
manager._repo = None
|
||||
assert manager.reload_skills() is True
|
||||
assert "injection_valid" in registry.INJECTION_SKILLS_REGISTRY
|
||||
assert "injection_broken" not in registry.INJECTION_SKILLS_REGISTRY
|
||||
finally:
|
||||
registry.INJECTION_SKILLS_REGISTRY.clear()
|
||||
registry.INJECTION_SKILLS_REGISTRY.update(saved_injection)
|
||||
registry.SKILLS_REGISTRY.clear()
|
||||
registry.SKILLS_REGISTRY.update(saved_skills)
|
||||
@ -306,6 +306,45 @@ class TestDockerNodesRoutes:
|
||||
assert response.json()["environment"] == "GNS3=1\nGNS4=0"
|
||||
assert response.json()["extra_hosts"] == "test:127.0.0.1"
|
||||
|
||||
async def test_docker_update_empty_strings_do_not_recreate_container(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project
|
||||
) -> None:
|
||||
"""
|
||||
Web clients serialize empty form fields as "" while unset values are
|
||||
stored as None on the node: a full PUT must not see a phantom change
|
||||
and recreate the container for nothing.
|
||||
"""
|
||||
|
||||
params = {"name": "DOCKER-EMPTY", "image": "nginx", "environment": ""}
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.list_images", return_value=[{"image": "nginx"}]):
|
||||
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": "8bd8153ea8f5"}):
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_docker_node", project_id=compute_project.id), json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["environment"] is None # "" normalized at creation
|
||||
assert response.json()["console_http_path"] == "/"
|
||||
node_id = response.json()["node_id"]
|
||||
|
||||
with asyncio_patch("gns3server.compute.docker.docker_vm.DockerVM.update") as mock:
|
||||
response = await compute_client.put(
|
||||
app.url_path_for("compute:update_docker_node", project_id=compute_project.id, node_id=node_id),
|
||||
json={
|
||||
"name": "DOCKER-EMPTY",
|
||||
"start_command": "",
|
||||
"environment": "",
|
||||
"extra_hosts": "",
|
||||
"console_http_path": "",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert not mock.called # no real change: the container must not be recreated
|
||||
assert response.json()["start_command"] is None
|
||||
assert response.json()["console_http_path"] == "/"
|
||||
|
||||
|
||||
async def test_docker_start_capture(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None:
|
||||
|
||||
|
||||
@ -382,6 +382,7 @@ class TestEthernetSwitchNodesRoutes:
|
||||
response = await compute_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
# the port's relay bridge and TAP are released from the kernel bridge
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
relay = f"{node.id}-0"
|
||||
@ -390,6 +391,92 @@ class TestEthernetSwitchNodesRoutes:
|
||||
call(f"bridge delete {relay}"),
|
||||
])
|
||||
|
||||
async def test_ethernet_switch_update_nio(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
params = self._udp_params()
|
||||
params["filters"] = {"delay": [10, 0]}
|
||||
response = await compute_client.post(url, json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
params["filters"] = {"packet_loss": [10]}
|
||||
params["markers"] = {}
|
||||
url = app.url_path_for(
|
||||
"compute:update_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
response = await compute_client.put(url, json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["filters"] == {"packet_loss": [10]}
|
||||
|
||||
# update_nio re-applies the filters on the port's uBridge relay
|
||||
relay = node._ubridge_bridge_name(0)
|
||||
node._ubridge_send.assert_any_call(f"bridge reset_packet_filters {relay}")
|
||||
|
||||
async def test_ethernet_switch_toggle_marker(
|
||||
self,
|
||||
app: FastAPI,
|
||||
compute_client: AsyncClient,
|
||||
compute_project: Project,
|
||||
ethernet_switch: dict
|
||||
) -> None:
|
||||
|
||||
# a marker installed via the NIO registers in the node's filter-bridge map
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0"
|
||||
)
|
||||
params = self._udp_params()
|
||||
params["markers"] = {"icmp": {"bpf": "icmp", "link_id": "link-1", "enabled": True}}
|
||||
response = await compute_client.post(url, json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:toggle_ethernet_switch_marker",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
marker_name="icmp"
|
||||
)
|
||||
response = await compute_client.put(url, json={"enabled": False})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {"marker_name": "icmp", "enabled": False}
|
||||
relay = node._ubridge_bridge_name(0)
|
||||
node._ubridge_send.assert_any_call(f"bridge enable_packet_filter {relay} icmp off")
|
||||
|
||||
# toggling an unknown marker is a 404
|
||||
url = app.url_path_for(
|
||||
"compute:toggle_ethernet_switch_marker",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
marker_name="nope"
|
||||
)
|
||||
response = await compute_client.put(url, json={"enabled": True})
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
|
||||
async def test_ethernet_switch_start_capture(
|
||||
self,
|
||||
app: FastAPI,
|
||||
|
||||
77
tests/api/routes/controller/test_netmiko.py
Normal file
77
tests/api/routes/controller/test_netmiko.py
Normal file
@ -0,0 +1,77 @@
|
||||
#
|
||||
# Copyright (C) 2020 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/>.
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
class TestNetmikoRoutes:
|
||||
|
||||
async def test_device_types(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
"""
|
||||
Test listing the device types supported by the installed Netmiko library.
|
||||
"""
|
||||
|
||||
pytest.importorskip("netmiko", reason="netmiko is not installed")
|
||||
import netmiko
|
||||
|
||||
response = await client.get(app.url_path_for("get_netmiko_device_types"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
data = response.json()
|
||||
assert data["netmiko_version"] == netmiko.__version__
|
||||
|
||||
device_types = data["device_types"]
|
||||
assert len(device_types) > 0
|
||||
|
||||
names = [entry["name"] for entry in device_types]
|
||||
assert names == sorted(names)
|
||||
|
||||
by_name = {entry["name"]: entry for entry in device_types}
|
||||
assert by_name["cisco_ios"]["telnet"] is False
|
||||
assert by_name["cisco_ios"]["custom"] is False
|
||||
assert by_name["cisco_ios_telnet"]["telnet"] is True
|
||||
|
||||
# '_ssh' aliases and the 'autodetect' pseudo device type are filtered out
|
||||
assert not [name for name in names if name.endswith("_ssh")]
|
||||
assert "autodetect" not in names
|
||||
|
||||
# GNS3-copilot custom drivers are flagged as custom
|
||||
assert by_name["gns3_vpcs_telnet"]["custom"] is True
|
||||
assert by_name["gns3_vpcs_telnet"]["telnet"] is True
|
||||
|
||||
async def test_device_types_unavailable(self, app: FastAPI, client: AsyncClient, monkeypatch) -> None:
|
||||
"""
|
||||
Test that a 501 is returned when Netmiko is not installed.
|
||||
"""
|
||||
|
||||
from gns3server.api.routes.controller import netmiko as netmiko_route
|
||||
|
||||
def _raise_import_error():
|
||||
raise ImportError("No module named 'netmiko'")
|
||||
|
||||
monkeypatch.setattr(netmiko_route, "_load_netmiko_device_types", _raise_import_error)
|
||||
monkeypatch.setattr(netmiko_route, "_device_types_cache", None)
|
||||
|
||||
response = await client.get(app.url_path_for("get_netmiko_device_types"))
|
||||
assert response.status_code == status.HTTP_501_NOT_IMPLEMENTED
|
||||
# the HTTP exception handler formats errors with a "message" key
|
||||
assert "ai-features" in response.json()["message"]
|
||||
@ -146,7 +146,8 @@ class TestTemplateRoutes:
|
||||
"version": "3.0",
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs",
|
||||
"tags": ["tag1", "tag2"]
|
||||
"tags": ["tag1", "tag2"],
|
||||
"netmiko_device_type": "generic_termserver_telnet"
|
||||
}
|
||||
|
||||
response = await client.post(app.url_path_for("create_template"), json=params)
|
||||
@ -156,12 +157,59 @@ class TestTemplateRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["template_id"] == template_id
|
||||
assert response.json()["tags"] == ["tag1", "tag2"]
|
||||
assert response.json()["netmiko_device_type"] == "generic_termserver_telnet"
|
||||
|
||||
params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True}
|
||||
params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True, "netmiko_device_type": "cisco_ios"}
|
||||
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["name"] == "VPCS_TEST_RENAMED"
|
||||
assert response.json()["netmiko_device_type"] == "cisco_ios"
|
||||
|
||||
# the field can also be cleared with an empty string
|
||||
params = {"netmiko_device_type": ""}
|
||||
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["netmiko_device_type"] == ""
|
||||
|
||||
async def test_template_appliance_metadata_roundtrip(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
"""
|
||||
Appliance metadata persists with the template: create, read back,
|
||||
and replace on update. Unknown fields are kept (extra=allow) so that
|
||||
future appliance registry fields do not vanish.
|
||||
"""
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
params = {
|
||||
"template_id": template_id,
|
||||
"name": "VPCS_METADATA",
|
||||
"compute_id": "local",
|
||||
"template_type": "vpcs",
|
||||
"appliance_metadata": {
|
||||
"vendor_name": "Test vendor",
|
||||
"default_username": "admin",
|
||||
"future_field": "kept",
|
||||
},
|
||||
}
|
||||
|
||||
response = await client.post(app.url_path_for("create_template"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
metadata = response.json()["appliance_metadata"]
|
||||
assert metadata["vendor_name"] == "Test vendor"
|
||||
assert metadata["default_username"] == "admin"
|
||||
assert metadata["future_field"] == "kept"
|
||||
|
||||
# read back from the database
|
||||
response = await client.get(app.url_path_for("get_template", template_id=template_id))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["appliance_metadata"] == metadata
|
||||
|
||||
# the metadata object is replaced as a whole on update
|
||||
params = {"appliance_metadata": {"default_username": "root"}}
|
||||
response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["appliance_metadata"] == {"default_username": "root"}
|
||||
|
||||
async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
|
||||
@ -106,7 +106,8 @@ class TestRoutes:
|
||||
async with aconnect_ws(path, client, params=params) as ws:
|
||||
json_notification = await ws.receive_json()
|
||||
assert json_notification['event'] == {
|
||||
'message': 'Could not authenticate while connecting to controller WebSocket: Could not validate credentials'
|
||||
'message': 'Could not authenticate while connecting to controller WebSocket: '
|
||||
'Invalid token (DecodeError) (received token sha256 prefix: 4d4f92fb)'
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -24,8 +24,10 @@ These tests cover:
|
||||
* 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;
|
||||
* persistent volumes being seeded host-side and bound directly at their
|
||||
real in-container paths (no post-start bridge racing the NOS boot);
|
||||
* the docker_exec console dispatch in start();
|
||||
* the SKIP_INIT volume bridge and container-side _fix_permissions passes.
|
||||
* the container-side _fix_permissions passes.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
@ -235,28 +237,36 @@ async def test_create_interface_names_sets_max_ethernet(compute_project, manager
|
||||
async def test_create_drops_etc_network_for_skip_init(compute_project, manager):
|
||||
|
||||
response = _create_response(None, volumes={"/opt/srlinux/appmgr": None})
|
||||
seed_proc = MagicMock()
|
||||
seed_proc.communicate = AsyncioMagicMock(return_value=(b"seedcid", b""))
|
||||
seed_proc.returncode = 0
|
||||
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"))
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=seed_proc):
|
||||
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
|
||||
assert "/etc/network" not in targets
|
||||
# the declared volumes are bound DIRECTLY at their real paths —
|
||||
# no /gns3volumes aliasing and no post-start bridge
|
||||
assert "/opt/srlinux/appmgr" in targets
|
||||
assert "/etc/opt/srlinux" in targets
|
||||
assert not any(t.startswith("/gns3volumes/") for t 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
|
||||
@ -321,7 +331,6 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager):
|
||||
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"):
|
||||
@ -330,8 +339,8 @@ async def test_start_docker_exec_dispatches_console(compute_project, manager):
|
||||
|
||||
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()
|
||||
# SKIP_INIT path still runs the permission fix (volumes are already
|
||||
# seeded and bound at create time — no post-start bridge anymore)
|
||||
vm._fix_permissions.assert_called_once()
|
||||
|
||||
|
||||
@ -346,19 +355,18 @@ async def test_start_without_skip_init_skips_vendor_passes(compute_project, mana
|
||||
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()
|
||||
# init.sh runs (no SKIP_INIT) → no vendor permission pass
|
||||
vm._fix_permissions.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _fix_permissions — container-side, skips dead containers, targets /gns3volumes
|
||||
# _fix_permissions — container-side, skips dead containers, targets volume paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@ -387,7 +395,7 @@ async def test_fix_permissions_skips_missing_container(compute_project, manager)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fix_permissions_targets_gns3volumes(compute_project, manager):
|
||||
async def test_fix_permissions_targets_volume_paths(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1")
|
||||
vm._volumes = ["/etc/opt/srlinux", "/var/log/srlinux"]
|
||||
@ -404,37 +412,120 @@ async def test_fix_permissions_targets_gns3volumes(compute_project, manager):
|
||||
await vm._fix_permissions()
|
||||
# one exec per volume
|
||||
assert mock_exec.call_count == 2
|
||||
# each script must target /gns3volumes<volume>, not the raw path
|
||||
# each script must target the real in-container path (the direct
|
||||
# bind mount), never the old /gns3volumes alias
|
||||
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
|
||||
assert "/gns3volumes" not in script
|
||||
assert '"/etc/opt/srlinux"' in script or '"/var/log/srlinux"' in script
|
||||
assert 'chown' in script
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _setup_skip_init_volumes — bridge via docker exec
|
||||
# _prepare_volumes — host-side seeding (docker create + cp + rm)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _seed_proc(stdout=b"seedcid\n", returncode=0):
|
||||
proc = MagicMock()
|
||||
proc.communicate = AsyncioMagicMock(return_value=(stdout, b""))
|
||||
proc.returncode = returncode
|
||||
return proc
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_setup_skip_init_volumes_runs_exec(compute_project, manager):
|
||||
async def test_prepare_volumes_seeds_unmarked_volume(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
|
||||
image_info = {"Config": {"Volumes": {}}}
|
||||
|
||||
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
|
||||
return_value=_seed_proc()) as mock_exec:
|
||||
await vm._prepare_volumes(image_info)
|
||||
# docker create + docker cp + docker rm
|
||||
assert mock_exec.call_count == 3
|
||||
argvs = [c.args for c in mock_exec.call_args_list]
|
||||
assert argvs[0][1:3] == ("create", "srlinux:latest")
|
||||
assert argvs[1][1:4] == ("cp", "-a", "seedcid:/etc/opt/srlinux/.")
|
||||
assert argvs[2][1:3] == ("rm", "-f")
|
||||
host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux")
|
||||
assert os.path.exists(os.path.join(host_dir, ".gns3_perms"))
|
||||
|
||||
# a second create() must not re-seed (marker present): no docker CLI call
|
||||
await vm._prepare_volumes(image_info)
|
||||
assert mock_exec.call_count == 3
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_volumes_never_overwrites_marked_volume(compute_project, manager):
|
||||
"""Regression guard: a volume that ever started (marker present) holds the
|
||||
node's saved configuration — re-seeding would reset it to factory."""
|
||||
|
||||
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
|
||||
extra_volumes=["/etc/opt/srlinux"])
|
||||
host_dir = os.path.join(vm.working_dir, "etc", "opt", "srlinux")
|
||||
os.makedirs(host_dir, exist_ok=True)
|
||||
marker = os.path.join(host_dir, ".gns3_perms")
|
||||
open(marker, "w").close()
|
||||
saved = os.path.join(host_dir, "config.json")
|
||||
with open(saved, "w") as f:
|
||||
f.write('{"user": "config"}')
|
||||
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=_seed_proc()) as mock_exec:
|
||||
await vm._prepare_volumes({"Config": {"Volumes": {}}})
|
||||
mock_exec.assert_not_called()
|
||||
with open(saved) as f:
|
||||
assert f.read() == '{"user": "config"}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_volumes_tolerates_missing_image_path(compute_project, manager):
|
||||
"""A volume path the image does not contain (e.g. XRd's /xr-storage-shadow)
|
||||
starts empty — cp fails, the marker is still written, no raise."""
|
||||
|
||||
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
|
||||
extra_volumes=["/xr-storage-shadow"])
|
||||
calls = {"n": 0}
|
||||
|
||||
def proc_factory(*args, **kwargs):
|
||||
# first call (docker create) succeeds, second (docker cp) fails,
|
||||
# third (docker rm) succeeds
|
||||
codes = [0, 1, 0]
|
||||
proc = _seed_proc(returncode=codes[calls["n"]])
|
||||
calls["n"] += 1
|
||||
return proc
|
||||
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
side_effect=proc_factory):
|
||||
await vm._prepare_volumes({"Config": {"Volumes": {}}})
|
||||
assert calls["n"] == 3 # rm still ran (finally path)
|
||||
host_dir = os.path.join(vm.working_dir, "xr-storage-shadow")
|
||||
assert os.path.exists(os.path.join(host_dir, ".gns3_perms"))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_volumes_skips_without_skip_init(compute_project, manager):
|
||||
|
||||
vm = _make_vm(compute_project, manager) # no SKIP_INIT
|
||||
with patch("asyncio.subprocess.create_subprocess_exec",
|
||||
return_value=_seed_proc()) as mock_exec:
|
||||
await vm._prepare_volumes({"Config": {"Volumes": {"/etc/opt/srlinux": None}}})
|
||||
mock_exec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_volumes_raises_when_seed_container_fails(compute_project, manager):
|
||||
"""If `docker create` itself fails, creation must abort loudly instead of
|
||||
binding an empty directory over the NOS's config path."""
|
||||
|
||||
vm = _make_vm(compute_project, manager, environment="GNS3_SKIP_INIT=1",
|
||||
extra_volumes=["/etc/opt/srlinux"])
|
||||
proc = _seed_proc(stdout=b"", returncode=1)
|
||||
|
||||
with patch("asyncio.subprocess.create_subprocess_exec", return_value=proc):
|
||||
with pytest.raises(DockerError):
|
||||
await vm._prepare_volumes({"Config": {"Volumes": {}}})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -463,12 +554,15 @@ def test_cleanup_console_resources_no_writer(compute_project, manager):
|
||||
# _LazyExecTelnetServer — upstream aliveness + reconnect/recreate logic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_lazy_server(compute_project, manager):
|
||||
def _make_lazy_server(compute_project, manager, environment="GNS3_CONSOLE_CMD=/opt/srlinux/bin/sr_cli"):
|
||||
"""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")
|
||||
vm = _make_vm(compute_project, manager, environment=environment)
|
||||
srv = _LazyExecTelnetServer(
|
||||
vm, manager, "e90e34656842", "/opt/srlinux/bin/sr_cli",
|
||||
allow_resize=vm._console_resize,
|
||||
)
|
||||
srv._create_exec = AsyncioMagicMock()
|
||||
srv._on_naws = AsyncioMagicMock()
|
||||
srv._resize_exec = AsyncioMagicMock()
|
||||
return srv
|
||||
|
||||
|
||||
@ -531,6 +625,74 @@ async def test_first_connect_creates_exec(compute_project, manager):
|
||||
srv._create_exec.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_connect_sets_tall_default_pty_geometry(compute_project, manager):
|
||||
"""The exec PTY must start tall/wide: a 24-row initial geometry makes CLIs
|
||||
that page on the PTY window size (IOS-XR pager) park at --More-- for
|
||||
clients that never send NAWS (netmiko, bare telnet)."""
|
||||
|
||||
srv = _make_lazy_server(compute_project, manager)
|
||||
await srv.client_connected_hook()
|
||||
srv._resize_exec.assert_called_once_with(511, 10000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_naws_resizes_exec_by_default(compute_project, manager):
|
||||
"""Client-driven NAWS (WS terminal-size frames) reaches the exec resize."""
|
||||
|
||||
srv = _make_lazy_server(compute_project, manager)
|
||||
await srv._on_naws(120, 40)
|
||||
srv._resize_exec.assert_called_once_with(120, 40)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_client_naws_ignored_when_resize_disabled(compute_project, manager):
|
||||
"""GNS3_CONSOLE_RESIZE=0: client resizes must not change the shared exec
|
||||
geometry (paging CLIs need the tall default for concurrent netmiko)."""
|
||||
|
||||
srv = _make_lazy_server(
|
||||
compute_project, manager,
|
||||
environment="GNS3_CONSOLE_RESIZE=0",
|
||||
)
|
||||
assert srv._allow_resize is False
|
||||
await srv._on_naws(120, 40)
|
||||
srv._resize_exec.assert_not_called()
|
||||
# the tall default is still applied at exec creation (internal path)
|
||||
await srv.client_connected_hook()
|
||||
srv._resize_exec.assert_called_once_with(511, 10000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_last_client_disconnect_restores_tall_default(compute_project, manager):
|
||||
"""When the last console client leaves, the exec goes back to the tall
|
||||
no-NAWS default so a later non-NAWS client (netmiko) doesn't inherit a
|
||||
browser geometry and hit PTY-window paging."""
|
||||
|
||||
srv = _make_lazy_server(compute_project, manager)
|
||||
srv._exec_id = "abc"
|
||||
writer = AsyncioMagicMock()
|
||||
await srv._disconnect_client(writer)
|
||||
srv._resize_exec.assert_called_once_with(511, 10000)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_size_arriving_before_exec_wins_over_default(compute_project, manager):
|
||||
"""A client size that races the exec creation (WS control frame / NAWS
|
||||
arriving inside client_connected_hook) must not be overwritten by the
|
||||
tall default once the exec exists."""
|
||||
|
||||
srv = _make_lazy_server(compute_project, manager)
|
||||
assert srv._exec_id is None
|
||||
# real _resize_exec (not the mock) records the size when no exec exists
|
||||
srv._resize_exec = _LazyExecTelnetServer._resize_exec.__get__(srv)
|
||||
await srv._on_naws(120, 40)
|
||||
assert srv._client_size == (120, 40)
|
||||
|
||||
srv._resize_exec = AsyncioMagicMock()
|
||||
await srv.client_connected_hook()
|
||||
srv._resize_exec.assert_called_once_with(120, 40)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconnect_live_exec_not_recreated(compute_project, manager):
|
||||
"""Reconnecting while the exec is alive must NOT recreate it."""
|
||||
|
||||
@ -51,11 +51,16 @@ def test_temporary_directory(compute_project, manager):
|
||||
assert isinstance(node.temporary_directory, str)
|
||||
|
||||
|
||||
def test_console(compute_project, manager):
|
||||
def test_console(compute_project, manager, port_manager):
|
||||
|
||||
node = VPCSVM("test", "00010203-0405-0607-0809-0a0b0c0d0e0f", compute_project, manager)
|
||||
node.console = 5011
|
||||
assert node.console == 5011
|
||||
# pick a port that is actually free on this host: a hardcoded one may be
|
||||
# taken by a running gns3server/qemu on a dev machine, and the setter would
|
||||
# silently replace it with the next free port
|
||||
console_port = port_manager.get_free_tcp_port(node.project)
|
||||
port_manager.release_tcp_port(console_port, node.project)
|
||||
node.console = console_port
|
||||
assert node.console == console_port
|
||||
node.console = None
|
||||
assert node.console is None
|
||||
|
||||
|
||||
@ -155,7 +155,10 @@ def unauthorized_client(base_client: AsyncClient, test_user: User) -> AsyncClien
|
||||
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
||||
def authorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
|
||||
|
||||
access_token = auth_service.create_access_token(test_user.username)
|
||||
# Sign with the default secret key: the class-scoped token must stay valid
|
||||
# across every test, but "run_around_tests" resets the config and forces
|
||||
# jwt_secret_key back to the default for each test function.
|
||||
access_token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||
base_client.headers = {
|
||||
**base_client.headers,
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
@ -168,7 +171,9 @@ async def client(base_client: AsyncClient) -> AsyncClient:
|
||||
|
||||
# The super admin is automatically created when the users table is created
|
||||
# this account that can access all endpoints without restrictions.
|
||||
access_token = auth_service.create_access_token("admin")
|
||||
# Sign with the default secret key so the token matches the one enforced
|
||||
# by "run_around_tests" when the config is reset for each test function.
|
||||
access_token = auth_service.create_access_token("admin", secret_key=DEFAULT_JWT_SECRET_KEY)
|
||||
base_client.headers = {
|
||||
**base_client.headers,
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
|
||||
210
tests/controller/test_appliance.py
Normal file
210
tests/controller/test_appliance.py
Normal file
@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 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/>.
|
||||
|
||||
import pydantic
|
||||
import pytest
|
||||
|
||||
from gns3server.controller.appliance import Appliance
|
||||
from gns3server.controller.appliance_to_template import ApplianceToTemplate
|
||||
from gns3server.schemas.controller.appliances import ApplianceModel
|
||||
|
||||
|
||||
# v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions)
|
||||
XRD_V8 = {
|
||||
"registry_version": 8,
|
||||
"appliance_id": "e4a3a5fe-3a13-521b-abd1-ab9483e83aa2",
|
||||
"name": "XRd Control Plane",
|
||||
"category": "router",
|
||||
"description": "Cisco IOS XRd Control Plane",
|
||||
"vendor_name": "Cisco",
|
||||
"vendor_url": "https://www.cisco.com/",
|
||||
"product_name": "XRd Control Plane",
|
||||
"status": "experimental",
|
||||
"availability": "service-contract",
|
||||
"maintainer": "GNS3 Team",
|
||||
"maintainer_email": "developers@gns3.net",
|
||||
"usage": "XRd usage",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "Default template settings",
|
||||
"default": True,
|
||||
"template_type": "docker",
|
||||
"template_properties": {
|
||||
"adapters": 24,
|
||||
"image": "ios-xr/xrd-control-plane:24.4.1",
|
||||
"console_type": "docker_exec",
|
||||
"environment": "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh",
|
||||
"extra_volumes": ["/xr-storage"],
|
||||
"custom_adapters": [
|
||||
{"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"},
|
||||
{"adapter_number": 1, "port_name": "Gi0/0/0/0"},
|
||||
],
|
||||
"extra_configs": [
|
||||
{"target": "/firstboot.cfg", "content": "!\nend\n"}
|
||||
],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _appliance(data, builtin=True):
|
||||
return Appliance("test.gns3a", data, builtin=builtin)
|
||||
|
||||
|
||||
def test_v8_docker_appliance_validates_with_custom_adapters():
|
||||
# the discriminated union routes to ApplianceV8 and accepts custom_adapters
|
||||
model = ApplianceModel.model_validate(XRD_V8)
|
||||
assert model.registry_version == 8
|
||||
assert model.settings[0].template_properties.custom_adapters[0].port_name == "MgmtEth0/RP0/CPU0/0"
|
||||
|
||||
|
||||
def test_v8_docker_appliance_type():
|
||||
assert _appliance(XRD_V8).type == "docker"
|
||||
|
||||
|
||||
def test_v8_type_from_default_settings():
|
||||
# the default set wins over the other sets
|
||||
appliance = dict(
|
||||
XRD_V8,
|
||||
settings=[
|
||||
{"name": "a", "template_type": "docker", "template_properties": {"image": "test:latest"}},
|
||||
{"name": "b", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}},
|
||||
],
|
||||
)
|
||||
# the fixture must be a loadable appliance, not an unreachable shape
|
||||
ApplianceModel.model_validate(appliance)
|
||||
assert _appliance(appliance).type == "qemu"
|
||||
|
||||
|
||||
def test_v8_qemu_appliance_type_without_default():
|
||||
appliance = dict(
|
||||
XRD_V8,
|
||||
settings=[{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}],
|
||||
)
|
||||
ApplianceModel.model_validate(appliance)
|
||||
assert _appliance(appliance).type == "qemu"
|
||||
|
||||
|
||||
def test_v6_docker_appliance_type():
|
||||
appliance = {
|
||||
"registry_version": 6,
|
||||
"name": "v6 docker",
|
||||
"status": "stable",
|
||||
"docker": {"image": "test:latest"},
|
||||
}
|
||||
assert _appliance(appliance).type == "docker"
|
||||
|
||||
|
||||
def test_v8_docker_install_conversion():
|
||||
"""
|
||||
The vendor NOS v8 shape (docker_exec console, env knobs, extra volumes,
|
||||
custom adapters, extra configs) converts into a docker template.
|
||||
"""
|
||||
|
||||
template = ApplianceToTemplate().new_template(_appliance(XRD_V8).asdict(), None, "local")
|
||||
|
||||
assert template["template_type"] == "docker"
|
||||
assert template["image"] == "ios-xr/xrd-control-plane:24.4.1"
|
||||
assert template["console_type"] == "docker_exec"
|
||||
assert template["environment"] == "GNS3_SKIP_INIT=1\nGNS3_CONSOLE_CMD=/pkg/bin/xr_cli.sh"
|
||||
assert template["extra_volumes"] == ["/xr-storage"]
|
||||
assert template["custom_adapters"] == [
|
||||
{"adapter_number": 0, "port_name": "MgmtEth0/RP0/CPU0/0"},
|
||||
{"adapter_number": 1, "port_name": "Gi0/0/0/0"},
|
||||
]
|
||||
assert template["extra_configs"] == [{"target": "/firstboot.cfg", "content": "!\nend\n"}]
|
||||
|
||||
|
||||
def test_v8_docker_settings_require_image():
|
||||
"""
|
||||
template_properties must be validated against the model matching
|
||||
template_type: a docker settings set without the required image is
|
||||
rejected instead of being silently misrouted to another union member.
|
||||
"""
|
||||
|
||||
appliance = dict(
|
||||
XRD_V8,
|
||||
settings=[
|
||||
{"name": "only", "default": True, "template_type": "docker",
|
||||
"template_properties": {"adapters": 2}},
|
||||
],
|
||||
)
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
ApplianceModel.model_validate(appliance)
|
||||
|
||||
|
||||
def test_v8_template_properties_validated_against_template_type():
|
||||
"""
|
||||
Invalid enum values in qemu properties must be rejected at load time,
|
||||
not silently discarded by a misrouted union member.
|
||||
"""
|
||||
|
||||
appliance = dict(
|
||||
XRD_V8,
|
||||
settings=[
|
||||
{"name": "only", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "boot_priority": "zzz"}},
|
||||
],
|
||||
)
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
ApplianceModel.model_validate(appliance)
|
||||
|
||||
|
||||
def test_v8_qemu_kvm_property_validates():
|
||||
appliance = dict(
|
||||
XRD_V8,
|
||||
settings=[
|
||||
{"name": "only", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "kvm": "disable"}},
|
||||
],
|
||||
)
|
||||
model = ApplianceModel.model_validate(appliance)
|
||||
assert model.settings[0].template_properties.kvm == "disable"
|
||||
|
||||
|
||||
def test_v8_qemu_cpu_throttling_range():
|
||||
"""
|
||||
cpu_throttling in v8 properties uses the same type and range as the
|
||||
qemu template (int, 0-800).
|
||||
"""
|
||||
|
||||
settings = {"name": "only", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "cpu_throttling": 500}}
|
||||
model = ApplianceModel.model_validate(dict(XRD_V8, settings=[settings]))
|
||||
assert model.settings[0].template_properties.cpu_throttling == 500
|
||||
|
||||
for bad in (150.5, 900):
|
||||
bad_settings = dict(settings, template_properties={"ram": 512, "cpu_throttling": bad})
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
ApplianceModel.model_validate(dict(XRD_V8, settings=[bad_settings]))
|
||||
|
||||
|
||||
def test_v8_version_idlepc_validates():
|
||||
appliance = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "0x613080c0"}])
|
||||
ApplianceModel.model_validate(appliance)
|
||||
|
||||
bad = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "not-an-idlepc"}])
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
ApplianceModel.model_validate(bad)
|
||||
|
||||
|
||||
def test_v8_netmiko_device_type_empty_string_clears():
|
||||
appliance = dict(XRD_V8, netmiko_device_type="")
|
||||
model = ApplianceModel.model_validate(appliance)
|
||||
assert model.netmiko_device_type == ""
|
||||
154
tests/controller/test_appliance_manager.py
Normal file
154
tests/controller/test_appliance_manager.py
Normal file
@ -0,0 +1,154 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 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/>.
|
||||
|
||||
import uuid
|
||||
import pytest
|
||||
|
||||
from gns3server.controller.appliance import Appliance
|
||||
from gns3server.controller.appliance_manager import ApplianceManager
|
||||
|
||||
|
||||
def _v8_appliance(settings, versions=None):
|
||||
"""
|
||||
A minimal but fully valid registry version 8 appliance (it must pass
|
||||
ApplianceModel validation, like appliances loaded by load_appliances).
|
||||
"""
|
||||
|
||||
data = {
|
||||
"registry_version": 8,
|
||||
"appliance_id": str(uuid.uuid4()),
|
||||
"name": "Test appliance",
|
||||
"category": "router",
|
||||
"description": "Appliance description",
|
||||
"vendor_name": "Test vendor",
|
||||
"vendor_url": "https://example.com/",
|
||||
"product_name": "Test product",
|
||||
"status": "stable",
|
||||
"maintainer": "Test maintainer",
|
||||
"maintainer_email": "maintainer@example.com",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"settings": settings,
|
||||
}
|
||||
if versions:
|
||||
data["versions"] = versions
|
||||
return data
|
||||
|
||||
|
||||
class _FakeTemplatesService:
|
||||
"""
|
||||
Stands in for TemplatesService so install_appliance can be exercised
|
||||
without a controller instance or database.
|
||||
"""
|
||||
|
||||
created = []
|
||||
|
||||
def __init__(self, templates_repo):
|
||||
self._templates_repo = templates_repo
|
||||
|
||||
async def create_template(self, template_create):
|
||||
_FakeTemplatesService.created.append(template_create)
|
||||
return {"name": template_create.name}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_docker_version_skips_image_resolution(monkeypatch):
|
||||
"""
|
||||
v8 docker appliances have no image files: installing a version must not
|
||||
resolve an image directory (default_images_directory does not support
|
||||
docker) nor iterate the appliance images list.
|
||||
"""
|
||||
|
||||
_FakeTemplatesService.created = []
|
||||
appliance_data = _v8_appliance(
|
||||
[
|
||||
{"name": "default", "default": True, "template_type": "docker",
|
||||
"template_properties": {"image": "xrd:latest"}},
|
||||
],
|
||||
versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
|
||||
)
|
||||
manager = ApplianceManager()
|
||||
appliance = Appliance("test.gns3a", appliance_data)
|
||||
manager._appliances[appliance.id] = appliance
|
||||
|
||||
def _boom(image_type):
|
||||
raise AssertionError(f"default_images_directory must not be called for docker (got '{image_type}')")
|
||||
|
||||
monkeypatch.setattr("gns3server.controller.appliance_manager.default_images_directory", _boom)
|
||||
monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
|
||||
|
||||
await manager.install_appliance(uuid.UUID(appliance.id), "1.0", None, None, None, None)
|
||||
|
||||
assert len(_FakeTemplatesService.created) == 1
|
||||
template = _FakeTemplatesService.created[0].model_dump()
|
||||
assert template["template_type"] == "docker"
|
||||
# the version image name is injected into the template
|
||||
assert template["image"] == "xrd:1.0"
|
||||
# appliance metadata survives the install and validates through TemplateCreate
|
||||
metadata = template["appliance_metadata"]
|
||||
assert metadata["vendor_name"] == "Test vendor"
|
||||
assert metadata["appliance_id"] == appliance.id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path):
|
||||
"""
|
||||
v8 IOU versions install: the version image is mapped to the template
|
||||
path (an IOU template has no 'image' field).
|
||||
"""
|
||||
|
||||
_FakeTemplatesService.created = []
|
||||
appliance_data = _v8_appliance(
|
||||
[
|
||||
{"name": "default", "default": True, "template_type": "iou",
|
||||
"template_properties": {"ethernet_adapters": 4, "ram": 256}},
|
||||
],
|
||||
versions=[{"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}],
|
||||
)
|
||||
manager = ApplianceManager()
|
||||
appliance = Appliance("test.gns3a", appliance_data)
|
||||
manager._appliances[appliance.id] = appliance
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gns3server.controller.appliance_manager.default_images_directory",
|
||||
lambda image_type: str(tmp_path),
|
||||
)
|
||||
monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
|
||||
|
||||
await manager.install_appliance(uuid.UUID(appliance.id), "15.9", None, None, None, None)
|
||||
|
||||
assert len(_FakeTemplatesService.created) == 1
|
||||
template = _FakeTemplatesService.created[0].model_dump()
|
||||
assert template["template_type"] == "iou"
|
||||
assert template["path"] == "i86bi-linux-l3-15.9.bin"
|
||||
assert "image" not in template
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_version_not_found(monkeypatch):
|
||||
manager = ApplianceManager()
|
||||
appliance_data = _v8_appliance(
|
||||
[{"name": "only", "default": True, "template_type": "docker",
|
||||
"template_properties": {"image": "xrd:latest"}}],
|
||||
versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
|
||||
)
|
||||
appliance = Appliance("test.gns3a", appliance_data)
|
||||
manager._appliances[appliance.id] = appliance
|
||||
|
||||
from gns3server.controller.controller_error import ControllerNotFoundError
|
||||
|
||||
with pytest.raises(ControllerNotFoundError):
|
||||
await manager.install_appliance(uuid.UUID(appliance.id), "9.9", None, None, None, None)
|
||||
623
tests/controller/test_appliance_to_template.py
Normal file
623
tests/controller/test_appliance_to_template.py
Normal file
@ -0,0 +1,623 @@
|
||||
#!/usr/bin/env python
|
||||
#
|
||||
# Copyright (C) 2026 GNS3 Technologies Inc.
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU 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/>.
|
||||
|
||||
import pytest
|
||||
import pydantic
|
||||
|
||||
from gns3server.controller.appliance_to_template import ApplianceToTemplate
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
from gns3server.schemas.controller.appliances import ApplianceModel
|
||||
|
||||
|
||||
# reduced mirror of the upstream vyos.gns3a (registry version 8, qemu, 2 settings sets)
|
||||
VYOS_V8 = {
|
||||
"registry_version": 8,
|
||||
"appliance_id": "f82b74c4-0f30-456f-a582-63daca528502",
|
||||
"name": "VyOS Universal Router",
|
||||
"category": "router",
|
||||
"description": "VyOS",
|
||||
"vendor_name": "VyOS Inc.",
|
||||
"vendor_url": "https://vyos.io/",
|
||||
"product_name": "VyOS Universal Router",
|
||||
"status": "stable",
|
||||
"maintainer": "VyOS Inc.",
|
||||
"maintainer_email": "support@vyos.io",
|
||||
"usage": "appliance usage",
|
||||
"symbol": "vyos.svg",
|
||||
"settings": [
|
||||
{
|
||||
"name": "default x86_64",
|
||||
"default": True,
|
||||
"template_type": "qemu",
|
||||
"template_properties": {
|
||||
"adapter_type": "virtio-net-pci",
|
||||
"adapters": 10,
|
||||
"port_name_format": "eth{0}",
|
||||
"ram": 2048,
|
||||
"cpus": 4,
|
||||
"hda_disk_interface": "virtio",
|
||||
"platform": "x86_64",
|
||||
"console_type": "telnet",
|
||||
"boot_priority": "c",
|
||||
"uefi": False,
|
||||
"on_close": "shutdown_signal",
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "1.5 x86_64",
|
||||
"inherit_default_properties": True,
|
||||
"template_type": "qemu",
|
||||
"template_properties": {
|
||||
"ram": 8192,
|
||||
"cpus": 4,
|
||||
},
|
||||
},
|
||||
],
|
||||
"images": [
|
||||
{
|
||||
"filename": "vyos-1.5.1-kvm-amd64.qcow2",
|
||||
"version": "1.5.1",
|
||||
"md5sum": "816ec7c3699a9e4f19e2b8765fd3d7eb",
|
||||
"filesize": 667549696,
|
||||
},
|
||||
{
|
||||
"filename": "vyos-1.4.5-kvm-amd64.qcow2",
|
||||
"version": "1.4.5",
|
||||
"md5sum": "06ccf7e3ed3f948a23c995133b5fbfce",
|
||||
"filesize": 557645824,
|
||||
},
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"name": "1.5.1",
|
||||
"settings": "1.5 x86_64",
|
||||
"images": {"hda_disk_image": "vyos-1.5.1-kvm-amd64.qcow2"},
|
||||
},
|
||||
{
|
||||
"name": "1.4.5",
|
||||
"images": {"hda_disk_image": "vyos-1.4.5-kvm-amd64.qcow2"},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_v8_netmiko_device_type_copied_to_template():
|
||||
appliance = dict(VYOS_V8, netmiko_device_type="vyos_ssh")
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local")
|
||||
assert template["netmiko_device_type"] == "vyos_ssh"
|
||||
|
||||
|
||||
def test_v8_netmiko_device_type_validates():
|
||||
model = ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="vyos_ssh"))
|
||||
assert model.netmiko_device_type == "vyos_ssh"
|
||||
|
||||
with pytest.raises(pydantic.ValidationError):
|
||||
ApplianceModel.model_validate(dict(VYOS_V8, netmiko_device_type="Not Valid!"))
|
||||
|
||||
|
||||
def test_v8_version_referenced_settings_with_inheritance():
|
||||
"""
|
||||
A version referencing a named settings set must select it and inherit
|
||||
the default set properties (vyos 1.5.1 -> "1.5 x86_64", ram overridden to 8192).
|
||||
"""
|
||||
|
||||
version = VYOS_V8["versions"][0]
|
||||
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
|
||||
|
||||
assert template["template_type"] == "qemu"
|
||||
assert template["version"] == "1.5.1"
|
||||
# inherited from the default settings
|
||||
assert template["adapters"] == 10
|
||||
assert template["adapter_type"] == "virtio-net-pci"
|
||||
assert template["platform"] == "x86_64"
|
||||
# overridden by the selected settings
|
||||
assert template["ram"] == 8192
|
||||
# appliance level fields
|
||||
assert template["name"] == "VyOS Universal Router"
|
||||
assert template["category"] == "router"
|
||||
assert template["usage"] == "appliance usage"
|
||||
assert template["symbol"] == "vyos.svg"
|
||||
# version images are injected
|
||||
assert template["hda_disk_image"] == "vyos-1.5.1-kvm-amd64.qcow2"
|
||||
|
||||
|
||||
def test_v8_default_settings_selected_when_version_has_no_reference():
|
||||
"""
|
||||
A version without a settings reference falls back to the default settings set.
|
||||
"""
|
||||
|
||||
version = VYOS_V8["versions"][1]
|
||||
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
|
||||
|
||||
assert template["version"] == "1.4.5"
|
||||
assert template["ram"] == 2048
|
||||
assert template["hda_disk_image"] == "vyos-1.4.5-kvm-amd64.qcow2"
|
||||
|
||||
|
||||
def test_v8_version_level_overrides():
|
||||
"""
|
||||
category/usage/symbol defined at the version level override the appliance level.
|
||||
"""
|
||||
|
||||
version = dict(VYOS_V8["versions"][1], category="firewall", usage="version usage", symbol="firewall.svg")
|
||||
template = ApplianceToTemplate().new_template(VYOS_V8, version, "local")
|
||||
|
||||
assert template["category"] == "firewall"
|
||||
assert template["usage"] == "version usage"
|
||||
assert template["symbol"] == "firewall.svg"
|
||||
|
||||
|
||||
def test_v8_properties_take_precedence_over_version_and_appliance():
|
||||
"""
|
||||
Fields defined in template_properties win over the version and appliance levels.
|
||||
"""
|
||||
|
||||
settings = dict(
|
||||
VYOS_V8["settings"][0],
|
||||
template_properties=dict(VYOS_V8["settings"][0]["template_properties"], usage="settings usage"),
|
||||
)
|
||||
appliance = dict(VYOS_V8, settings=[settings])
|
||||
version = dict(VYOS_V8["versions"][1], usage="version usage")
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
assert template["usage"] == "settings usage"
|
||||
|
||||
|
||||
def test_v8_template_properties_name_and_category():
|
||||
"""
|
||||
name/category defined in template_properties are used for the template.
|
||||
"""
|
||||
|
||||
settings = dict(
|
||||
VYOS_V8["settings"][0],
|
||||
template_properties=dict(VYOS_V8["settings"][0]["template_properties"], name="VyOS 1.4", category="guest"),
|
||||
)
|
||||
appliance = dict(VYOS_V8, settings=[settings])
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["name"] == "VyOS 1.4"
|
||||
assert template["category"] == "guest"
|
||||
|
||||
|
||||
def test_v8_multilayer_switch_category_mapping():
|
||||
settings = dict(
|
||||
VYOS_V8["settings"][0],
|
||||
template_properties=dict(VYOS_V8["settings"][0]["template_properties"]),
|
||||
)
|
||||
settings["template_properties"].pop("name", None)
|
||||
appliance = dict(VYOS_V8, category="multilayer_switch", settings=[settings])
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["category"] == "switch"
|
||||
|
||||
|
||||
def test_v8_default_symbol_fallback():
|
||||
"""
|
||||
Without any symbol, a docker guest gets the docker symbol, other guests the qemu one.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "guest",
|
||||
"settings": [{"name": "only", "template_type": "docker", "template_properties": {"image": "test:latest"}}],
|
||||
}
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["symbol"] == ":/symbols/docker_guest.svg"
|
||||
assert template["template_type"] == "docker"
|
||||
assert template["image"] == "test:latest"
|
||||
|
||||
appliance["settings"][0]["template_type"] = "qemu"
|
||||
appliance["settings"][0]["template_properties"] = {"ram": 512}
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["symbol"] == ":/symbols/qemu_guest.svg"
|
||||
|
||||
|
||||
def test_v8_no_inheritance_when_disabled():
|
||||
settings = dict(
|
||||
VYOS_V8["settings"][1],
|
||||
inherit_default_properties=False,
|
||||
template_properties={"ram": 4096, "adapters": 2},
|
||||
)
|
||||
appliance = dict(VYOS_V8, settings=[VYOS_V8["settings"][0], settings])
|
||||
version = dict(VYOS_V8["versions"][0], settings="1.5 x86_64")
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
assert template["ram"] == 4096
|
||||
assert template["adapters"] == 2
|
||||
# not inherited
|
||||
assert "adapter_type" not in template
|
||||
assert "platform" not in template
|
||||
|
||||
|
||||
def test_v8_unknown_settings_reference_raises():
|
||||
version = dict(VYOS_V8["versions"][0], settings="does not exist")
|
||||
|
||||
with pytest.raises(ControllerError, match="Could not find settings 'does not exist'"):
|
||||
ApplianceToTemplate().new_template(VYOS_V8, version, "local")
|
||||
|
||||
|
||||
def test_v8_multiple_settings_without_default_raises():
|
||||
appliance = dict(VYOS_V8)
|
||||
appliance["settings"] = [
|
||||
dict(VYOS_V8["settings"][0], default=None),
|
||||
dict(VYOS_V8["settings"][1]),
|
||||
]
|
||||
|
||||
with pytest.raises(ControllerError, match="none is marked as default"):
|
||||
ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local")
|
||||
|
||||
|
||||
def test_v8_single_settings_selected_without_default_flag():
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "router",
|
||||
"settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 1024}}],
|
||||
}
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["ram"] == 1024
|
||||
assert template["symbol"] == ":/symbols/router.svg"
|
||||
|
||||
|
||||
def test_v6_path_unchanged():
|
||||
"""
|
||||
Registry versions 1-6 keep using the top-level emulator blocks (regression check).
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 6,
|
||||
"name": "SRLinux",
|
||||
"category": "router",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"usage": "v6 usage",
|
||||
"docker": {
|
||||
"adapters": 35,
|
||||
"image": "ghcr.io/nokia/srlinux:latest",
|
||||
"console_type": "docker_exec",
|
||||
"environment": "GNS3_SKIP_INIT=1",
|
||||
},
|
||||
}
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
assert template["template_type"] == "docker"
|
||||
assert template["image"] == "ghcr.io/nokia/srlinux:latest"
|
||||
assert template["console_type"] == "docker_exec"
|
||||
assert template["adapters"] == 35
|
||||
assert template["usage"] == "v6 usage"
|
||||
|
||||
|
||||
def test_v6_netmiko_device_type_copied_to_template():
|
||||
appliance = {
|
||||
"registry_version": 6,
|
||||
"name": "SRLinux",
|
||||
"category": "router",
|
||||
"symbol": ":/symbols/router.svg",
|
||||
"netmiko_device_type": "nokia_srl",
|
||||
"docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"},
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["netmiko_device_type"] == "nokia_srl"
|
||||
|
||||
|
||||
def test_v8_iou_version_images_mapped_to_path():
|
||||
"""
|
||||
An IOU template takes the image as a path: the version image name must be
|
||||
mapped to 'path', never to an 'image' key.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "IOU L3",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "iou",
|
||||
"template_properties": {"ethernet_adapters": 4, "ram": 256}},
|
||||
],
|
||||
}
|
||||
version = {"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["template_type"] == "iou"
|
||||
assert template["path"] == "i86bi-linux-l3-15.9.bin"
|
||||
assert "image" not in template
|
||||
|
||||
|
||||
def test_v8_dynamips_idlepc_from_version():
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Cisco 7200",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "dynamips",
|
||||
"template_properties": {"ram": 512, "platform": "c7200"}},
|
||||
],
|
||||
}
|
||||
version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["idlepc"] == "0x613080c0"
|
||||
assert template["image"] == "c7200.bin"
|
||||
|
||||
|
||||
def test_v8_dynamips_settings_idlepc_precedence():
|
||||
"""
|
||||
An idlepc defined in template_properties wins over the version level.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Cisco 7200",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "dynamips",
|
||||
"template_properties": {"ram": 512, "idlepc": "0x6142da40"}},
|
||||
],
|
||||
}
|
||||
version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["idlepc"] == "0x6142da40"
|
||||
|
||||
|
||||
def test_v8_qemu_kvm_disable_forces_accel_tcg():
|
||||
"""
|
||||
kvm: disable is not a valid template property: it is converted to the
|
||||
equivalent qemu options like for registry versions 1-6.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "QEMU VM",
|
||||
"category": "guest",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}},
|
||||
],
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
assert template["options"] == "-m 512 -machine accel=tcg"
|
||||
assert "kvm" not in template
|
||||
|
||||
|
||||
def test_v8_qemu_kvm_allow_keeps_options():
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "QEMU VM",
|
||||
"category": "guest",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}},
|
||||
],
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
assert template["options"] == "-m 512"
|
||||
assert "kvm" not in template
|
||||
|
||||
|
||||
def test_v8_no_cross_type_inheritance():
|
||||
"""
|
||||
A version referencing a docker settings set must not inherit properties
|
||||
from a default qemu settings set.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Mixed",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "default qemu", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 2048, "adapters": 10}},
|
||||
{"name": "docker alt", "template_type": "docker",
|
||||
"template_properties": {"image": "xrd:latest", "adapters": 2}},
|
||||
],
|
||||
}
|
||||
version = {"name": "1.0", "settings": "docker alt", "images": {"image": "xrd:1.0"}}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["template_type"] == "docker"
|
||||
assert template["adapters"] == 2
|
||||
assert "ram" not in template
|
||||
assert template["image"] == "xrd:1.0"
|
||||
|
||||
|
||||
def test_v8_multiple_defaults_inherit_first_same_type():
|
||||
"""
|
||||
With several default settings sets of the same type, the first one is
|
||||
inherited from, matching the selection rule.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Multi",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "default one", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 1024}},
|
||||
{"name": "default two", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 2048}},
|
||||
{"name": "alt", "template_type": "qemu", "template_properties": {"cpus": 2}},
|
||||
],
|
||||
}
|
||||
version = {"name": "1.0", "settings": "alt"}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["ram"] == 1024
|
||||
assert template["cpus"] == 2
|
||||
|
||||
|
||||
def test_v8_symbol_fallback_uses_effective_category():
|
||||
"""
|
||||
The default symbol must reflect the effective category (template_properties,
|
||||
then version, then appliance), not the appliance-level one.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "qemu",
|
||||
"template_properties": {"ram": 512, "category": "guest"}},
|
||||
],
|
||||
}
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
assert template["category"] == "guest"
|
||||
assert template["symbol"] == ":/symbols/qemu_guest.svg"
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "guest",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}},
|
||||
],
|
||||
}
|
||||
version = {"name": "1.0", "category": "router"}
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
assert template["category"] == "router"
|
||||
assert template["symbol"] == ":/symbols/router.svg"
|
||||
|
||||
|
||||
def test_v8_reserved_keys_cannot_be_overridden():
|
||||
"""
|
||||
template_properties must not override the structural template fields.
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "only", "template_type": "docker",
|
||||
"template_properties": {
|
||||
"image": "xrd:latest",
|
||||
"template_type": "qemu",
|
||||
"compute_id": "evil-compute",
|
||||
"version": "9.9",
|
||||
}},
|
||||
],
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
assert template["template_type"] == "docker"
|
||||
assert template["compute_id"] == "local"
|
||||
assert "version" not in template
|
||||
|
||||
|
||||
def test_v8_get_template_type():
|
||||
"""
|
||||
get_template_type resolves the emulator type from the settings set
|
||||
selected for the version, like the install flow does.
|
||||
"""
|
||||
|
||||
converter = ApplianceToTemplate()
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Mixed",
|
||||
"category": "router",
|
||||
"settings": [
|
||||
{"name": "default", "default": True, "template_type": "qemu",
|
||||
"template_properties": {"ram": 512}},
|
||||
{"name": "alt", "template_type": "docker",
|
||||
"template_properties": {"image": "xrd:latest"}},
|
||||
],
|
||||
}
|
||||
|
||||
assert converter.get_template_type(appliance, {"name": "1.0", "settings": "alt"}) == "docker"
|
||||
assert converter.get_template_type(appliance, None) == "qemu"
|
||||
|
||||
v6 = {"registry_version": 6, "docker": {"image": "test:latest"}}
|
||||
assert converter.get_template_type(v6, None) == "docker"
|
||||
|
||||
|
||||
def test_v8_appliance_metadata_copied_to_template():
|
||||
"""
|
||||
Appliance metadata (vendor information, default credentials...) is kept
|
||||
on the template instead of being dropped, with version level values
|
||||
overriding the appliance level ones.
|
||||
"""
|
||||
|
||||
appliance = dict(VYOS_V8, default_username="vyos", default_password="vyospass")
|
||||
version = dict(VYOS_V8["versions"][1], default_username="vyos145")
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
metadata = template["appliance_metadata"]
|
||||
assert metadata["appliance_id"] == VYOS_V8["appliance_id"]
|
||||
assert metadata["vendor_name"] == "VyOS Inc."
|
||||
assert metadata["status"] == "stable"
|
||||
# the version level default_username overrides the appliance level one
|
||||
assert metadata["default_username"] == "vyos145"
|
||||
assert metadata["default_password"] == "vyospass"
|
||||
|
||||
|
||||
def test_v8_appliance_metadata_version_level_overrides():
|
||||
appliance = dict(VYOS_V8, installation_instructions="appliance instructions")
|
||||
version = dict(VYOS_V8["versions"][1], installation_instructions="version instructions")
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, version, "local")
|
||||
|
||||
assert template["appliance_metadata"]["installation_instructions"] == "version instructions"
|
||||
|
||||
|
||||
def test_v6_appliance_metadata_copied_to_template():
|
||||
"""
|
||||
Registry versions 1-6 appliances keep their metadata too (the fields
|
||||
they have: the v8-only ones are simply absent).
|
||||
"""
|
||||
|
||||
appliance = {
|
||||
"registry_version": 6,
|
||||
"name": "SRLinux",
|
||||
"category": "router",
|
||||
"description": "Nokia SR Linux",
|
||||
"vendor_name": "Nokia",
|
||||
"docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"},
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
metadata = template["appliance_metadata"]
|
||||
assert metadata["description"] == "Nokia SR Linux"
|
||||
assert metadata["vendor_name"] == "Nokia"
|
||||
assert "default_username" not in metadata
|
||||
|
||||
|
||||
def test_no_appliance_metadata_when_appliance_has_none():
|
||||
appliance = {
|
||||
"registry_version": 8,
|
||||
"name": "Test",
|
||||
"category": "guest",
|
||||
"settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}}],
|
||||
}
|
||||
|
||||
template = ApplianceToTemplate().new_template(appliance, None, "local")
|
||||
|
||||
assert "appliance_metadata" not in template
|
||||
@ -15,13 +15,22 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import sys
|
||||
import json
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.controller.compute import Compute
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ComputeConflictError
|
||||
from gns3server.controller.controller_error import (
|
||||
ControllerError,
|
||||
ControllerNotFoundError,
|
||||
ControllerUnauthorizedError,
|
||||
ComputeConflictError,
|
||||
)
|
||||
from pydantic import SecretStr
|
||||
from tests.utils import asyncio_patch, AsyncioMagicMock
|
||||
|
||||
@ -524,3 +533,100 @@ async def test_get_ip_on_same_subnet(controller):
|
||||
},
|
||||
]
|
||||
assert await compute1.get_ip_on_same_subnet(compute2) == ('192.168.2.1', '192.168.1.2')
|
||||
|
||||
|
||||
class FakeWebSocket:
|
||||
"""
|
||||
Minimal aiohttp WebSocketResponse stand-in for notification stream tests.
|
||||
"""
|
||||
|
||||
def __init__(self, frames):
|
||||
self._frames = list(frames)
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *exc_info):
|
||||
return False
|
||||
|
||||
def __aiter__(self):
|
||||
return self
|
||||
|
||||
async def __anext__(self):
|
||||
if not self._frames:
|
||||
raise StopAsyncIteration
|
||||
return self._frames.pop(0)
|
||||
|
||||
|
||||
def _text_frame(payload):
|
||||
return SimpleNamespace(type=aiohttp.WSMsgType.TEXT, data=json.dumps(payload))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_notification_poison_frame_autoreconnects(compute, monkeypatch):
|
||||
"""
|
||||
A malformed frame must not permanently kill the notification stream: the
|
||||
error is logged, clients are notified and a reconnection is scheduled.
|
||||
"""
|
||||
|
||||
emit_mock = MagicMock()
|
||||
monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock)
|
||||
frames = [
|
||||
_text_frame({"action": "ping", "event": {"cpu_usage_percent": 10.0, "memory_usage_percent": 20.0, "disk_usage_percent": 30.0}}),
|
||||
_text_frame({"event": {"poison": True}}), # missing "action": raises KeyError in the receive loop
|
||||
]
|
||||
session = MagicMock()
|
||||
session.closed = False
|
||||
session.ws_connect = MagicMock(return_value=FakeWebSocket(frames))
|
||||
compute._http_session = session
|
||||
|
||||
# allow the reconnection to be scheduled during the test
|
||||
monkeypatch.delattr(sys, "_called_from_test", raising=False)
|
||||
from gns3server.api.server import app as gns3_app
|
||||
monkeypatch.setattr(gns3_app.state, "exiting", False)
|
||||
|
||||
async def fake_connect():
|
||||
compute._reconnect_attempted = True
|
||||
monkeypatch.setattr(compute, "connect", fake_connect)
|
||||
|
||||
# must not raise despite the poison frame
|
||||
await compute._connect_notification()
|
||||
|
||||
actions = [c.args[0] for c in emit_mock.call_args_list]
|
||||
assert actions.count("compute.updated") >= 2 # one for the ping, one for the disconnect
|
||||
assert compute._connected is False
|
||||
|
||||
# the reconnection scheduled by the finally block fires after 1 second
|
||||
await asyncio.sleep(1.2)
|
||||
assert compute._reconnect_attempted is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, monkeypatch):
|
||||
"""
|
||||
HTTP-level failures (401/403/404...) reach connect() as ControllerError
|
||||
subclasses. They must notify clients, schedule a retry and still raise for
|
||||
explicit callers. They used to silently kill the fire-and-forget connect()
|
||||
task started at controller startup: no notification, no retry.
|
||||
"""
|
||||
|
||||
compute._connected = False
|
||||
emit_mock = MagicMock()
|
||||
monkeypatch.setattr(compute._controller.notification, "controller_emit", emit_mock)
|
||||
|
||||
async def raise_unauthorized(*args, **kwargs):
|
||||
raise ControllerUnauthorizedError("Invalid authentication for compute 'my_compute_id'")
|
||||
|
||||
monkeypatch.setattr(compute, "_run_http_query", raise_unauthorized)
|
||||
monkeypatch.delattr(sys, "_called_from_test", raising=False)
|
||||
scheduled_delays = []
|
||||
monkeypatch.setattr(asyncio.get_event_loop(), "call_later", lambda delay, callback: scheduled_delays.append(delay))
|
||||
|
||||
with pytest.raises(ControllerUnauthorizedError):
|
||||
await compute.connect()
|
||||
|
||||
assert compute._last_error == "Invalid authentication for compute 'my_compute_id'"
|
||||
assert compute.connected is False
|
||||
actions = [c.args[0] for c in emit_mock.call_args_list]
|
||||
assert "compute.updated" in actions
|
||||
assert scheduled_delays == [5] # first exponential backoff delay
|
||||
|
||||
@ -378,9 +378,9 @@ async def test_available_filters(project, compute):
|
||||
link.create = AsyncioMagicMock()
|
||||
assert link.available_filters() == []
|
||||
|
||||
# Ethernet switch is not supported should return 0 filters
|
||||
# The brctl Ethernet switch hosts filters on its per-port uBridge relays
|
||||
await link.add_node(node1, 0, 4)
|
||||
assert link.available_filters() == []
|
||||
assert len(link.available_filters()) > 0
|
||||
|
||||
node2 = Node(project, compute, "node2", node_type="vpcs")
|
||||
node2._ports = [EthernetPort("E0", 0, 0, 4)]
|
||||
|
||||
@ -56,20 +56,21 @@ def _valid_bpf():
|
||||
return stack
|
||||
|
||||
|
||||
async def _make_link(project, port_cls=EthernetPort):
|
||||
"""Build a created UDPLink between two VPCS nodes on a mocked compute.
|
||||
async def _make_link(project, port_cls=EthernetPort, node_types=("vpcs", "vpcs")):
|
||||
"""Build a created UDPLink between two nodes on a mocked compute.
|
||||
|
||||
``port_cls`` defaults to EthernetPort; pass SerialPort for a serial link
|
||||
(the link's link_type follows the port).
|
||||
(the link's link_type follows the port). ``node_types`` overrides the
|
||||
endpoint node types (e.g. a switch-to-switch link).
|
||||
"""
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "local"
|
||||
compute.host = "example.com"
|
||||
|
||||
node1 = Node(project, compute, "n1", node_type="vpcs")
|
||||
node1 = Node(project, compute, "n1", node_type=node_types[0])
|
||||
node1._ports = [port_cls("E0", 0, 0, 0)]
|
||||
node2 = Node(project, compute, "n2", node_type="vpcs")
|
||||
node2 = Node(project, compute, "n2", node_type=node_types[1])
|
||||
node2._ports = [port_cls("E0", 0, 0, 1)]
|
||||
|
||||
async def subnet(_other):
|
||||
@ -112,6 +113,23 @@ async def test_start_marker_stores_entry(project):
|
||||
assert entry["highlight_duration"] == 800
|
||||
assert entry["enabled"] is True
|
||||
assert entry["capture_node_id"] in {n["node"].id for n in link._nodes}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_marker_on_ethernet_switch_link(project):
|
||||
"""A switch-to-switch link can host a marker: the brctl Ethernet switch
|
||||
runs a per-port uBridge relay the `mark` filter attaches to."""
|
||||
|
||||
with _valid_bpf():
|
||||
link = await _make_link(project, node_types=("ethernet_switch", "ethernet_switch"))
|
||||
await link.start_marker("icmp", "icmp")
|
||||
|
||||
entry = link.markers["icmp"]
|
||||
switch_ids = {n["node"].id for n in link._nodes}
|
||||
assert entry["capture_node_id"] in switch_ids
|
||||
# the marker rides exactly one side's NIO and is pushed via update()
|
||||
carrying = [d for d in link._link_data if "icmp" in d["markers"]]
|
||||
assert len(carrying) == 1
|
||||
assert "inherited_from" not in entry
|
||||
|
||||
|
||||
|
||||
@ -142,6 +142,9 @@ def test_json(node, compute):
|
||||
"tags": [],
|
||||
"custom_adapters": [],
|
||||
"console_auto_start": False,
|
||||
"netmiko_device_type": None,
|
||||
"default_username": None,
|
||||
"default_password": None,
|
||||
"ports": [
|
||||
{
|
||||
"adapter_number": 0,
|
||||
@ -179,6 +182,9 @@ def test_json(node, compute):
|
||||
"custom_adapters": [],
|
||||
"tags": [],
|
||||
"console_auto_start": False,
|
||||
"netmiko_device_type": None,
|
||||
"default_username": None,
|
||||
"default_password": None,
|
||||
}
|
||||
|
||||
|
||||
@ -383,6 +389,84 @@ async def test_update_only_controller(node, compute):
|
||||
assert not node._project.emit_notification.called
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_netmiko_device_type(node, compute):
|
||||
"""
|
||||
netmiko_device_type is a controller-only property: updating it must not
|
||||
call the compute and must be visible in the node json.
|
||||
"""
|
||||
|
||||
compute.put = AsyncioMagicMock()
|
||||
node._project.emit_notification = AsyncioMagicMock()
|
||||
node._project.dump = MagicMock()
|
||||
|
||||
await node.update(netmiko_device_type="cisco_ios_telnet")
|
||||
assert not compute.put.called
|
||||
assert node.netmiko_device_type == "cisco_ios_telnet"
|
||||
assert node.asdict()["netmiko_device_type"] == "cisco_ios_telnet"
|
||||
|
||||
# the field can also be cleared with an empty string
|
||||
await node.update(netmiko_device_type="")
|
||||
assert node.netmiko_device_type == ""
|
||||
assert node.asdict()["netmiko_device_type"] == ""
|
||||
|
||||
|
||||
def test_netmiko_device_type_from_template_kwargs(compute, project):
|
||||
"""
|
||||
A node created with the template properties as kwargs inherits
|
||||
netmiko_device_type without sending it to the compute.
|
||||
"""
|
||||
|
||||
node = Node(project, compute, "test", node_type="vpcs", netmiko_device_type="nokia_srl")
|
||||
assert node.netmiko_device_type == "nokia_srl"
|
||||
# controller-only: must not leak into the compute properties
|
||||
assert "netmiko_device_type" not in node.properties
|
||||
assert node.asdict(topology_dump=True)["netmiko_device_type"] == "nokia_srl"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_default_credentials(node, compute):
|
||||
"""
|
||||
default_username/default_password are controller-only properties: updating
|
||||
them must not call the compute and must be persisted in the node json.
|
||||
"""
|
||||
|
||||
compute.put = AsyncioMagicMock()
|
||||
node._project.emit_notification = AsyncioMagicMock()
|
||||
node._project.dump = MagicMock()
|
||||
|
||||
await node.update(default_username="admin", default_password="secret")
|
||||
assert not compute.put.called
|
||||
assert node.default_username == "admin"
|
||||
assert node.default_password == "secret"
|
||||
assert node.asdict()["default_username"] == "admin"
|
||||
assert node.asdict(topology_dump=True)["default_password"] == "secret"
|
||||
|
||||
# credentials never leak into the compute properties
|
||||
assert "default_username" not in node.properties
|
||||
assert "default_password" not in node.properties
|
||||
|
||||
# both fields can be cleared with an empty string
|
||||
await node.update(default_username="", default_password="")
|
||||
assert node.default_username == ""
|
||||
assert node.default_password == ""
|
||||
|
||||
|
||||
def test_default_credentials_from_template_kwargs(compute, project):
|
||||
"""
|
||||
A node created from a template with appliance metadata inherits the
|
||||
default credentials without sending them to the compute.
|
||||
"""
|
||||
|
||||
node = Node(project, compute, "test", node_type="vpcs",
|
||||
default_username="root", default_password="cisco123")
|
||||
assert node.default_username == "root"
|
||||
assert node.default_password == "cisco123"
|
||||
assert "default_username" not in node.properties
|
||||
assert "default_password" not in node.properties
|
||||
assert node.asdict(topology_dump=True)["default_username"] == "root"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_no_changes(node, compute):
|
||||
"""
|
||||
|
||||
@ -204,6 +204,48 @@ async def test_add_node_local(controller):
|
||||
project.emit_notification.assert_any_call("node.created", node.asdict())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_node_from_template_seeds_default_credentials(controller):
|
||||
"""
|
||||
The appliance metadata stays template level: creating a node from a
|
||||
template seeds the default credentials on the node and must not leak
|
||||
the metadata into the node properties sent to the compute.
|
||||
"""
|
||||
|
||||
compute = MagicMock()
|
||||
compute.id = "local"
|
||||
controller._computes["local"] = compute
|
||||
project = Project(controller=controller, name="Test")
|
||||
project.emit_notification = MagicMock()
|
||||
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
|
||||
template = {
|
||||
"name": "VPCS_TEST",
|
||||
"template_type": "vpcs",
|
||||
"compute_id": "local",
|
||||
"default_name_format": "PC{0}",
|
||||
"properties": {"startup_script": "test.cfg"},
|
||||
"appliance_metadata": {
|
||||
"vendor_name": "Test vendor",
|
||||
"default_username": "admin",
|
||||
"default_password": "secret",
|
||||
},
|
||||
}
|
||||
|
||||
node = await project.add_node_from_template(template)
|
||||
|
||||
# credentials seeded from the appliance metadata
|
||||
assert node.default_username == "admin"
|
||||
assert node.default_password == "secret"
|
||||
# the metadata itself never reaches the node properties
|
||||
assert "appliance_metadata" not in node.properties
|
||||
assert "default_username" not in node.properties
|
||||
assert "default_password" not in node.properties
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_node_non_local(controller):
|
||||
"""
|
||||
|
||||
@ -451,6 +451,55 @@ async def test_update(project):
|
||||
}, timeout=120)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_ethernet_switch_nio(project):
|
||||
"""
|
||||
Link updates must reach an Ethernet switch endpoint: the brctl switch has
|
||||
a PUT NIO route, so only the Dynamips-hosted hub side stays skipped.
|
||||
"""
|
||||
|
||||
compute1 = MagicMock()
|
||||
|
||||
node_vpcs = Node(project, compute1, "node1", node_type="vpcs")
|
||||
node_vpcs._ports = [EthernetPort("E0", 0, 0, 4)]
|
||||
node_switch = Node(project, compute1, "node2", node_type="ethernet_switch")
|
||||
node_switch._ports = [EthernetPort("E0", 0, 3, 1)]
|
||||
|
||||
async def subnet_callback(compute2):
|
||||
return ("192.168.1.1", "192.168.1.2")
|
||||
|
||||
compute1.get_ip_on_same_subnet.side_effect = subnet_callback
|
||||
|
||||
async def compute1_callback(path, data={}, **kwargs):
|
||||
if "/ports/udp" in path:
|
||||
response = MagicMock()
|
||||
response.json = {"udp_port": 1024}
|
||||
return response
|
||||
|
||||
compute1.post.side_effect = compute1_callback
|
||||
compute1.put = AsyncioMagicMock()
|
||||
compute1.host = "example.com"
|
||||
|
||||
link = UDPLink(project)
|
||||
await link.add_node(node_vpcs, 0, 4)
|
||||
await link.add_node(node_switch, 3, 1)
|
||||
assert link.created
|
||||
|
||||
await link.update_filters({"delay": [10, 0]})
|
||||
compute1.put.assert_any_call(
|
||||
"/projects/{}/ethernet_switch/nodes/{}/adapters/3/ports/1/nio".format(project.id, node_switch.id),
|
||||
data={
|
||||
"lport": 1024,
|
||||
"rhost": "192.168.1.1",
|
||||
"rport": 1024,
|
||||
"type": "nio_udp",
|
||||
"suspend": False,
|
||||
"markers": {},
|
||||
"filters": {}
|
||||
}, timeout=221
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_suspend(project):
|
||||
compute1 = MagicMock()
|
||||
|
||||
89
tests/utils/test_notification_queue.py
Normal file
89
tests/utils/test_notification_queue.py
Normal file
@ -0,0 +1,89 @@
|
||||
#
|
||||
# Copyright (C) 2026 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/>.
|
||||
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from gns3server.utils.notification_queue import NotificationQueue
|
||||
|
||||
|
||||
async def _feed(queue, until, interval=0.02):
|
||||
"""
|
||||
Continuously put dummy events on the queue to simulate sustained load
|
||||
(e.g. high marker.match rates).
|
||||
"""
|
||||
|
||||
seq = 0
|
||||
while time.monotonic() < until:
|
||||
queue.put_nowait(("dummy", {"seq": seq}, {}))
|
||||
seq += 1
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_first_get_returns_ping():
|
||||
|
||||
queue = NotificationQueue()
|
||||
action, event, _ = await asyncio.wait_for(queue.get(1), 1)
|
||||
assert action == "ping"
|
||||
assert "cpu_usage_percent" in event
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_idle_queue_pings_after_timeout():
|
||||
|
||||
queue = NotificationQueue()
|
||||
await queue.get(0.3) # consume the first immediate ping
|
||||
|
||||
start = time.monotonic()
|
||||
action, _, _ = await asyncio.wait_for(queue.get(0.3), 1)
|
||||
assert action == "ping"
|
||||
assert time.monotonic() - start >= 0.25 # had to wait for the idle timeout
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ping_not_starved_under_sustained_load():
|
||||
"""
|
||||
Regression test: a continuously-fed queue must still emit a ping at least
|
||||
every `timeout` seconds. The old idle-timeout-only ping never fired under
|
||||
sustained event load, so clients stopped receiving compute statistics
|
||||
(no more compute.updated events) until the event flow paused.
|
||||
"""
|
||||
|
||||
queue = NotificationQueue()
|
||||
action, _, _ = await queue.get(0.5) # consume the first immediate ping
|
||||
assert action == "ping"
|
||||
|
||||
until = time.monotonic() + 2.0
|
||||
producer = asyncio.create_task(_feed(queue, until))
|
||||
try:
|
||||
pings = 0
|
||||
events = 0
|
||||
deadline = time.monotonic() + 2.5
|
||||
while time.monotonic() < deadline:
|
||||
action, _, _ = await asyncio.wait_for(queue.get(0.5), 1)
|
||||
if action == "ping":
|
||||
pings += 1
|
||||
else:
|
||||
events += 1
|
||||
# real events still flow...
|
||||
assert events > 0
|
||||
# ...and pings interleave roughly every 0.5s instead of starving
|
||||
assert pings >= 2
|
||||
finally:
|
||||
producer.cancel()
|
||||
Loading…
x
Reference in New Issue
Block a user