mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 13:35:31 +03:00
feat(docker): packet filters on kernel-datapath links via tc netem
Impairment filters with a netem equivalent (delay, packet_loss, corrupt) now run on the kernel datapath as one tc netem qdisc per veth host end, pushed through uBridge's netlink tc module (tc netem set / tc reset). This removes the relay delay filter's nanosleep bottleneck (upstream Both link endpoints carry the filters: each end's qdisc egress covers the traffic entering that container, so every direction is impaired exactly once -- the same net effect as the relay, where both directions cross the single filtered bridge. - controller: eligibility keys on relay-only types (frequency_drop, bpf) instead of any filter; kernel links push the real filters (the synthetic suspend frequency_drop no longer flips a suspended link to relay on reopen/reset); update_filters 409s only relay-only types; available_filters hides them on kernel links - compute: create_nio whitelists netem-capable filter types; the NIO update path keeps its own guard (PUT mutates the NIO in place); _ubridge_apply_netem translates the filter dict into one atomic netem-set (full re-apply IS the reconcile); node restart restores the qdisc, link deletion detaches it (the veth survives the link) - e2e (27/27): delay 100 -> 200.2 ms RTT, atomic replace 50 -> 100.2 ms, clear -> baseline, packet_loss 30 -> 48 % round-trip (expected 51 %), suspend/resume and node restart keep the qdisc, link delete leaves no residual impairment
This commit is contained in:
parent
94b5495683
commit
0a8d430d50
@ -24,12 +24,12 @@ Frames never leave the kernel.
|
||||
| Link | uBridge bridge, TAP fd ↔ UDP, userspace copy | per-link kernel bridge `gns3{link_id[:11]}`, zero-copy |
|
||||
| Relay latency | ~0.3 ms RTT | ~0.05 ms RTT (measured) |
|
||||
| Link attach at runtime | yes (relay attaches the pre-existing TAP) | yes (brctl addif / add_nio_ethernet — interface untouched) |
|
||||
| Packet filters | uBridge userspace filters | not supported yet (tc netem roadmap) → relay fallback |
|
||||
| Packet filters | uBridge userspace filters | tc netem on the veth host end (delay / packet_loss / corrupt); frequency_drop & bpf stay relay-only |
|
||||
| Capture / markers | relay bridge | uBridge AF_PACKET modules on the veth host end |
|
||||
|
||||
The work landed in four stages on branch stack `feat/docker-kernel-datapath` →
|
||||
The work landed in five stages on branch stack `feat/docker-kernel-datapath` →
|
||||
`feat/docker-kernel-capture` → `feat/docker-kernel-markers` →
|
||||
`feat/docker-veth-everywhere`:
|
||||
`feat/docker-veth-everywhere` → `feat/docker-kernel-filters`:
|
||||
|
||||
1. **Kernel datapath** — NIOBridge NIO type, per-link bridges, carrier-based
|
||||
suspend, crash-safe reconciliation.
|
||||
@ -39,6 +39,10 @@ The work landed in four stages on branch stack `feat/docker-kernel-datapath` →
|
||||
4. **veth-everywhere** — the TAP path deleted; every adapter is a veth and the
|
||||
datapath is a *runtime* decision (kernel or relay) that never touches a
|
||||
running container's interfaces.
|
||||
5. **Filters** — impairment filters (delay, packet_loss, corrupt) become one
|
||||
tc netem qdisc per veth host end, pushed through uBridge's netlink `tc`
|
||||
module. This also removes the relay delay filter's nanosleep bottleneck
|
||||
(upstream #2827) from the kernel path.
|
||||
|
||||
## Adapter interface types
|
||||
|
||||
@ -121,10 +125,10 @@ endpoints became eligible are upgraded to the kernel datapath automatically.
|
||||
|
||||
| Operation | Kernel-datapath action |
|
||||
|---|---|
|
||||
| Create / attach | `brctl create` (EEXIST tolerated via `brctl show` verify) + `link set up` + `brctl addif` both ends (concurrent, race-tolerant) + carrier up |
|
||||
| Delete / detach | marker teardown → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The veth survives — unlike a relay bridge, whose death dropped its filters, an orphaned AF_PACKET marker would keep sniffing, hence the explicit teardown |
|
||||
| Create / attach | `brctl create` (EEXIST tolerated via `brctl show` verify) + `link set up` + `brctl addif` both ends (concurrent, race-tolerant) + carrier up + markers + `tc netem set` (filters) |
|
||||
| Delete / detach | marker teardown → `tc reset` (netem teardown — the veth survives the link) → carrier off → `brctl delif` → `brctl delete` (last endpoint wins; EBUSY/ENOENT suppressed). The veth survives — unlike a relay bridge, whose death dropped its filters, an orphaned AF_PACKET marker would keep sniffing, hence the explicit teardown |
|
||||
| Reset (`POST /links/{id}/reset`) | delete + create (re-evaluates eligibility) |
|
||||
| Suspend (`PUT /links/{id}` `{"suspend": true}`) | veth host end admin-state down both ends — 100 % loss, no synthetic filter needed; resume restores |
|
||||
| Suspend (`PUT /links/{id}` `{"suspend": true}`) | veth host end admin-state down both ends — 100 % loss, no synthetic filter needed; resume restores (netem qdisc survives the carrier flap) |
|
||||
|
||||
## Capture
|
||||
|
||||
@ -156,11 +160,38 @@ marker enable_kernel <if> <name> <on|off> # off = installed but silent
|
||||
|
||||
## Packet filters
|
||||
|
||||
Not supported on the kernel datapath yet: a link with active filters is wired
|
||||
on the relay (where uBridge's userspace filters apply), and adding filters to
|
||||
an existing kernel link returns 409. Kernelization via `tc netem` on the veth
|
||||
is the planned next stage — it also removes the relay's `delay` filter
|
||||
nanosleep bottleneck under high packet rates.
|
||||
Impairment filters run **in the kernel** as tc netem qdiscs on the veth host
|
||||
ends (uBridge `tc netem set`, raw netlink — no `tc` binary needed):
|
||||
|
||||
| GNS3 filter | netem mapping | Notes |
|
||||
|---|---|---|
|
||||
| `delay [ms, jitter]` | `delay X jitter Y` | jitter 0 omitted |
|
||||
| `packet_loss [%]` | `loss P` | per direction (see below) |
|
||||
| `corrupt [%]` | `corrupt P` | |
|
||||
| `frequency_drop` | — no netem equivalent | 409 on kernel links; relay fallback |
|
||||
| `bpf` | — needs a tc classifier | 409 on kernel links; relay fallback |
|
||||
|
||||
Semantics:
|
||||
|
||||
* **Both endpoints** receive the filter dict and attach one qdisc to *their*
|
||||
veth host end. The qdisc's egress covers traffic entering that container,
|
||||
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²).
|
||||
* **Reconcile = full re-apply.** `netem set` is an atomic replace (NLM_F_REPLACE),
|
||||
so every NIO update just rebuilds the qdisc from the current filters; no
|
||||
per-parameter diffing. An empty filter set detaches it (`tc reset`,
|
||||
ENOENT-tolerated).
|
||||
* **Node restart** restores the qdisc from the NIO (like capture and markers);
|
||||
**link deletion** detaches it explicitly — the veth 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.
|
||||
* A link carrying `frequency_drop` or `bpf` is wired on the **relay** (they
|
||||
only exist in uBridge's userspace filters); `available_filters` hides them
|
||||
on kernel links and setting them returns 409 with a clear message.
|
||||
|
||||
## uBridge command surface
|
||||
|
||||
@ -173,9 +204,14 @@ brctl create / delete / addif / delif / show
|
||||
link set <if> up|down
|
||||
capture start_kernel / stop_kernel
|
||||
marker add_kernel / delete_kernel / enable_kernel
|
||||
tc netem set <if> [delay <ms>] [jitter <ms>] [loss <%>] [dup <%>] [corrupt <%>] / tc reset <if>
|
||||
bridge add_nio_ethernet / add_nio_udp / start / stop / start_capture / stop_capture
|
||||
```
|
||||
|
||||
Note: `tc reset` currently replies 207-ENOENT when no qdisc is attached — the
|
||||
server tolerates it; making the command idempotent in uBridge is a desirable
|
||||
polish.
|
||||
|
||||
## Configuration
|
||||
|
||||
```ini
|
||||
@ -193,6 +229,15 @@ End-to-end on a five-container FRR topology (mixed runtime-drawn and reloaded
|
||||
links): 8/8 links on the kernel datapath, ping RTT ≈ 0.05 ms, FDB learning,
|
||||
suspend = 100 % loss / resume restores, project reopen upgrades relay links,
|
||||
runtime link creation on running containers, marker pcaps exact (tx/rx
|
||||
pairs), capture freeze on stop, server restart reconciliation. Unit tests:
|
||||
pairs), capture freeze on stop, server restart reconciliation.
|
||||
|
||||
Filters (two-container kernel link, 27/27 checks): baseline 0.06 ms →
|
||||
`delay 100` = 200.2 ms RTT (2× per direction) → `delay 50` = 100.2 ms
|
||||
(atomic replace) → clear = baseline (qdisc detached); `packet_loss 30` =
|
||||
48 % round-trip loss (expected 51 % = 1 − 0.7²); `frequency_drop`/`bpf` =
|
||||
409; suspend with delay active = 100 % loss, resume keeps the qdisc
|
||||
(200.1 ms); node restart restores it (200.1 ms); link delete detaches it
|
||||
(`/usr/sbin/tc qdisc show` — no netem left); re-created link has no residual
|
||||
impairment. Unit tests:
|
||||
`tests/compute/docker/test_docker_kernel_datapath.py`,
|
||||
`tests/controller/test_kernel_datapath_link.py`.
|
||||
|
||||
@ -361,8 +361,18 @@ class BaseManager:
|
||||
nio.suspend = nio_settings.get("suspend", False)
|
||||
elif nio_settings["type"] == "nio_bridge":
|
||||
nio = NIOBridge(nio_settings["bridge"])
|
||||
if nio_settings.get("filters"):
|
||||
raise ComputeError("Packet filters are not supported on kernel-datapath links")
|
||||
# Impairment filters with a tc netem equivalent run on the veth
|
||||
# host end; the relay-only types are rejected here (the controller
|
||||
# already keeps them off kernel links — this is the second guard).
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio_settings.get("filters") or {})
|
||||
if unsupported:
|
||||
raise ComputeError(
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link "
|
||||
"(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported)))
|
||||
)
|
||||
nio.filters = nio_settings.get("filters", {})
|
||||
# Markers ride the NIO and attach to the veth host interface via
|
||||
# uBridge's AF_PACKET marker module (marker add_kernel).
|
||||
nio.markers = nio_settings.get("markers", {})
|
||||
|
||||
@ -40,6 +40,7 @@ from gns3server.utils.asyncio.raw_command_server import AsyncioRawCommandServer
|
||||
from gns3server.utils.asyncio.ssh_server import AsyncioSSHServer
|
||||
from gns3server.utils.asyncio.telnet_server import AsyncioTelnetServer
|
||||
from gns3server.utils.hostname import is_rfc1123_hostname_valid
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
from ..adapters.ethernet_adapter import EthernetAdapter
|
||||
from ..base_node import BaseNode
|
||||
@ -1856,8 +1857,10 @@ class DockerVM(BaseNode):
|
||||
self._kernel_veths[(adapter_number, port_number)] = host_ifc
|
||||
log.debug(
|
||||
"Created veth adapter {adapter_number} port {port_number} with MAC address {mac_address} in namespace {namespace}".format(
|
||||
adapter_number=adapter_number, port_number=port_number,
|
||||
mac_address=mac_address, namespace=self._namespace,
|
||||
adapter_number=adapter_number,
|
||||
port_number=port_number,
|
||||
mac_address=mac_address,
|
||||
namespace=self._namespace,
|
||||
)
|
||||
)
|
||||
|
||||
@ -1931,6 +1934,9 @@ class DockerVM(BaseNode):
|
||||
# Markers carried by the NIO attach to the veth host end (AF_PACKET
|
||||
# taps) — the anchor is the interface, not a relay bridge.
|
||||
await self._ubridge_apply_markers(host_ifc, nio)
|
||||
# Impairment filters become one tc netem qdisc on the veth host
|
||||
# end (restored here on node restart, like the capture above).
|
||||
await self._ubridge_apply_netem(host_ifc, nio.filters)
|
||||
return
|
||||
|
||||
# Relay NIO. On a veth-backed adapter (the unified Docker interface)
|
||||
@ -1992,10 +1998,14 @@ class DockerVM(BaseNode):
|
||||
|
||||
if self.ubridge:
|
||||
if isinstance(nio, NIOBridge):
|
||||
if nio.filters:
|
||||
# The controller keeps relay-only filters off kernel links and
|
||||
# create_nio rejects them on the POST path; this PUT path
|
||||
# mutates the NIO in place, so guard here too.
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(nio.filters or {})
|
||||
if unsupported:
|
||||
raise DockerError(
|
||||
"Packet filters are not supported on kernel-datapath links; "
|
||||
"delete and recreate the link to use them"
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link "
|
||||
"(no uBridge relay in the forwarding path)".format(", ".join(sorted(unsupported)))
|
||||
)
|
||||
if self.status == "started":
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
@ -2003,6 +2013,9 @@ class DockerVM(BaseNode):
|
||||
# Incremental marker reconcile on the veth anchor —
|
||||
# the relay-datapath equivalent of the branch below.
|
||||
await self._ubridge_apply_markers(host_ifc, nio)
|
||||
# netem set is an atomic replace, so a full re-apply
|
||||
# IS the incremental reconcile for impairment filters.
|
||||
await self._ubridge_apply_netem(host_ifc, nio.filters)
|
||||
await self._set_adapter_carrier(adapter_number, not nio.suspend, port_number)
|
||||
return
|
||||
|
||||
@ -2085,7 +2098,14 @@ class DockerVM(BaseNode):
|
||||
await self._ubridge_send(cmd)
|
||||
return
|
||||
await super()._ubridge_add_marker_filter(
|
||||
bridge_name, name, bpf, pcap_path, tag=tag, link_id=link_id, direction=direction, data_link_type=data_link_type
|
||||
bridge_name,
|
||||
name,
|
||||
bpf,
|
||||
pcap_path,
|
||||
tag=tag,
|
||||
link_id=link_id,
|
||||
direction=direction,
|
||||
data_link_type=data_link_type,
|
||||
)
|
||||
|
||||
async def _ubridge_delete_marker_filter(self, bridge_name, name):
|
||||
@ -2115,6 +2135,43 @@ class DockerVM(BaseNode):
|
||||
return
|
||||
await super()._ubridge_enable_marker_filter(anchor, name, state)
|
||||
|
||||
async def _ubridge_apply_netem(self, host_ifc, filters):
|
||||
"""
|
||||
Translate the NIO's impairment filters into one tc netem qdisc on the
|
||||
veth host end. The supported types (delay, packet_loss, corrupt) merge
|
||||
into a single qdisc — its egress covers the traffic entering this
|
||||
container, and with both link endpoints applying theirs, every
|
||||
direction of the link is impaired exactly once (the same net effect
|
||||
as the relay, where both directions cross the single filtered bridge).
|
||||
``netem set`` atomically replaces whatever qdisc was attached, so a
|
||||
full re-apply on every NIO update *is* the incremental reconcile. An
|
||||
empty filter set detaches the qdisc (``tc reset``).
|
||||
|
||||
:param host_ifc: veth host-end interface name
|
||||
:param filters: NIO filters dictionary ({"delay": [ms, jitter], ...})
|
||||
"""
|
||||
|
||||
parts = []
|
||||
delay = filters.get("delay")
|
||||
if delay:
|
||||
parts.append(f"delay {int(delay[0])}")
|
||||
if len(delay) > 1 and int(delay[1]):
|
||||
parts.append(f"jitter {int(delay[1])}")
|
||||
for filter_type, netem_kw in (("packet_loss", "loss"), ("corrupt", "corrupt")):
|
||||
values = filters.get(filter_type)
|
||||
if values and int(values[0]):
|
||||
parts.append(f"{netem_kw} {int(values[0])}")
|
||||
if parts:
|
||||
await self._ubridge_send('tc netem set "{ifc}" {params}'.format(ifc=host_ifc, params=" ".join(parts)))
|
||||
return
|
||||
try:
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
except UbridgeError as e:
|
||||
# "No such file or directory" = no qdisc was attached: already
|
||||
# clean. Everything else is a real failure and must surface.
|
||||
if "No such file" not in str(e):
|
||||
raise
|
||||
|
||||
async def _remove_kernel_markers(self, host_ifc):
|
||||
"""
|
||||
Tear down every kernel marker anchored to *host_ifc*: the veth survives
|
||||
@ -2154,6 +2211,12 @@ class DockerVM(BaseNode):
|
||||
host_ifc = self._kernel_veths.get((adapter_number, port_number))
|
||||
if host_ifc is not None:
|
||||
await self._remove_kernel_markers(host_ifc)
|
||||
# The veth survives link deletion, so an orphaned netem qdisc
|
||||
# would keep impairing whatever attaches to the adapter next
|
||||
# (kernel link or relay). Best-effort: ENOENT just means no
|
||||
# qdisc was attached.
|
||||
with contextlib.suppress(UbridgeError):
|
||||
await self._ubridge_send(f'tc reset "{host_ifc}"')
|
||||
if self.status == "started":
|
||||
await self._set_adapter_carrier(adapter_number, False, port_number)
|
||||
with contextlib.suppress(UbridgeError):
|
||||
|
||||
@ -20,8 +20,9 @@ Interface for kernel-datapath bridge NIOs.
|
||||
|
||||
A NIO of this type instructs the node to enslave its adapter's veth host end
|
||||
into the named Linux kernel bridge instead of wiring a uBridge UDP relay.
|
||||
Frames then flow entirely in the kernel (veth -> bridge -> veth). Packet
|
||||
filters are not available on this datapath (they live in the uBridge relay);
|
||||
Frames then flow entirely in the kernel (veth -> bridge -> veth). Impairment
|
||||
filters ride the NIO and run as one tc netem qdisc per veth host end (the
|
||||
types without a netem equivalent — frequency_drop, bpf — stay relay-only);
|
||||
markers and packet capture are served by uBridge's AF_PACKET modules on the
|
||||
veth host end (marker add_kernel / capture start_kernel).
|
||||
"""
|
||||
|
||||
@ -116,8 +116,9 @@ class Link:
|
||||
"""
|
||||
Whether this link is wired on the kernel datapath (veth pairs
|
||||
enslaved into a per-link Linux bridge — no uBridge relay in the
|
||||
forwarding path, so filters and markers are unavailable; capture is
|
||||
served by uBridge's AF_PACKET module).
|
||||
forwarding path). Impairment filters run as tc netem on the veth
|
||||
host end; markers and capture are served by uBridge's AF_PACKET
|
||||
module.
|
||||
"""
|
||||
return any(d.get("type") == "nio_bridge" for d in (getattr(self, "_link_data", None) or []))
|
||||
|
||||
@ -234,11 +235,18 @@ class Link:
|
||||
except FilterValidationError as e:
|
||||
raise ControllerError(f"Invalid packet filter parameters: {e!s}")
|
||||
|
||||
if new_filters and self.kernel_datapath:
|
||||
raise ControllerError(
|
||||
"Packet filters are not supported on kernel-datapath links (no uBridge relay "
|
||||
"in the forwarding path); delete and recreate the link to use filters"
|
||||
)
|
||||
if self.kernel_datapath:
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
unsupported = KERNEL_UNSUPPORTED_FILTERS.intersection(new_filters or {})
|
||||
if unsupported:
|
||||
raise ControllerError(
|
||||
"Packet filter(s) {} cannot run on a kernel-datapath link (no uBridge relay "
|
||||
"in the forwarding path; delay, packet loss and corrupt are served by tc "
|
||||
"netem on the veth); delete and recreate the link to use them".format(
|
||||
", ".join(sorted(unsupported))
|
||||
)
|
||||
)
|
||||
|
||||
if new_filters != self.filters:
|
||||
self._filters = new_filters
|
||||
@ -637,6 +645,13 @@ class Link:
|
||||
"""
|
||||
filter_node = self._get_filter_node()
|
||||
if filter_node:
|
||||
if self.kernel_datapath:
|
||||
# Kernel-datapath links serve filters via tc netem on the
|
||||
# veth host end; types without a netem equivalent (which
|
||||
# only run in the uBridge relay) are hidden from the picker.
|
||||
from gns3server.utils.packet_filter_validation import KERNEL_UNSUPPORTED_FILTERS
|
||||
|
||||
return [f for f in FILTERS if f["type"] not in KERNEL_UNSUPPORTED_FILTERS]
|
||||
return FILTERS
|
||||
return []
|
||||
|
||||
|
||||
@ -20,7 +20,10 @@ import asyncio
|
||||
import logging
|
||||
|
||||
from gns3server.config import Config
|
||||
from gns3server.utils.packet_filter_validation import validate_bpf_syntax
|
||||
from gns3server.utils.packet_filter_validation import (
|
||||
KERNEL_UNSUPPORTED_FILTERS,
|
||||
validate_bpf_syntax,
|
||||
)
|
||||
|
||||
from .controller_error import ControllerError, ControllerNotFoundError
|
||||
from .link import _UNSET, Link
|
||||
@ -82,19 +85,32 @@ class UDPLink(Link):
|
||||
"""Whether NIO creation is waiting for missing node images."""
|
||||
return self._deferred
|
||||
|
||||
def _get_node_filters(self, node1, node2):
|
||||
def _get_node_filters(self, node1, node2, kernel=None):
|
||||
"""
|
||||
Determine which node gets the active filters applied.
|
||||
|
||||
:param kernel: explicit datapath decision for the caller's context.
|
||||
``_prepare`` must pass it: it clears ``_link_data`` (the source
|
||||
of the ``kernel_datapath`` property) before rebuilding the NIO
|
||||
specs, so deriving the datapath there would read the previous
|
||||
run's. Callers operating on established link data (``update``)
|
||||
derive it from the property.
|
||||
|
||||
:returns: Tuple of (node1_filters, node2_filters)
|
||||
"""
|
||||
# Kernel-datapath links implement suspend natively (interface carrier
|
||||
# driven by the NIO suspend flag). The synthetic frequency_drop filter
|
||||
# that get_active_filters() injects for suspended links is a
|
||||
# relay-datapath emulation mechanism — sending it would trip the
|
||||
# compute-side kernel guard, so kernel links always push empty filters.
|
||||
if self.kernel_datapath:
|
||||
return {}, {}
|
||||
# relay-datapath emulation mechanism, so kernel links push the *real*
|
||||
# filters straight from storage. Both endpoints receive them: each
|
||||
# end's compute attaches one tc netem qdisc to its veth host end
|
||||
# (egress = traffic entering that container), 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.
|
||||
if kernel is None:
|
||||
kernel = self.kernel_datapath
|
||||
if kernel:
|
||||
return self._filters, self._filters
|
||||
filter_node = self._get_filter_node()
|
||||
return (
|
||||
self.get_active_filters() if filter_node == node1 else {},
|
||||
@ -135,9 +151,11 @@ class UDPLink(Link):
|
||||
"""
|
||||
Whether this link can be wired on the kernel datapath (veth pairs
|
||||
enslaved into a per-link Linux bridge) instead of the uBridge UDP
|
||||
relay. The kernel path has no userspace relay, so filters — which
|
||||
live in the relay — disqualify it. Markers and capture are served
|
||||
by uBridge's AF_PACKET modules on the veth host end.
|
||||
relay. Impairment filters with a tc netem equivalent (delay,
|
||||
packet_loss, corrupt) are served on the veth host end; the others
|
||||
(frequency_drop, bpf) only exist in the uBridge relay and disqualify
|
||||
the kernel path. Capture and markers are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
|
||||
Docker adapters are born as veth pairs (unified interface), so the
|
||||
datapath is a runtime decision — links attach to running containers
|
||||
@ -152,7 +170,11 @@ class UDPLink(Link):
|
||||
return False
|
||||
if node1.compute.id != node2.compute.id:
|
||||
return False
|
||||
if self.get_active_filters():
|
||||
# Look at the *stored* filters, not get_active_filters(): a suspended
|
||||
# link reports the synthetic frequency_drop emulation, which is
|
||||
# relay-only mechanics and must not flip a kernel link to the relay
|
||||
# on reopen/reset.
|
||||
if KERNEL_UNSUPPORTED_FILTERS.intersection(self._filters or {}):
|
||||
return False
|
||||
if _is_unix_socket_docker(node1) or _is_unix_socket_docker(node2):
|
||||
return False
|
||||
@ -192,11 +214,27 @@ class UDPLink(Link):
|
||||
# Markers ride the NIO like on the relay datapath, routed by
|
||||
# capture node; they attach to the veth host end via uBridge's
|
||||
# AF_PACKET marker module instead of a relay `mark` filter.
|
||||
# Filters (delay/packet_loss/corrupt — eligibility guarantees no
|
||||
# other type is present) become one tc netem qdisc per veth host
|
||||
# end, pushed to both endpoints.
|
||||
bridge_name = "gns3" + self._id.replace("-", "")[:11]
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=True)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
self._link_data = [
|
||||
{"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node1_markers, "suspend": self._suspended},
|
||||
{"type": "nio_bridge", "bridge": bridge_name, "filters": {}, "markers": node2_markers, "suspend": self._suspended},
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"filters": node1_filters,
|
||||
"markers": node1_markers,
|
||||
"suspend": self._suspended,
|
||||
},
|
||||
{
|
||||
"type": "nio_bridge",
|
||||
"bridge": bridge_name,
|
||||
"filters": node2_filters,
|
||||
"markers": node2_markers,
|
||||
"suspend": self._suspended,
|
||||
},
|
||||
]
|
||||
return [
|
||||
(node1, adapter_number1, port_number1, self._link_data[0]),
|
||||
@ -223,7 +261,7 @@ class UDPLink(Link):
|
||||
_allocate_port(node1.compute), _allocate_port(node2.compute)
|
||||
)
|
||||
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2)
|
||||
node1_filters, node2_filters = self._get_node_filters(node1, node2, kernel=False)
|
||||
node1_markers, node2_markers = self._get_node_markers(node1, node2)
|
||||
|
||||
# Build the tunnel specs for both sides. Index 0 is always node1 so
|
||||
@ -331,7 +369,7 @@ class UDPLink(Link):
|
||||
self._link_data[1]["suspend"] = self._suspended
|
||||
if node2.node_type != "ethernet_hub":
|
||||
await node2.put(
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=221
|
||||
f"/adapters/{adapter_number2}/ports/{port_number2}/nio", data=self._link_data[1], timeout=120
|
||||
)
|
||||
|
||||
async def delete(self):
|
||||
|
||||
@ -74,15 +74,18 @@ class BridgeNIO(BaseModel):
|
||||
"""
|
||||
Kernel-datapath bridge Network Input/Output properties. The NIO tells the
|
||||
node to enslave its veth host end into the named kernel bridge instead of
|
||||
wiring a uBridge UDP relay. ``filters`` must stay empty on this datapath
|
||||
(rejected server-side); markers and packet capture are served by uBridge's
|
||||
AF_PACKET modules on the veth host end.
|
||||
wiring a uBridge UDP relay. Impairment filters with a tc netem equivalent
|
||||
(delay, packet_loss, corrupt) run on the veth host end; the relay-only
|
||||
kinds (frequency_drop, bpf) are rejected. Markers and packet capture are
|
||||
served by uBridge's AF_PACKET modules on the veth host end.
|
||||
"""
|
||||
|
||||
type: BridgeNIOType
|
||||
bridge: str = Field(..., description="Kernel bridge name e.g. gns3a1b2c3d4e5")
|
||||
suspend: Optional[bool] = Field(None, description="Suspend the NIO")
|
||||
filters: Optional[dict] = Field(None, description="Packet filters (unsupported, must be empty)")
|
||||
filters: Optional[dict] = Field(
|
||||
None, description="Packet filters served by tc netem on the veth host end (delay/packet_loss/corrupt)"
|
||||
)
|
||||
markers: Optional[dict] = Field(None, description="Traffic-insight markers (attached to the veth host end)")
|
||||
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@ Packet filter parameter validation utilities.
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -15,7 +15,13 @@ class FilterValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Any]:
|
||||
# Packet filters with no kernel-datapath equivalent (no tc netem mapping):
|
||||
# they only run in the uBridge userspace relay, so a link carrying one is
|
||||
# wired on the relay, and adding them to a kernel-datapath link is rejected.
|
||||
KERNEL_UNSUPPORTED_FILTERS = frozenset({"frequency_drop", "bpf"})
|
||||
|
||||
|
||||
def validate_bpf_syntax(bpf_expression: str) -> Dict[str, Optional[str]]:
|
||||
"""
|
||||
Validate BPF filter expression syntax using tcpdump.
|
||||
|
||||
|
||||
@ -55,7 +55,7 @@ async def vm(compute_project, manager):
|
||||
|
||||
vm = DockerVM("test", str(uuid.uuid4()), compute_project, manager, "ubuntu:latest", aux_type="none")
|
||||
vm._cid = "e90e34656842"
|
||||
vm.mac_address = '02:42:3d:b7:93:00'
|
||||
vm.mac_address = "02:42:3d:b7:93:00"
|
||||
vm._start_interface_monitor = AsyncioMagicMock()
|
||||
vm._stop_interface_monitor = AsyncioMagicMock()
|
||||
return vm
|
||||
@ -65,6 +65,7 @@ async def vm(compute_project, manager):
|
||||
# NIO factory
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_create_nio_bridge(vm):
|
||||
|
||||
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
|
||||
@ -73,10 +74,25 @@ def test_create_nio_bridge(vm):
|
||||
assert nio.suspend is False
|
||||
|
||||
|
||||
def test_create_nio_bridge_rejects_filters(vm):
|
||||
def test_create_nio_bridge_accepts_netem_filters(vm):
|
||||
"""
|
||||
Impairment filters with a tc netem equivalent ride the kernel NIO (they
|
||||
become one netem qdisc on the veth host end); relay-only types are
|
||||
rejected.
|
||||
"""
|
||||
|
||||
with pytest.raises(ComputeError):
|
||||
vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": {"loss": [10]}})
|
||||
nio = vm.manager.create_nio(
|
||||
{"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [50, 5], "packet_loss": [10]}}
|
||||
)
|
||||
assert isinstance(nio, NIOBridge)
|
||||
assert nio.filters == {"delay": [50, 5], "packet_loss": [10]}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("filters", [{"frequency_drop": [10]}, {"bpf": ["icmp"]}])
|
||||
def test_create_nio_bridge_rejects_relay_only_filters(vm, filters):
|
||||
|
||||
with pytest.raises(ComputeError, match="kernel-datapath"):
|
||||
vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE, "filters": filters})
|
||||
|
||||
|
||||
def test_create_nio_bridge_accepts_markers(vm):
|
||||
@ -94,6 +110,7 @@ def test_create_nio_bridge_accepts_markers(vm):
|
||||
# Adapter wiring (start path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_kernel_connection(vm):
|
||||
|
||||
@ -110,8 +127,8 @@ async def test_add_ubridge_kernel_connection(vm):
|
||||
call.send(f'docker delete_veth "{guest_ifc}"'),
|
||||
call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'),
|
||||
call.send(f'link set "{host_ifc}" down'),
|
||||
call.send(f'docker set_mac_addr {guest_ifc} 02:42:3d:b7:93:00'),
|
||||
call.send(f'docker move_to_ns {guest_ifc} 42 eth0'),
|
||||
call.send(f"docker set_mac_addr {guest_ifc} 02:42:3d:b7:93:00"),
|
||||
call.send(f"docker move_to_ns {guest_ifc} 42 eth0"),
|
||||
# link attach
|
||||
call.send(f'brctl create "{BRIDGE}"'),
|
||||
call.send(f'link set "{BRIDGE}" up'),
|
||||
@ -124,6 +141,26 @@ async def test_add_ubridge_kernel_connection(vm):
|
||||
assert "bridge0" not in vm._bridges
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_kernel_connection_applies_netem(vm):
|
||||
"""
|
||||
Filters carried by the NIO become a tc netem qdisc at start (node
|
||||
restart restore), exactly like capture and markers.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
vm._namespace = 42
|
||||
nio = vm.manager.create_nio(
|
||||
{"type": "nio_bridge", "bridge": BRIDGE, "filters": {"delay": [50, 5], "packet_loss": [10]}}
|
||||
)
|
||||
|
||||
await vm._add_ubridge_connection(nio, 0)
|
||||
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
netem_calls = [c for c in vm._ubridge_hypervisor.method_calls if "netem set" in str(c)]
|
||||
assert call.send(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss 10') in netem_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_ubridge_connection_none_nio_creates_veth(vm):
|
||||
"""
|
||||
@ -138,11 +175,14 @@ async def test_add_ubridge_connection_none_nio_creates_veth(vm):
|
||||
await vm._add_ubridge_connection(None, 0)
|
||||
|
||||
host_ifc, guest_ifc = vm._veth_names(0, 0)
|
||||
vm._ubridge_hypervisor.assert_has_calls([
|
||||
call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'),
|
||||
call.send(f'link set "{host_ifc}" down'),
|
||||
call.send(f'docker move_to_ns {guest_ifc} 42 eth0'),
|
||||
], any_order=True)
|
||||
vm._ubridge_hypervisor.assert_has_calls(
|
||||
[
|
||||
call.send(f'docker create_veth "{host_ifc}" "{guest_ifc}"'),
|
||||
call.send(f'link set "{host_ifc}" down'),
|
||||
call.send(f"docker move_to_ns {guest_ifc} 42 eth0"),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
assert vm._kernel_veths[(0, 0)] == host_ifc
|
||||
# no relay bridge and no TAP for an unconnected adapter
|
||||
assert "bridge0" not in vm._bridges
|
||||
@ -165,13 +205,16 @@ async def test_add_ubridge_connection_udp_relays_over_veth(vm):
|
||||
await vm._add_ubridge_connection(nio, 0)
|
||||
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
vm._ubridge_hypervisor.assert_has_calls([
|
||||
call.send(f'docker create_veth "{host_ifc}" "{vm._veth_names(0, 0)[1]}"'),
|
||||
call.send("bridge create bridge0"),
|
||||
call.send(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'),
|
||||
call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"),
|
||||
call.send("bridge start bridge0"),
|
||||
], any_order=True)
|
||||
vm._ubridge_hypervisor.assert_has_calls(
|
||||
[
|
||||
call.send(f'docker create_veth "{host_ifc}" "{vm._veth_names(0, 0)[1]}"'),
|
||||
call.send("bridge create bridge0"),
|
||||
call.send(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'),
|
||||
call.send("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"),
|
||||
call.send("bridge start bridge0"),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
assert "bridge0" in vm._bridges
|
||||
|
||||
|
||||
@ -199,13 +242,14 @@ async def test_add_ubridge_kernel_connection_cleans_up_on_failure(vm):
|
||||
# Link attach / detach
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_nio_kernel_tolerates_bridge_create_race(vm):
|
||||
|
||||
# Both endpoints create the per-link bridge concurrently; the loser must
|
||||
# verify (brctl show) instead of failing.
|
||||
async def send(command):
|
||||
if 'brctl create' in command:
|
||||
if "brctl create" in command:
|
||||
raise UbridgeError("Could not create bridge: File exists")
|
||||
|
||||
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
|
||||
@ -223,7 +267,7 @@ async def test_connect_nio_kernel_tolerates_bridge_create_race(vm):
|
||||
async def test_connect_nio_kernel_create_failure_propagates(vm):
|
||||
|
||||
async def send(command):
|
||||
if 'brctl create' in command or 'brctl show' in command:
|
||||
if "brctl create" in command or "brctl show" in command:
|
||||
raise UbridgeError("Could not create bridge: No such device")
|
||||
|
||||
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
|
||||
@ -255,11 +299,15 @@ async def test_remove_kernel_nio(vm):
|
||||
|
||||
await vm.adapter_remove_nio_binding(0)
|
||||
|
||||
vm._ubridge_send.assert_has_calls([
|
||||
call(f'link set "{host_ifc}" down'),
|
||||
call(f'brctl delif "{BRIDGE}" "{host_ifc}"'),
|
||||
call(f'brctl delete "{BRIDGE}"'),
|
||||
], any_order=True)
|
||||
vm._ubridge_send.assert_has_calls(
|
||||
[
|
||||
call(f'tc reset "{host_ifc}"'),
|
||||
call(f'link set "{host_ifc}" down'),
|
||||
call(f'brctl delif "{BRIDGE}" "{host_ifc}"'),
|
||||
call(f'brctl delete "{BRIDGE}"'),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
assert vm._ethernet_adapters[0].get_nio(0) is None
|
||||
# the veth itself stays: it is the adapter interface, not the link
|
||||
assert vm._kernel_veths[(0, 0)] == host_ifc
|
||||
@ -273,7 +321,7 @@ async def test_remove_kernel_nio_tolerates_peer_winning_bridge_delete(vm):
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
|
||||
async def send(command):
|
||||
if 'brctl delif' in command or 'brctl delete' in command:
|
||||
if "brctl delif" in command or "brctl delete" in command:
|
||||
raise UbridgeError("busy")
|
||||
|
||||
vm._ubridge_send = AsyncioMagicMock(side_effect=send)
|
||||
@ -287,6 +335,7 @@ async def test_remove_kernel_nio_tolerates_peer_winning_bridge_delete(vm):
|
||||
# Carrier / suspend
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_adapter_carrier_kernel(vm):
|
||||
|
||||
@ -297,10 +346,12 @@ async def test_set_adapter_carrier_kernel(vm):
|
||||
await vm._set_adapter_carrier(0, True)
|
||||
await vm._set_adapter_carrier(0, False)
|
||||
|
||||
vm._ubridge_send.assert_has_calls([
|
||||
call(f'link set "{host_ifc}" up'),
|
||||
call(f'link set "{host_ifc}" down'),
|
||||
])
|
||||
vm._ubridge_send.assert_has_calls(
|
||||
[
|
||||
call(f'link set "{host_ifc}" up'),
|
||||
call(f'link set "{host_ifc}" down'),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@ -308,6 +359,7 @@ async def test_set_adapter_carrier_kernel(vm):
|
||||
# on a running node)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_nio_udp_relays_over_veth(vm):
|
||||
|
||||
@ -318,12 +370,15 @@ async def test_connect_nio_udp_relays_over_veth(vm):
|
||||
nio = vm.manager.create_nio({"type": "nio_udp", "lport": 4242, "rport": 4343, "rhost": "127.0.0.1"})
|
||||
await vm._connect_nio(0, nio)
|
||||
|
||||
vm._ubridge_send.assert_has_calls([
|
||||
call("bridge create bridge0"),
|
||||
call(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'),
|
||||
call("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"),
|
||||
call("bridge start bridge0"),
|
||||
], any_order=True)
|
||||
vm._ubridge_send.assert_has_calls(
|
||||
[
|
||||
call("bridge create bridge0"),
|
||||
call(f'bridge add_nio_ethernet bridge0 "{host_ifc}"'),
|
||||
call("bridge add_nio_udp bridge0 4242 127.0.0.1 4343"),
|
||||
call("bridge start bridge0"),
|
||||
],
|
||||
any_order=True,
|
||||
)
|
||||
assert "bridge0" in vm._bridges
|
||||
|
||||
|
||||
@ -348,18 +403,89 @@ async def test_connect_nio_udp_on_veth_is_idempotent(vm):
|
||||
# Guards and cleanup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nio_kernel_rejects_filters(vm):
|
||||
async def test_update_nio_kernel_rejects_relay_only_filters(vm):
|
||||
"""
|
||||
The PUT route mutates the NIO in place (no create_nio), so the update
|
||||
path carries its own guard against relay-only filter types.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
|
||||
vm._ethernet_adapters[0].add_nio(0, nio)
|
||||
nio.filters = {"loss": [50]}
|
||||
nio.filters = {"frequency_drop": [10]}
|
||||
|
||||
with pytest.raises(DockerError, match="kernel-datapath"):
|
||||
await vm.adapter_update_nio_binding(0, nio)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nio_kernel_applies_netem(vm):
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm.status = "started"
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
vm._kernel_veths[(0, 0)] = host_ifc
|
||||
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
|
||||
vm._ethernet_adapters[0].add_nio(0, nio)
|
||||
nio.filters = {"delay": [50, 5], "packet_loss": [10], "corrupt": [2]}
|
||||
|
||||
await vm.adapter_update_nio_binding(0, nio)
|
||||
vm._ubridge_send.assert_any_call(f'tc netem set "{host_ifc}" delay 50 jitter 5 loss 10 corrupt 2')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nio_kernel_clears_netem(vm):
|
||||
"""
|
||||
Filters dropped to empty must detach the qdisc (the veth survives link
|
||||
changes); ENOENT (no qdisc was attached) is tolerated.
|
||||
"""
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
vm._ubridge_send = AsyncioMagicMock()
|
||||
vm.status = "started"
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
vm._kernel_veths[(0, 0)] = host_ifc
|
||||
nio = vm.manager.create_nio({"type": "nio_bridge", "bridge": BRIDGE})
|
||||
vm._ethernet_adapters[0].add_nio(0, nio)
|
||||
nio.filters = {}
|
||||
|
||||
await vm.adapter_update_nio_binding(0, nio)
|
||||
vm._ubridge_send.assert_any_call(f'tc reset "{host_ifc}"')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_netem_reset_tolerates_missing_qdisc(vm):
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
async def refuse(command):
|
||||
raise UbridgeError("207-Could not reset qdisc on gv0: No such file or directory")
|
||||
|
||||
vm._ubridge_send = refuse
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
# ENOENT means "already clean" — must not raise
|
||||
await vm._ubridge_apply_netem(host_ifc, {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_netem_reset_reraises_real_errors(vm):
|
||||
|
||||
vm._ubridge_hypervisor = MagicMock()
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
|
||||
async def refuse(command):
|
||||
raise UbridgeError("206-Could not reset qdisc on gv0: Operation not permitted")
|
||||
|
||||
vm._ubridge_send = refuse
|
||||
host_ifc, _ = vm._veth_names(0, 0)
|
||||
with pytest.raises(UbridgeError):
|
||||
await vm._ubridge_apply_netem(host_ifc, {})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_nio_kernel_suspend_toggles_carrier(vm):
|
||||
|
||||
@ -451,9 +577,16 @@ async def test_connect_nio_kernel_restarts_capture(vm):
|
||||
# Markers (AF_PACKET taps on the veth host end)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _marker_spec(bpf="icmp", tag=1, enabled=True, direction=None, link_id="l1"):
|
||||
return {"bpf": bpf, "tag": tag, "enabled": enabled, "direction": direction,
|
||||
"data_link_type": "DLT_EN10MB", "link_id": link_id}
|
||||
return {
|
||||
"bpf": bpf,
|
||||
"tag": tag,
|
||||
"enabled": enabled,
|
||||
"direction": direction,
|
||||
"data_link_type": "DLT_EN10MB",
|
||||
"link_id": link_id,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -88,12 +88,46 @@ async def test_kernel_datapath_not_eligible_across_computes(project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kernel_datapath_not_eligible_with_filters(project):
|
||||
async def test_kernel_datapath_eligible_with_netem_filters(project):
|
||||
"""
|
||||
Impairment filters with a tc netem equivalent (delay, packet_loss,
|
||||
corrupt) no longer disqualify the kernel datapath — they become one
|
||||
netem qdisc per veth host end.
|
||||
"""
|
||||
|
||||
link, node1, node2 = await _kernel_link(project)
|
||||
link._filters = {"delay": [10, 0]}
|
||||
link._filters = {"delay": [10, 0], "packet_loss": [5], "corrupt": [1]}
|
||||
assert link._kernel_datapath_eligible(node1, node2) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kernel_datapath_not_eligible_with_relay_only_filters(project):
|
||||
"""
|
||||
frequency_drop and bpf only exist in the uBridge userspace relay — a
|
||||
link carrying one stays on the relay.
|
||||
"""
|
||||
|
||||
link, node1, node2 = await _kernel_link(project)
|
||||
link._filters = {"delay": [10, 0], "frequency_drop": [7]}
|
||||
assert link._kernel_datapath_eligible(node1, node2) is False
|
||||
|
||||
link._filters = {"bpf": ["icmp"]}
|
||||
assert link._kernel_datapath_eligible(node1, node2) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kernel_datapath_eligible_when_suspended(project):
|
||||
"""
|
||||
A suspended link reports the synthetic frequency_drop emulation through
|
||||
get_active_filters() — that is relay-only mechanics and must not flip a
|
||||
kernel link to the relay on reopen/reset (suspend is carrier-driven).
|
||||
"""
|
||||
|
||||
link, node1, node2 = await _kernel_link(project)
|
||||
link._suspended = True
|
||||
assert link.get_active_filters() == {"frequency_drop": [-1]}
|
||||
assert link._kernel_datapath_eligible(node1, node2) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kernel_datapath_eligible_with_markers(project):
|
||||
@ -191,7 +225,7 @@ async def test_prepare_relay_link_allocates_udp_ports(project):
|
||||
|
||||
compute1.get_ip_on_same_subnet.side_effect = subnet_callback
|
||||
|
||||
async def port_callback(path, data={}, **kwargs):
|
||||
async def port_callback(path, data=None, **kwargs):
|
||||
response = MagicMock()
|
||||
response.json = {"udp_port": 1024}
|
||||
return response
|
||||
@ -212,13 +246,29 @@ async def test_prepare_relay_link_allocates_udp_ports(project):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_filters_rejected_on_kernel_link(project):
|
||||
async def test_update_netem_filters_accepted_on_kernel_link(project):
|
||||
|
||||
link, node1, node2 = await _kernel_link(project)
|
||||
await link._prepare()
|
||||
|
||||
await link.update_filters({"delay": [10, 0], "packet_loss": [5]})
|
||||
assert link.filters == {"delay": [10, 0], "packet_loss": [5]}
|
||||
# both endpoints carry the filters (one netem qdisc per veth host end)
|
||||
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
|
||||
assert by_node[node1.id]["filters"] == {"delay": [10, 0], "packet_loss": [5]}
|
||||
assert by_node[node2.id]["filters"] == {"delay": [10, 0], "packet_loss": [5]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_relay_only_filters_rejected_on_kernel_link(project):
|
||||
|
||||
link, _n1, _n2 = await _kernel_link(project)
|
||||
await link._prepare()
|
||||
|
||||
with pytest.raises(ControllerError, match="kernel-datapath"):
|
||||
await link.update_filters({"delay": [10, 0]})
|
||||
await link.update_filters({"frequency_drop": [10]})
|
||||
with pytest.raises(ControllerError, match="kernel-datapath"):
|
||||
await link.update_filters({"bpf": ["icmp"]})
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@ -565,7 +565,7 @@ async def test_update_ethernet_switch_nio(project):
|
||||
"markers": {},
|
||||
"filters": {},
|
||||
},
|
||||
timeout=221,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user