gns3-server/tests/e2e/test_docker_kernel_datapath.py
YueGuobin 86651604c4
test(e2e): relay capture and markers on both relay engines
The relay engines' observable paths had no live coverage: every
capture/start call in the suite sat on a kernel anchor (AF_PACKET
capture start_kernel) and markers were only asserted on marker
add_kernel. The two relay engines' own command shapes were unit-tested
only:
  * bridge engine  - bridge start_capture / bridge add_packet_filter ... mark
  * iol_bridge engine - iol_bridge start_capture / iol_bridge add_packet_filter ... mark

Docker relay control (bridge engine, 1 passed 33s): the capture start
REST call writes the ICMP exchange to a pcap off the relay bridge; a
mark filter attached with capture_node_id n1 signals exactly 10
marker.match events over the dedicated marker WS for 5 pings (5
requests + 5 replies, both directions), carrying node_id/link_id/tag and
the busybox frame length, writes exactly those 10 frames to the marker
pcap, and deleting the marker takes the pcap with it.

IOU relay control (iol_bridge engine, 1 passed 173s): the same two
checks on iol_bridge start_capture and the port's mark filter, driven
by the real IOS ping through the fabric socket relay.

Not duplicated in the ESW/VPCS/cloud relay controls: they issue the same
uBridge bridge commands through their own call sites, which stay covered
by unit tests; the IOL container relay goes through the same iol_bridge
engine already exercised here.
2026-10-06 00:21:10 +08:00

650 lines
32 KiB
Python

