Merge branch '3.1' into python-3.15-support

This commit is contained in:
Jeremy Grossmann 2026-10-16 10:39:56 +02:00 committed by GitHub
commit 2821fbd42a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
60 changed files with 2493 additions and 300 deletions

54
.githooks/pre-commit Executable file
View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Pre-commit checks mirroring the CI checks that are fast enough to run on
# every commit (justfile "lint" + "type-check"; pytest and pip check are not
# run here). Only staged Python files are checked.
#
# Install with: just install-hooks
set -euo pipefail
# Prefer the repo virtualenv, fall back to whatever python is on PATH
PYTHON="venv/bin/python"
if [ ! -x "$PYTHON" ]; then
PYTHON="python"
fi
if ! command -v "$PYTHON" >/dev/null 2>&1; then
echo "pre-commit: no python interpreter found, skipping checks" >&2
exit 0
fi
# Staged .py files (deletions excluded)
mapfile -t staged < <(git diff --cached --name-only --diff-filter=d -- '*.py')
if [ ${#staged[@]} -eq 0 ]; then
exit 0
fi
status=0
echo "pre-commit: ruff check"
"$PYTHON" -m ruff check --exit-non-zero-on-fix "${staged[@]}" || status=1
echo "pre-commit: ruff format --check"
"$PYTHON" -m ruff format --check --diff "${staged[@]}" || status=1
# mypy is configured with files = ["gns3server"] in pyproject.toml, i.e. CI
# never type-checks tests: only pass staged files from that package.
mypy_targets=()
for f in "${staged[@]}"; do
case "$f" in
gns3server/*) mypy_targets+=("$f") ;;
esac
done
if [ ${#mypy_targets[@]} -gt 0 ]; then
echo "pre-commit: mypy"
"$PYTHON" -m mypy "${mypy_targets[@]}" || status=1
fi
if [ "$status" -ne 0 ]; then
echo ""
echo "pre-commit: checks failed. Fix the issues above (try 'just format')," >&2
echo "or bypass with: git commit --no-verify" >&2
fi
exit "$status"

View File

@ -18,7 +18,7 @@ langgraph-checkpoint-postgres>=3.0.4
langchain-openai>=1.1.10
langchain-anthropic>=1.3.4
langchain-google-genai>=4.2.1
langchain-aws>=1.3.1
langchain-aws>=1.8.1
langchain-ollama>=1.0.1
langchain-deepseek>=1.0.1
langchain-xai>=1.2.2
@ -35,7 +35,7 @@ netmiko>=4.8.0
nornir>=3.5.0
nornir-netmiko>=1.0.1
nornir-utils>=0.2.0
nornir-salt>=0.23.0
nornir-salt>=0.23.4
# Environment & Configuration
python-dotenv>=1.2.1
@ -45,7 +45,7 @@ PyJWT>=2.10.1
psycopg-pool>=3.1.0
# Skills Repository Management
PyYAML>=6.0.0
PyYAML>=6.0.3
GitPython>=3.1.0
# Note: httpx is only for testing (in dev-requirements.txt)

View File

@ -97,7 +97,7 @@ Everything in the root namespace is built the same way: gns3-server creates the
uBridge sits off the forwarding path — the dashed edges are control and observation, never data flow. The `qdisc install` edge is uBridge configuring the kernel over netlink; the kernel then runs those qdiscs in-flight on the host end's egress (they are stages of the transmit path, not packet consumers). The `frame copies` edge is the AF_PACKET tap: bound to the host end, the kernel clones every frame the interface sends or receives to uBridge's packet socket (the tcpdump mechanism) — read-only copies; the originals never leave the kernel.
Relay link on the same unified veth and the same single compute as above — what the link becomes when `enable_kernel_datapath` is off (cross-compute wiring and an endpoint that cannot anchor — VPCS, a cloud node, … — fall back too; across computes the UDP hop rides the real network instead of loopback, the copy count unchanged):
Relay link on the same unified veth and the same single compute as above — what the link becomes when `enable_kernel_datapath` is off (cross-compute wiring and an endpoint that cannot anchor — a cloud node, … — fall back too; across computes the UDP hop rides the real network instead of loopback, the copy count unchanged):
```mermaid
flowchart LR
@ -243,7 +243,7 @@ The veth pairs already exist at attach time — every adapter is born as a veth
## Capture
Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel <if> "<pcap>" [dlt]` / `capture stop_kernel`. Single capture per uBridge process (second concurrent → EALREADY): the server tracks which anchor owns the slot, refuses a second port's capture with a clear error instead of leaving it wedged, and only the owning port's stop issues the process-wide `capture stop_kernel`. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge.
Kernel links capture via uBridge's AF_PACKET module bound to the veth host end: `capture start_kernel <if> "<pcap>" [dlt]` / `capture stop_kernel <if>`. One capture per interface, any number of interfaces concurrently — a node can capture several ports at once; a same-interface duplicate answers EALREADY, which the server's per-port `capturing` flag already refuses before uBridge sees it. Stops are per interface too: each port's stop names its own anchor, never touching a sibling port's capture. Start/stop key on the **NIO type**, not on veth presence — a relay NIO riding a veth captures at its relay bridge.
## Markers

View File

@ -238,4 +238,4 @@ enable_kernel_datapath = True
## Roadmap
The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` — it absorbs the peer's anchor into its own kernel bridge, and switch-to-switch cascades with it), and so has the IOL runner container (`iol-docker-kernel-datapath.md`). Every router and switch node type now anchors; VPCS, the hub, the cloud and the NAT node stay on the relay by design. Cross-compute kernel links need VXLAN/GENEVE encapsulation — a separate, still-deferred project.
The Ethernet switch has landed (see `ethernet-switch-kernel-datapath.md` — it absorbs the peer's anchor into its own kernel bridge, and switch-to-switch cascades with it), and so has the IOL runner container (`iol-docker-kernel-datapath.md`) and VPCS (`vpcs-kernel-datapath.md` — it attaches to a pre-created anchor with `-e -d`). Every router and switch node type now anchors, and so does VPCS; the hub, the cloud and the NAT node stay on the relay by design. Cross-compute kernel links need VXLAN/GENEVE encapsulation — a separate, still-deferred project.

View File

@ -37,8 +37,9 @@ on the relay:
* **switch ↔ switch** cascades ride the kernel too — through a veth pair
joining the two bridges (one end per switch, each side applying its own
port mode to its own end); see the wire format below;
* anchor-less peers (VPCS, cloud, the Dynamips-hosted hub, generic
`GNS3_UNIX_SOCKET_NIO` containers);
* anchor-less peers (cloud, the Dynamips-hosted hub, generic
`GNS3_UNIX_SOCKET_NIO` containers, a VPCS binary without `-n` — see
`vpcs-kernel-datapath.md`);
* cross-compute links, non-Ethernet ports: the common rules.
**Relay port TAPs.** The switch's own relay-path port TAPs (the

View File

@ -274,4 +274,4 @@ The anchor lifecycle needs no configuration of its own — the capability probes
## Roadmap
Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized; with the IOL runner container anchored, every node type in scope can now anchor (hub, cloud, NAT and VPCS stay on the relay by decision), so the encapsulation work is all that remains.
Cross-compute kernel links need VXLAN/GENEVE encapsulation — deferred until every node type is kernelized; with the IOL runner container anchored, every node type in scope can now anchor (hub, cloud and NAT stay on the relay by decision; VPCS anchors since `vpcs-kernel-datapath.md`), so the encapsulation work is all that remains.

View File

@ -83,6 +83,235 @@ On the kernel datapath the 13 types ride three kernel objects on the anchor. The
| `quota [bytes, %]` | `tc quota_drop <if> <bytes> <pct>` — after the byte quota is consumed, each further packet drops with the given chance (100 = hard cutoff) | kernel-only type; needs `ebpf=1`; per-direction byte counters |
| `window_drop [start, outage, %, period?, jitter?]` | `tc window_drop <if> <start> <outage> <pct> [<period> [<jitter>]]` — packets drop with the given chance inside `[start, start+outage)` | kernel-only type; needs `ebpf=1`; see the window semantics below |
## Parameter reference
The API surface of the 13 types — parameters, ranges and cross-filter rules, validated by the controller before anything reaches uBridge (`gns3server/utils/packet_filter_validation.py`; the same data drives `available_filters`, which reports it per link). Trailing parameters are optional everywhere: a client sending only the first N values of a filter keeps working. **K** marks the kernel-datapath-only types; two further *parameters* are kernel-only and marked inline. The row order matches the Filter types table above. The **UI name** column is the display name served by `available_filters` (`gns3server/controller/link.py`, the `FILTERS` catalog) and shown by the Web UI — `gemodel` appears there as *Gilbert-Elliot loss*, not under its API type.
| Filter | UI name | Parameters | Constraint |
|---|---|---|---|
| `delay` | Delay | Latency (1…32767 ms) · Jitter (0…32767 ms) · Distribution | Distribution kernel-only, needs Jitter > 0; the four shapes are compared in the Per-filter guide |
| `packet_loss` | Packet loss | Chance (0…100 %) · Correlation (0…100 %) | Correlation kernel-only; Chance ≲ Correlation collapses (see Semantics) |
| `corrupt` | Corrupt | Chance (0…100 %) | |
| `duplicate` **K** | Duplicate | Chance (0…100 %) · Correlation (0…100 %) | |
| `rate` **K** | Bandwidth limit | Rate — tc-style string, e.g. `"512kbit"` (≤ 100gbit) | integer + unit; bps-family units count bytes/s |
| `reorder` **K** | Reorder | Reorder (0…100 %) · Correlation (0…100 %) · Gap (1…1000 packets) | requires `delay` |
| `gemodel` **K** | Gilbert-Elliot loss | p (good-to-bad) · r (bad-to-good) · 1-h (bad-state loss) (each 0…100 %) | mutually exclusive with `packet_loss` |
| `seed` **K** | Random seed | Seed (0…4294967295) | pins the netem draws only |
| `limit` **K** | Queue limit | Limit (1…1000000 packets) | queue depth above the 1000 default |
| `bpf` | Berkeley Packet Filter (BPF) | Filters — text, one expression per line | any match drops |
| `frequency_drop` | Frequency drop | Frequency (−1…32767) | −1 drops everything; 0 disables |
| `quota` **K** | Byte quota | Quota (1…10¹⁵ bytes) · Chance (0…100 %) | both required; Chance 100 = hard cutoff |
| `window_drop` **K** | Time window drop | Start (0…10¹² ms) · Outage (1…10¹² ms) · Chance (0…100 %) · Period (1…10¹² ms) · Jitter (0…10⁹ ms) | 3/4/5 params = one-shot / recurring / randomized flap; Period ≥ Outage |
Cross-filter rules (violations return 409): `reorder` requires `delay`; `gemodel` and `packet_loss` map to the same netem loss slot and are mutually exclusive; `window_drop` Period ≥ Outage; `delay` Distribution requires Jitter > 0; `rate` must be an integer plus a unit no larger than 100gbit.
The disabled convention: a filter whose first value is 0 (or an empty string) reads as off and is skipped. Two exceptions — `delay [0, 0]` is the explicit off while `delay [0, X>0]` is a validation error (Latency ≥ 1), and `window_drop` is active whenever present (`Start = 0` means the outage begins immediately, not off).
## Per-filter guide
What each filter is for and what each parameter means. Ranges live in the Parameter reference above and are not repeated beyond the parameter names; mechanics live in the sections below. Order matches the Parameter reference table; (kernel-only) in a heading marks a type the relay cannot run.
### `delay` — fixed latency with jitter
**Purpose**: propagation delay — geographic distance, satellite hops; amplifying RTT to observe TCP window/BDP behaviour, protocol timers, interactive traffic; combined with `rate`, a narrowband high-latency path.
| Parameter | Meaning |
|---|---|
| Latency (1…32767 ms) | fixed one-way delay added per direction — both endpoints apply it, so RTT ≈ 2×. Minimum 1; `[0, 0]` is the explicit off |
| Jitter (0…32767 ms) | per-packet offset drawn in Latency ± Jitter; omitted = 0 |
| Distribution | the shape of the jitter draw (kernel-only, needs Jitter > 0; default uniform) — see the table below |
The four distributions, for Jitter = 5 ms:
| Distribution | Shape | Effect | Use for |
|---|---|---|---|
| uniform (default) | flat rectangle | every offset in −5…+5 ms equally likely, hard bound | simplest model — worst case is exactly ±J |
| normal | bell (Gaussian) | most offsets near 0, extremes rare, ±5 almost never reached | mild, concentrated jitter — same-DC, premium lines |
| pareto | heavy tail (L shape) | most offsets ≈0 with occasional spikes several times the typical value | queueing spikes, congestion bursts; stress-testing timeout/retransmit logic |
| paretonormal | bell body + Pareto tail | concentrated majority with occasional spikes | closest to measured Internet/WAN jitter; the netem-recommended default |
Real-network jitter is heavy-tailed because delay variation is mostly queueing — a buffer filling up produces one large spike, "calm most of the time, occasionally wild", exactly a bell body with a heavy tail. `normal` suffices for intra-DC paths; use `pareto` / `paretonormal` for the WAN. (Implementation aside: the kernel stores a pre-sampled lookup table, not the distribution's math — which is why `tc qdisc show` cannot print the name.)
**Note**: the first packet after a suspend/resume takes one extra interval (netem idle baseline; steady state exact); a large delay combined with `rate` needs `limit` raised (see `limit`).
**Example**: `{"delay": [80, 5, "paretonormal"]}` — 80 ± 5 ms per direction, heavy-tailed jitter.
### `packet_loss` — random loss
**Purpose**: wireless loss, congestion tail-drop; observing TCP backoff and retransmission, application retries, protocol tolerance.
| Parameter | Meaning |
|---|---|
| Chance (0…100 %) | independent per-packet loss probability, per direction — a round trip measures ≈ 1−(1−P)² |
| Correlation (0…100 %, kernel-only) | consecutive-loss correlation — a loss raises the next packet's loss probability, forming bursts |
**Note**: Chance ≲ Correlation collapses toward zero effective loss (kernel netem defect, quantified in Semantics) — for bursty loss at a chosen mean use `gemodel`. The relay strips this parameter; sending only the single chance argument keeps working.
**Example**: `{"packet_loss": [2]}`; correlated `{"packet_loss": [10, 25]}` (10 < 25 sits in the collapse zone — measured ≈1.8 % effective; shown for syntax only).
### `corrupt` — bit errors
**Purpose**: degraded physical layer — CRC errors, wireless interference, failing optics; checksum coverage, retransmission, FEC.
| Parameter | Meaning |
|---|---|
| Chance (0…100 %) | per-packet probability of content corruption. The damaged frame **passes** — the receiver's checksum does the dropping, and a capture on the link shows the damaged frame |
**Example**: `{"corrupt": [5]}`.
### `duplicate` — duplicated packets (kernel-only)
**Purpose**: the real-world prototype is **wireless link-layer retransmission** — an 802.11 ARQ retransmit of a frame the receiver already got (the ACK was lost); 1–3 % duplicates are normal on WiFi captures. Transient L2 loops (the STP convergence window) and bonding failover also duplicate. Tests protocol idempotence and sequence-number/dedup handling (TCP is immune by sequence number; non-idempotent application logic executes twice).
| Parameter | Meaning |
|---|---|
| Chance (0…100 %) | per-packet probability of being copied (the original continues; a copy is added) |
| Correlation (0…100 %) | consecutive-duplicate correlation — wireless retransmissions cluster in time (the marginal-signal periods); Chance ≲ Correlation collapses the same way, see Semantics |
**Note**: the copy draws its own delay (under `delay 100` the duplicate arrives at a different time than the original); duplicates have no gemodel equivalent — for bursty duplication keep Chance at or above the Correlation.
**Example**: `{"duplicate": [2]}` — mild WiFi-like duplication; `{"duplicate": [10, 25]}` — syntax illustration (10 < 25 sits in the collapse zone, measured ≈1.8 % effective); `{"duplicate": [25, 25]}` — chance at the correlation does not collapse, ≈16.7 % effective (measured anchor).
### `rate` — bandwidth limit (kernel-only)
**Purpose**: narrowband access lines (512k, 2M), bandwidth bottlenecks; QoS, queuing and bulk-transfer behaviour.
| Parameter | Meaning |
|---|---|
| Rate | tc-style integer + unit: `bit`/`kbit`/`mbit`/`gbit` count bits/s, `bps`/`kbps`/`mbps` count bytes/s, ≤ 100gbit |
**Note**: the limit shapes **each direction independently** (symmetric full duplex — one shaper per endpoint, no shared budget: 10 Mbit/s each way simultaneously). Asymmetric rates (e.g. 20M down / 1M up) are not expressible through the link `filters` dict; use in-guest netem on each side. Per-packet serialization time ≈ packet size / rate, applied once per direction → ≈2× on RTT; a large `rate` combined with a large `delay` needs `limit` raised (see `limit`).
**Example**: `{"rate": ["512kbit"]}`.
### `reorder` — out-of-order delivery (kernel-only)
**Purpose**: multipath forwarding (ECMP, bonding), parallel switch fabrics; TCP SACK, reordering buffers, RACK.
| Parameter | Meaning |
|---|---|
| Reorder (0…100 %) | the chance a packet is held back until a later packet has passed it |
| Correlation (0…100 %) | consecutive-reorder correlation |
| Gap (1…1000 packets) | reorder cadence — every gap-th packet sent immediately, the rest held back to create reordering; without it, purely probabilistic |
**Note**: requires `delay` (validated with 409). The mechanism is **queue-jumping**: `delay` holds packets in the netem queue until due, and reorder lets the chosen packets skip the wait and transmit immediately, overtaking the packets still held in that same queue — the one `limit` bounds. Aggressive reorder + rate + delay overflows `limit` the same way (tail-drops no filter asked for), and the reorder depth is bounded by the delay (a packet is held at most `delay` ms).
**Example**: `{"delay": [50], "reorder": [25, 25, 5]}` — 25 % reordering at a 5-packet cadence on a 50 ms path.
### `gemodel` — bursty loss, Gilbert-Elliot (kernel-only)
**Purpose**: real loss comes in bursts — microburst congestion, transient memory exhaustion, wireless fades. Use it for "chosen mean rate + tunable burst shape"; it is immune to the correlation collapse.
| Parameter | Meaning |
|---|---|
| p (good-to-bad, 0…100 %) | good→bad transition — per-packet chance of entering a burst; burst spacing averages ≈ 1/p packets |
| r (bad-to-good, 0…100 %) | bad→good transition — average burst length ≈ 1/r packets |
| 1−h (bad-state loss, 0…100 %) | per-packet loss chance inside the bad state (the good state loses nothing) |
Steady-state mean one-way loss = **p/(p+r)×(1-h)** — "one-way" meaning per direction: each endpoint runs its own independent instance (uncorrelated state machines), so a round trip compounds to ≈ 1−(1−mean)²; loss in only one direction is not expressible through the link `filters` dict (it is pushed to both endpoints symmetrically — use in-guest netem for that).
**Note**: mutually exclusive with `packet_loss` (one netem loss slot; 409). Same mean, different script — and the script is what protocols feel (scattered losses trigger TCP fast retransmit; a burst wiping a window stalls into RTO):
```
packet_loss [5] (independent): ──X───X────X───X────X───X── 5 % per packet, evenly scattered
gemodel [1, 19, 100] (bursty): ────────▒▒▒▒▒───────────▒▒▒── long clean runs, ≈5 packets die together ≈100 apart
```
Choosing parameters: pick the target mean M and average burst length L (packets) → r ≈ 1/L, p ≈ M·r/(1−M) (with 1-h = 100); lower 1-h to let some packets survive inside a burst.
**Example**: `{"gemodel": [1, 19, 100]}` — 5 % mean loss, bursts of ≈5 packets fully lost, ≈100 packets apart.
### `seed` — reproducible randomness (kernel-only)
**Purpose**: reproducible experiments and A/B comparisons — same seed + same filter set → the identical loss/jitter/corruption/duplication sequence.
| Parameter | Meaning |
|---|---|
| Seed (0…4294967295) | the netem PRNG seed |
**Note**: pins the netem draws only — the eBPF percentage draws (quota, window) ride a PRNG seeded at program load, outside `seed`'s reach; any filter-set change resets schedules and counters. Two more boundaries: reproduction also requires the same traffic pattern (draws are consumed per arriving packet — classifier-stage drops upstream change the sequence), and a fixed seed pins one realization, not the distribution — for statistical conclusions (average throughput under loss) run several seeds.
**Example**: `{"packet_loss": [10], "seed": [42]}`.
### `limit` — impairment queue depth (kernel-only)
**Purpose**: the netem queue's capacity — it exists for `rate` + `delay` combinations: the backlog grows toward the bandwidth-delay product, and past `limit` it tail-drops, losing packets no filter asked to lose. Alone it does essentially nothing (no delay, no backlog).
| Parameter | Meaning |
|---|---|
| Limit (1…1000000 packets) | packets the queue can hold; 1000 default |
Rule of thumb: limit ≈ rate × delay / average packet size, with headroom. Symptom to watch for: with rate + delay set, measured throughput below the configured rate or loss no filter asked for usually means the 1000 default is overflowing — raise `limit`.
**Example**: `{"rate": ["10mbit"], "delay": [2000], "limit": [5000]}` — 10 Mbit/s × 2 s ≈ 2.5 MB ≈ 1700 full-size packets, past the 1000 default → 5000.
### `bpf` — match-drop by content
**Purpose**: selective blackholing — drop only ICMP (a ping blackhole), only BGP (`tcp port 179`) to watch sessions drop, OSPF hellos (`ip proto 89`) for convergence timing, VRRP advertisements (`proto 112`) to force a failover.
| Parameter | Meaning |
|---|---|
| Filters | libpcap/tcpdump expression, **one per line**, any match drops. Each line is compiled with `tcpdump -d` before apply; a line that fails to compile on the compute is skipped with a warning |
**Note**: the semantics are match-drop — only matches drop; "allow only X" is written `not <X>`. Runs on both datapaths (cls_bpf on the kernel, libpcap in the relay).
**Example**: `{"bpf": "icmp"}`; multi-line `{"bpf": "tcp port 179\nip proto 89"}`.
### `frequency_drop` — deterministic periodic drops
**Purpose**: deterministic loss with no dice — an exact drop cadence for worst paths: retransmission, FEC, fast-fail logic; also experiments needing a steadily advancing drop counter.
| Parameter | Meaning |
|---|---|
| Frequency (−1…32767) | positive N = drop every Nth packet; −1 = drop everything; 0 = disabled |
**Note**: the two datapaths count differently — the relay runs one counter shared by both directions (phase-locked); the kernel counts per direction (N=3 → 55.6 % round-trip, measured 57 %), exact atomic counters (see Semantics).
**Example**: `{"frequency_drop": [3]}` — every 3rd packet.
### `quota` — traffic quota (kernel-only)
**Purpose**: quota/billing — a volume budget exhausted cuts or degrades the link; application resilience to "cut off mid-stream".
| Parameter | Meaning |
|---|---|
| Quota (1…10¹⁵ bytes) | the byte budget, counted per direction — until it is consumed the filter is fully transparent (counting only) |
| Chance (0…100 %) | an independent per-packet drop chance after the quota is consumed — 100 = hard cutoff, lower = degraded service. The draw mathematics equal `packet_loss`, but it runs in the eBPF classifier stage: no Correlation parameter (immune to the correl collapse), outside `seed`'s reach, and its drops are invisible to captures/markers (see Semantics) |
**Note**: counting starts when the filter is applied; any filter-set change resets the counters (reconcile = reset + re-apply, see Semantics). Needs `ebpf=1`.
**Example**: `{"quota": [1000000000, 100]}` — hard cut after 1 GB.
### `window_drop` — scheduled outage (kernel-only)
**Purpose**: planned outages (a maintenance window "link dies at T for M seconds"), recurring flaps (failing cable, wireless fades), link-flap convergence and failover testing.
| Parameter | Meaning |
|---|---|
| Start (0…10¹² ms) | window start, relative to the moment of apply; 0 = begins immediately (valid and active) |
| Outage (1…10¹² ms) | window length |
| Chance (0…100 %) | an **independent per-packet draw** inside the window — equivalent to `packet_loss` gated to the window (per direction, a round trip compounds to ≈ 1−(1−C)²; 100 = full outage, lower = a degraded window) |
| Period (1…10¹² ms, optional) | recurring: outages at [Start + k·Period, Start + k·Period + Outage) for k = 0, 1, 2, …; duty cycle = Outage/Period; omitted = one-shot |
| Jitter (0…10⁹ ms, optional) | breaks the metronome phase: each cycle re-draws the outage length and the cycle gap independently, uniformly in nominal ± Jitter (integer-ms grid; 0 = the fixed schedule) — stops protocol timers from phase-locking with the outage |
Timelines of the three forms (▒ = outage):
```
one-shot [1000, 1500, 100]:
pass ────────┬▒▒▒▒▒▒▒▒┬──────────────── (down t=1.0–2.5 s, passes before and after)
recurring [1000, 1500, 100, 10000]:
pass ──┬▒down 1.5s▒┬───────────────┬▒down 1.5s▒┬────── (every 10 s, 15 % duty cycle)
t=1.0s 2.5s t=11.0s 12.5s
jittered [0, 2000, 100, 10000, 500]:
▒~1.8s▒───── ~9.7s ─────▒~2.2s▒───── ~10.4s ─────▒~1.5s▒… (length and gap re-drawn per cycle within ±0.5 s)
```
**Note**: **any filter change, node restart or link reset restarts the schedule** — a one-shot window that has already passed must be re-armed: apply immediately before generating traffic, or use a Period. Both endpoints run their own schedule; a round trip survives only when both directions are outside their windows (see Semantics). Needs `ebpf=1` plus the uBridge mode token.
**Example**: `{"window_drop": [1000, 1500, 100]}` — dead 1.5 s starting at t=1 s, one-shot; `{"window_drop": [0, 2000, 100, 10000, 500]}` — dead 2 s now, recurring every ~10 ± 0.5 s.
## Semantics
* **Both endpoints** receive the filter dict and attach one qdisc to *their* anchor. The qdisc's egress covers traffic entering that node, so every direction of the link is impaired exactly once — the same net effect as the relay, where both directions cross the single filtered bridge. A `delay 100` link measures ≈200 ms RTT; `packet_loss 30` measures ≈51 % round-trip (1 − 0.7²); `rate 512kbit` adds ≈2× the per-packet serialization time to the RTT.
@ -93,7 +322,6 @@ On the kernel datapath the 13 types ride three kernel objects on the anchor. The
* **frequency_drop counting semantics differ per datapath.** The relay's single filtered bridge counts packets of BOTH directions through one counter; the kernel attaches one classifier per anchor, so each direction drops every Nth independently. For a round-trip measurement with every-Nth N: relay ≈ 1/N of frames (phase-locked), kernel = 1 − ((N−1)/N)² (e.g. N=3 → 55.6 % round-trip loss, measured 57 %). The kernel count is exact (atomic counter), unlike netem's stochastic loss.
* **Classifier order** on clsact egress: the eBPF stateful filter runs at prio 1, `bpf_drop` expressions at 10–99 — a packet dropped by the stateful modes never reaches the expression drops, and dropped packets never reach netem. Within the stateful program the modes evaluate in the fixed order nth → quota → window → flow.
* **window_drop semantics.** `start` is relative to the moment the filter is applied — and because reconcile re-sets the classifier on every apply (any filter change, node restart, link reset reloads the program after the netem reset), every such event **restarts the schedule**. 3 parameters = one single outage (`[start, start+outage)`), traffic passes before and after — the classic "link dies at T, comes back at T+outage". 4 parameters = recurring flap: `period ≥ outage` (validated), so the outage occupies `outage/period` of each cycle. 5 parameters = randomized flap: each cycle's outage and period are re-drawn uniformly in nominal ± `jitter` (integer-ms grid; jitter 0 draws nothing and equals the fixed schedule). Inside a window each packet drops with the given chance (100 = full outage, lower = degraded service during the window). Both endpoints run their own schedule, started ~simultaneously by the controller — a round trip survives only when **both** directions are outside their windows. Note `start = 0` (outage begins immediately) is a valid, active configuration — unlike other filters, a zero first parameter does not mean "disabled". A PUT re-sending an unchanged filters dict is normally a controller no-op, but one still carrying `window_drop` is reconciled anyway, so re-applying the same window re-arms a one-shot outage that has already passed (arm the filter immediately before generating traffic, or use a period for a repeatable event).
* **Validation** mirrors the tc grammar: `reorder` requires `delay`, `gemodel` and `packet_loss` are mutually exclusive (both map to the netem loss keyword), `distribution` requires jitter > 0, rate must be integer + unit ≤ 100gbit.
* **Node restart** restores the qdisc from the NIO (like capture and markers); **link deletion** detaches it explicitly — the anchor survives the link and an orphaned qdisc would keep impairing the next one.
* **Suspend** keeps the qdisc (carrier-driven loss); the first packet after resume takes one extra delay interval (known netem idle-baseline behaviour) and steady state is exact.
* **bpf reconcile = flush + re-add** (`tc bpf_drop flush`, then one add per line at priorities 10, 11, …). `tc reset` (link delete / filter clear) is the full restore in uBridge: filters → clsact → root qdisc, idempotent.
@ -115,7 +343,38 @@ A fully-loaded example — `delay 100 jitter 10`, `packet_loss 10`, `corrupt 5`,
## REST surface
Filters travel on the link API: `PUT /v3/links/{link_id}` with a `filters` dict, validated by the controller (the dependency and exclusivity rules above return 409 on violation) and pushed to both endpoints on create, update and project reopen. Each link payload carries read-only `available_filters` — the per-type intersection of what both endpoint computes can run, per the capability reporting above.
Filters travel on the link API: a `filters` dict on the link object (`filter type → positional values`), validated by the controller (the cross-filter rules above return 409 on violation) and pushed to both endpoints on create, update and project reopen.
| Method | Path | Purpose | Privilege |
|---|---|---|---|
| GET | `/v3/projects/{project_id}/links/{link_id}` | read the link incl. its `filters` | Link.Audit |
| PUT | `/v3/projects/{project_id}/links/{link_id}` | replace the `filters` dict | Link.Modify |
| GET | `/v3/projects/{project_id}/links/{link_id}/available_filters` | the per-link vocabulary with parameter names, ranges and units | Link.Audit |
Setting filters is a plain link update — send only the `filters` property:
```json
PUT /v3/projects/{project_id}/links/{link_id}
{
"filters": {
"delay": [50, 5],
"packet_loss": [1]
}
}
```
`available_filters` is the authoritative per-link vocabulary: kernel-only types appear only on kernel-datapath links whose endpoint computes report a uBridge able to run them, and relay links list the five relay types only (per the capability reporting above).
Common recipes — all values are per direction, so round trips double the delay and compound the loss (see Semantics):
| Goal | `filters` value |
|---|---|
| WAN latency with jitter | `{"delay": [50, 5]}` |
| 1 % random loss | `{"packet_loss": [1]}` |
| Bursty ~5 % mean loss | `{"gemodel": [1, 19, 100]}` — mean = p/(p+r)×(1-h) = 5 % |
| 512 kbit/s cap | `{"rate": ["512kbit"]}` |
| Drop all ICMP | `{"bpf": "icmp"}` |
| 1.5 s full outage starting now, one-shot | `{"window_drop": [0, 1500, 100]}` |
## Where the chain sits

View File

@ -0,0 +1,65 @@
<!--
SPDX-License-Identifier: CC-BY-SA-4.0
See LICENSE file for licensing information.
-->
> This documentation is AI-generated with reference to actual code and verified against real-kernel testing. AI can make mistakes — please verify against the source code when in doubt.
# VPCS kernel datapath
A VPCS node's single adapter anchors on a **persistent TAP** that the server creates and the vpcs process *attaches to* (`vpcs -e -d <anchor> -n`), the same server-creates/guest-attaches shape as QEMU's netdev. Links between a VPCS node and any other anchoring node type on the same compute are wired in the kernel (per-link Linux bridge) instead of the uBridge UDP relay; the compute side is the shared `KernelDatapathMixin`, which is anchor-agnostic.
## Architecture
The anchor follows the shared naming contract of `gns3server/utils/kernel_anchor.py`: `gp{node_id[:8]}e0p0` (14 chars, within the kernel's 16-byte `IFNAMSIZ`). The startup sequence is ordered — the TAP must exist before the process that reads it:
```
server → uBridge: tap create "gp70264b59e0p0" open /dev/net/tun + TUNSETIFF + TUNSETPERSIST; born administratively DOWN
server → uBridge: tap set_owner gp70264b59e0p0 <uid> hand the device to the server's own uid (vpcs is its child process)
server → vpcs: vpcs -e -d gp70264b59e0p0 -n tap mode, attach to that device, no host-side configuration
```
vpcs's `open_tap()` opens `/dev/net/tun` and issues `TUNSETIFF` with that name: the device already exists and its owner is vpcs's own uid, so the kernel takes the *attach* path — no privileges needed. (Only *creating* a tun/tap device needs `CAP_NET_ADMIN`; attaching to an owned one does not.) uBridge is never in the data path: a frame travels `vpcs read/write ↔ tap fd ↔ gp anchor ↔ per-link bridge ↔ peer anchor`, and every interface-level operation afterwards — carrier (`link set up/down`), bridge membership, tc filters, capture, markers — is the server driving uBridge over its control channel. The per-link bridge, filters, capture, markers and suspend all ride the same mixin paths as every other anchor-backed node type — including the Ethernet switch fast path (a switch link absorbs the `gp` anchor into the switch's own bridge).
The `setcap cap_net_admin` in the vpcs install target covers *standalone* use, where vpcs creates and activates its own TAP: creation always needs `CAP_NET_ADMIN`, and so does the `open_tap()` IFF_UP activation. Under GNS3 the capability is unnecessary — the activation silently no-ops without it (the server drives the anchor's carrier itself), which is why the server-side gating never asks for it.
```mermaid
flowchart LR
subgraph VA["vpcs process"]
ip["user-space IP stack"]
end
subgraph RootNS["root namespace"]
gpA["gp anchor TAP"]
BR["per-link bridge"]
end
ip <-->|"tap fd (-e -d)"| gpA
gpA <-->|"bridge port"| BR
```
## The three vpcs-side requirements
The stock vpcs build cannot carry this datapath; three things have to be true of the binary, all verified against [the vpcs fork](https://github.com/GNS3/vpcs):
* **`-n` (no host auto-configuration)** — without it, every guest `ip` command runs `ifconfig <tap> <addr> netmask <mask> up` on the *anchor itself*: the server owns the anchor's addresses and bridge membership, so the guest reconfiguring it ranges from noise (a failed `ifconfig` on a name that cannot resolve) to sabotage (an address on an L2-only anchor). `-n` also needs the `ifconfig` to follow the `-d` name when it does run — the stock build hardcodes `tap0`.
* **`-d` only in single-VPC mode** — `-i 1` is how the server always launches vpcs, so this is free.
* **`IFNAMSIZ`, not `IFNAMESIZ`** — upstream vpcs has a one-letter bug: `globle.h` defines its own `IFNAMESIZ 12`, `open_tap()` sizes its name buffer with it (`char dev[IFNAMESIZ]`) while the length guard checks the kernel's `IFNAMSIZ` (16). A 14-char anchor name passes the guard, overflows the 12-byte buffer, and reaches `TUNSETIFF` truncated to 12 chars — a nonexistent device, which the kernel tries to *create*, which needs `CAP_NET_ADMIN`, which the unprivileged vpcs does not have: `EPERM`, every time. The fork uses `IFNAMSIZ` consistently (worth an upstream PR; names up to 12 chars never trip it, which is why four years of `-d tap0` never noticed).
## Gating and fallback
A compute reports two capabilities for this (`/capabilities`, probed like every other kernel-datapath capability): `ubridge_tap` (uBridge's persistent-TAP module) and `vpcs_tap` (the binary answers `-n` in its usage — probed once per binary identity). A kernel link needs both strict-True on the compute; anything else keeps the node on the relay, where it always works. The node also decides its own datapath at start: without the probes it keeps the legacy local-UDP-tunnel shape (`-s/-c/-t` to the uBridge relay), with them it anchors and a relay link rides the anchor as an AF_PACKET endpoint — the relay-mode negative control covers both shapes.
## Stop order and the delete race
The vpcs process holds the anchor's fd, so stop is the QEMU order: terminate the process first, then `tap delete` — a held device refuses deletion and the suppressed delete would leak it. vpcs's daemon parent additionally kills its console child and exits *before* the child's fds are actually released, so the server can reap the tracked parent while the tap is still held: the shared `_delete_anchor_taps` now verifies each tap is gone from `/sys/class/net` and retries a bounded three times with a grace period (this race is not VPCS-specific — any holder that releases its fd a beat after its process was reaped hits it).
## Verified (real kernel, real vpcs)
The e2e scenario (`tests/e2e/test_vpcs_kernel_datapath.py`) drives two real VPCS nodes through the controller API and their TCP consoles:
* kernel link reports `kernel_datapath`, anchors exist from node start, the per-link bridge enslaves exactly the two of them; real ICMP crosses the tap-fd leg both ways;
* the L2-anchor spec §E.1 (the anchor stays address-free — the assertion `-n` exists for) and §E.2 (a VPCS sends nothing spontaneously, so the silence window needs no guest shutdown);
* `delay 100` lands as netem on both anchors (RTT grows ~200 ms); suspend admin-downs the anchor and kills the traffic; a capture on the link writes a real pcap;
* link delete releases the anchor but it survives (the running vpcs holds it); re-creating the link re-enslaves it;
* node stop terminates vpcs, deletes the anchor and the orphan bridges; a restart recreates the TAP, rewires the link from the NIO and the reconfigured VPCS pings again;
* deleting the project leaves no anchors and no bridges behind;
* the relay negative control (isolated relay-configured instance) wires nothing into the kernel and still pings, with the relay filters served by the uBridge bridge engine — verified with both the fork binary (relay on the anchor) and a stock binary (the legacy UDP tunnel).

View File

@ -31,6 +31,7 @@ from gns3server.compute.ubridge.tc_probe import (
probe_tap_support,
probe_tc_capabilities,
)
from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap
from gns3server.utils.path import get_default_project_directory
from gns3server.utils.tc_capabilities import usable_ebpf_modes
from gns3server.version import __version__
@ -70,6 +71,10 @@ async def get_capabilities(request: Request) -> dict:
# anchor. None = unknown (probe failed or uBridge too old).
ubridge_bridge_tap = await probe_bridge_tap_support()
# VPCS tap-mode anchoring (-e -d with -n): what lets a VPCS node carry a
# kernel link. None = unknown (probe failed or binary missing).
vpcs_tap = await probe_vpcs_tap()
# record the controller hostname or IP address
if request.client:
request.app.state.controller_host = request.client.host
@ -85,4 +90,5 @@ async def get_capabilities(request: Request) -> dict:
"ubridge_tap": ubridge_tap,
"ubridge_iol_tap": ubridge_iol_tap,
"ubridge_bridge_tap": ubridge_bridge_tap,
"vpcs_tap": vpcs_tap,
}

View File

@ -20,9 +20,9 @@ API routes for images.
import os
import urllib.parse
from typing import List
from typing import List, Optional
from fastapi import APIRouter, Body, HTTPException, Request, status
from fastapi import APIRouter, Body, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
from gns3server.compute.docker import Docker
@ -54,13 +54,23 @@ async def pull_docker_image(image: str = Body(..., embed=True, min_length=1, pat
@router.post("/docker/images/load", status_code=status.HTTP_204_NO_CONTENT)
async def load_docker_image(request: Request) -> None:
async def load_docker_image(
request: Request,
image: Optional[str] = Query(None, pattern=r"^\S+$", description="Tag of the image in the archive"),
digest: Optional[str] = Query(
None, pattern=r"^\S+$", description="Image id reported by the controller host daemon"
),
) -> None:
"""
Load a Docker image into the Docker daemon from a docker save tar stream.
The optional image tag and digest let the compute remember which image the
controller pushed and how the local daemon identifies it (see
Docker.load_image).
"""
docker_manager = Docker.instance()
await docker_manager.load_image(request.stream())
await docker_manager.load_image(request.stream(), image=image, digest=digest)
@router.get("/dynamips/images")

View File

@ -175,16 +175,16 @@ async def reload_vpcs_node(node: VPCSVM = Depends(dep_node)) -> None:
@router.post(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def create_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Add a NIO (Network Input/Output) to the node.
The adapter number on the VPCS node is always 0.
@ -198,16 +198,16 @@ async def create_vpcs_node_nio(
@router.put(
"/{node_id}/adapters/{adapter_number}/ports/{port_number}/nio",
status_code=status.HTTP_201_CREATED,
response_model=schemas.UDPNIO,
response_model=Union[schemas.UDPNIO, schemas.BridgeNIO],
dependencies=[Depends(compute_authentication)],
)
async def update_vpcs_node_nio(
*,
adapter_number: int = Path(..., ge=0, le=0),
port_number: int,
nio_data: schemas.UDPNIO,
nio_data: Union[schemas.UDPNIO, schemas.BridgeNIO],
node: VPCSVM = Depends(dep_node),
) -> schemas.UDPNIO:
) -> Union[schemas.UDPNIO, schemas.BridgeNIO]:
"""
Update a NIO (Network Input/Output) on the node.
The adapter number on the VPCS node is always 0.
@ -217,6 +217,7 @@ async def update_vpcs_node_nio(
nio.filters.clear()
if nio_data.filters:
nio.filters = nio_data.filters
nio.suspend = nio_data.suspend
nio.markers = nio_data.markers or {}
await node.port_update_nio_binding(port_number, nio)
return nio.asdict()

View File

@ -26,6 +26,7 @@ from fastapi import APIRouter, Body, Depends, Query, status
import gns3server.db.models as models
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerBadRequestError, ControllerError
from gns3server.db.repositories.computes import ComputesRepository
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.services.computes import ComputesService
@ -80,6 +81,42 @@ async def connect_compute(compute_id: Union[str, UUID]) -> None:
await compute.connect(report_failed_connection=True)
@router.post(
"/{compute_id}/disconnect",
status_code=status.HTTP_204_NO_CONTENT,
responses={409: {"model": schemas.ErrorMessage, "description": "Compute is used by opened projects"}},
# dependencies=[Depends(has_privilege("Compute.Audit"))] # FIXME: this is a temporary workaround due to a bug in the web-ui
)
async def disconnect_compute(compute_id: Union[str, UUID], force: Optional[bool] = False) -> None:
"""
Disconnect from a compute on the controller.
The compute is not reconnected until an explicit connection is requested,
or the controller restarts with auto_connect enabled for this compute.
Rejected with 409 when an opened project still uses the compute, unless
force is true. Forcing does not stop anything on the compute: only the
controller-side control channel (notification stream and HTTP session) is
torn down, nodes keep running unmanaged.
Required privilege: Compute.Audit
"""
if str(compute_id) == "local":
raise ControllerBadRequestError("Cannot disconnect the local compute")
controller = Controller.instance()
compute = controller.get_compute(str(compute_id))
if not force:
open_projects = controller.get_compute_open_projects(compute)
if open_projects:
project_names = ", ".join(f"'{project.name}'" for project in open_projects)
raise ControllerError(
f"Compute '{compute_id}' is used by opened projects ({project_names}), "
"disconnect anyway with force=true"
)
await compute.disconnect()
@router.get(
"/{compute_id}",
response_model=schemas.Compute,

View File

@ -1057,10 +1057,6 @@ class BaseNode:
# than skipping them as "already installed".
self._marker_filter_bridges.clear()
self._marker_specs.clear()
# ...and so is the kernel capture, uBridge's single AF_PACKET slot
# (meaningful for the kernel-datapath node types; see
# KernelDatapathMixin._reserve_kernel_capture).
self._kernel_capture_ifc = None
async def add_ubridge_udp_connection(self, bridge_name, source_nio, destination_nio):
"""

View File

@ -30,7 +30,7 @@ BUILTIN_NODES = {"cloud": Cloud, "nat": Nat, "ethernet_hub": EthernetHub, "ether
class BuiltinNodeFactory:
"""
Factory to create an builtin object based on the node type.
Factory to create a builtin object based on the node type.
"""
def __new__(cls, name, node_id, project, manager, node_type, **kwargs):

View File

@ -21,6 +21,7 @@ import sys
import gns3server.utils.asyncio
import gns3server.utils.interfaces
from gns3server.compute.ubridge.ubridge_error import UbridgeError
from gns3server.utils.kernel_anchor import is_kernel_datapath_interface
from ...base_node import BaseNode
from ...error import NodeError
@ -52,7 +53,9 @@ class Cloud(BaseNode):
if not ports or len(ports) == 0:
self._ports_mapping = []
for interface in self._interfaces():
if not interface["special"]:
# kernel-datapath interfaces (node anchors, cascade veths, VXLAN
# devices) belong to their creating link or node, never the cloud
if not interface["special"] and not is_kernel_datapath_interface(interface["name"]):
self._ports_mapping.append(
{
"interface": interface["name"],
@ -80,8 +83,10 @@ class Cloud(BaseNode):
host_interfaces = []
network_interfaces = gns3server.utils.interfaces.interfaces()
for interface in network_interfaces:
# Hide GNS3 internal bridges (e.g. EthernetSwitch kernel bridges)
if interface["name"].lower().startswith("gns3"):
# Hide GNS3 internal interfaces: kernel bridges ("gns3*" names) and
# the kernel-datapath devices (node anchors, cascade veths, VXLAN
# devices) a link or node of some project owns
if interface["name"].lower().startswith("gns3") or is_kernel_datapath_interface(interface["name"]):
continue
host_interfaces.append(
{

View File

@ -768,14 +768,11 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode):
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
anchor = self._kernel_ports.get(port_number)
if anchor is not None:
# One capture per uBridge process — see
# _reserve_kernel_capture; a failed start rolls the port's
# flag back so it cannot later stop the winner's capture.
# One capture per interface; a failed start rolls the port's
# flag back.
try:
self._reserve_kernel_capture(anchor)
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
self._release_kernel_capture(anchor)
nio.stop_packet_capture()
raise
else:
@ -797,12 +794,8 @@ class EthernetSwitch(KernelDatapathMixin, BaseNode):
if self._ubridge_hypervisor and self._ubridge_hypervisor.is_running():
anchor = self._kernel_ports.get(port_number)
if anchor is not None:
# Process-wide and argument-less: only the port owning the
# slot may stop it (a second port's stop would kill this
# capture while its own flag still says capturing).
if self._kernel_capture_owned_by(anchor):
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(anchor)
# Per-interface stop: only this port's capture is stopped.
await self._ubridge_send(f"capture stop_kernel {anchor}")
else:
ubridge_bridge = self._ubridge_bridge_name(port_number)
await self._ubridge_send(f"bridge stop_capture {ubridge_bridge}")

View File

@ -66,6 +66,11 @@ class Docker(BaseManager):
self._session = None
self._api_version = DOCKER_MINIMUM_API_VERSION
self._host_checked = False
# Image name -> (controller digest, local id) for the images synced from
# the controller. The controller pins the id its own daemon reports,
# which is not comparable with the id this daemon reports for the same
# content (see load_image()).
self._image_id_translations = {}
def _select_node_class(self, **kwargs):
"""
@ -419,11 +424,16 @@ class Docker(BaseManager):
progress_callback(f"Success pulling image {image}")
@locking
async def load_image(self, stream, progress_callback=None):
async def load_image(self, stream, image=None, digest=None, progress_callback=None):
"""
Load a Docker image into the Docker daemon from a docker save tar stream
:param stream: An async iterable of bytes (the tar produced by docker save)
:param image: Tag of the image in the archive. Together with digest, the
(controller digest, local id) pair is remembered so that node creation
can accept the digest the controller pinned for this image
:param digest: Image id as reported by the Docker daemon on the controller
host (the value the controller pins in the node create payload)
:param progress_callback: A function that receive a log message about image load progress
"""
@ -470,6 +480,39 @@ class Docker(BaseManager):
if progress_callback:
progress_callback("Docker image loaded")
if image and digest:
await self._remember_image_id_translation(image, digest)
async def _remember_image_id_translation(self, image, controller_digest):
"""
Remember how the local daemon identifies an image that was just loaded
from the controller.
Docker ids are not comparable across image stores: for the same content
a classic store reports the config digest, while a containerd store (or
an image loaded from an OCI archive) reports the manifest digest. The
controller pins the id of its own daemon, so recording the pair lets
DockerVM accept that pin for the image it actually received instead of
asking for another full sync on every node creation.
"""
try:
image_infos = await self.query("GET", f"images/{image}/json")
except DockerError as e:
log.warning(f"Image '{image}' has been loaded but cannot be inspected: {e}")
return
local_id = image_infos.get("Id")
if local_id:
self._image_id_translations[image] = (controller_digest, local_id)
def image_id_translation(self, image):
"""
:returns: the (controller digest, local id) pair recorded the last time
this image was loaded from the controller, None otherwise
"""
return self._image_id_translations.get(image)
async def list_images(self):
"""
Gets Docker image list.

View File

@ -591,15 +591,20 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
if image_infos is None:
raise DockerError(f"Cannot get information for image '{self._image}', please try again.")
if self._image_digest and image_infos.get("Id") != self._image_digest:
local_id = image_infos.get("Id")
if self._image_digest and local_id != self._image_digest:
# the tag exists but points to a different image (e.g. a moved :latest):
# report it as missing so the controller re-syncs the expected version
local_id = image_infos.get("Id")
log.info(
f"Image '{self._image}' version mismatch on this compute: "
f"local id '{local_id}' != expected '{self._image_digest}'"
)
raise ImageMissingError(self._image)
# report it as missing so the controller re-syncs the expected version.
# Image ids are not comparable across image stores (a classic store
# reports the config digest where a containerd store, or an image
# loaded from an OCI archive, reports the manifest digest), so accept
# the pin for the image this compute received from the controller.
if self.manager.image_id_translation(self._image) != (self._image_digest, local_id):
log.info(
f"Image '{self._image}' version mismatch on this compute: "
f"local id '{local_id}' != expected '{self._image_digest}'"
)
raise ImageMissingError(self._image)
available_cpus = psutil.cpu_count(logical=True)
if self._cpus > available_cpus:
@ -2007,16 +2012,13 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
if isinstance(nio, NIOBridge):
# Kernel datapath: no relay bridge exists — capture via uBridge's
# AF_PACKET module bound to the anchor (a veth host end for the
# standard container, a persistent TAP for an IOL runner). One
# capture per uBridge process: the slot is claimed per anchor,
# and a failed start rolls the port's capturing flag back so the
# port cannot later stop the winner's capture.
# standard container, a persistent TAP for an IOL runner).
# uBridge serves one capture per interface, any number of
# interfaces; a failed start rolls the port's capturing flag back.
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
try:
self._reserve_kernel_capture(host_ifc)
await self._ubridge_send(f'capture start_kernel {host_ifc} "{output_file}"')
except Exception:
self._release_kernel_capture(host_ifc)
nio.stop_packet_capture()
raise
return
@ -2038,13 +2040,11 @@ class DockerVM(DockerKernelDatapathMixin, BaseNode):
raise DockerError("Cannot stop the packet capture: uBridge is not running")
nio = self._ethernet_adapters[adapter_number].get_nio(port_number)
if isinstance(nio, NIOBridge):
# Idempotent on the uBridge side (no active capture is OK), but
# process-wide and argument-less: only the port owning the slot
# may stop it, or a second port's stop would kill this capture.
# Per-interface stop: only this port's capture is stopped (the
# capturing flag above gates it, so uBridge's 208-on-idle-
# interface can only surface on a genuine desync).
host_ifc = self._kernel_host_ifc(adapter_number, port_number)
if self._kernel_capture_owned_by(host_ifc):
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(host_ifc)
await self._ubridge_send(f"capture stop_kernel {host_ifc}")
return
bridge_name = self._bridge_name(adapter_number, port_number)
await self._ubridge_send(f"bridge stop_capture {bridge_name}")

View File

@ -46,7 +46,7 @@ PLATFORMS = {
DEVICES = {
"atm_switch": ATMSwitch,
"frame_relay_switch": FrameRelaySwitch,
"ethernet_switch": EthernetSwitch,
"ethernet_switch": EthernetSwitch, # The Dynamips Ethernet switch has been replaced by a Linux bridge
"ethernet_hub": EthernetHub,
}

View File

@ -19,6 +19,8 @@ Interface for Dynamips virtual Ethernet switch module ("ethsw").
http://github.com/GNS3/dynamips/blob/master/README.hypervisor#L558
"""
# Note: the Dynamips Ethernet switch has been replaced by a Linux bridge
import logging
from gns3server.utils import parse_version

View File

@ -1491,15 +1491,11 @@ class Router(KernelDatapathMixin, BaseNode):
if self.ubridge:
anchor = self._kernel_host_ifc(slot_number, port_number)
if anchor is not None:
# One capture per uBridge process — see
# _reserve_kernel_capture; a failed start rolls the
# port's flag back so it cannot later stop the winner's
# capture.
# One capture per interface; a failed start rolls the
# port's flag back.
try:
self._reserve_kernel_capture(anchor)
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
self._release_kernel_capture(anchor)
nio.stop_packet_capture()
raise
log.debug(
@ -1538,11 +1534,9 @@ class Router(KernelDatapathMixin, BaseNode):
if isinstance(nio, NIOBridge):
nio.stop_packet_capture()
anchor = self._kernel_host_ifc(slot_number, port_number)
if self.ubridge and anchor is not None and self._kernel_capture_owned_by(anchor):
# Process-wide and argument-less: only the port owning the
# slot may stop it.
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(anchor)
if self.ubridge and anchor is not None:
# Per-interface stop: only this port's capture is stopped.
await self._ubridge_send(f"capture stop_kernel {anchor}")
return
await nio.stop_packet_capture()

View File

@ -1828,19 +1828,16 @@ class IOUVM(KernelDatapathMixin, BaseNode):
if self.ubridge:
if isinstance(nio, NIOBridge):
# Kernel link: capture on the port's TAP anchor (AF_PACKET,
# one capture per uBridge process — see _reserve_kernel_capture;
# a failed start rolls the port's flag back so the port cannot
# later stop the winner's capture).
# one capture per interface — a failed start rolls the port's
# flag back).
anchor = self._kernel_host_ifc(adapter_number, port_number)
try:
if anchor is None:
raise self._kernel_error(
f"Bay {adapter_number}/{port_number} of IOU '{self._name}' has no TAP anchor to capture on"
)
self._reserve_kernel_capture(anchor)
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
self._release_kernel_capture(anchor)
nio.stop_packet_capture()
raise
else:
@ -1869,13 +1866,10 @@ class IOUVM(KernelDatapathMixin, BaseNode):
log.debug(f'IOU "{self._name}" [{self._id}]: stopping packet capture on {adapter_number}/{port_number}')
if self.ubridge:
if isinstance(nio, NIOBridge):
# Process-wide and argument-less: only the port owning the
# slot may stop it (a second port's stop would kill this
# capture while its own flag still says capturing).
# Per-interface stop: only this port's capture is stopped.
anchor = self._kernel_host_ifc(adapter_number, port_number)
if self._kernel_capture_owned_by(anchor):
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(anchor)
if anchor is not None:
await self._ubridge_send(f"capture stop_kernel {anchor}")
else:
await self._ubridge_send(
f"iol_bridge stop_capture {self._iol_bridge_name()} {adapter_number} {port_number}"

View File

@ -44,6 +44,7 @@ A node type using this mixin provides:
failures surface as ``DockerError`` / ``QemuError`` rather than ``NodeError``.
"""
import asyncio
import contextlib
import logging
import os
@ -213,12 +214,34 @@ class KernelDatapathMixin:
a held device answers EBADFD and the best-effort delete below would
leak it. The sweep is suppressed per TAP — an anchor already gone
is not an error — and the registry is cleared either way.
A holder's fd can be released a moment after its process was
reaped (VPCS's daemon parent kills its child then exits — the
server reaps the parent before the child's fds are actually gone),
so the first delete races EBUSY. Every tap that is still on the
host afterwards (same-host sysfs check, zero cost on the happy
path) gets a small grace and up to three retries; a survivor is
warned about, not fatal.
"""
if self.ubridge:
for tap in self._kernel_taps.values():
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
for attempt in range(3):
if not os.path.exists(f"/sys/class/net/{tap}"):
break
await asyncio.sleep(0.3)
with contextlib.suppress(UbridgeError):
await self._ubridge_send(f'tap delete "{tap}"')
else:
if os.path.exists(f"/sys/class/net/{tap}"):
log.warning(
"Node '%s' [%s]: anchor TAP %s survived its delete — a holder still has it open",
self._name,
self._id,
tap,
)
await self._remove_kernel_bridges()
self._kernel_taps.clear()
@ -241,52 +264,6 @@ class KernelDatapathMixin:
return not (isinstance(nio, NIOBridge) and nio.bridge is None)
# The anchor whose kernel capture currently holds the uBridge process's
# single AF_PACKET capture slot (None = free; see _reserve_kernel_capture).
# Class-level default so every node type gets it without touching its
# __init__; BaseNode._stop_ubridge resets it when uBridge goes away.
_kernel_capture_ifc = None
def _reserve_kernel_capture(self, anchor):
"""
Claim uBridge's single kernel-capture slot for *anchor*.
uBridge serves one AF_PACKET capture per process (a static slot: a
second ``capture start_kernel`` answers EALREADY) while the server
models captures per port — a second kernel port of the same node is
refused here with a clear error. Without the claim the loser's port
would stay marked as capturing, and its stop would issue the
process-wide, argument-less ``capture stop_kernel``, killing the
winner's capture while its own flag still says capturing.
"""
if self._kernel_capture_ifc is not None and self._kernel_capture_ifc != anchor:
raise self._kernel_error(
f"Cannot start the packet capture: this node's uBridge already captures "
f"{self._kernel_capture_ifc} (one kernel capture per node); stop that capture first"
)
self._kernel_capture_ifc = anchor
def _release_kernel_capture(self, anchor):
"""
Release the slot if *anchor* holds it: after a stop, or after a
failed start whose port must not stay marked as capturing.
"""
if self._kernel_capture_ifc == anchor:
self._kernel_capture_ifc = None
def _kernel_capture_owned_by(self, anchor):
"""
Whether *anchor* may issue the process-wide ``capture stop_kernel``:
only the port holding the slot — or a node whose slot is empty (a
capture predating the tracker), where stopping is the historical
best-effort behaviour. A second port must never reach it: the
command takes no interface and would stop the owner's capture.
"""
return self._kernel_capture_ifc is None or self._kernel_capture_ifc == anchor
async def _kernel_attach(self, anchor, nio):
"""
Attach an anchor to the per-link kernel bridge carried by *nio*
@ -319,10 +296,7 @@ class KernelDatapathMixin:
await self._ubridge_send(f'brctl addif "{nio.bridge}" "{anchor}"')
if nio.capturing:
# Restore a capture that was active before a node restart
# (mirrors the relay path's start_capture). The slot is claimed
# like a fresh start: a second kernel port's replay is refused
# with a clear error instead of an EALREADY deep in the wiring.
self._reserve_kernel_capture(anchor)
# (mirrors the relay path's start_capture).
await self._ubridge_send(f'capture start_kernel {anchor} "{nio.pcap_output_file}"')
# Markers carried by the NIO attach to the anchor (AF_PACKET taps) —
# the anchor is the interface, not a relay bridge.

View File

@ -1758,14 +1758,11 @@ class QemuVM(KernelDatapathMixin, BaseNode):
anchor = self._kernel_host_ifc(adapter_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Kernel link: capture on the adapter's TAP anchor (AF_PACKET,
# one capture per uBridge process — see _reserve_kernel_capture;
# a failed start rolls the port's flag back so the port cannot
# later stop the winner's capture).
# one capture per interface — a failed start rolls the port's
# flag back).
try:
self._reserve_kernel_capture(anchor)
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
self._release_kernel_capture(anchor)
nio.stop_packet_capture()
raise
else:
@ -1792,12 +1789,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
if self.ubridge:
anchor = self._kernel_host_ifc(adapter_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Process-wide and argument-less: only the port owning the
# slot may stop it (a second port's stop would kill this
# capture while its own flag still says capturing).
if self._kernel_capture_owned_by(anchor):
await self._ubridge_send("capture stop_kernel")
self._release_kernel_capture(anchor)
# Per-interface stop: only this port's capture is stopped.
await self._ubridge_send(f"capture stop_kernel {anchor}")
else:
await self._ubridge_send("bridge stop_capture {name}".format(name=f"QEMU-{self._id}-{adapter_number}"))

View File

@ -0,0 +1,92 @@
#
# 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/>.
#
"""
Standalone VPCS tap-mode probe.
A VPCS node anchors a kernel-datapath link on a persistent TAP the vpcs
process attaches to by name (``-e -d <anchor>``). That shape needs a vpcs
build that also refuses to auto-configure the host side of the TAP (``-n``):
without the flag every ``ip`` command inside the guest runs
``ifconfig <tap> … up`` on the anchor — the server owns the anchor's
addresses and bridge membership, so a vpcs touching them ranges from noise
(an ifconfig on a name that cannot resolve) to sabotage (reconfiguring an
interface that does exist).
The probe asks the configured binary for its usage (``vpcs -h``) and looks
for the ``-n`` line, caching the answer by binary identity (path, mtime,
size, ctime) exactly like the uBridge probes — the same binary never
answers differently, but replacing it must re-probe. This feeds the compute
``/capabilities`` payload (``vpcs_tap``) so the controller can gate VPCS
kernel links on it. Never raises: any failure (missing binary, timeout)
means unknown, reported as None and cached as such — the safe answer keeps
the node on the relay datapath.
"""
import asyncio
import logging
import os
import shutil
from gns3server.config import Config
from gns3server.utils.asyncio import subprocess_check_output
log = logging.getLogger(__name__)
# binary identity (path, mtime, size, ctime) -> whether -n is available
# (None = probed and unknown)
_cache: dict[tuple[str, int, int, int], bool | None] = {}
_lock = asyncio.Lock()
# The stable tail of the -n usage line (the flag itself is wrapped in ANSI
# escapes, the description is not).
_NO_HOSTCONF_NEEDLE = "do not auto-configure"
async def probe_vpcs_tap(timeout: float = 5.0) -> bool | None:
"""
Whether this host's VPCS binary can anchor a kernel link (tap mode with
``-n``), probed once per binary identity.
:returns: True when the usage advertises ``-n``, False when it does not
(an upstream build), None when the probe failed (unknown binary).
"""
config = Config.instance()
vpcs_path = config.settings.VPCS.vpcs_path
if not os.path.isabs(vpcs_path):
vpcs_path = shutil.which(vpcs_path)
if not vpcs_path or not os.path.isfile(vpcs_path):
return None
try:
stat = os.stat(vpcs_path)
key = (vpcs_path, stat.st_mtime_ns, stat.st_size, stat.st_ctime_ns)
except OSError:
return None
async with _lock:
if key in _cache:
return _cache[key]
try:
output = await asyncio.wait_for(subprocess_check_output(vpcs_path, "-h"), timeout=timeout)
except (OSError, asyncio.TimeoutError, ValueError) as e:
log.debug("VPCS tap-mode probe failed: %s", e)
supported = None
else:
supported = _NO_HOSTCONF_NEEDLE in output
_cache[key] = supported
return supported

View File

@ -31,16 +31,20 @@ import sys
from gns3server.utils import parse_version
from gns3server.utils.asyncio import monitor_process, subprocess_check_output, wait_for_process_termination
from gns3server.utils.kernel_anchor import kernel_anchor_name
from ..adapters.ethernet_adapter import EthernetAdapter
from ..base_node import BaseNode
from ..kernel_datapath import KernelDatapathMixin
from ..nios.nio_bridge import NIOBridge
from ..nios.nio_udp import NIOUDP
from .vpcs_error import VPCSError
from .vpcs_probe import probe_vpcs_tap
log = logging.getLogger(__name__)
class VPCSVM(BaseNode):
class VPCSVM(KernelDatapathMixin, BaseNode):
module_name = "vpcs"
"""
@ -62,6 +66,13 @@ class VPCSVM(BaseNode):
self._vpcs_version = None
self._started = False
self._local_udp_tunnel = None
self._execute_lock = asyncio.Lock()
# Kernel-datapath state (the anchor is a persistent TAP uBridge
# creates and the vpcs process attaches to — see the datapath
# section below).
self._tap_datapath = False
self._kernel_taps = {}
self._ubridge_tc_caps = None
# VPCS settings
if startup_script is not None and not self.script_file: # We disallow override at startup
@ -72,6 +83,15 @@ class VPCSVM(BaseNode):
def ethernet_adapter(self):
return self._ethernet_adapter
@property
def _ethernet_adapters(self):
"""
The adapter list the kernel-datapath mixin walks (the per-link
bridge sweep): VPCS owns exactly one single-port adapter.
"""
return [self._ethernet_adapter]
async def close(self):
"""
Closes this VPCS VM.
@ -89,10 +109,14 @@ class VPCSVM(BaseNode):
self.manager.port_manager.release_udp_port(self._local_udp_tunnel[1].lport, self._project)
self._local_udp_tunnel = None
await self._stop_ubridge()
# stop() tears the datapath down in the right order (the vpcs
# process holds the anchor TAPs open — a held device refuses
# deletion and would leak); the else branch covers a node that is
# not running but may still hold a uBridge with anchors.
if self.is_running():
self._terminate_process()
await self.stop()
else:
await self._stop_ubridge()
return True
@ -225,34 +249,51 @@ class VPCSVM(BaseNode):
await self._check_requirements()
if not self.is_running():
nio = self._ethernet_adapter.get_nio(0)
command = self._build_command()
# uBridge owns the anchor TAP and must be up before it is
# created — and before vpcs attaches to it. The whole setup runs
# inside the try so a failure at any step (probe refused, tap
# create, launch) takes the datapath down again: a node that
# reports stopped must not keep a uBridge process and a
# root-namespace TAP alive.
self._ubridge_tc_caps = None
try:
log.info(f"Starting VPCS: {command}")
self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log")
log.info(f"Logging to {self._vpcs_stdout_file}")
flags = 0
if sys.platform.startswith("win32"):
flags = subprocess.CREATE_NEW_PROCESS_GROUP
with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd:
self.command_line = " ".join(command)
self._process = await asyncio.create_subprocess_exec(
*command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags
)
monitor_process(self._process, self._termination_callback)
await self._start_ubridge()
if nio:
await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._prepare_tap_datapath()
await self.start_wrap_console()
command = self._build_command()
try:
log.info(f"Starting VPCS: {command}")
self._vpcs_stdout_file = os.path.join(self.working_dir, "vpcs.log")
log.info(f"Logging to {self._vpcs_stdout_file}")
flags = 0
if sys.platform.startswith("win32"):
flags = subprocess.CREATE_NEW_PROCESS_GROUP
with open(self._vpcs_stdout_file, "w", encoding="utf-8") as fd:
self.command_line = " ".join(command)
self._process = await asyncio.create_subprocess_exec(
*command, stdout=fd, stderr=subprocess.STDOUT, cwd=self.working_dir, creationflags=flags
)
monitor_process(self._process, self._termination_callback)
log.info(f"VPCS instance {self.name} started PID={self._process.pid}")
self._started = True
self.status = "started"
except (OSError, subprocess.SubprocessError) as e:
vpcs_stdout = self.read_vpcs_stdout()
log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
if nio:
# a link that outlived a stop is re-attached from its NIO
await self._connect_nio(0, nio)
await self._set_adapter_carrier(0, not nio.suspend)
await self.start_wrap_console()
log.info(f"VPCS instance {self.name} started PID={self._process.pid}")
self._started = True
self.status = "started"
except (OSError, subprocess.SubprocessError) as e:
vpcs_stdout = self.read_vpcs_stdout()
log.error(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
raise VPCSError(f"Could not start VPCS {self._vpcs_path()}: {e}\n{vpcs_stdout}")
except Exception:
# uBridge was started (and the anchor TAP created) before the
# launch: don't leave either behind on a failed start.
await self._stop_ubridge()
raise
async def _termination_callback(self, returncode):
"""
@ -263,11 +304,9 @@ class VPCSVM(BaseNode):
if self._started:
log.info("VPCS process has stopped, return code: %d", returncode)
self._started = False
self.status = "stopped"
self._process = None
await self._stop_ubridge()
await super().stop()
# stop() tears the datapath down under the execute lock (an API
# stop may be running concurrently) and sets the status.
await self.stop()
if returncode != 0:
self.project.emit(
"log.error",
@ -279,24 +318,156 @@ class VPCSVM(BaseNode):
Stops the VPCS process.
"""
await self._stop_ubridge()
if self.is_running():
self._terminate_process()
if self._process.returncode is None:
try:
await wait_for_process_termination(self._process, timeout=3)
except asyncio.TimeoutError:
if self._process.returncode is None:
try:
self._process.kill()
except OSError as e:
log.error(f"Cannot stop the VPCS process: {e}")
async with self._execute_lock:
# Release the datapath once vpcs is gone: the process holds the
# anchor TAP open (it is the guest side), and uBridge's tap
# delete refuses a held device ("Device or resource busy") —
# deleting it while vpcs still ran leaked the persistent TAP,
# because that best-effort delete is suppressed. The process
# monitor calls stop() on its own when vpcs dies, so an API stop
# and a process-death stop run concurrently in the normal case;
# the lock serializes their TAP sweeps (the second one finds
# uBridge already gone and sweeps nothing).
if self.is_running():
self._terminate_process()
if self._process.returncode is None:
try:
await wait_for_process_termination(self._process, timeout=3)
except asyncio.TimeoutError:
if self._process.returncode is None:
log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running')
try:
self._process.kill()
except OSError as e:
log.error(f"Cannot stop the VPCS process: {e}")
if self._process.returncode is None:
log.warning(f'VPCS VM "{self._name}" with PID={self._process.pid} is still running')
self._process = None
self._started = False
await super().stop()
self._process = None
self._started = False
await self._stop_ubridge()
await super().stop()
# ------------------------------------------------------------------
# Kernel datapath: the adapter anchor is a persistent TAP
# ------------------------------------------------------------------
def _tap_name(self, adapter_number, port_number=0):
"""
Deterministic anchor TAP name for the adapter port (the shared
utils.kernel_anchor naming contract — the controller names a peer's
anchor with the same function when an Ethernet switch absorbs it).
"""
return kernel_anchor_name("vpcs", self._id, adapter_number, port_number)
def _kernel_host_ifc(self, adapter_number, port_number=0):
"""
The persistent TAP this adapter owns, or None before the node
started (or on the legacy UDP relay datapath).
"""
return self._kernel_taps.get((adapter_number, port_number))
def _kernel_anchors(self):
return set(self._kernel_taps.values())
def _kernel_error(self, message):
return VPCSError(message)
async def _prepare_tap_datapath(self):
"""
Decide this start's datapath: a vpcs build with ``-n`` (no host
auto-configuration) and a uBridge with the persistent-TAP module
anchor the adapter on a persistent TAP the vpcs process attaches to
(``-e -d``). Anything else (upstream vpcs, old uBridge, no
CAP_NET_ADMIN) keeps the legacy UDP relay datapath — kernel links
are refused on it.
"""
self._tap_datapath = False
self._kernel_taps.clear()
if not self.ubridge:
return
vpcs_tap = await probe_vpcs_tap()
if vpcs_tap is not True:
# Without -n every guest `ip` command would ifconfig the anchor
# on the host — the server owns the anchor's addresses and
# bridge membership, so the relay datapath is the only safe
# shape. False (a build definitively without -n) is actionable
# for the user; None (probe failed) stays server-log-only.
log.warning(
"VPCS '%s': vpcs '%s' cannot anchor a kernel link (no -n support)", self._name, self._vpcs_path()
)
if vpcs_tap is False:
self.project.emit(
"log.warning",
{
"message": f"VPCS '{self._name}': the vpcs binary does not support tap mode without host "
"auto-configuration (-n); this node runs on the legacy relay datapath and cannot carry "
"kernel links"
},
)
return
probe_error = await self._probe_persistent_taps(f"gp{self._id.replace('-', '')[:8]}prob")
if probe_error is not None:
log.warning("VPCS '%s': uBridge cannot create persistent TAPs (%s)", self._name, probe_error)
self.project.emit(
"log.warning",
{
"message": f"VPCS '{self._name}': uBridge does not support persistent TAPs ({probe_error}); "
"this node runs on the legacy relay datapath and cannot carry kernel links"
},
)
return
self._tap_datapath = True
# The shared lifecycle sweeps leftovers first, creates the TAP and
# hands it to this user (the unprivileged vpcs process opens it
# itself), leaving it down until a link attaches.
await self._create_anchor_taps([(0, 0)], set_owner=True)
async def _stop_ubridge(self):
"""
Stops uBridge, removing the anchor TAP first: it lives in the root
namespace and outlives the process (the tap module needs the control
channel to delete it), and vpcs holding the other end keeps the
device alive until it exits — the caller must terminate vpcs first,
or the suppressed tap delete leaks the anchor. The next start
spawns a fresh uBridge, so the tc capabilities must be probed again.
"""
await self._delete_anchor_taps()
self._ubridge_tc_caps = None
await super()._stop_ubridge()
async def _connect_nio(self, port_number, nio):
"""
Attach a link's NIO to the adapter's TAP anchor: a kernel link
enslaves the TAP into the per-link kernel bridge, a relay link
attaches it to the uBridge relay bridge as an AF_PACKET endpoint.
The legacy datapath (no anchor) bridges the local UDP tunnel
instead.
"""
bridge_name = f"VPCS-{self._id}"
if not self._tap_datapath:
if isinstance(nio, NIOBridge):
raise VPCSError(
f"VPCS '{self._name}' runs on the legacy relay datapath (no anchor TAP) "
"and cannot carry a kernel link"
)
await self.add_ubridge_udp_connection(bridge_name, self._local_udp_tunnel[1], nio)
return
anchor = self._kernel_host_ifc(0, port_number)
if anchor is None:
raise VPCSError(f"VPCS '{self._name}' has no anchor TAP interface; restart the node to attach a link to it")
if isinstance(nio, NIOBridge):
await self._kernel_attach(anchor, nio)
return
await self._relay_attach(anchor, bridge_name, nio)
async def reload(self):
"""
@ -386,8 +557,15 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
# The compute-side backstop of the controller's duplicate-port guard:
# a port carries at most one NIO, and overwriting the one a link
# still owns orphans that link's teardown (its host state leaks).
if self._ethernet_adapter.get_nio(port_number) is not None:
raise VPCSError(f"Port {port_number} of VPCS '{self._name}' already has a link")
if self.is_running():
await self.add_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._connect_nio(port_number, nio)
await self._set_adapter_carrier(0, not nio.suspend)
self._ethernet_adapter.add_nio(port_number, nio)
log.info(f'VPCS "{self._name}" [{self.id}]: {nio} added to port {port_number}')
@ -405,7 +583,19 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
if self.is_running():
await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
if self._tap_datapath and isinstance(nio, NIOBridge):
# Filters or markers changed on an attached kernel link:
# re-apply on the anchor, no re-enslaving.
anchor = self._kernel_host_ifc(0, port_number)
if anchor is not None:
await self._kernel_update(anchor, nio)
elif self._tap_datapath:
# Relay link on the TAP anchor: re-apply on its bridge.
await self._ubridge_apply_filters(f"VPCS-{self._id}", nio.filters)
await self._ubridge_apply_markers(f"VPCS-{self._id}", nio)
else:
await self.update_ubridge_udp_connection(f"VPCS-{self._id}", self._local_udp_tunnel[1], nio)
await self._set_adapter_carrier(0, not nio.suspend)
async def port_remove_nio_binding(self, port_number):
"""
@ -419,11 +609,16 @@ class VPCSVM(BaseNode):
if not self._ethernet_adapter.port_exists(port_number):
raise VPCSError(f"Port {port_number} doesn't exist on adapter {self._ethernet_adapter}")
nio = self._ethernet_adapter.get_nio(port_number)
await self.stop_capture(port_number)
if self.is_running():
await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}"))
if self._tap_datapath and isinstance(nio, NIOBridge):
await self._remove_kernel_nio(nio, 0, port_number)
elif self._tap_datapath:
await self._relay_detach(f"VPCS-{self._id}")
else:
await self._ubridge_send("bridge delete {name}".format(name=f"VPCS-{self._id}"))
nio = self._ethernet_adapter.get_nio(port_number)
if isinstance(nio, NIOUDP):
self.manager.port_manager.release_udp_port(nio.lport, self._project)
self._ethernet_adapter.remove_nio(port_number)
@ -461,9 +656,22 @@ class VPCSVM(BaseNode):
nio.start_packet_capture(output_file)
if self.ubridge:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(name=f"VPCS-{self._id}", output_file=output_file)
)
anchor = self._kernel_host_ifc(0, port_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Kernel link: capture on the adapter's TAP anchor (AF_PACKET,
# one capture per interface — a failed start rolls the port's
# flag back).
try:
await self._ubridge_send(f'capture start_kernel {anchor} "{output_file}"')
except Exception:
nio.stop_packet_capture()
raise
else:
await self._ubridge_send(
'bridge start_capture {name} "{output_file}"'.format(
name=f"VPCS-{self._id}", output_file=output_file
)
)
log.info(f"VPCS '{self.name}' [{self.id}]: starting packet capture on port {port_number}")
@ -480,7 +688,12 @@ class VPCSVM(BaseNode):
nio.stop_packet_capture()
if self.ubridge:
await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}"))
anchor = self._kernel_host_ifc(0, port_number)
if isinstance(nio, NIOBridge) and anchor is not None:
# Per-interface stop: only this port's capture is stopped.
await self._ubridge_send(f"capture stop_kernel {anchor}")
else:
await self._ubridge_send("bridge stop_capture {name}".format(name=f"VPCS-{self._id}"))
log.info(f"VPCS '{self.name}' [{self.id}]: stopping packet capture on port {port_number}")
@ -511,6 +724,7 @@ class VPCSVM(BaseNode):
tap mode options:
-d vm device name, works only when -i is set to 1
-n do not auto-configure the host tap interface
hypervisor mode option:
-H port run as the hypervisor listening on the tcp 'port'
@ -532,20 +746,28 @@ class VPCSVM(BaseNode):
else:
log.warning("The VPCS relay feature could not be disabled because the VPCS version is below 0.8b")
# use the local UDP tunnel to uBridge instead
if not self._local_udp_tunnel:
self._local_udp_tunnel = self._create_local_udp_tunnel()
nio = self._local_udp_tunnel[0]
if nio and isinstance(nio, NIOUDP):
# UDP tunnel
command.extend(["-s", str(nio.lport)]) # source UDP port
command.extend(["-c", str(nio.rport)]) # destination UDP port
try:
command.extend(
["-t", socket.gethostbyname(nio.rhost)]
) # destination host, we need to resolve the hostname because VPCS doesn't support it
except socket.gaierror as e:
raise VPCSError(f"Can't resolve hostname {nio.rhost}")
if self._tap_datapath:
# Kernel-datapath anchor: uBridge created the persistent TAP
# before the launch, vpcs attaches to it by name and must leave
# its host-side configuration alone (-n).
command.extend(["-e"]) # tap mode
command.extend(["-d", self._tap_name(0, 0)]) # the anchor TAP
command.extend(["-n"]) # no host auto-configuration on the anchor
else:
# use the local UDP tunnel to uBridge instead
if not self._local_udp_tunnel:
self._local_udp_tunnel = self._create_local_udp_tunnel()
nio = self._local_udp_tunnel[0]
if nio and isinstance(nio, NIOUDP):
# UDP tunnel
command.extend(["-s", str(nio.lport)]) # source UDP port
command.extend(["-c", str(nio.rport)]) # destination UDP port
try:
command.extend(
["-t", socket.gethostbyname(nio.rhost)]
) # destination host, we need to resolve the hostname because VPCS doesn't support it
except socket.gaierror as e:
raise VPCSError(f"Can't resolve hostname {nio.rhost}")
if self.script_file:
command.extend([os.path.basename(self.script_file)])

View File

@ -201,10 +201,12 @@ class Controller:
if computes:
for c in computes:
try:
# FIXME: Task exception was never retrieved
await self.add_compute(
compute_id=str(c.compute_id),
connect=False,
# computes flagged auto_connect connect in the
# background, one second after the controller started
connect=c.auto_connect,
wait_connection=False,
**c.dict(exclude_unset=True, exclude={"compute_id", "created_at", "updated_at"}),
ssl_context=self._ssl_context,
)
@ -625,9 +627,12 @@ class Controller:
if connect:
if wait_connection:
await compute.connect()
else:
# call compute.connect() later to give time to the controller to be fully started
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute.connect()))
elif not hasattr(sys, "_called_from_test"):
# connect in the background to give time to the controller to be fully
# started; goes through _try_reconnect so connection errors don't die
# as unretrieved task exceptions. Skipped during tests: the timer would
# leak the connection attempt into another test's event loop.
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(compute._try_reconnect()))
self.notification.controller_emit("compute.created", compute.asdict())
return compute
else:
@ -645,6 +650,17 @@ class Controller:
if compute in project.computes:
await project.close()
def get_compute_open_projects(self, compute):
"""
Return the opened projects using a compute.
:returns: List of opened projects
"""
return [
project for project in self._projects.values() if compute in project.computes and project.status == "opened"
]
def compute_has_open_project(self, compute):
"""
Check is compute has a project opened.
@ -652,10 +668,7 @@ class Controller:
:returns: True if a project is open
"""
for project in self._projects.values():
if compute in project.computes and project.status == "opened":
return True
return False
return bool(self.get_compute_open_projects(compute))
async def delete_compute(self, compute_id):
"""

View File

@ -61,6 +61,7 @@ class Compute:
name=None,
console_host=None,
ssl_context=None,
auto_connect=False,
):
self._http_session = None
assert controller is not None
@ -80,6 +81,11 @@ class Compute:
self._connected = False
self._notifications = None
self._closed = False # Close mean we are destroying the compute node
# True when the user asked to disconnect: blocks every reconnection
# path (retry backoff, notification stream reconnect, lazy connect)
# until connect() is called again
self._manual_disconnect = False
self._auto_connect = auto_connect
self._controller = controller
self._set_auth(user, password)
self._cpu_usage_percent = 0
@ -154,6 +160,11 @@ class Compute:
async def update(self, **kwargs):
if "auto_connect" in kwargs:
# stored separately from the generic attributes below so that
# asdict() reports it (it is persisted in the database, the
# controller-side copy is only a mirror for notifications)
self._auto_connect = kwargs.pop("auto_connect")
for kw in kwargs:
if kw not in ("user", "password"):
setattr(self, kw, kwargs[kw])
@ -323,6 +334,7 @@ class Compute:
"host": self._host,
"port": self._port,
"user": self._user,
"auto_connect": self._auto_connect,
"connected": self._connected,
"cpu_usage_percent": self._cpu_usage_percent,
"memory_usage_percent": self._memory_usage_percent,
@ -368,7 +380,9 @@ class Compute:
:param params: Optional dict of query parameters to append to the URL
"""
if not self._connected and not dont_connect:
# a manually disconnected compute is not lazily reconnected either: the
# request fails like for any unreachable compute
if not self._connected and not self._manual_disconnect and not dont_connect:
if self._id == "vm" and not self._controller.gns3vm.running:
await self._controller.gns3vm.start()
await self.connect()
@ -381,6 +395,9 @@ class Compute:
"""
We catch error during reconnect
"""
if self._manual_disconnect:
return # stop reconnecting, the compute was manually disconnected
try:
await self.connect()
except ControllerError:
@ -413,6 +430,8 @@ class Compute:
Check if remote server is accessible
"""
# an explicit connection request cancels a manual disconnect
self._manual_disconnect = False
if not self._connected and not self._closed and self.host:
try:
log.info(f"Connecting to compute '{self._id}'")
@ -474,6 +493,31 @@ class Compute:
self._last_error = None
self._controller.notification.controller_emit("compute.updated", self.asdict())
async def disconnect(self):
"""
Manually disconnect from the compute. The controller stops reconnecting
(retry backoff, notification stream reconnect and lazy connection are
all blocked) until connect() is called again, or the controller
restarts with auto_connect enabled for this compute.
"""
self._manual_disconnect = True
if self._notifications:
# Cancelling the notification task runs its finally block: the
# compute is marked disconnected and clients are notified. The
# flag set above keeps it from scheduling a reconnection.
self._notifications.cancel()
try:
await self._notifications
except asyncio.CancelledError:
pass
self._notifications = None
if self._http_session and not self._http_session.closed:
await self._http_session.close()
if self._connected:
self._connected = False
self._controller.notification.controller_emit("compute.updated", self.asdict())
async def _connect_notification(self):
"""
Connect to the notification stream
@ -526,9 +570,12 @@ class Compute:
# 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"):
# The reconnection goes through _try_reconnect (not connect
# directly) so that a manual disconnect landing within the 1
# second window is still honoured by the time the timer fires
if not self._manual_disconnect and 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()))
asyncio.get_event_loop().call_later(1, lambda: asyncio.ensure_future(self._try_reconnect()))
def _getUrl(self, path):
host = self._host

View File

@ -527,7 +527,9 @@ class Node:
res = False
if last_missing_image:
try:
res = await self._upload_missing_image(self._node_type, last_missing_image)
res = await self._upload_missing_image(
self._node_type, last_missing_image, data.get("image_digest")
)
except ControllerError as upload_error:
# For Docker the image is pulled from a registry: a
# failed pull (unknown tag, no network, private image)
@ -1075,14 +1077,18 @@ class Node:
f"/projects/{self._project.id}/{self._node_type}/nodes/{self._id}{path}", **kwargs
)
async def _upload_missing_image(self, type, img):
async def _upload_missing_image(self, type, img, image_digest=None):
"""
Search an image on local computer and upload it to remote compute
if the image exists
:param image_digest: digest pinned for this image in the create payload,
forwarded so the compute can translate it (see
_sync_missing_docker_image)
"""
if self._node_type == "docker":
return await self._sync_missing_docker_image(img)
return await self._sync_missing_docker_image(img, image_digest)
image = self._find_local_image(type, img)
if image is None:
@ -1119,11 +1125,17 @@ class Node:
if image_id:
data["image_digest"] = image_id
async def _sync_missing_docker_image(self, image):
async def _sync_missing_docker_image(self, image, image_digest=None):
"""
Export a Docker image from the Docker daemon on the controller host and
stream it to the remote compute, or ask the compute to pull it when the
image is not available locally
:param image_digest: digest pinned for this image in the create payload.
It is sent along with the archive so the compute can remember how its
own daemon identifies the image it received: image ids are not
comparable across image stores, so the pin alone would keep being
reported as a mismatch after every sync.
"""
from gns3server.compute.docker import Docker
@ -1147,8 +1159,11 @@ class Node:
self.project.emit_notification(
"log.info", {"message": f"Syncing Docker image '{image}' to compute '{self._compute.name}'"}
)
params = {"image": image}
if image_digest:
params["digest"] = image_digest
try:
await self._compute.post("/docker/images/load", data=response.content, timeout=None)
await self._compute.post("/docker/images/load", data=response.content, timeout=None, params=params)
finally:
response.close()
self.project.emit_notification(

View File

@ -221,7 +221,11 @@ class UDPLink(Link):
be present or absent independently of the tap module. Dynamips
Ethernet slot ports anchor on persistent TAPs the hypervisor opens
(the tap module again — the Dynamips binary itself creates nothing),
so it gates like QEMU. A kernel link is an Ethernet segment, so
so it gates like QEMU. A VPCS node's single adapter anchors on a
persistent TAP the vpcs process attaches to by name (``-e -d``),
which needs the tap module plus a vpcs build that keeps its hands
off the host side of the TAP (``-n``) — its own capability on top
of the tap one. A kernel link is an Ethernet segment, so
non-Ethernet ports (IOU serial, Dynamips serial/ATM/POS adapters)
stay on the relay whatever the node's capabilities.
"""
@ -291,6 +295,14 @@ class UDPLink(Link):
# merely opens (nio create_tap) — the same tap-module capability
# QEMU needs, not a Dynamips-specific one.
return capabilities.get("ubridge_tap") is True
if node.node_type == "vpcs":
# The anchor is a uBridge-created TAP the vpcs process attaches
# to (-e -d): the tap module must exist, and the binary must
# support -n — without it every guest `ip` command would
# ifconfig the anchor on the host. Both strict True: an
# unreported one keeps the link on the relay, where it always
# works.
return capabilities.get("ubridge_tap") is True and capabilities.get("vpcs_tap") is True
return False
async def _prepare(self):

View File

@ -15,7 +15,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 sqlalchemy import Column, Integer, String
from sqlalchemy import Boolean, Column, Integer, String, false
from .base import GUID, BaseTable
@ -30,3 +30,4 @@ class Compute(BaseTable):
port = Column(Integer)
user = Column(String)
password = Column(String)
auto_connect = Column(Boolean, nullable=False, server_default=false())

View File

@ -61,6 +61,7 @@ class ComputesRepository(BaseRepository):
port=compute_create.port,
user=compute_create.user,
password=compute_create.password.get_secret_value() if compute_create.password else None,
auto_connect=bool(compute_create.auto_connect),
)
self._db_session.add(db_compute)
await self._db_session.commit()
@ -74,6 +75,9 @@ class ComputesRepository(BaseRepository):
update_values = compute_update.model_dump(exclude_unset=True)
if compute_update.password is not None:
update_values["password"] = compute_update.password.get_secret_value()
if update_values.get("auto_connect") is None:
# the column is NOT NULL: treat an explicit null as "leave unchanged"
update_values.pop("auto_connect", None)
query = update(models.Compute).where(models.Compute.compute_id == compute_id).values(update_values)

View File

@ -0,0 +1,26 @@
"""add auto_connect to computes table
Revision ID: f7d3a9c2e5b1
Revises: d9e8a2b7c401
Create Date: 2026-10-15 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "f7d3a9c2e5b1"
down_revision = "d9e8a2b7c401"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column("computes", sa.Column("auto_connect", sa.Boolean(), nullable=False, server_default=sa.false()))
def downgrade() -> None:
op.drop_column("computes", "auto_connect")

View File

@ -67,3 +67,9 @@ class Capabilities(BaseModel):
"(bridge delete_nio_tap, the swappable TAP leg an IOL runner container's per-port bridge "
"needs to wire its links on the kernel datapath); absent when the probe failed",
)
vpcs_tap: Optional[bool] = Field(
None,
description="Whether the VPCS binary can anchor a kernel link (tap mode with -n, which "
"keeps the guest from auto-configuring the host side of the anchor TAP); absent when the "
"probe failed",
)

View File

@ -49,6 +49,9 @@ class ComputeBase(BaseModel):
user: Optional[str] = None
password: Optional[SecretStr] = None
name: Optional[str] = None
auto_connect: Optional[bool] = Field(
False, description="Whether to automatically connect to the compute when the controller starts"
)
model_config = ConfigDict(use_enum_values=True)
@ -87,6 +90,7 @@ class ComputeUpdate(ComputeBase):
user: Optional[str] = None
password: Optional[SecretStr] = None
auto_connect: Optional[bool] = None # no default: update schemas must not publish defaults
model_config = ConfigDict(
json_schema_extra={
"example": {
@ -107,6 +111,8 @@ class Compute(DateTimeModelMixin, ComputeBase):
port: int = Field(..., gt=0, le=65535)
compute_id: Union[str, uuid.UUID]
name: str
# defaulted: topology files and the local compute dict may not carry it
auto_connect: bool = False
connected: Optional[bool] = Field(None, description="Whether the controller is connected to the compute or not")
cpu_usage_percent: Optional[float] = Field(None, description="CPU usage of the compute", ge=0, le=100)
memory_usage_percent: Optional[float] = Field(None, description="Memory usage of the compute", ge=0, le=100)

View File

@ -15,19 +15,44 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
from typing import List, Union
from uuid import UUID
import gns3server.db.models as models
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import (
ControllerBadRequestError,
ControllerError,
ControllerNotFoundError,
)
from gns3server.db.repositories.computes import ComputesRepository
async def _connect_in_background(compute: Compute) -> None:
try:
await compute.connect()
except ControllerError:
# the connection failure is reported via compute.updated and the
# retry backoff is already scheduled by the connect attempt itself
pass
# fire-and-forget background connections: keep a reference so the task is not
# garbage-collected mid-flight, drop it once it is done
_background_connect_tasks: set[asyncio.Task] = set()
def _schedule_background_connect(compute: Compute) -> None:
task = asyncio.ensure_future(_connect_in_background(compute))
_background_connect_tasks.add(task)
task.add_done_callback(_background_connect_tasks.discard)
class ComputesService:
def __init__(self, computes_repo: ComputesRepository):
@ -50,6 +75,13 @@ class ComputesService:
**compute_create.model_dump(exclude_unset=True, exclude={"compute_id"}),
)
self._controller.notification.controller_emit("compute.created", compute.asdict())
# auto_connect connects in the background — including when the compute
# was already registered on the controller — so the create request
# never waits for, or fails on, the connection. An explicit
# ?connect=true keeps its synchronous behavior via add_compute.
if compute_create.auto_connect and not compute.connected:
_schedule_background_connect(compute)
return db_compute
async def get_compute(self, compute_id: Union[str, UUID]) -> Union[models.Compute, dict]:
@ -68,11 +100,26 @@ class ComputesService:
) -> models.Compute:
compute = self._controller.get_compute(str(compute_id))
# remember the previous value to detect the false -> true transition below
previous_auto_connect = False
if compute_update.auto_connect:
previous_db_compute = await self._computes_repo.get_compute(compute_id)
if previous_db_compute:
previous_auto_connect = bool(previous_db_compute.auto_connect)
await compute.update(**compute_update.model_dump(exclude_unset=True))
db_compute = await self._computes_repo.update_compute(compute_id, compute_update)
if not db_compute:
raise ControllerNotFoundError(f"Compute '{compute_id}' not found")
self._controller.notification.controller_emit("compute.updated", compute.asdict())
# Enabling auto_connect (false -> true) connects in the background: the
# request returns immediately and the connection status flips via
# compute.updated. The true -> true case is skipped on purpose so that
# saving an edit does not reconnect a manually disconnected compute.
if compute_update.auto_connect and not previous_auto_connect and not compute.connected:
_schedule_background_connect(compute)
return db_compute
async def delete_compute(self, compute_id: Union[str, UUID]) -> None:

View File

@ -22,10 +22,11 @@ deterministically: ``g<type>{node_id[:8]}e{adapter}p{port}`` — ``gv`` for a
Docker veth host end, ``gq`` for a QEMU TAP, ``gi`` for an IOU port TAP,
``gd`` for a Dynamips port TAP, ``gx`` for an IOL runner container's port
TAP (a Docker node whose guest leg is unix sockets, so its anchor is a TAP
like IOU's, not a veth). The prefixes keep anchors out of the ``gns3``
bridge/TAP name space and name-collision-free on a host; the 8 hex chars of
the node id plus adapter/port keep every name unique and within IFNAMSIZ
(15).
like IOU's, not a veth), ``gp`` for a VPCS TAP (the vpcs process attaches to
the pre-created anchor with ``-e -d`` instead of speaking UDP). The
prefixes keep anchors out of the ``gns3`` bridge/TAP name space and
name-collision-free on a host; the 8 hex chars of the node id plus
adapter/port keep every name unique and within IFNAMSIZ (15).
The compute classes create their anchors with these helpers (single source
of truth), and so does the controller when a switch link has to name the
@ -35,6 +36,7 @@ is what makes links to stopped nodes wireable.
"""
import logging
import re
log = logging.getLogger(__name__)
@ -46,6 +48,7 @@ ANCHOR_PREFIX = {
"qemu": "gq",
"iou": "gi",
"dynamips": "gd",
"vpcs": "gp",
}
@ -111,3 +114,36 @@ def kernel_cascade_names(link_id):
stem = str(link_id).replace("-", "")[:10]
return f"gs{stem}0", f"gs{stem}1"
# Host-side interfaces the kernel datapath owns outright, recognised by
# exact shape — the prefix plus its fixed-length id-derived tail — never by
# bare prefix, so a user interface that merely starts with "gv" or "gs" is
# not swallowed. Consumers keep these out of human-facing interface lists:
# each belongs to the link or node that created it for as long as the
# project exists (the names are deterministic, valid whether it runs or
# not), so nothing else may bridge to it.
_MANAGED_ANCHOR_PREFIXES = frozenset(ANCHOR_PREFIX.values())
_MANAGED_ANCHOR_SUFFIX_SHAPE = re.compile(r"[0-9a-f]{8}e\d{1,2}p\d{1,2}")
_MANAGED_CASCADE_SHAPE = re.compile(r"gs[0-9a-f]{10}[01]")
# a cross-compute VXLAN device: "gvl" + 8 hex of the link id + 4 hex of the
# endpoint node id (the cross-compute VXLAN naming keeps its devices out of
# the anchor and "gns3" namespaces the same deterministic way)
_MANAGED_VXLAN_SHAPE = re.compile(r"gvl[0-9a-f]{12}")
def is_kernel_datapath_interface(name):
"""
Whether *name* is a host interface the kernel datapath manages by name —
a node anchor, a switch cascade veth end, or a cross-compute VXLAN
device. True for exactly the names the naming contract above can
produce: the cloud node (and any other interface picker) uses this to
refuse offering them, since bridging to one would race whatever link or
node already owns it.
"""
# "gvl" begins with the docker anchor prefix "gv", so an anchor miss must
# still fall through to the VXLAN shape
if name[:2] in _MANAGED_ANCHOR_PREFIXES and _MANAGED_ANCHOR_SUFFIX_SHAPE.fullmatch(name[2:]):
return True
return _MANAGED_CASCADE_SHAPE.fullmatch(name) is not None or _MANAGED_VXLAN_SHAPE.fullmatch(name) is not None

View File

@ -9,6 +9,11 @@ install:
python -m pip install --upgrade pip
python -m pip install -e '.[ai-features,dev]'
# Install the git pre-commit hook (lint + type-check on staged files)
[group('dev')]
install-hooks:
git config core.hooksPath .githooks
# Apply ruff autofixes for the enforced rules
[group('dev')]
format:

View File

@ -1,5 +1,5 @@
[build-system]
requires = ["setuptools>=61.0", "wheel"]
requires = ["setuptools>=84.0.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]

View File

@ -44,6 +44,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@ -71,6 +72,7 @@ class TestCapabilitiesRoutes:
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": None,
"vpcs_tap": None,
}
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
@ -82,6 +84,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
@ -109,6 +112,7 @@ class TestCapabilitiesRoutes:
"ubridge_tap": None,
"ubridge_iol_tap": None,
"ubridge_bridge_tap": None,
"vpcs_tap": None,
}
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
@ -120,6 +124,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_tc"] == {
@ -141,6 +146,7 @@ class TestCapabilitiesRoutes:
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_tap"] is True
@ -175,6 +181,24 @@ class TestCapabilitiesRoutes:
)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["ubridge_bridge_tap"] is True
async def test_get_reports_vpcs_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
"""
The VPCS tap-mode capability is what makes a VPCS node
kernel-eligible, so it travels with the other compute capabilities.
"""
async def fake_vpcs_tap_probe():
return True
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_vpcs_tap", fake_vpcs_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
assert response.status_code == status.HTTP_200_OK
assert response.json()["vpcs_tap"] is True

View File

@ -792,7 +792,7 @@ class TestEthernetSwitchNodesRoutes:
)
)
assert response.status_code == status.HTTP_204_NO_CONTENT
node._ubridge_send.assert_any_call("capture stop_kernel")
node._ubridge_send.assert_any_call(f"capture stop_kernel {anchor}")
async def test_ethernet_switch_batch_create_anchor_nio(
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict

View File

@ -54,3 +54,16 @@ class TestImagesRoutes:
stream = mock.call_args[0][0]
assert hasattr(stream, "__aiter__")
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_load_docker_image_with_image_and_digest(self, app: FastAPI, compute_client: AsyncClient) -> None:
digest = "sha256:" + "a" * 64
with asyncio_patch("gns3server.compute.docker.Docker.load_image") as mock:
response = await compute_client.post(
app.url_path_for("compute:load_docker_image"),
params={"image": "gns3/frr:latest", "digest": digest},
content=b"docker-save-tar-bytes",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
# the tag and the digest pinned by the controller reach the manager
assert mock.call_args[1] == {"image": "gns3/frr:latest", "digest": digest}

View File

@ -15,6 +15,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/>.
import asyncio
import uuid
from unittest.mock import MagicMock, patch
@ -22,6 +23,7 @@ import pytest
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller import Controller
from gns3server.schemas.controller.computes import Compute
pytestmark = pytest.mark.asyncio
@ -62,7 +64,7 @@ class TestComputeRoutes:
for param, value in params.items():
assert response.json()[param] == value
async def test_compute_list(self, app: FastAPI, client: AsyncClient) -> None:
async def test_compute_list(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
response = await client.get(app.url_path_for("get_computes"))
assert response.status_code == status.HTTP_200_OK
@ -263,3 +265,152 @@ class TestComputeFeatures:
# response = await client.post(app.url_path_for("autoidlepc", compute_id=compute_id) + "/auto_idlepc", json=params)
# assert mock.called
# assert response.status_code == status.HTTP_200_OK
class TestComputeAutoConnect:
async def test_compute_create_with_auto_connect(self, app: FastAPI, client: AsyncClient) -> None:
params = {
"protocol": "http",
"host": "localhost",
"port": 84,
"user": "julien",
"password": "secure",
"auto_connect": True,
}
with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["auto_connect"] is True
# the create request returns immediately, the connection happens
# in the background
await asyncio.sleep(0)
assert connect_mock.call_count == 1
# persisted, not just echoed back
compute_id = response.json()["compute_id"]
response = await client.get(app.url_path_for("get_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
async def test_compute_create_auto_connect_defaults_to_false(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 85, "user": "julien", "password": "secure"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["auto_connect"] is False
async def test_compute_update_enable_auto_connect_connects(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 86, "name": "auto-connect-target"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
compute_id = response.json()["compute_id"]
with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
response = await client.put(
app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True}
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
await asyncio.sleep(0) # give the background connection task a tick
assert connect_mock.call_count == 1
# saving the same value again must not reconnect: a manual
# disconnect has to survive an unrelated edit of the compute
response = await client.put(
app.url_path_for("update_compute", compute_id=compute_id), json={"auto_connect": True}
)
assert response.status_code == status.HTTP_200_OK
await asyncio.sleep(0)
assert connect_mock.call_count == 1
async def test_compute_update_preserves_auto_connect(self, app: FastAPI, client: AsyncClient) -> None:
params = {
"protocol": "http",
"host": "localhost",
"port": 87,
"user": "julien",
"password": "secure",
"auto_connect": True,
}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
compute_id = response.json()["compute_id"]
response = await client.put(app.url_path_for("update_compute", compute_id=compute_id), json={"name": "renamed"})
assert response.status_code == status.HTTP_200_OK
assert response.json()["name"] == "renamed"
response = await client.get(app.url_path_for("get_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["auto_connect"] is True
class TestComputeDisconnect:
async def test_compute_disconnect(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 88, "user": "julien", "password": "secure"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
compute_id = response.json()["compute_id"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
assert compute._manual_disconnect is True
assert compute.connected is False
async def test_compute_disconnect_in_use_rejected(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 89, "user": "julien", "password": "secure"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
compute_id = response.json()["compute_id"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
open_project = MagicMock()
open_project.name = "project-using-the-compute"
with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[open_project]):
response = await client.post(app.url_path_for("disconnect_compute", compute_id=compute_id))
assert response.status_code == status.HTTP_409_CONFLICT
# the offending project names are reported to the client
assert "project-using-the-compute" in response.json()["message"]
# the compute is left untouched
assert compute._manual_disconnect is False
assert compute.connected is True
async def test_compute_disconnect_in_use_forced(self, app: FastAPI, client: AsyncClient) -> None:
params = {"protocol": "http", "host": "localhost", "port": 90, "user": "julien", "password": "secure"}
response = await client.post(app.url_path_for("create_compute"), json=params)
assert response.status_code == status.HTTP_201_CREATED
compute_id = response.json()["compute_id"]
compute = Controller.instance().get_compute(compute_id)
compute._connected = True
with patch("gns3server.controller.Controller.get_compute_open_projects", return_value=[MagicMock()]):
response = await client.post(
app.url_path_for("disconnect_compute", compute_id=compute_id), params={"force": True}
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert compute._manual_disconnect is True
assert compute.connected is False
async def test_compute_disconnect_local_rejected(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.post(app.url_path_for("disconnect_compute", compute_id="local"))
assert response.status_code == status.HTTP_400_BAD_REQUEST
async def test_compute_disconnect_unknown(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.post(app.url_path_for("disconnect_compute", compute_id=str(uuid.uuid4())))
assert response.status_code == status.HTTP_404_NOT_FOUND

View File

@ -156,6 +156,36 @@ async def test_json_without_ports(on_gns3vm, compute_project, manager):
}
async def test_kernel_datapath_interfaces_are_hidden(linux_platform, compute_project, manager):
"""
Kernel-datapath interfaces (node anchors, cascade veths, VXLAN devices)
are neither pre-filled as cloud ports nor listed as host interfaces,
while look-alike user interfaces survive both.
"""
host_interfaces = [
{"name": "eth0", "special": False, "type": "ethernet"},
{"name": "gv00010203e0p0", "special": False, "type": "ethernet"}, # docker anchor
{"name": "gq00010203e0p0", "special": False, "type": "ethernet"}, # qemu anchor
{"name": "gi00010203e0p0", "special": False, "type": "ethernet"}, # iou anchor
{"name": "gd00010203e0p0", "special": False, "type": "ethernet"}, # dynamips anchor
{"name": "gx00010203e0p0", "special": False, "type": "ethernet"}, # iol runner anchor
{"name": "gp00010203e0p0", "special": False, "type": "ethernet"}, # vpcs anchor
{"name": "gs1a2b3c4d5e0", "special": False, "type": "ethernet"}, # cascade veth end
{"name": "gvl1a2b3c4d0001", "special": False, "type": "ethernet"}, # VXLAN device
{"name": "gv0", "special": False, "type": "ethernet"}, # look-alike user interface
{"name": "gs1a2b3c4d5e", "special": False, "type": "ethernet"}, # look-alike (no side digit)
{"name": "gvl1a2b3c4d5e", "special": False, "type": "ethernet"}, # look-alike (short tail)
]
with patch("gns3server.utils.interfaces.interfaces", return_value=host_interfaces):
cloud = Cloud("cloud1", str(uuid.uuid4()), compute_project, manager, ports=None)
interfaces = [interface["name"] for interface in cloud.asdict()["interfaces"]]
assert [port["interface"] for port in cloud.ports_mapping] == ["eth0", "gv0", "gs1a2b3c4d5e", "gvl1a2b3c4d5e"]
assert interfaces == ["eth0", "gv0", "gs1a2b3c4d5e", "gvl1a2b3c4d5e"]
async def test_update_port_mappings(on_gns3vm, compute_project):
"""
We don't allow an empty interface in the middle of port list

View File

@ -276,6 +276,38 @@ async def test_load_image():
assert any("Loaded image" in message for message in messages)
@pytest.mark.asyncio
async def test_load_image_records_image_id_translation():
"""
The digest pinned by the controller is the id of its own daemon and is not
comparable with the id this daemon reports for the same content, so the pair
is remembered to accept the pin during node creation.
"""
class Content:
def __init__(self):
self._chunks = [b'{"stream": "Loaded image: ubuntu:latest"}', b""]
async def read(self, size):
return self._chunks.pop(0)
response = MagicMock()
response.content = Content()
async def tar_stream():
yield b"tar-bytes"
controller_digest = "sha256:" + "a" * 64
local_id = "sha256:" + "b" * 64
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": local_id}) as query_mock:
await Docker.instance().load_image(tar_stream(), image="ubuntu:latest", digest=controller_digest)
query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json")
assert Docker.instance().image_id_translation("ubuntu:latest") == (controller_digest, local_id)
assert Docker.instance().image_id_translation("nginx:latest") is None
@pytest.mark.asyncio
async def test_load_image_error():

View File

@ -997,14 +997,16 @@ async def test_stop_capture_kernel(vm):
await vm.stop_capture(0)
assert nio.capturing is False
vm._ubridge_send.assert_any_call("capture stop_kernel")
vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc}")
@pytest.mark.asyncio
async def test_start_capture_second_concurrent_fails(vm):
async def test_start_capture_ubridge_ealready_rolls_the_flag_back(vm):
"""
uBridge's kernel capture is a singleton per process: a second concurrent
capture returns EALREADY and must surface to the caller.
uBridge answers EALREADY when its interface is already being captured
(the server's per-port flag should have refused earlier; this is the
desync net): the error must surface and the port must not stay marked
as capturing.
"""
async def send(command):
@ -1022,19 +1024,16 @@ async def test_start_capture_second_concurrent_fails(vm):
await vm.start_capture(0, "/tmp/capture.pcap")
# the failed start must not leave the port marked as capturing: a stuck
# flag refuses the retry and lets this port's stop kill the winner
# flag refuses the retry
assert nio.capturing is False
assert vm._kernel_capture_ifc is None
@pytest.mark.asyncio
async def test_start_capture_second_port_on_the_same_node_is_refused(vm):
async def test_capture_two_ports_at_once_and_stop_them_independently(vm):
"""
uBridge serves one kernel capture per process while the server models
captures per port: a second port's capture must be refused with a clear
error and its flag rolled back — otherwise its stop would issue the
process-wide, argument-less ``capture stop_kernel`` and kill the first
port's capture while its own flag still says capturing.
uBridge serves one kernel capture per interface: two ports of the same
node capture concurrently, and each port's stop names only its own
anchor — a sibling port's capture is never touched.
"""
vm._ubridge_hypervisor = MagicMock()
@ -1051,48 +1050,22 @@ async def test_start_capture_second_port_on_the_same_node_is_refused(vm):
vm._ethernet_adapters[1].add_nio(0, nio2)
await vm.start_capture(0, "/tmp/capture.pcap")
with pytest.raises(DockerError, match="one kernel capture per node"):
await vm.start_capture(1, "/tmp/capture2.pcap")
await vm.start_capture(1, "/tmp/capture2.pcap")
assert nio1.capturing is True
assert nio2.capturing is False
assert nio2.capturing is True
vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc1} "/tmp/capture.pcap"')
assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list)
@pytest.mark.asyncio
async def test_stop_capture_on_a_port_that_does_not_own_the_slot_skips_stop_kernel(vm):
"""
``capture stop_kernel`` is process-wide and takes no interface: a stop on
a port whose flag says capturing while another port owns the slot must
not reach uBridge — it would stop the owner's capture.
"""
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_send = AsyncioMagicMock()
vm.status = "started"
vm._ethernet_adapters.append(type(vm._ethernet_adapters[0])())
host_ifc1, _ = vm._veth_names(0, 0)
host_ifc2, _ = vm._veth_names(1, 0)
vm._kernel_veths[(0, 0)] = host_ifc1
vm._kernel_veths[(1, 0)] = host_ifc2
nio1 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
nio2 = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
vm._ethernet_adapters[0].add_nio(0, nio1)
vm._ethernet_adapters[1].add_nio(0, nio2)
await vm.start_capture(0, "/tmp/capture.pcap") # port 0 owns the slot
nio2.start_packet_capture("/tmp/capture2.pcap") # a stale second flag
vm._ubridge_send.assert_any_call(f'capture start_kernel {host_ifc2} "/tmp/capture2.pcap"')
await vm.stop_capture(1)
assert nio1.capturing is True
assert nio2.capturing is False
assert not any("stop_kernel" in str(c) for c in vm._ubridge_send.call_args_list)
vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc2}")
assert not any(f"stop_kernel {host_ifc1}" in str(c) for c in vm._ubridge_send.call_args_list)
# the owner's capture is untouched and still stoppable
await vm.stop_capture(0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
vm._ubridge_send.assert_any_call(f"capture stop_kernel {host_ifc1}")
@pytest.mark.asyncio

View File

@ -538,6 +538,49 @@ async def test_create_image_digest_mismatch(compute_project, manager):
query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json")
@pytest.mark.asyncio
async def test_create_image_digest_translated_for_this_daemon(compute_project, manager):
"""
Image ids are not comparable across image stores: the daemon on the
controller host may pin the config digest while this daemon reports the
manifest digest for the same content. An image this compute received from
the controller is accepted through the translation recorded at load time.
"""
controller_digest = "sha256:" + "a" * 64
local_id = "sha256:" + "b" * 64
manager._image_id_translations["ubuntu:latest"] = (controller_digest, local_id)
response = {"Id": local_id, "Warnings": []}
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as mock:
vm = DockerVM(
"test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest=controller_digest
)
await vm.create()
# the last query is the container creation: the translated pin let it through
assert mock.call_args[0] == ("POST", f"containers/create?name={vm.docker_name}")
assert vm._cid == local_id
@pytest.mark.asyncio
async def test_create_image_digest_translated_only_for_the_synced_content(compute_project, manager):
"""
A tag that now points to some other image on this daemon stays missing, even
when this image name was synced from the controller before.
"""
controller_digest = "sha256:" + "a" * 64
manager._image_id_translations["ubuntu:latest"] = (controller_digest, "sha256:" + "c" * 64)
response = {"Id": "sha256:" + "b" * 64, "Warnings": []}
vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", image_digest=controller_digest)
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value=response) as query_mock:
with pytest.raises(ImageMissingError, match="ubuntu:latest"):
await vm.create()
# only the image inspect happened: no container was created from the stale image
query_mock.assert_called_once_with("GET", "images/ubuntu:latest/json")
@pytest.mark.asyncio
async def test_create_with_user(compute_project, manager):

View File

@ -581,7 +581,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(router):
assert not any("bind_filter" in c for c in _hypervisor_commands(router))
await router.stop_capture(0, 0)
router._ubridge_send.assert_any_call("capture stop_kernel")
router._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}")
@pytest.mark.asyncio

View File

@ -371,7 +371,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm):
vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP00} "/tmp/test.pcap"')
await vm.stop_capture(0, 0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP00}")
@pytest.mark.asyncio

View File

@ -328,7 +328,7 @@ async def test_capture_on_a_kernel_link_uses_the_anchor(vm):
vm._ubridge_send.assert_any_call(f'capture start_kernel {TAP0} "/tmp/test.pcap"')
await vm.stop_capture(0)
vm._ubridge_send.assert_any_call("capture stop_kernel")
vm._ubridge_send.assert_any_call(f"capture stop_kernel {TAP0}")
@pytest.mark.asyncio

View File

@ -0,0 +1,78 @@
#
# 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 os
import stat
import pytest
from gns3server.compute.vpcs.vpcs_probe import probe_vpcs_tap
pytestmark = pytest.mark.asyncio
def _fake_vpcs(path, usage):
path.write_text(f"#!/bin/sh\ncat <<'EOF'\n{usage}\nEOF\n")
mode = path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
os.chmod(path, mode)
async def test_probe_reports_dash_n_build(config, tmp_path):
binary = tmp_path / "vpcs"
_fake_vpcs(
binary,
" -d device device name, works only when -i is set to 1\n"
" -n do not auto-configure the host tap interface\n",
)
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is True
async def test_probe_reports_upstream_build(config, tmp_path):
binary = tmp_path / "vpcs"
_fake_vpcs(
binary,
" -d device device name, works only when -i is set to 1\n",
)
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is False
async def test_probe_reports_missing_binary(config, tmp_path):
config.settings.VPCS.vpcs_path = str(tmp_path / "does-not-exist")
assert await probe_vpcs_tap() is None
async def test_probe_caches_by_binary_identity(config, tmp_path):
"""
The same binary answers once; replacing it (a different mtime/size)
re-probes.
"""
binary = tmp_path / "vpcs"
_fake_vpcs(binary, " -n do not auto-configure\n")
config.settings.VPCS.vpcs_path = str(binary)
assert await probe_vpcs_tap() is True
# replace with a build without the flag: the binary identity changed,
# so the cached True must not answer
_fake_vpcs(binary, " -d device device name\n")
os.utime(binary, (0, 0))
assert await probe_vpcs_tap() is False

View File

@ -45,7 +45,12 @@ async def vm(compute_project, manager, tmpdir, ubridge_path):
vm._start_ubridge = AsyncioMagicMock()
vm._ubridge_hypervisor = MagicMock()
vm._ubridge_hypervisor.is_running.return_value = True
return vm
# The legacy-datapath tests run with an unknown probe answer (None):
# the tap datapath stays off without a project warning, exactly like a
# host whose probe failed. The tap-datapath tests patch the probe to
# True (or False, when they assert the fallback) on top.
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=None)):
yield vm
@pytest.mark.asyncio
@ -350,3 +355,175 @@ async def test_close(vm):
await vm.start()
await vm.close()
assert vm.is_running() is False
# ----------------------------------------------------------------------
# Kernel datapath (the adapter anchor is a persistent TAP the vpcs process
# attaches to with -e -d, kept away from host auto-configuration with -n)
# ----------------------------------------------------------------------
async def _start_on_tap_datapath(vm):
"""
Start the node with both probes answering (a -n-capable vpcs build, a
uBridge with the tap module), returning the recorded uBridge commands.
"""
# a startup.vpc left in the shared working directory by an earlier test
# would append its name to the command line (the script tests run in any
# order) — start from a clean one.
try:
os.remove(os.path.join(vm.working_dir, "startup.vpc"))
except FileNotFoundError:
pass
commands = []
async def record_send(command):
commands.append(command)
vm._ubridge_send = AsyncioMagicMock(side_effect=record_send)
process = MagicMock()
process.returncode = None
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec:
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
return mock_exec, commands
@pytest.mark.asyncio
async def test_start_tap_datapath(vm):
"""
A -n-capable vpcs build on a tap-capable uBridge anchors the adapter on
a persistent TAP and speaks tap mode: -e -d <anchor> -n, no UDP tunnel.
"""
mock_exec, commands = await _start_on_tap_datapath(vm)
assert vm._tap_datapath is True
assert vm._kernel_taps == {(0, 0): "gp00010203e0p0"}
assert mock_exec.call_args[0] == (
vm._vpcs_path(),
"-p",
str(vm._internal_console_port),
"-m",
"1",
"-i",
"1",
"-F",
"-R",
"-e",
"-d",
"gp00010203e0p0",
"-n",
)
# the anchor was created through uBridge's tap module, owned by this user
assert 'tap create "gp00010203e0p0"' in commands
assert f"tap set_owner gp00010203e0p0 {os.getuid()}" in commands
# no local UDP tunnel on this datapath
assert vm._local_udp_tunnel is None
@pytest.mark.asyncio
async def test_start_legacy_datapath_when_binary_lacks_dash_n(vm):
"""
A vpcs build without -n cannot be kept away from the host side of the
anchor, so the node keeps the legacy UDP relay datapath.
"""
process = MagicMock()
process.returncode = None
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=False)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process) as mock_exec:
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
assert vm._tap_datapath is False
assert vm._kernel_taps == {}
command = mock_exec.call_args[0]
assert "-e" not in command
assert "-n" not in command
assert "-s" in command and "-c" in command and "-t" in command
@pytest.mark.asyncio
async def test_stop_terminates_process_before_deleting_taps(vm):
"""
The vpcs process holds the anchor TAP open: a held device refuses
deletion, so the TAP delete must follow the process termination (the
suppressed delete of the old order leaked the anchor).
"""
calls = []
process = MagicMock()
process.returncode = None
process.terminate.side_effect = lambda: calls.append("terminate")
async def record_send(command):
commands.append(command)
commands = []
vm._ubridge_send = AsyncioMagicMock(side_effect=record_send)
with patch("gns3server.compute.vpcs.vpcs_vm.probe_vpcs_tap", new=AsyncioMagicMock(return_value=True)):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM._check_requirements", return_value=True):
with asyncio_patch("asyncio.create_subprocess_exec", return_value=process):
with asyncio_patch("gns3server.compute.vpcs.vpcs_vm.VPCSVM.start_wrap_console"):
await vm.start()
with asyncio_patch("gns3server.utils.asyncio.wait_for_process_termination"):
await vm.stop()
assert calls == ["terminate"]
# every tap delete (the start-time sweep and the stop-time teardown)
# happened after the process termination
assert all(calls.index("terminate") < commands.index(c) for c in commands if c.startswith("tap delete"))
@pytest.mark.asyncio
async def test_add_nio_binding_duplicate_port_rejected(vm):
"""
A port carries at most one NIO: overwriting the one a link still owns
orphans that link's teardown.
"""
nio = VPCS.instance().create_nio(
{"type": "nio_udp", "lport": 4242, "rport": 4243, "rhost": "127.0.0.1", "filters": {}}
)
await vm.port_add_nio_binding(0, nio)
with pytest.raises(VPCSError, match="already has a link"):
await vm.port_add_nio_binding(0, nio)
@pytest.mark.asyncio
async def test_kernel_nio_on_legacy_datapath_rejected(vm):
"""
A kernel link to a node running on the legacy relay datapath (no anchor)
is refused with a clear error instead of a late uBridge failure.
"""
vm._process = MagicMock()
vm._process.returncode = None
vm._tap_datapath = False
nio = VPCS.instance().create_nio({"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {}})
with pytest.raises(VPCSError, match="legacy relay datapath"):
await vm.port_add_nio_binding(0, nio)
@pytest.mark.asyncio
async def test_kernel_nio_attach_enslaves_the_anchor(vm):
"""
A kernel link enslaves the anchor into the per-link bridge and installs
the impairment filters the NIO carries — the same _kernel_attach flow
every anchor-backed node type uses.
"""
await _start_on_tap_datapath(vm)
nio = VPCS.instance().create_nio({"type": "nio_bridge", "bridge": "gns3testbridge", "filters": {"delay": [100]}})
await vm.port_add_nio_binding(0, nio)
sent = [call[0][0] for call in vm._ubridge_send.call_args_list]
assert 'brctl create "gns3testbridge"' in sent
assert 'brctl addif "gns3testbridge" "gp00010203e0p0"' in sent
assert any(c.startswith('tc netem set "gp00010203e0p0" delay 100') for c in sent)

View File

@ -29,11 +29,13 @@ from gns3server.api.server import app as gns3_app
from gns3server.controller.compute import Compute
from gns3server.controller.controller_error import (
ComputeConflictError,
ComputeError,
ControllerError,
ControllerNotFoundError,
ControllerUnauthorizedError,
)
from gns3server.controller.project import Project
from gns3server.version import __version__
from tests.utils import AsyncioMagicMock, asyncio_patch
@ -376,6 +378,7 @@ async def test_json(compute):
"host": "example.com",
"port": 84,
"user": "test",
"auto_connect": False,
"cpu_usage_percent": 0,
"memory_usage_percent": 0,
"disk_usage_percent": 0,
@ -696,3 +699,87 @@ async def test_connect_http_error_notifies_schedules_retry_and_raises(compute, m
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
def test_auto_connect_constructor_default(controller):
assert Compute("default_auto_connect", controller=controller).asdict()["auto_connect"] is False
assert Compute("enabled_auto_connect", controller=controller, auto_connect=True).asdict()["auto_connect"] is True
@pytest.mark.asyncio
async def test_update_auto_connect(compute, controller):
compute._controller._notification = MagicMock()
compute._controller.save = MagicMock()
assert compute.asdict()["auto_connect"] is False
await compute.update(auto_connect=True)
assert compute.asdict()["auto_connect"] is True
controller.notification.controller_emit.assert_called_with("compute.updated", compute.asdict())
@pytest.mark.asyncio
async def test_disconnect_stops_the_notification_stream(compute, controller):
"""
disconnect() tears down the notification stream, marks the compute
disconnected and notifies clients — without scheduling any reconnection.
"""
assert compute.connected is True
compute._controller._notification = MagicMock()
started = asyncio.Event()
async def fake_notification_stream():
started.set()
try:
await asyncio.Event().wait()
except asyncio.CancelledError:
raise
compute._notifications = asyncio.gather(asyncio.create_task(fake_notification_stream()))
await started.wait()
await compute.disconnect()
assert compute._manual_disconnect is True
assert compute.connected is False
assert compute._notifications is None
compute._controller._notification.controller_emit.assert_called_with("compute.updated", compute.asdict())
@pytest.mark.asyncio
async def test_manual_disconnect_blocks_reconnect_and_lazy_connect(compute):
"""
After a manual disconnect no reconnection path may bring the compute back:
not the retry backoff, not the lazy connection of http_query.
"""
await compute.disconnect()
with patch("gns3server.controller.compute.Compute.connect", new_callable=AsyncioMagicMock) as connect_mock:
await compute._try_reconnect()
connect_mock.assert_not_called()
with pytest.raises(ComputeError):
await compute.http_query("GET", "/capabilities")
connect_mock.assert_not_called()
@pytest.mark.asyncio
async def test_connect_cancels_manual_disconnect(compute, monkeypatch):
compute._manual_disconnect = True
async def fake_capabilities(method, path, **kwargs):
return SimpleNamespace(json={"version": __version__})
async def fake_notification_stream():
return None
monkeypatch.setattr(compute, "_run_http_query", fake_capabilities)
monkeypatch.setattr(compute, "_connect_notification", fake_notification_stream)
await compute.connect()
assert compute._manual_disconnect is False
assert compute.connected is True

View File

@ -123,10 +123,11 @@ async def _link(project, node1, node2):
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project):
"""
A node type with no anchor of its own (VPCS, cloud, ...) keeps the relay
however capable the compute is — and a QEMU node on a compute whose
uBridge cannot create persistent TAPs does too, because it runs on the
legacy relay datapath there.
A node type with no anchor of its own (cloud, ...) keeps the relay
however capable the compute is — a QEMU node on a compute whose uBridge
cannot create persistent TAPs does too (it runs on the legacy relay
datapath there), and so does a VPCS node whose binary cannot anchor
(no -n support, so no vpcs_tap capability).
"""
compute = _tap_capable_compute(tap_support=None)
@ -227,6 +228,77 @@ async def test_dynamips_eligibility_follows_the_tap_capability(project):
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_vpcs_with_tap_support(project):
"""
A VPCS node anchors on a persistent TAP the vpcs process attaches to
(-e -d), which needs the tap module plus a vpcs build with -n — both
capabilities reported, and mixable with docker/qemu/iou/dynamips
endpoints on the same compute.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "pc1", node_type="vpcs")
node2 = _node(project, compute, "pc2", node_type="vpcs")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
for peer in (
_node(project, compute, "docker1"),
_node(project, compute, "qemu1", node_type="qemu"),
_node(project, compute, "iou1", node_type="iou"),
_node(project, compute, "r1", node_type="dynamips"),
):
link = await _link(project, node1, peer)
assert link._kernel_datapath_eligible(node1, peer) is True
@pytest.mark.asyncio
async def test_vpcs_eligible_for_the_switch_fast_path(project):
"""
A switch link absorbs the peer's anchor into the switch's own bridge,
so only the peer must be able to anchor — a capable VPCS does (the
controller names its gp anchor with the shared contract), an
incapable one stays on the switch's relay port.
"""
switch = _node(project, _tap_capable_compute(), "sw1", node_type="ethernet_switch")
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "vpcs_tap": True}
pc = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, switch, pc)
assert link._kernel_datapath_eligible(switch, pc) is True
incapable = MagicMock()
incapable.id = "compute-1"
incapable.capabilities = {"ubridge_tap": True}
pc = _node(project, incapable, "pc1", node_type="vpcs")
link = await _link(project, switch, pc)
assert link._kernel_datapath_eligible(switch, pc) is False
@pytest.mark.asyncio
async def test_vpcs_eligibility_needs_both_capabilities(project):
"""
The tap module and the vpcs -n build land independently: a compute
reporting only one of them keeps VPCS on the relay, and so does an
unreported/failed probe (strict True on both).
"""
node2 = _node(project, _tap_capable_compute(), "docker1")
for capabilities in ({"vpcs_tap": True}, {"ubridge_tap": True, "vpcs_tap": None}, {"ubridge_tap": True}):
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = capabilities
node1 = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_dynamips_serial_ports(project):
"""

View File

@ -1110,10 +1110,89 @@ async def test_sync_missing_docker_image_from_controller_daemon(compute, control
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response) as save_mock:
assert await node._upload_missing_image("docker", "gns3/frr:latest") is True
save_mock.assert_called_with("GET", "images/gns3/frr:latest/get", timeout=None)
compute.post.assert_called_with("/docker/images/load", data=response.content, timeout=None)
compute.post.assert_called_with(
"/docker/images/load", data=response.content, timeout=None, params={"image": "gns3/frr:latest"}
)
response.close.assert_called_once_with()
@pytest.mark.asyncio
async def test_sync_missing_docker_image_forwards_pinned_digest(compute, controller):
project = Project(str(uuid.uuid4()), controller=controller)
node = Node(
project, compute, "demo", node_id=str(uuid.uuid4()), node_type="docker", properties={"image": "gns3/frr:latest"}
)
digest = "sha256:" + "a" * 64
response = MagicMock()
response.content = MagicMock()
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=response):
assert await node._upload_missing_image("docker", "gns3/frr:latest", digest) is True
compute.post.assert_called_with(
"/docker/images/load",
data=response.content,
timeout=None,
params={"image": "gns3/frr:latest", "digest": digest},
)
@pytest.mark.asyncio
async def test_create_docker_node_syncs_missing_image_with_the_pinned_digest(compute, controller):
"""
When the compute reports a Docker image as missing, the create retries after
the sync and the load call carries the pinned digest, so the compute can
translate it into the id of its own daemon (image ids are not comparable
across image stores).
"""
project = Project(str(uuid.uuid4()), controller=controller)
node = Node(
project,
compute,
"demo",
node_id=str(uuid.uuid4()),
node_type="docker",
properties={"image": "gns3/frr:latest", "adapters": 1},
)
controller_image_id = "sha256:" + "a" * 64
conflict = ComputeConflictError(
f"/projects/{project.id}/docker/nodes",
{
"message": "The image 'gns3/frr:latest' is missing",
"image": "gns3/frr:latest",
"exception": "ImageMissingError",
},
)
created = MagicMock()
created.status = 200
created.json = {}
# the create call, the image sync, then the create retry
responses = [conflict, MagicMock(), created]
async def resp(*args, **kwargs):
result = responses.pop(0)
if isinstance(result, Exception):
raise result
return result
compute.post = AsyncioMagicMock(side_effect=resp)
load_response = MagicMock()
load_response.content = MagicMock()
with asyncio_patch("gns3server.compute.docker.Docker.query", return_value={"Id": controller_image_id}):
with asyncio_patch("gns3server.compute.docker.Docker.http_query", return_value=load_response):
assert await node.create() is True
# first call is the node creation, then the image sync, then the retry
sync_call = compute.post.call_args_list[1]
assert sync_call[0][0] == "/docker/images/load"
assert sync_call[1]["params"] == {"image": "gns3/frr:latest", "digest": controller_image_id}
assert compute.post.call_args_list[2][1]["data"]["image_digest"] == controller_image_id
@pytest.mark.asyncio
async def test_sync_missing_docker_image_pull_fallback(compute, controller):

View File

@ -235,6 +235,21 @@ class Compute:
},
)
def create_vpcs_node(self, project_id, name, startup_script=None):
"""A VPCS node: the real vpcs binary on this host behind the server's
TCP console (the driver sends ``ip``/``ping`` like a user would). A
kernel-datapath scenario additionally needs a build with ``-n``
(no host auto-configuration) — see GNS3_E2E_VPCS and the ``vpcs_tap``
capability."""
properties = {"console_type": "telnet"}
if startup_script is not None:
properties["startup_script"] = startup_script
return self.call(
"POST",
f"/projects/{project_id}/nodes",
{"compute_id": "local", "name": name, "node_type": "vpcs", "properties": properties},
)
def create_link(self, project_id, a, b):
return self.call(
"POST",
@ -298,6 +313,9 @@ allocate_aux_console_ports = False
mmap_support = False
sparse_memory_support = False
ghost_ios_support = False
[VPCS]
vpcs_path = {vpcs}
"""
@ -374,8 +392,17 @@ def _spawn_isolated(tmpdir, kernel):
os.makedirs(os.path.join(tmpdir, "projects"), exist_ok=True)
port = _free_port()
# GNS3_E2E_VPCS points at a vpcs build with -n (tap mode without host
# auto-configuration) — what the kernel-datapath scenario needs. The
# local fork build (where this feature is developed) is the default when
# present; otherwise the stock binary in PATH keeps the relay scenarios
# running (the kernel scenario skips itself on the vpcs_tap capability).
vpcs = os.environ.get("GNS3_E2E_VPCS")
if not vpcs:
fork_build = os.path.expanduser("~/myCode/GNS3/vpcs/src/vpcs")
vpcs = fork_build if os.path.isfile(fork_build) else (shutil.which("vpcs") or "vpcs")
config = ISOLATED_CONFIG.format(
port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel
port=port, ubridge=ubridge, dynamips=dynamips, images=images, tmp=tmpdir, kernel=kernel, vpcs=vpcs
)
config_path = os.path.join(tmpdir, "gns3_server.conf")
with open(config_path, "w") as f:
@ -702,6 +729,13 @@ def ios_console(server, project_id, node_id):
return IOSConsole(host, node["console"])
def vpcs_console(server, project_id, node_id):
"""The console of a VPCS node: the vpcs binary's own TCP daemon behind
the server-side console — same dumb driver (its prompt ``PC1>`` matches
the shared prompt regex)."""
return ios_console(server, project_id, node_id)
def configure_ios(
console, hostname, eth_ip=None, eth_ipv6=None, serial_ip=None, clock=False, eth_if="f1/0", serial_if="s2/0"
):
@ -769,6 +803,34 @@ def wait_ping(console, target, attempts=4, repeat=3, timeout=1):
return result
def vpcs_ping(console, target, count=5, timeout=1):
"""Run a VPCS ping; returns {success, avg, raw} (success in percent).
The reply lines are ``<host> icmp_seq=<n> ttl=<t> time=<ms> ms`` in both
families (VPCS prints the same shape for v4 and v6 targets)."""
out = console.run(f"ping {target} -c {count} -w {int(timeout * 1000)}", timeout=count * timeout + 25)
replies = re.findall(r"icmp_seq=\d+ ttl=\d+ time=([\d.]+) ms", out)
return {
"success": int(100 * len(replies) / count),
"avg": round(sum(float(rtt) for rtt in replies) / len(replies), 1) if replies else None,
"raw": out,
}
def wait_vpcs_ping(console, target, attempts=4, count=5, timeout=1):
"""The VPCS twin of wait_ping: the first exchange of a fresh adjacency
is ARP (a lost first ping is legitimate), so retry until it fully
succeeds; returns the last result either way."""
result = None
for _ in range(attempts):
result = vpcs_ping(console, target, count=count, timeout=timeout)
if result["success"] == 100:
return result
time.sleep(1.5)
return result
# ---------------------------------------------------------------------------
# Docker guest driver (Docker Engine API over the daemon socket)
# ---------------------------------------------------------------------------
@ -1330,6 +1392,13 @@ def qemu_anchor_name(node_id, adapter, port=0):
return "gq" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}"
def vpcs_anchor_name(node_id, adapter=0, port=0):
"""The VPCS node's anchor TAP name: one persistent TAP for the single
adapter, attached to by name through -e -d (see VPCSVM._tap_name and
utils.kernel_anchor)."""
return "gp" + node_id.replace("-", "")[:8] + f"e{adapter}p{port}"
def stage_iol_base_config(server):
"""
Copy the shipped iol-xe base config into an isolated instance's configs

View File

@ -316,6 +316,110 @@ def test_docker_kernel_fast_path():
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge
def test_docker_kernel_ten_concurrent_captures():
"""Ten kernel captures at once on one node's uBridge. The capture side
picks the first local started node, so all ten links capture on node 1's
single uBridge process — the scale the old one-capture-per-process limit
could never reach. Each link gets its own traffic (its own subnet on its
own eth), and a partial stop must leave the untouched captures running."""
server = harness.live_server(kernel=True)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-multicap-e2e")
pid = project["project_id"]
try:
daemon = harness.DockerDaemon()
nodes = []
for suffix, address in (("1", R1_IP), ("2", R2_IP)):
node = compute.create_docker_node(pid, f"MC-{suffix}", image, adapters=10)
harness.configure_docker_interfaces(node, address)
nodes.append(_resolve_container(daemon, node))
n1, n2 = nodes
c1, c2 = n1["container_id"], n2["container_id"]
n1_id, n2_id = n1["node_id"], n2["node_id"]
links = [compute.create_link(pid, (n1_id, i, 0), (n2_id, i, 0)) for i in range(10)]
assert all(link["kernel_datapath"] is True for link in links), links
print(".. starting containers")
for node in nodes:
compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start")
harness.docker_wait_address(daemon, c1, R1_IP)
harness.docker_wait_address(daemon, c2, R2_IP)
# ten captures before any of the per-link interfaces is addressed, so
# every later frame (up to and including the ARP each first ping
# triggers) lands in a pcap
captures = [
compute.call(
"POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
for link in links
]
# eth1..eth9: one subnet each, wired by hand inside the guests; the
# settle window lets the guests' address-add chatter (each v4 addr
# fires two IGMP reports ~1 s apart) land inside an open capture
# instead of a later snapshot
for i in range(1, 10):
for container_id, host in ((c1, 1), (c2, 2)):
code, output = daemon.exec(
container_id,
["sh", "-c", f"ip link set eth{i} up && ip addr add 10.20.{i}.{host}/24 dev eth{i}"],
)
assert code == 0, output
time.sleep(2)
def ping_link(i):
# link 0 rides the init.sh-addressed eth0; the rest their own subnet
if i == 0:
result = harness.docker_ping(daemon, c1, R2_IP, count=2)
assert result["loss"] == 0, result["raw"]
else:
code, output = daemon.exec(c1, ["ping", "-c", "2", "-W", "1", f"10.20.{i}.2"])
assert code == 0, output
for i in range(10):
ping_link(i)
time.sleep(0.5)
paths = [capture["capture_file_path"] for capture in captures]
first_counts = [harness.pcap_records(path)[0] for path in paths]
# 2 ARP + 2 echo requests + 2 replies is the floor per link
assert all(count >= 4 for count in first_counts), list(zip(paths, first_counts, strict=True))
print(f".. all 10 pcaps captured concurrently: {first_counts}")
# stop half. A stop is synchronous (the reply follows the reader
# thread's join), so from its return that pcap can no longer grow —
# the post-stop counts are an exact-freeze baseline
for link in links[5:]:
compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop")
stopped_base = [harness.pcap_records(paths[i])[0] for i in range(5, 10)]
# fresh traffic on ALL segments: the stopped half must stay frozen
# even while its links carry frames, the live half must record them
for i in range(10):
ping_link(i)
time.sleep(0.5)
for i in range(5, 10):
assert harness.pcap_records(paths[i])[0] == stopped_base[i - 5], f"stopped pcap {i} grew"
for i in range(5):
assert harness.pcap_records(paths[i])[0] >= first_counts[i] + 4, f"live pcap {i} did not grow"
print(".. per-interface stop froze exactly the stopped half")
for link in links[:5]:
compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop")
assert all(
compute.call("GET", f"/projects/{pid}/links/{link['link_id']}")["capturing"] is False for link in links
)
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
def test_docker_kernel_filter_matrix():
"""One filter type at a time on a live kernel link, each asserted by its
effect on real traffic and its tc state. Types the uBridge on this

View File

@ -0,0 +1,260 @@
#
# Copyright (C) 2026 GNS3 Technologies Inc.
# This program is free software and is redistributed under the same terms as gns3-server.
# See the LICENSE file for licensing information.
#
"""
Live end-to-end test for the VPCS node's kernel datapath (pytest marker
``e2e``): two real VPCS nodes (the local vpcs binary, its own TCP console
driven like a user would), linked through the controller REST API. VPCS is
the node type whose guest leg attaches to a *pre-created* anchor: uBridge
creates the persistent TAP and hands it to this user, and the vpcs process
opens it by name (``-e -d``) while ``-n`` keeps its hands off the host side
of the anchor — the same server-creates/guest-attaches shape as QEMU.
* two VPCS nodes on one compute report ``kernel_datapath`` when the
compute's uBridge can create persistent TAPs (``ubridge_tap``) and the
vpcs build has ``-n`` (``vpcs_tap``); the anchor (``gp`` TAP) exists from
node start, before any link, and the per-link bridge enslaves exactly the
two of them;
* real ICMP crosses the [vpcs tun fd ↔ anchor TAP] leg;
* the L2-anchor spec §E.1: with ``-n`` the anchor carries no address — vpcs
never ran its ``ifconfig`` on it; §E.2 comes free: a VPCS sends nothing
spontaneously (no CDP, no keepalive), so the silence window needs no
guest shutdown;
* a ``delay 100`` filter lands as netem on both anchors, RTT grows ~200 ms;
* suspend admin-downs the anchor and kills the traffic; resume restores;
* a capture on the kernel link writes a real pcap;
* link delete releases the anchor from the bridge and it survives (the
running vpcs holds it open) — re-creating the link re-enslaves it;
* node stop terminates vpcs first, then deletes the anchor (a held TAP
refuses deletion — the order is the point); a restart recreates the TAP
and rewires the link from the NIO;
* deleting the project leaves no anchors and no bridges behind.
``test_vpcs_relay_control`` is the negative control on an isolated
relay-configured instance: nothing is enslaved, no per-link bridge exists,
and the relay still carries the pings — with the legacy UDP tunnel on a
stock vpcs, or as an AF_PACKET endpoint on the anchor with a ``-n`` build
(the datapath a link rides is a server configuration, not a node property).
"""
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# VPCS has one adapter with one port; the kernel scenario addresses it.
ETH = (0, 0)
PC1_IP = "10.1.1.1"
PC2_IP = "10.1.1.2"
def _configure(console, ip):
console.run("set pcname E2E-PC")
out = console.run(f"ip {ip}/24")
assert ip in out, out
def test_vpcs_kernel_datapath():
server = harness.live_server(kernel=True)
compute = server.compute
caps = compute.capabilities()
if caps.get("ubridge_tap") is not True:
pytest.skip(f"this compute's uBridge cannot create persistent TAPs (ubridge_tap={caps.get('ubridge_tap')})")
if caps.get("vpcs_tap") is not True:
pytest.skip(
f"this compute's vpcs binary cannot anchor a kernel link (vpcs_tap={caps.get('vpcs_tap')}); "
"point GNS3_E2E_VPCS (or the server's VPCS.vpcs_path) at a vpcs build with -n"
)
project = compute.create_project("vpcs-e2e")
pid = project["project_id"]
a1 = a2 = None
try:
n1 = compute.create_vpcs_node(pid, "E2E-PC1")
n2 = compute.create_vpcs_node(pid, "E2E-PC2")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.vpcs_anchor_name(n1_id, *ETH)
a2 = harness.vpcs_anchor_name(n2_id, *ETH)
# The link is created while both nodes are stopped: the kernel NIO
# is bound with no anchors in existence yet and wired by node start
# (the deferred-wiring path, same as every anchor-backed node).
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "anchors must not exist before start"
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
assert harness.wait_until(lambda: harness.tap_exists(a1) and harness.tap_exists(a2), timeout=15), (a1, a2)
c1 = harness.vpcs_console(server, pid, n1_id)
c2 = harness.vpcs_console(server, pid, n2_id)
# NetworkManager can release an NM-managed TAP from its bridge after
# (re-)enslavement (docs/bugs/networkmanager-tap-release.md); take
# the anchors out of its reach for the rest of the run (the restart
# below re-attaches them under a fresh device).
harness.unmanage_from_networkmanager(a1, a2)
# Kernel objects: one persistent TAP per node, and the per-link
# bridge has exactly the two anchors as members.
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1: with -n the anchor stays address-free — this
# is the assertion the -n flag exists for (an upstream vpcs would
# have ifconfig'd the anchor on the guest's `ip` command below).
if harness.l2only_supported():
harness.assert_pure_l2(a1)
harness.assert_pure_l2(a2)
harness.assert_pure_l2(bridge)
else:
print(".. uBridge without link l2only: skipping the §E.1 assertions")
harness.assert_forwarding(a1)
harness.assert_forwarding(a2)
_configure(c1, PC1_IP)
_configure(c2, PC2_IP)
baseline = harness.wait_vpcs_ping(c1, PC2_IP)
assert baseline["success"] == 100, baseline["raw"]
reverse = harness.wait_vpcs_ping(c2, PC1_IP)
assert reverse["success"] == 100, reverse["raw"]
# L2-anchor spec §E.2: a VPCS speaks only when spoken to (no
# platform chatter), so the silence window needs no guest shutdown —
# the two anchors and the bridge must be quiet 2.5 s after the pings.
if harness.l2only_supported():
harness.assert_idle_silence(a1, a2, bridge)
else:
print(".. uBridge without link l2only: skipping the §E.2 assertion")
# delay 100: netem on both anchors (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.wait_vpcs_ping(c1, PC2_IP)
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, (baseline, delayed)
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {}})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.wait_vpcs_ping(c1, PC2_IP)
assert fast["success"] == 100 and fast["avg"] < harness.FAST_RTT_MS, fast
# suspend: anchor admin-down — the tun fd writes go nowhere and
# nothing crosses; resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.vpcs_ping(c1, PC2_IP, count=2, timeout=0.5)
assert dead["success"] == 0, dead["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100
# capture: on the anchor, a real pcap
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.vpcs_ping(c1, PC2_IP, count=3)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
count, ethertypes = harness.pcap_records(capture["capture_file_path"])
assert count >= 4, f"{capture['capture_file_path']}: {count} records"
assert "0800" in ethertypes, ethertypes
# delete / re-create the kernel link: the anchor leaves the bridge
# but survives (the running vpcs holds it open), and re-creating
# re-enslaves it without touching the process.
compute.call("DELETE", f"/projects/{pid}/links/{lid}")
assert harness.bridge_members(bridge) is None
assert harness.tap_exists(a1) and harness.tap_exists(a2)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
bridge = harness.link_bridge_name(lid)
assert link["kernel_datapath"] is True, link
assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100
# node stop: vpcs is terminated first and only then the anchor is
# deleted (the process holds the TAP open — a held device refuses
# deletion and would leak); a restart recreates the TAP, rewires the
# link from the NIO that outlived it, and the reconfigured VPCS
# pings again.
print(".. stopping and restarting PC1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
assert harness.wait_until(lambda: harness.tap_exists(a1), timeout=15), a1
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
c1.close()
c1 = harness.vpcs_console(server, pid, n1_id)
_configure(c1, PC1_IP)
assert harness.wait_vpcs_ping(c1, PC2_IP, attempts=5)["success"] == 100
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the nodes: anchors and bridges go with them
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_vpcs_relay_control():
"""
Negative control: the same topology with ``enable_kernel_datapath =
false`` (an isolated instance) wires nothing into the kernel — no
per-link bridge, no enslavement — and still pings, with the relay
filters served by the uBridge bridge engine.
"""
server = harness.live_server(kernel=False)
compute = server.compute
project = compute.create_project("vpcs-relay-e2e")
pid = project["project_id"]
try:
n1 = compute.create_vpcs_node(pid, "E2E-PC1")
n2 = compute.create_vpcs_node(pid, "E2E-PC2")
n1_id, n2_id = n1["node_id"], n2["node_id"]
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is False, link
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
compute.call("POST", f"/projects/{pid}/nodes/{n2_id}/start")
c1 = harness.vpcs_console(server, pid, n1_id)
c2 = harness.vpcs_console(server, pid, n2_id)
_configure(c1, PC1_IP)
_configure(c2, PC2_IP)
# no kernel wiring: the per-link bridge does not exist
assert harness.bridge_members(harness.link_bridge_name(lid)) is None
relay_ping = harness.wait_vpcs_ping(c1, PC2_IP)
assert relay_ping["success"] == 100, relay_ping["raw"]
# the relay filters keep working: one uBridge bridge filters both
# directions, delay 100 => RTT >= 150 ms
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": {"delay": [100]}})
delayed = harness.wait_vpcs_ping(c1, PC2_IP)
assert delayed["success"] == 100, delayed["raw"]
assert delayed["avg"] >= 150, delayed
c1.close()
c2.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)

View File

@ -401,6 +401,7 @@ async def test_path_matching_does_not_interpret_sql_wildcards(inventory, config)
async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, config):
from alembic import command
from alembic.config import Config
from alembic.script import ScriptDirectory
image_file(config)
await scan(inventory)
@ -414,7 +415,12 @@ async def test_migration_roundtrip_preserves_rows_and_relationships(inventory, c
cfg = Config()
cfg.set_main_option("script_location", "gns3server:db_migrations")
cfg.attributes["connection"] = connection
command.stamp(cfg, "d9e8a2b7c401")
# The catalog is built from the models, so it already carries the head
# schema. Stamping head (not the revision that happened to be newest when
# this test was written) keeps the round-trip valid for later migrations:
# every revision above the inventory one is rolled back before it is
# replayed, instead of being applied on top of its own schema.
command.stamp(cfg, ScriptDirectory.from_config(cfg).get_current_head())
command.downgrade(cfg, "c7e4a9f1d2b6")
assert connection.execute(text("SELECT count(*) FROM image_template_map")).scalar_one() == 1
assert connection.execute(text("SELECT image_id FROM images")).scalar_one() == original["image_id"]

View File

@ -21,7 +21,13 @@ Ethernet-switch link), plus the cascade veth naming for switch-to-switch
links.
"""
from gns3server.utils.kernel_anchor import anchor_suffix, kernel_anchor_name, kernel_anchor_type, kernel_cascade_names
from gns3server.utils.kernel_anchor import (
anchor_suffix,
is_kernel_datapath_interface,
kernel_anchor_name,
kernel_anchor_type,
kernel_cascade_names,
)
NODE_ID = "00010203-0405-0607-0809-0a0b0c0d0e0f"
LINK_ID = "1a2b3c4d-5e6f-4a5b-8c9d-0e1f2a3b4c5d"
@ -33,6 +39,7 @@ def test_every_node_type_has_its_own_prefix():
assert kernel_anchor_name("iou", NODE_ID, 0, 3) == "gi00010203e0p3"
assert kernel_anchor_name("dynamips", NODE_ID, 6, 48) == "gd00010203e6p48"
assert kernel_anchor_name("iol_docker", NODE_ID, 0, 0) == "gx00010203e0p0"
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) == "gp00010203e0p0"
def test_anchor_type_keys_iol_runner_containers_apart():
@ -55,7 +62,7 @@ def test_anchor_type_keys_iol_runner_containers_apart():
def test_unknown_node_types_have_no_anchor():
assert kernel_anchor_name("ethernet_switch", NODE_ID, 0, 0) is None
assert kernel_anchor_name("vpcs", NODE_ID, 0, 0) is None
assert kernel_anchor_name("cloud", NODE_ID, 0, 0) is None
def test_names_fit_ifnamsiz():
@ -85,3 +92,32 @@ def test_cascade_names_stay_out_of_the_node_anchor_namespace():
# cascade end is never mistaken for another node's anchor
for end in kernel_cascade_names(LINK_ID):
assert not any(end.startswith(prefix) for prefix in ("gv", "gq", "gi", "gd", "gx"))
def test_every_name_the_contract_produces_is_a_managed_interface():
for node_type in ("docker", "qemu", "iou", "dynamips", "iol_docker", "vpcs"):
assert is_kernel_datapath_interface(kernel_anchor_name(node_type, NODE_ID, 0, 0))
for end in kernel_cascade_names(LINK_ID):
assert is_kernel_datapath_interface(end)
# a cross-compute VXLAN device: "gvl" + 8 hex of the link id + 4 hex of the node id
assert is_kernel_datapath_interface("gvl" + LINK_ID.replace("-", "")[:8] + NODE_ID.replace("-", "")[:4])
def test_lookalike_interfaces_are_not_managed():
# recognition is by exact shape, not bare prefix: user interfaces that
# merely start with a managed prefix stay visible to the cloud node
for name in (
"eth0",
"g",
"gs",
"gvl",
"gv0",
"gp1",
"gv00010203e0p", # anchor without the port
"gv00010203Xe0p0", # non-hex node id
"gs1a2b3c4d5e", # cascade without the side digit
"gs1a2b3c4d5e2", # side digit other than 0/1
"gvl1a2b3c4d5e", # VXLAN with a short tail
"GV00010203E0P0", # the contract only ever produces lowercase
):
assert not is_kernel_datapath_interface(name)