#
# Copyright (C) 2026 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Live end-to-end tests for the standard Docker node's kernel datapath (pytest
marker ``e2e``): two real Alpine containers, addressed from the host the way
a GNS3 user does (the node's persistent ``/etc/network/interfaces``, applied
by the container's init.sh) and driven through the Docker daemon socket the
server itself uses. The scenarios cover:
* ``test_docker_kernel_fast_path`` — the veth datapath itself: the link
reports ``kernel_datapath``, the per-link kernel bridge enslaves exactly
the two veth host ends, real ICMP crosses kernel-only, the L2-anchor spec
§E assertions hold (no L3 identity, FORWARDING ports, idle silence), a
``delay 100`` filter lands as netem on both host ends, suspend kills the
traffic at the carrier, capture writes a real pcap, link delete/re-create
and container stop/start leave the wiring consistent, and deleting the
project leaves no veth or bridge behind.
* ``test_docker_kernel_filter_matrix`` — one filter type at a time on a live
kernel link, each asserted by its effect on real traffic (RTT, loss,
per-seq outage evidence) and its tc state, from netem core and the netem
extensions to the cls_bpf and eBPF classifiers; the kernel-only types are
offered by ``available_filters`` and everything restores cleanly.
* ``test_docker_relay_control`` — the negative control on an isolated
relay-configured instance: no per-link kernel bridge, the wire is the
uBridge relay, the kernel-only filter types are hidden — and the relay's
own filter set (delay, frequency_drop, bpf) really shapes the wire, as
uBridge userspace filters on the filter node's bridge
(``bridge add_packet_filter``); its capture (``bridge start_capture``)
and its markers (``bridge add_packet_filter … mark``) ride the same
relay engine, not the kernel datapath's AF_PACKET modules.
* ``test_docker_relay_to_kernel_reopen_upgrade`` — the server's datapath
choice is flipped (relay → kernel) across a restart and the project is
reopened: the link is rebuilt on the kernel datapath and forwards.
The image is pinned by digest (``harness.ensure_docker_image``): a cached
copy costs no network, a miss pulls through the server's own pull route,
and no cache + no registry skips the scenario.
"""
import os
import re
import time
import pytest
from tests.e2e import harness
pytestmark = pytest.mark.e2e
# every standard Docker adapter has a single port
ETH = (0, 0)
R1_IP = "10.1.1.1"
R2_IP = "10.1.1.2"
def _resolve_container(daemon, node):
"""Fill in the node's container id from the daemon (the controller's
node payload does not carry the compute-side id), by the deterministic
name the server gives the container."""
container_id = daemon.container_id(f"GNS3.{node['name']}.{node['project_id']}")
assert container_id, f"container for {node['name']} not found on the Docker daemon"
node["container_id"] = container_id
return node
def _create_topology(server, pid, image, name):
"""Two containers with addressed eth0s. The harness reaches their guests
through the Docker daemon socket — the same socket the server uses."""
compute = server.compute
daemon = harness.DockerDaemon()
nodes = []
for suffix, address in (("1", R1_IP), ("2", R2_IP)):
node = compute.create_docker_node(pid, f"{name}-{suffix}", image)
harness.configure_docker_interfaces(node, address)
nodes.append(_resolve_container(daemon, node))
return nodes[0], nodes[1], daemon
def _start_addresses(compute, pid, daemon, nodes, addresses):
"""Start the containers and wait for init.sh to have applied their
addresses (busybox ifup runs a moment after container start)."""
for node in nodes:
compute.call("POST", f"/projects/{pid}/nodes/{node['node_id']}/start")
for node, address in zip(nodes, addresses, strict=False):
harness.docker_wait_address(daemon, node["container_id"], address)
def _put_filters(compute, pid, lid, filters):
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"filters": filters})
def _clear_filters(compute, pid, lid, *anchors):
"""Clear the link's filters and assert the teardown reached the kernel:
no netem qdisc and no clsact left on the anchors."""
_put_filters(compute, pid, lid, {})
for anchor in anchors:
assert "netem" not in harness.qdiscs(anchor), harness.qdiscs(anchor)
assert "clsact" not in harness.qdiscs(anchor), harness.qdiscs(anchor)
def _filter_types(compute, pid, lid):
return {entry["type"] for entry in compute.call("GET", f"/projects/{pid}/links/{lid}/available_filters")}
def _matches(marker_ws, marker_name):
return [e for e in marker_ws.events("marker.match") if e["filter"] == marker_name]
def _silence_guest(daemon, container_id):
"""Silence the guest for the idle-silence measurement — the docker
analogue of shutting the IOS interfaces in the dynamips scenario. The
link goes down first (a guest's own chatter is not just IPv6: Linux
re-sends its boot-time IGMP membership report seconds later), then its
IPv6 stack is switched off so the re-up does not re-fire DAD/MLD. The
IPv4 address stays configured and answers once the link is back."""
code, output = daemon.exec(
container_id,
["sh", "-c", "ip link set eth0 down && echo 1 > /proc/sys/net/ipv6/conf/eth0/disable_ipv6"],
)
assert code == 0, output
def _unsilence_guest(daemon, container_id):
code, output = daemon.exec(container_id, ["ip", "link", "set", "eth0", "up"])
assert code == 0, output
def test_docker_kernel_fast_path():
server = harness.live_server(kernel=True)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-e2e")
pid = project["project_id"]
a1 = a2 = bridge = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
c1, c2 = n1["container_id"], n2["container_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
# The link is created while both containers are stopped: the kernel
# NIO is bound with no anchors in existence yet and attached by
# container start (the deferred-wiring path).
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), "the veth pair is born at container start"
print(".. starting containers")
_start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP))
# Kernel objects: the veth host ends exist and the per-link bridge
# enslaves exactly the two of them.
assert harness.tap_exists(a1) and harness.tap_exists(a2), (a1, a2)
assert harness.bridge_members(bridge) == sorted([a1, a2]), harness.bridge_members(bridge)
# L2-anchor spec §E.1 (anchors and bridge carry no L3 identity;
# skipped on a uBridge without `link l2only`) and the forwarding
# guard — a DOWN bridge silently forwards nothing.
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)
print(".. pinging between the containers over the kernel link")
baseline = harness.docker_wait_ping(daemon, c1, R2_IP)
assert baseline["loss"] == 0, baseline["raw"]
assert baseline["avg"] is not None and baseline["avg"] < 50, baseline
# §E.2: with the guests silenced the segment is silent for 5 s —
# the host anchors do not generate the MLD/DAD flood an unhardened
# device produces (baseline: 6 frames / 2 s).
if harness.l2only_supported():
_silence_guest(daemon, c1)
_silence_guest(daemon, c2)
harness.assert_idle_silence(a1, a2, bridge)
_unsilence_guest(daemon, c1)
_unsilence_guest(daemon, c2)
assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
# delay 100: netem on both host ends (each impairs one direction),
# one-way ~100 ms => RTT grows by ~200 ms
_put_filters(compute, pid, lid, {"delay": [100]})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.docker_wait_ping(daemon, c1, R2_IP)
assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed)
_put_filters(compute, pid, lid, {})
assert "netem" not in harness.qdiscs(a1), harness.qdiscs(a1)
fast = harness.docker_wait_ping(daemon, c1, R2_IP)
assert fast["loss"] == 0 and fast["avg"] < 50, fast
# suspend: the host end admin-downs (carrier loss into the
# container), the link is dead; resume restores
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": True})
assert not harness.tap_up(a1)
dead = harness.docker_ping(daemon, c1, R2_IP, count=3, timeout=1)
assert dead["loss"] == 100, dead["raw"]
compute.call("PUT", f"/projects/{pid}/links/{lid}", {"suspend": False})
assert harness.tap_up(a1)
assert harness.docker_wait_ping(daemon, c1, R2_IP)["loss"] == 0
# capture: AF_PACKET on the anchor writes a real pcap
capture = compute.call("POST", f"/projects/{pid}/links/{lid}/capture/start", {"data_link_type": "DLT_EN10MB"})
harness.docker_ping(daemon, c1, R2_IP)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{lid}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 4, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
# link delete detaches both ends (the veths survive — they are the
# adapters); re-creating enslaves them again and the traffic returns
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.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
# container stop: the veth host ends are deleted explicitly (a veth
# outlives its container's netns) and the bridge goes with them; a
# restart rebuilds the whole wiring from the NIO
print(".. stopping and restarting container 1")
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/stop")
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
assert harness.bridge_members(bridge) in (None, [a2]), harness.bridge_members(bridge)
compute.call("POST", f"/projects/{pid}/nodes/{n1_id}/start")
harness.docker_wait_address(daemon, c1, R1_IP)
assert harness.tap_exists(a1)
assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15)
assert harness.docker_wait_ping(daemon, c1, R2_IP, attempts=5)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
# deleting the project closed the containers: veths and bridges go
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge
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
compute cannot serve (bpf without cbpf, the eBPF modes without ebpf) are
skipped rather than failed — the capability chain is ``available_filters``'
contract, and a compute reporting nothing keeps the full list."""
server = harness.live_server(kernel=True)
compute = server.compute
caps = (compute.capabilities() or {}).get("ubridge_tc") or {}
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-filter-e2e")
pid = project["project_id"]
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
c1 = n1["container_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
lid = link["link_id"]
assert link["kernel_datapath"] is True, link
_start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP))
baseline = harness.docker_wait_ping(daemon, c1, R2_IP)
assert baseline["loss"] == 0, baseline["raw"]
# The kernel link offers the kernel-only types the compute's uBridge
# reports it can run (all of them on this uBridge).
offered = _filter_types(compute, pid, lid)
assert {"delay", "packet_loss", "corrupt", "duplicate", "bpf"} <= offered, offered
if not {"rate", "reorder", "gemodel", "seed", "limit"} & offered:
pytest.skip(f"this compute's uBridge reports no netem extensions: {sorted(offered)}")
# -- netem core ----------------------------------------------------
print(".. delay 100")
_put_filters(compute, pid, lid, {"delay": [100]})
assert "netem" in harness.qdiscs(a1) and "netem" in harness.qdiscs(a2), (
harness.qdiscs(a1),
harness.qdiscs(a2),
)
delayed = harness.docker_ping(daemon, c1, R2_IP)
print(f".. {delayed['avg']} ms RTT")
assert delayed["loss"] == 0 and delayed["avg"] >= 150, (baseline, delayed)
print(".. delay 50 jitter 20 distribution normal")
_put_filters(compute, pid, lid, {"delay": [50, 20, "normal"]})
# the jitter parameter lands verbatim...; the sampled RTT mean is a
# random variable (per-direction ~N(50, 20)), so only the elevation
# is asserted, with margin — 10 packets put the sample mean's sigma
# around 9 ms
assert re.search(r"delay 50ms\s+20ms", harness.qdiscs(a1)), harness.qdiscs(a1)
jittered = harness.docker_ping(daemon, c1, R2_IP, count=10)
print(f".. {jittered['avg']} ms RTT")
assert jittered["loss"] == 0 and jittered["avg"] >= 65, jittered
_clear_filters(compute, pid, lid, a1, a2)
print(".. packet_loss 30")
_put_filters(compute, pid, lid, {"packet_loss": [30]})
lossy = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {lossy['loss']} % round-trip loss")
# round trip = 1 - 0.7^2 ~ 51 %; the stochastic draw must land
# somewhere between "clearly lossy" and "not a black hole"
assert 20 <= lossy["loss"] <= 80, lossy["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. corrupt 30")
_put_filters(compute, pid, lid, {"corrupt": [30]})
assert "corrupt 30%" in harness.qdiscs(a1), harness.qdiscs(a1)
# a corrupted frame fails its checksum and is dropped: the same
# round-trip arithmetic as packet_loss (1 - 0.7^2)
corrupt = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {corrupt['loss']} % round-trip loss")
assert 20 <= corrupt["loss"] <= 80, corrupt["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. duplicate 50")
_put_filters(compute, pid, lid, {"duplicate": [50]})
assert "dup" in harness.qdiscs(a1), harness.qdiscs(a1)
# duplicated frames are absorbed by the stack: no loss, traffic flows
dups = harness.docker_ping(daemon, c1, R2_IP, count=5)
assert dups["loss"] == 0, dups["raw"]
_clear_filters(compute, pid, lid, a1, a2)
# -- netem extensions (kernel-only) --------------------------------
print(".. delay 50 + reorder 25 correl 50 gap 5")
_put_filters(compute, pid, lid, {"delay": [50], "reorder": [25, 50, 5]})
assert "reorder" in harness.qdiscs(a1), harness.qdiscs(a1)
reordered = harness.docker_ping(daemon, c1, R2_IP, count=10)
assert reordered["loss"] <= 60, reordered["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. delay 50 + seed 42 + limit 5000")
_put_filters(compute, pid, lid, {"delay": [50], "seed": [42], "limit": [5000]})
assert "limit 5000" in harness.qdiscs(a1), harness.qdiscs(a1)
seeded = harness.docker_ping(daemon, c1, R2_IP)
assert seeded["loss"] == 0 and seeded["avg"] >= 80, seeded
_clear_filters(compute, pid, lid, a1, a2)
print(".. gemodel 100/0/30 (steady-state bad, 30 % loss)")
_put_filters(compute, pid, lid, {"gemodel": [100, 0, 30]})
assert "gemodel" in harness.qdiscs(a1), harness.qdiscs(a1)
gemodel = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {gemodel['loss']} % round-trip loss")
assert 20 <= gemodel["loss"] <= 90, gemodel["raw"]
_clear_filters(compute, pid, lid, a1, a2)
print(".. rate 512kbit (1400-byte pings)")
_put_filters(compute, pid, lid, {"rate": ["512kbit"]})
assert "rate" in harness.qdiscs(a1).lower(), harness.qdiscs(a1)
# 1400+28 bytes at 512 kbit ≈ 22 ms serialization per direction
rated = harness.docker_ping(daemon, c1, R2_IP, size=1400)
print(f".. {rated['avg']} ms RTT")
assert rated["loss"] == 0 and rated["avg"] >= 30, (baseline, rated)
_clear_filters(compute, pid, lid, a1, a2)
# -- cls_bpf match-drop --------------------------------------------
if caps.get("cbpf"):
print(".. bpf 'icmp' drops everything")
_put_filters(compute, pid, lid, {"bpf": ["icmp"]})
assert "clsact" in harness.qdiscs(a1), harness.qdiscs(a1)
dropped = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert dropped["loss"] == 100, dropped["raw"]
print(".. bpf 'greater 150' discriminates by frame size")
_put_filters(compute, pid, lid, {"bpf": ["greater 150"]})
small = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert small["loss"] == 0, small["raw"]
big = harness.docker_ping(daemon, c1, R2_IP, count=4, size=300)
assert big["loss"] == 100, big["raw"]
_clear_filters(compute, pid, lid, a1, a2)
else:
print(".. uBridge reports no cbpf: skipping the bpf filter checks")
# -- eBPF stateful classifier --------------------------------------
modes = caps.get("ebpf_modes") or []
if "nth" in modes:
print(".. frequency_drop 3 (every 3rd packet per direction)")
_put_filters(compute, pid, lid, {"frequency_drop": [3]})
nth = harness.docker_ping(daemon, c1, R2_IP, count=20)
print(f".. {nth['loss']} % round-trip loss")
# kernel counts per direction: 1 - (2/3)^2 = 55.6 % round trip
assert 30 <= nth["loss"] <= 80, nth["raw"]
_put_filters(compute, pid, lid, {"frequency_drop": [-1]})
all_dropped = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert all_dropped["loss"] == 100, all_dropped["raw"]
_clear_filters(compute, pid, lid, a1, a2)
if "quota" in modes:
print(".. quota 3000 bytes hard cutoff")
_put_filters(compute, pid, lid, {"quota": [3000, 100]})
# 1400-byte pings: the per-direction byte caps run out after
# ~2 packets, then nothing passes until the quota is reset
cut = harness.docker_ping(daemon, c1, R2_IP, count=5, size=1400)
assert cut["loss"] >= 40, cut["raw"]
after = harness.docker_ping(daemon, c1, R2_IP, count=4)
assert after["loss"] == 100, after["raw"]
_clear_filters(compute, pid, lid, a1, a2)
assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0
if "window" in modes:
print(".. window_drop [1000, 2000, 100] — one outage with passes on both sides")
_put_filters(compute, pid, lid, {"window_drop": [1000, 2000, 100]})
# 25 pings at 5/s: seqs before t=1 s and after t=3 s answer, the
# middle is gone — per-sequence evidence of a bounded outage
code, output = daemon.exec(c1, ["ping", "-c", "25", "-i", "0.2", "-W", "1", R2_IP], timeout=30)
replied = {int(m.group(1)) for m in re.finditer(r"seq=(\d+)", output)}
missing = set(range(25)) - replied
assert 0 in replied and 24 in replied, (sorted(replied), output)
assert missing and max(missing) - min(missing) >= 5, (sorted(replied), output)
_clear_filters(compute, pid, lid, a1, a2)
assert harness.docker_ping(daemon, c1, R2_IP, count=4)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
def test_docker_relay_control():
"""Negative control: the same topology on an isolated relay-configured
instance wires nothing into the kernel — no per-link bridge, no
enslavement — and still pings over the uBridge UDP relay, its filters,
capture and markers all riding the relay engine."""
server = harness.live_server(kernel=False)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-relay-e2e")
pid = project["project_id"]
a1 = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
_start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP))
# No kernel wiring: no per-link bridge, the veth host end is just a
# relay endpoint (up, pure L2 — the hardening is a creation-time
# property, not a datapath one)
assert harness.bridge_members(harness.link_bridge_name(link["link_id"])) is None
if harness.tap_exists(a1):
assert harness.tap_up(a1), a1
if harness.l2only_supported():
harness.assert_pure_l2(a1)
relay_ping = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
assert relay_ping["loss"] == 0, relay_ping["raw"]
# available_filters hides the kernel-only types on the relay
offered = _filter_types(compute, pid, link["link_id"])
assert "delay" in offered and "frequency_drop" in offered, offered
assert not (offered & {"rate", "reorder", "gemodel", "duplicate", "seed", "limit", "quota", "window_drop"}), (
offered
)
# The relay's filters are real uBridge userspace filters walked by
# the bridge's two listener threads: both directions of the wire
# cross them. delay 100 creates one delay line per direction, so the
# RTT grows by ~200 ms.
print(".. relay filters: delay 100 ms (one delay line per direction, RTT +~200 ms)")
_put_filters(compute, pid, link["link_id"], {"delay": [100]})
harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
delayed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)
assert delayed["loss"] == 0, delayed["raw"]
assert delayed["avg"] >= 150, (relay_ping, delayed)
_put_filters(compute, pid, link["link_id"], {})
fast = harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)
assert fast["loss"] == 0 and fast["avg"] < 50, (relay_ping, fast)
# frequency_drop counts every packet crossing the bridge, both
# directions sharing one counter, so on an alternating ping stream
# the same direction dies on every crossing (probed against uBridge
# directly: replies drop, round trips die wholesale). -1 is the
# drop-everything encoding the suspend emulation uses.
print(".. relay filters: frequency_drop 2 (shared counter, one direction dies)")
_put_filters(compute, pid, link["link_id"], {"frequency_drop": [2]})
dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=10)
print(f".. {dropped['loss']} % round-trip loss")
assert dropped["loss"] >= 50, dropped["raw"]
_put_filters(compute, pid, link["link_id"], {"frequency_drop": [-1]})
blackholed = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert blackholed["loss"] == 100, blackholed["raw"]
# bpf on the relay is uBridge's libpcap userspace match-drop — a
# different engine from the kernel datapath's cls_bpf
print(".. relay filters: bpf 'icmp' drops every ICMP frame")
_put_filters(compute, pid, link["link_id"], {"bpf": ["icmp"]})
icmp_dropped = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert icmp_dropped["loss"] == 100, icmp_dropped["raw"]
print(".. relay filters: bpf 'greater 150' discriminates by frame size")
_put_filters(compute, pid, link["link_id"], {"bpf": ["greater 150"]})
small = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4)
assert small["loss"] == 0, small["raw"]
big = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=4, size=300)
assert big["loss"] == 100, big["raw"]
_put_filters(compute, pid, link["link_id"], {})
assert harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)["loss"] == 0
# capture on the relay: the same REST call as the kernel datapath's,
# hosted by the relay node's uBridge bridge (bridge start_capture)
# instead of AF_PACKET on an anchor
print(".. relay capture: bridge start_capture writes the ICMP exchange")
capture = compute.call(
"POST", f"/projects/{pid}/links/{link['link_id']}/capture/start", {"data_link_type": "DLT_EN10MB"}
)
harness.docker_ping(daemon, n1["container_id"], R2_IP)
time.sleep(1)
compute.call("POST", f"/projects/{pid}/links/{link['link_id']}/capture/stop")
path = capture["capture_file_path"]
count, ethertypes = harness.pcap_records(path)
assert count >= 4, f"{path}: {count} records"
assert "0800" in ethertypes, (path, ethertypes)
# markers on the relay are uBridge's `mark` packet filter on the
# relay bridge (bridge add_packet_filter ... mark), signalled over
# the same dedicated marker WS — the relay engine's observability
# path (the kernel datapath's marker add_kernel is the other one)
print(".. relay marker: 5 ICMP echoes through a mark filter on the relay bridge")
marker_ws = harness.WebSocketCollector(server, f"/projects/{pid}/notifications/markers/ws")
try:
m = compute.call(
"POST",
f"/projects/{pid}/links/{link['link_id']}/markers",
{"name": "m-icmp", "bpf": "icmp", "tag": 4242, "capture_node_id": n1_id},
)
assert m["capture_node_id"] == n1_id and m["enabled"] is True, m
result = harness.docker_ping(daemon, n1["container_id"], R2_IP, count=5)
assert result["loss"] == 0, result["raw"]
harness.wait_until(lambda: len(_matches(marker_ws, "m-icmp")) >= 10, timeout=10)
icmp = _matches(marker_ws, "m-icmp")
assert len(icmp) == 10, len(icmp) # 5 requests + 5 replies
assert {e["dir"] for e in icmp} == {"tx", "rx"}, [e["dir"] for e in icmp]
for event in icmp:
assert event["node_id"] == n1_id, event
assert event["link_id"] == link["link_id"], event
assert event["tag"] == 4242, event
assert event["len"] == 98, event # busybox ping frame
# the marker's pcap on the host holds exactly the matched frames
markers_dir = os.path.join(compute.call("GET", f"/projects/{pid}")["path"], "project-files", "markers")
pcap = os.path.join(markers_dir, f"{n1_id}_{link['link_id']}_m-icmp.pcap")
assert os.path.exists(pcap), os.listdir(markers_dir)
count, ethertypes = harness.pcap_records(pcap)
assert count == 10, count
assert set(ethertypes) == {"0800"}, ethertypes
# deleting the marker removes its pcap with it
compute.call("DELETE", f"/projects/{pid}/links/{link['link_id']}/markers/m-icmp")
assert harness.wait_until(lambda: not os.path.exists(pcap), timeout=10), pcap
finally:
marker_ws.close()
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1), timeout=15), a1
def test_docker_relay_to_kernel_reopen_upgrade():
"""The server's datapath choice is a live decision: with the project
created on a relay instance, flipping ``enable_kernel_datapath`` across
a server restart and reopening the project rebuilds the link on the
kernel datapath (the upgrade path a deployment takes) and it forwards."""
if os.environ.get("GNS3_E2E_URL"):
pytest.skip("this scenario restarts the server — it needs an isolated instance")
server = harness.live_server(kernel=False)
compute = server.compute
image = harness.ensure_docker_image(server)
project = compute.create_project("docker-upgrade-e2e")
pid = project["project_id"]
a1 = a2 = bridge = None
try:
n1, n2, daemon = _create_topology(server, pid, image, "E2E")
n1_id, n2_id = n1["node_id"], n2["node_id"]
a1 = harness.docker_anchor_name(n1_id, *ETH)
a2 = harness.docker_anchor_name(n2_id, *ETH)
link = compute.create_link(pid, (n1_id, *ETH), (n2_id, *ETH))
assert link["kernel_datapath"] is False, link
bridge = harness.link_bridge_name(link["link_id"])
_start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP))
assert harness.bridge_members(bridge) is None
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP)["loss"] == 0
# Flip the datapath choice and reopen the project: every link
# re-runs _prepare, which re-evaluates eligibility live.
print(".. restarting the server with enable_kernel_datapath=True")
server.restart(kernel=True)
compute = server.compute
compute.call("POST", f"/projects/{pid}/open")
n1 = _resolve_container(daemon, compute.node(pid, n1_id))
n2 = _resolve_container(daemon, compute.node(pid, n2_id))
link = compute.call("GET", f"/projects/{pid}/links/{link['link_id']}")
assert link["kernel_datapath"] is True, link
assert not harness.tap_exists(a1), "containers are stopped after the reopen"
_start_addresses(compute, pid, daemon, (n1, n2), (R1_IP, R2_IP))
assert harness.wait_until(lambda: harness.bridge_members(bridge) == sorted([a1, a2]), timeout=15)
assert harness.docker_wait_ping(daemon, n1["container_id"], R2_IP, attempts=5)["loss"] == 0
except BaseException:
harness.release(server, pid, failed=True)
raise
harness.release(server, pid, failed=False)
assert harness.wait_until(lambda: not harness.tap_exists(a1) and not harness.tap_exists(a2), timeout=15), (
a1,
a2,
)
assert harness.wait_until(lambda: harness.bridge_members(bridge) is None, timeout=10), bridge