gns3-server/tests/controller/test_kernel_datapath_link.py
YueGuobin 21b866aeed
feat: cross-compute VXLAN kernel datapath
Cross-compute links can ride kernel VXLAN tunnels instead of the uBridge
UDP relay: each endpoint creates one VXLAN device (uBridge's rtnetlink
vxlan module) enslaved into the same per-link Linux bridge the same-host
datapath uses, so filters, markers, capture and suspend keep running on
the anchor unchanged. Opt-in (Server.enable_vxlan_cross_compute, default
off) because the tunnel caps the usable MTU at the underlay minus the
outer headers; the relay stays the default and the fallback.

The controller derives what the pair must agree on: one VNI per link
(upper VNI space, per-link random salt) and deterministic device names
(gvl{link}{node}) that make crashed-run leftovers claimable
(show -> delete -> create). Same-host compute pairs, switch endpoints
and serial/unix-socket containers stay on the relay.

A VNI the kernel refuses self-heals on every path it can surface:
interactive create retries a fresh salt then degrades to the relay;
project open re-dispatches refused batch entries the same way; node
start recognises the refused device, stops the half-started node,
re-wires both ends on a fresh VNI (or degrades the link) and retries
the start. The device MTU is derived from the lower device (dev=) and
the per-link bridge inherits it - no configuration. The capability
probe (ubridge_vxlan in /capabilities) runs one scratch create/delete
on a scratch port that never equals the configured dstport.

The e2e scenario runs two computes on one machine (a network namespace
joined by a veth pair as the fabric) and needs root.
2026-10-17 14:11:00 +08:00

1527 lines
57 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/>.
#
"""
Controller-side kernel-datapath link selection: eligibility of Docker-to-
Docker links, NIO emission and the filters/markers guards.
"""
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from gns3server.config import Config
from gns3server.controller.compute import ComputeError
from gns3server.controller.controller_error import ComputeConflictError, ControllerError
from gns3server.controller.link import FILTERS
from gns3server.controller.node import Node
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.ports.serial_port import SerialPort
from gns3server.controller.udp_link import UDPLink
from gns3server.utils.kernel_anchor import kernel_anchor_name, kernel_cascade_names
from gns3server.utils.vxlan import vxlan_device_name
from tests.utils import AsyncioMagicMock
def _node(project, compute, name, node_type="docker", status="stopped", environment=None):
node = Node(project, compute, name, node_type=node_type)
node._ports = [EthernetPort("eth0", 0, 0, 0)]
node._status = status
if environment is not None:
node._properties = {"environment": environment}
return node
async def _kernel_link(project, **kwargs):
compute = MagicMock()
compute.id = "compute-1"
node1 = _node(project, compute, "docker1", **kwargs)
node2 = _node(project, compute, "docker2", **kwargs)
link = UDPLink(project)
# batch=True: skip the interactive create() (the test drives _prepare()
# itself) — matches the project-open bulk path.
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link, node1, node2
def _mock_compute_http(compute):
"""Wire the async compute HTTP callbacks the relay paths need: POST
(port reservation, NIO creation) and PUT (filter updates — without an
awaitable put, a relay filter update blows up on the sync MagicMock,
which is what made this file fail when run alone: some other module's
fixtures happened to mask it in a full run)."""
async def subnet_callback(other):
return ("192.168.1.1", "192.168.1.2")
compute.get_ip_on_same_subnet.side_effect = subnet_callback
async def post_callback(path, data=None, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1024}
return response
compute.post.side_effect = post_callback
async def put_callback(path, data=None, **kwargs):
response = MagicMock()
response.json = {}
return response
compute.put.side_effect = put_callback
async def _relay_link(project):
"""A docker↔qemu link (mixed node types) — never kernel-eligible, so the
relay _prepare path runs (UDP ports reserved through the mocked compute)."""
compute = MagicMock()
compute.id = "compute-1"
_mock_compute_http(compute)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "qemu1", node_type="qemu")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link, node1, node2
@pytest.mark.asyncio
async def test_kernel_datapath_eligible(project):
link, node1, node2 = await _kernel_link(project)
assert link._kernel_datapath_eligible(node1, node2) is True
def _tap_capable_compute(tap_support=True, compute_id="compute-1"):
compute = MagicMock()
compute.id = compute_id
compute.capabilities = {"ubridge_tap": tap_support}
return compute
async def _link(project, node1, node2):
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_other_node_types(project):
"""
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)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "qemu1", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
compute = _tap_capable_compute()
node1 = _node(project, compute, "vpcs1", node_type="vpcs")
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_iou_with_iol_tap_support(project):
"""
IOU's Ethernet bays anchor on persistent TAPs bound to the IOL fabric
(iol_bridge add_nio_tap) — its own capability, asked per compute like
QEMU's, and mixable with docker/qemu endpoints on the same compute.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "iou1", node_type="iou")
node2 = _node(project, compute, "iou2", node_type="iou")
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")):
link = await _link(project, node1, peer)
assert link._kernel_datapath_eligible(node1, peer) is True
@pytest.mark.asyncio
async def test_iou_eligibility_is_its_own_capability(project):
"""
The tap module and the IOL-port TAP command land independently: a
compute reporting only one of them keeps IOU on the relay (an old
uBridge without add_nio_tap), and an unreported/failed probe does too.
"""
compute = _tap_capable_compute() # ubridge_tap only, no ubridge_iol_tap
node1 = _node(project, compute, "iou1", node_type="iou")
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": None}
node1 = _node(project, compute, "iou1", node_type="iou")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_dynamips_with_tap_support(project):
"""
Dynamips Ethernet slot ports anchor on persistent TAPs the hypervisor
opens (nio create_tap on a uBridge-created device), so they gate on the
tap module like QEMU — no Dynamips-specific capability — and mix with
docker/qemu/iou endpoints on the same compute.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "r1", node_type="dynamips")
node2 = _node(project, compute, "r2", node_type="dynamips")
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"),
):
link = await _link(project, node1, peer)
assert link._kernel_datapath_eligible(node1, peer) is True
@pytest.mark.asyncio
async def test_dynamips_eligibility_follows_the_tap_capability(project):
"""
A compute whose uBridge cannot create persistent TAPs keeps Dynamips on
the relay (the hypervisor would have nothing to open), and so does an
unreported/failed probe.
"""
node2 = _node(project, _tap_capable_compute(), "docker1")
for tap_support in (None, False):
compute = _tap_capable_compute(tap_support=tap_support)
node1 = _node(project, compute, "r1", node_type="dynamips")
link = await _link(project, node1, node2)
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):
"""
A Dynamips serial port (NM-4T, WIC-2T, ...) stays on the relay whatever
the compute's capabilities — the same per-port exclusion as IOU serial,
decided by the controller's port matrix.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True}
node1 = _node(project, compute, "r1", node_type="dynamips")
node1._ports = [SerialPort("Serial0/0", 0, 0, 0)]
node2 = _node(project, compute, "r2", node_type="dynamips")
node2._ports = [SerialPort("Serial0/0", 0, 0, 0)]
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_serial_ports(project):
"""
A kernel link is an Ethernet segment: an IOU serial port stays on the
relay whatever the compute's capabilities — dispatching it to the
kernel path would fail at the compute (no anchor on a serial bay).
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
node1 = _node(project, compute, "iou1", node_type="iou")
node1._ports = [SerialPort("Serial0/0", 0, 0, 0)]
node2 = _node(project, compute, "iou2", node_type="iou")
node2._ports = [SerialPort("Serial0/0", 0, 0, 0)]
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_qemu_with_tap_support(project):
"""
QEMU adapters anchor on a persistent TAP, so a compute whose uBridge has
the tap module gets kernel links too — including the mixed docker-to-qemu
case (both anchors end up in the same per-link kernel bridge).
"""
compute = _tap_capable_compute()
node1 = _node(project, compute, "qemu1", node_type="qemu")
node2 = _node(project, compute, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
node2 = _node(project, compute, "docker1")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_capability_is_asked_per_compute(project):
"""
A QEMU node on a compute without the tap module keeps the link on the
relay even when its peer sits on a capable compute — the link needs both
endpoints anchored, so the capability is a two-end AND.
"""
capable = _tap_capable_compute(compute_id="compute-1")
plain = _tap_capable_compute(tap_support=None, compute_id="compute-2")
node1 = _node(project, capable, "qemu1", node_type="qemu")
node2 = _node(project, plain, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
# same compute, but the compute cannot anchor: still the relay
node2 = _node(project, _tap_capable_compute(tap_support=None), "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_prepare_qemu_kernel_link_emits_bridge_nios(project):
"""
The prepared link data is the same NIOBridge spec Docker links use — no
UDP ports, no peer resolution — so nothing else in the controller has to
know which node type the endpoint is.
"""
compute = _tap_capable_compute()
node1 = _node(project, compute, "qemu1", node_type="qemu")
node2 = _node(project, compute, "qemu2", node_type="qemu")
link = await _link(project, node1, node2)
await link._prepare()
assert link.kernel_datapath is True
assert [d["type"] for d in link._link_data] == ["nio_bridge", "nio_bridge"]
assert link._link_data[0]["bridge"] == link._link_data[1]["bridge"]
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_across_computes(project):
compute1 = MagicMock()
compute1.id = "compute-1"
compute2 = MagicMock()
compute2.id = "compute-2"
node1 = _node(project, compute1, "docker1")
node2 = _node(project, compute2, "docker2")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
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], "packet_loss": [5], "corrupt": [1]}
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_with_frequency_drop(project):
"""
frequency_drop runs on the kernel datapath too (the eBPF classifier's
exact every-Nth mode) — no filter type forces the relay anymore; only
topology does (mixed node types, cross-compute, unix-socket
containers, config off).
"""
link, node1, node2 = await _kernel_link(project)
link._filters = {"delay": [10, 0], "frequency_drop": [7], "quota": [1000000, 100]}
assert link._kernel_datapath_eligible(node1, node2) is True
@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):
"""
Markers no longer disqualify the kernel datapath: they attach to the veth
host end via uBridge's AF_PACKET marker module (marker add_kernel).
"""
link, node1, node2 = await _kernel_link(project)
link._markers = {"m": {"bpf": "icmp"}}
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_with_project_marker_definitions(project):
"""
Project-level marker definitions are inherited by every new link and ride
the kernel datapath like private markers.
"""
link, node1, node2 = await _kernel_link(project)
project._marker_definitions = {"global-m": {"bpf": "icmp"}}
try:
assert link._kernel_datapath_eligible(node1, node2) is True
finally:
project._marker_definitions = {}
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_for_running_nodes(project):
"""
Docker adapters are born as veths (unified interface), so links attach
to running containers too — no stopped-node requirement.
"""
link, node1, node2 = await _kernel_link(project)
node1._status = "started"
node2._status = "started"
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_not_eligible_for_unix_socket_containers(project):
link, node1, node2 = await _kernel_link(project)
node2._properties = {"environment": "GNS3_UNIX_SOCKET_NIO=1"}
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_when_unix_socket_marker_is_falsy(project):
"""
GNS3_UNIX_SOCKET_NIO=0 (like false/no) leaves the container on the
standard veth wiring — the compute parses the value, so the controller
must not exclude it with a substring match, or the link silently stays
on the relay forever.
"""
link, node1, node2 = await _kernel_link(project)
node2._properties = {"environment": "GNS3_UNIX_SOCKET_NIO=0"}
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_when_the_marker_is_mid_line(project):
"""
A mid-line mention (NOTE=GNS3_UNIX_SOCKET_NIO=1) is not the marker:
the compute's line-based parsing does not select VendorDockerVM for it,
so the controller must not exclude the node either.
"""
link, node1, node2 = await _kernel_link(project)
node2._properties = {"environment": "NOTE=GNS3_UNIX_SOCKET_NIO=1"}
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_iol_runner_containers_gate_on_the_bridge_tap_capabilities(project):
"""
The IOL runner marker selects a container whose guest leg is unix
sockets: it can still anchor kernel links (persistent TAPs through the
bridge module's swappable TAP leg), but only when its compute reports
both ubridge_bridge_tap and ubridge_tap — an unreported capability (old
uBridge, failed probe) keeps the link on the relay, where it always
works. This is also the regression guard for the compute that cannot
serve the anchor lifecycle being handed a kernel link anyway.
"""
def iol_capable_compute(bridge_tap, tap):
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_bridge_tap": bridge_tap, "ubridge_tap": tap}
return compute
for environment in ("GNS3_IOL_RUNNER=1", "GNS3_IOL_RUNNER=1\nGNS3_IOL_STARTUP_CONFIG=cfg.txt"):
compute = iol_capable_compute(True, True)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "iol1", environment=environment)
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is True, environment
for bridge_tap, tap in ((None, True), (True, None), (False, True), (True, False)):
compute = iol_capable_compute(bridge_tap, tap)
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "iol1", environment=environment)
link = await _link(project, node1, node2)
assert link._kernel_datapath_eligible(node1, node2) is False, (environment, bridge_tap, tap)
@pytest.mark.asyncio
async def test_iol_runner_container_on_a_switch_gates_and_names_the_iol_docker_anchor(project):
"""
On the switch fast path an IOL runner peer gates on the same capability
pair — and when it passes, the switch absorbs an anchor named under the
``iol_docker`` key (a persistent TAP, not the ``gv`` veth namespace).
Without the capabilities the link must stay on the relay: the switch
defers a join against a missing anchor by design (a link to a stopped
node must not fail), so a gate miss here is a silently dead cable, not
a late error.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_bridge_tap": True, "ubridge_tap": True}
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "iol1", environment="GNS3_IOL_RUNNER=1")
link = await _link(project, switch, peer)
assert link._kernel_datapath_eligible(switch, peer) is True
entries = await link._prepare()
switch_nio = entries[0][3]
assert switch_nio["type"] == "nio_anchor"
assert switch_nio["anchor"] == kernel_anchor_name("iol_docker", peer.id, 0, 0)
assert switch_nio["anchor"].startswith("gx")
# a compute that cannot serve the swappable leg keeps the link relay
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "iol1", environment="GNS3_IOL_RUNNER=1")
link = await _link(project, switch, peer)
assert link._kernel_datapath_eligible(switch, peer) is False
@pytest.mark.asyncio
async def test_kernel_datapath_disabled_by_config(project):
settings = Config.instance().settings.Server
settings.enable_kernel_datapath = False
try:
link, node1, node2 = await _kernel_link(project)
assert link._kernel_datapath_eligible(node1, node2) is False
finally:
settings.enable_kernel_datapath = True
@pytest.mark.asyncio
async def test_prepare_kernel_link_emits_bridge_nios(project):
compute = MagicMock()
compute.id = "compute-1"
node1 = _node(project, compute, "docker1")
node2 = _node(project, compute, "docker2")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
entries = await link._prepare()
assert len(entries) == 2
expected_bridge = "gns3" + link.id.replace("-", "")[:11]
assert len(expected_bridge) == 15
for entry in entries:
assert entry[3]["type"] == "nio_bridge"
assert entry[3]["bridge"] == expected_bridge
# no UDP port reservation on the kernel datapath
compute.post.assert_not_called()
@pytest.mark.asyncio
async def test_prepare_relay_link_allocates_udp_ports(project):
compute1 = MagicMock()
compute1.id = "compute-1"
compute2 = MagicMock()
compute2.id = "compute-2"
async def subnet_callback(other):
return ("192.168.1.1", "192.168.1.2")
compute1.get_ip_on_same_subnet.side_effect = subnet_callback
async def port_callback(path, data=None, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1024}
return response
compute1.post.side_effect = port_callback
compute2.post.side_effect = port_callback
node1 = _node(project, compute1, "docker1")
node2 = _node(project, compute2, "docker2")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
entries = await link._prepare()
for entry in entries:
assert entry[3]["type"] == "nio_udp"
@pytest.mark.asyncio
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_frequency_drop_accepted_on_kernel_link(project):
"""
frequency_drop rides the kernel NIO to both endpoints like every other
filter type — the compute translates it to the eBPF every-Nth mode.
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
await link.update_filters({"frequency_drop": [10]})
assert link.filters == {"frequency_drop": [10]}
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == {"frequency_drop": [10]}
assert by_node[node2.id]["filters"] == {"frequency_drop": [10]}
@pytest.mark.asyncio
async def test_update_bpf_filters_accepted_on_kernel_link(project):
"""
bpf expressions ride the kernel NIO to BOTH endpoints (each end's
cls_bpf classifiers cover the traffic entering that container — the
same both-directions-once net effect as the relay's single filtered
bridge).
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
await link.update_filters({"bpf": ["icmp\nudp port 53"]})
assert link.filters == {"bpf": ["icmp\nudp port 53"]}
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == {"bpf": ["icmp\nudp port 53"]}
assert by_node[node2.id]["filters"] == {"bpf": ["icmp\nudp port 53"]}
@pytest.mark.asyncio
async def test_prepare_kernel_link_carries_markers(project):
"""
Markers on a kernel link ride the capture node's NIO (routed by
capture_node_id) exactly like on the relay datapath.
"""
link, node1, node2 = await _kernel_link(project)
link._markers = {"icmp": {"bpf": "icmp", "tag": 1, "capture_node_id": node1.id, "direction": None}}
entries = await link._prepare()
by_node = {entry[0].id: entry[3] for entry in entries}
assert by_node[node1.id]["type"] == "nio_bridge"
assert by_node[node1.id]["markers"]["icmp"]["bpf"] == "icmp"
assert by_node[node1.id]["markers"]["icmp"]["tag"] == 1
assert by_node[node2.id]["markers"] == {}
@pytest.mark.asyncio
async def test_start_marker_on_kernel_link_pushes_nio(project):
"""
start_marker works on kernel links: the marker is stored and pushed via
the NIO update like on the relay datapath (no kernel guard anymore).
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
link._created = True
bodies = []
async def put1(path, data=None, **kwargs):
bodies.append((node1, data))
async def put2(path, data=None, **kwargs):
bodies.append((node2, data))
node1.put = put1
node2.put = put2
with patch("gns3server.controller.udp_link.validate_bpf_syntax", return_value={"valid": True, "error": None}):
await link.start_marker("icmp", "icmp", tag=3)
entry = link._markers["icmp"]
assert entry["bpf"] == "icmp"
assert entry["capture_node_id"] == node1.id # auto-picked: first marker-capable node
n1_data = next(data for node, data in bodies if node is node1)
n2_data = next(data for node, data in bodies if node is node2)
assert n1_data["type"] == "nio_bridge"
assert n1_data["filters"] == {}
assert n1_data["markers"]["icmp"]["tag"] == 3
assert n2_data["markers"] == {}
@pytest.mark.asyncio
async def test_kernel_datapath_property(project):
link, _n1, _n2 = await _kernel_link(project)
assert link.kernel_datapath is False
await link._prepare()
assert link.kernel_datapath is True
@pytest.mark.asyncio
async def test_update_suspend_kernel_link_sends_no_synthetic_filter(project):
"""
Suspend is emulated on the relay datapath via a synthetic frequency_drop
filter (get_active_filters); kernel links implement it natively via
interface carrier and must push empty filters — the compute-side guard
rejects non-empty filters on kernel NIOs.
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
bodies = []
async def capture_put(path, data=None, **kwargs):
bodies.append(data)
node1.put = capture_put
node2.put = capture_put
await link.update_suspend(True)
assert link._suspended is True
assert len(bodies) == 2
for body in bodies:
assert body["filters"] == {}
assert body["suspend"] is True
# ---------------------------------------------------------------------------
# P6a: netem-extension filters (rate / reorder / gemodel / duplicate / seed /
# limit / distribution / correlation)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_kernel_datapath_eligible_with_netem_extension_filters(project):
"""
All netem-extension types have a tc netem equivalent — they keep the
link kernel-eligible (kernel/relay is a purely topological choice).
"""
link, node1, node2 = await _kernel_link(project)
link._filters = {
"delay": [100, 20, "normal"],
"packet_loss": [5, 25],
"duplicate": [10],
"reorder": [25, 0, 5],
"rate": ["512kbit"],
"gemodel": [100, 0, 30],
"seed": [42],
"limit": [5000],
"quota": [1000000, 100],
"window_drop": [0, 800, 100, 2400, 200],
}
# gemodel/packet_loss are mutually exclusive — drop packet_loss for the
# eligibility check (validation rejects the mix elsewhere)
link._filters.pop("packet_loss")
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_update_netem_extension_filters_accepted_on_kernel_link(project):
"""
The extension filters ride the kernel NIO to both endpoints like the
base netem surface.
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
filters = {"delay": [100, 20, "normal"], "rate": ["512kbit"], "reorder": [25, 0, 5]}
await link.update_filters(filters)
assert link.filters == filters
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == filters
assert by_node[node2.id]["filters"] == filters
@pytest.mark.asyncio
async def test_update_kernel_only_filters_rejected_on_relay_link(project):
"""
Netem-extension / quota / window_drop filters have no relay equivalent:
setting them on a created relay link is a 409. (The link is relay-wired
because the endpoints are mixed node types.)
"""
link, _n1, _n2 = await _relay_link(project)
await link._prepare()
assert link.kernel_datapath is False
link._created = True # the computes hold the UDP NIOs
with pytest.raises(ControllerError, match="kernel-datapath"):
await link.update_filters({"rate": ["512kbit"]})
with pytest.raises(ControllerError, match="kernel-datapath"):
await link.update_filters({"window_drop": [0, 800, 100, 2400]})
with pytest.raises(ControllerError, match="period must be greater than or equal"):
# cross-parameter rule mirrors the tc window grammar
await link.update_filters({"window_drop": [0, 2000, 100, 1000]})
# the stored filters are untouched
assert link.filters == {}
# frequency_drop still runs on the relay (its uBridge userspace filter)
await link.update_filters({"frequency_drop": [7]})
assert link.filters == {"frequency_drop": [7]}
@pytest.mark.asyncio
async def test_update_window_drop_accepted_on_kernel_link(project):
"""
window_drop rides the kernel NIO to both endpoints — including the
start=0 form (an immediate outage is NOT an "inactive" filter).
"""
link, node1, node2 = await _kernel_link(project)
await link._prepare()
filters = {"window_drop": [0, 800, 100, 2400, 200]}
await link.update_filters(filters)
assert link.filters == filters
by_node = {entry[0].id: entry[3] for entry in (await link._prepare())}
assert by_node[node1.id]["filters"] == filters
assert by_node[node2.id]["filters"] == filters
@pytest.mark.asyncio
async def test_update_kernel_only_filters_accepted_before_creation(project):
"""
While loading a project the datapath is not decided yet (update_filters
runs before _prepare) — a link that will be kernel-wired must accept the
extension filters. Only _prepare decides, dropping what the relay cannot
run.
"""
link, _n1, _n2 = await _kernel_link(project)
await link.update_filters({"rate": ["512kbit"], "reorder": [25, 0, 5], "delay": [100, 10]})
assert link.filters["rate"] == ["512kbit"]
@pytest.mark.asyncio
async def test_prepare_relay_link_strips_kernel_only_filters(project):
"""
A relay-wired link (mixed node types) built from a topology carrying
extension filters drops them with a warning instead of pushing an
unknown filter type to uBridge. frequency_drop stays — the relay's
userspace filter still serves it.
"""
link, node1, node2 = await _relay_link(project)
link._filters = {"frequency_drop": [7], "rate": ["512kbit"], "delay": [100, 20, "normal"], "packet_loss": [5, 25]}
entries = await link._prepare()
assert link.kernel_datapath is False
by_node = {entry[0].id: entry[3] for entry in entries}
assert by_node[node1.id]["type"] == "nio_udp"
# the relay's filter node keeps what it can run; rate, the distribution
# and the loss correlation are kernel-only and stripped (the far side
# carries no filters — the relay filters one bridge)
assert by_node[node1.id]["filters"] == {"frequency_drop": [7], "delay": [100, 20], "packet_loss": [5]}
assert by_node[node2.id]["filters"] == {}
@pytest.mark.asyncio
async def test_available_filters_gated_by_compute_capabilities(project):
"""
Kernel links only offer the filter types the endpoints' computes report
a uBridge able to run (ebpf_modes tokens, cbpf, netem keywords).
"""
link, node1, _node2 = await _kernel_link(project)
link._link_data = [{"type": "nio_bridge"}]
node1.compute.capabilities = {
"ubridge_tc": {
"netem": ["delay", "jitter", "loss", "rate"],
"ebpf": True,
"ebpf_modes": ["nth", "quota"],
"cbpf": False,
}
}
types = [f["type"] for f in link.available_filters()]
assert "frequency_drop" in types # nth declared
assert "quota" in types # quota declared
assert "window_drop" not in types # token missing
assert "bpf" not in types # cbpf off
assert "rate" in types # netem keyword present
assert "gemodel" not in types # netem keyword missing
assert "delay" in types # core netem runs on any tc-module build
@pytest.mark.asyncio
async def test_available_filters_unconstrained_without_report(project):
"""
Computes that report no uBridge tc capabilities (older servers, failed
probe) impose no constraint: the full list stays offered and apply-time
validation is the guard.
"""
link, _node1, _node2 = await _kernel_link(project)
link._link_data = [{"type": "nio_bridge"}]
assert [f["type"] for f in link.available_filters()] == [f["type"] for f in FILTERS]
# ---------------------------------------------------------------------------
# Ethernet switch fast path: the switch absorbs the peer's anchor
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_switch_links_select_the_kernel_fast_path(project):
"""
A link with an Ethernet switch endpoint rides the kernel fast path when
the *peer* can anchor — the switch side needs no anchoring capability
(its bridge exists by construction). A switch peer cascades through a
veth pair (one end per bridge — a kernel interface belongs to exactly
one bridge, so the pair is the only cable shape); anchor-less peers
stay on the relay.
"""
compute = MagicMock()
compute.id = "compute-1"
compute.capabilities = {"ubridge_tap": True, "ubridge_iol_tap": True}
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
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, switch, peer)
assert link._kernel_datapath_eligible(switch, peer) is True, peer.node_type
# a switch peer: cascade — the veth pair joins the two kernel bridges
switch2 = _node(project, compute, "sw2", node_type="ethernet_switch")
link = await _link(project, switch, switch2)
assert link._kernel_datapath_eligible(switch, switch2) is True
# an anchor-less peer
vpcs = _node(project, compute, "pc1", node_type="vpcs")
link = await _link(project, switch, vpcs)
assert link._kernel_datapath_eligible(switch, vpcs) is False
# a peer whose compute cannot anchor stays relay too
compute_no_tap = _tap_capable_compute(tap_support=None)
qemu = _node(project, compute_no_tap, "qemu1", node_type="qemu")
switch3 = _node(project, compute_no_tap, "sw3", node_type="ethernet_switch")
link = await _link(project, switch3, qemu)
assert link._kernel_datapath_eligible(switch3, qemu) is False
@pytest.mark.asyncio
async def test_switch_link_prepare_emits_anchor_and_external_bridge(project):
"""
The fast path's wire format: the switch receives ``nio_anchor`` naming
the peer's anchor (the shared naming contract, computable while the
node is stopped), the peer receives ``nio_bridge`` with no bridge —
its anchor is bridged by the switch. The link's tc impairments are
owned by exactly one end (the switch): both would hit the same
interface.
"""
compute = MagicMock()
compute.id = "compute-1"
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "docker1")
link = await _link(project, switch, peer)
link._filters = {"delay": [10]}
entries = await link._prepare()
switch_nio = entries[0][3]
peer_nio = entries[1][3]
assert switch_nio["type"] == "nio_anchor"
assert switch_nio["anchor"] == kernel_anchor_name("docker", peer.id, 0, 0)
assert switch_nio["filters"] == {"delay": [10]}
assert peer_nio["type"] == "nio_bridge"
assert peer_nio["bridge"] is None
assert peer_nio["filters"] == {}
assert link.kernel_datapath is True
# the reverse endpoint order must keep index alignment
link = await _link(project, peer, switch)
link._filters = {"delay": [10]}
entries = await link._prepare()
assert entries[0][3]["type"] == "nio_bridge"
assert entries[0][3]["bridge"] is None
assert entries[0][3]["filters"] == {}
assert entries[1][3]["type"] == "nio_anchor"
assert entries[1][3]["filters"] == {"delay": [10]}
@pytest.mark.asyncio
async def test_switch_link_repushes_the_switch_nio_on_node_start(project):
"""
The switch cannot observe the peer's anchors coming into existence: the
controller re-pushes the switch's NIO after a node of the link starts,
which completes a deferred join (or re-joins an anchor a peer restart
replaced). Relay links do nothing.
"""
compute = MagicMock()
compute.id = "compute-1"
switch = _node(project, compute, "sw1", node_type="ethernet_switch")
peer = _node(project, compute, "docker1")
link = await _link(project, switch, peer)
entries = await link._prepare()
link._created = True
switch.put = AsyncioMagicMock()
await link.node_started(peer)
switch.put.assert_called_once_with("/adapters/0/ports/0/nio", data=entries[0][3], timeout=120)
# a relay link never re-pushes anything
switch.put.reset_mock()
compute2 = MagicMock()
compute2.id = "compute-1"
relay_switch = _node(project, compute2, "sw2", node_type="ethernet_switch")
vpcs = _node(project, compute2, "pc1", node_type="vpcs")
relay_link = await _link(project, relay_switch, vpcs)
relay_link._created = True
relay_switch.put = AsyncioMagicMock()
await relay_link.node_started(vpcs)
assert not relay_switch.put.called
# ---------------------------------------------------------------------------
# Switch-to-switch cascade: one veth pair joins the two kernel bridges
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prepare_cascade_link_emits_paired_anchor_nios(project):
"""
A link between two Ethernet switches rides the kernel datapath through
one veth pair: each side gets an anchor NIO naming its own end and
carrying the other end's name (both sides create the pair when their
end is missing, so no ordering between the two NIO posts is needed).
Unlike the absorbed-anchor fast path, filters are two-sided — each end
is its own interface, so each direction of the link is impaired exactly
once like any other kernel link.
"""
compute = MagicMock()
compute.id = "compute-1"
sw1 = _node(project, compute, "sw1", node_type="ethernet_switch")
sw2 = _node(project, compute, "sw2", node_type="ethernet_switch")
link = await _link(project, sw1, sw2)
link._filters = {"delay": [10]}
entries = await link._prepare()
end0, end1 = kernel_cascade_names(link.id)
assert entries[0][3] == {
"type": "nio_anchor",
"anchor": end0,
"peer": end1,
"filters": {"delay": [10]},
"markers": {},
"suspend": False,
}
assert entries[1][3] == {
"type": "nio_anchor",
"anchor": end1,
"peer": end0,
"filters": {"delay": [10]},
"markers": {},
"suspend": False,
}
assert link.kernel_datapath is True
@pytest.mark.asyncio
async def test_node_started_repushes_both_cascade_ends(project):
"""
A cascade link has a switch on both ends and each end is its own veth
half: whichever switch starts (recreating its bridge and losing its
half's membership), both ends are re-pushed — each switch's update
re-checks and re-joins its own half.
"""
compute = MagicMock()
compute.id = "compute-1"
sw1 = _node(project, compute, "sw1", node_type="ethernet_switch")
sw2 = _node(project, compute, "sw2", node_type="ethernet_switch")
link = await _link(project, sw1, sw2)
entries = await link._prepare()
link._created = True
sw1.put = AsyncioMagicMock()
sw2.put = AsyncioMagicMock()
await link.node_started(sw1)
sw1.put.assert_called_once_with("/adapters/0/ports/0/nio", data=entries[0][3], timeout=120)
sw2.put.assert_called_once_with("/adapters/0/ports/0/nio", data=entries[1][3], timeout=120)
# ---------------------------------------------------------------------------
# Cross-compute VXLAN links
# ---------------------------------------------------------------------------
@contextmanager
def _cross_compute_enabled():
settings = Config.instance().settings.Server
original = settings.enable_vxlan_cross_compute
settings.enable_vxlan_cross_compute = True
try:
yield settings
finally:
settings.enable_vxlan_cross_compute = original
def _vxlan_compute(compute_id, vxlan=True):
compute = MagicMock()
compute.id = compute_id
compute.capabilities = {"ubridge_vxlan": vxlan}
return compute
async def _cross_compute_link(project, vxlan1=True, vxlan2=True, same_host=False):
"""A docker↔docker link whose endpoints live on two computes, with the
peer resolution (and the relay fallback's port reservation) mocked."""
compute1 = _vxlan_compute("compute-1", vxlan1)
compute2 = _vxlan_compute("compute-2", vxlan2)
async def subnet_callback(other):
return ("192.168.1.1", "192.168.1.1" if same_host else "192.168.1.2")
compute1.get_ip_on_same_subnet.side_effect = subnet_callback
async def post_callback(path, data=None, **kwargs):
response = MagicMock()
response.json = {"udp_port": 1024}
return response
compute1.post.side_effect = post_callback
compute2.post.side_effect = post_callback
node1 = _node(project, compute1, "docker1")
node2 = _node(project, compute2, "docker2")
link = UDPLink(project)
await link.add_node(node1, 0, 0, batch=True, dump=False)
await link.add_node(node2, 0, 0, batch=True, dump=False)
return link, node1, node2
@pytest.mark.asyncio
async def test_vxlan_cross_compute_disabled_by_config(project):
"""
Cross-compute links stay on the relay until the deployment opts in —
the VXLAN tunnel caps the usable MTU at the underlay minus the outer
headers, so it is opt-in.
"""
link, node1, node2 = await _cross_compute_link(project)
assert link._kernel_datapath_eligible(node1, node2) is False
with _cross_compute_enabled():
assert link._kernel_datapath_eligible(node1, node2) is True
@pytest.mark.asyncio
async def test_vxlan_cross_compute_requires_the_vxlan_capability(project):
"""
Both computes' uBridge must be able to create the tunnel device; an
unreported/failed probe (None) or an old build (False) keeps the link
on the relay, exactly like the other capability gates.
"""
with _cross_compute_enabled():
for vxlan1, vxlan2 in ((False, True), (True, False), (True, None), (None, None)):
link, node1, node2 = await _cross_compute_link(project, vxlan1=vxlan1, vxlan2=vxlan2)
assert link._kernel_datapath_eligible(node1, node2) is False
@pytest.mark.asyncio
async def test_vxlan_cross_compute_switch_stays_on_relay(project):
"""
The switch fast path absorbs the peer's anchor into the switch's own
bridge — a one-host shape; a switch link across computes keeps the
relay.
"""
switch = _node(project, _vxlan_compute("compute-1"), "switch1", node_type="ethernet_switch")
peer = _node(project, _vxlan_compute("compute-2"), "docker2")
link = await _link(project, switch, peer)
with _cross_compute_enabled():
assert link._kernel_datapath_eligible(switch, peer) is False
@pytest.mark.asyncio
async def test_prepare_vxlan_link_emits_tunnel_specs(project):
"""
Both ends get one nio_bridge sharing the per-link bridge name, each
carrying the same VNI/dstport (the pair must agree, and neither end can
ask the other) and the peer's address, with per-endpoint deterministic
device names.
"""
with _cross_compute_enabled():
link, node1, node2 = await _cross_compute_link(project)
entries = await link._prepare()
assert len(entries) == 2
expected_bridge = "gns3" + link.id.replace("-", "")[:11]
dstport = Config.instance().settings.Server.vxlan_dstport
specs = [entry[3] for entry in entries]
assert all(spec["type"] == "nio_bridge" for spec in specs)
assert all(spec["bridge"] == expected_bridge for spec in specs)
vxlan1, vxlan2 = specs[0]["vxlan"], specs[1]["vxlan"]
assert vxlan1["vni"] == vxlan2["vni"]
assert vxlan1["dstport"] == vxlan2["dstport"] == dstport
assert vxlan1["remote"] == "192.168.1.2"
assert vxlan2["remote"] == "192.168.1.1"
assert vxlan1["name"] == vxlan_device_name(link.id, node1.id)
assert vxlan2["name"] == vxlan_device_name(link.id, node2.id)
assert vxlan1["name"] != vxlan2["name"]
@pytest.mark.asyncio
async def test_prepare_vxlan_same_host_pair_falls_back_to_relay(project):
"""
Equal resolved addresses mean both computes share one host: the kernel
shares one VXLAN socket per dstport, so a tunnel pair cannot live there
— such links keep the relay, where the ends differ by UDP port.
"""
with _cross_compute_enabled():
link, node1, node2 = await _cross_compute_link(project, same_host=True)
entries = await link._prepare()
assert all(entry[3]["type"] == "nio_udp" for entry in entries)
@pytest.mark.asyncio
async def test_commit_vxlan_retries_with_a_fresh_vni(project):
"""
A refused commit (reactively: the VNI was already registered on the
host's shared socket) re-prepares the link with a bumped salt — a fresh
VNI, identical on both ends — and recommits.
"""
with _cross_compute_enabled():
link, node1, node2 = await _cross_compute_link(project)
vnis = []
async def commit(entries):
vnis.append(entries[0][3]["vxlan"]["vni"])
if len(vnis) == 1:
raise ComputeError("Could not create vxlan gvl...: File exists")
with patch.object(link, "_commit_nios", commit):
await link.create()
assert len(vnis) == 2
assert vnis[0] != vnis[1]
assert link._vxlan_downgraded is False
@pytest.mark.asyncio
async def test_commit_vxlan_degrades_to_relay_after_retries(project):
"""
When every VXLAN attempt fails, the link degrades to the UDP relay —
the opt-in datapath must never make a link uncreatable.
"""
with _cross_compute_enabled():
link, node1, node2 = await _cross_compute_link(project)
commits = []
async def commit(entries):
commits.append(entries)
if any(entry[3].get("vxlan") for entry in entries):
raise ComputeError("Could not create vxlan gvl...: File exists")
with patch.object(link, "_commit_nios", commit):
await link.create()
assert link._vxlan_downgraded is True
assert len(commits) == 4 # the three VXLAN attempts, then the relay
assert all(entry[3].get("vxlan") for entry in commits[0])
assert all(entry[3]["type"] == "nio_udp" for entry in commits[-1])
async def _prepared_vxlan_link(project):
"""A cross-compute link with its tunnel NIOs prepared (the shape a node
start replays) — call inside ``_cross_compute_enabled`` so the heal's
re-preparation stays on the tunnel too. _bind_members mirrors the
project-open finalize (the node's link back-reference is what lets the
heal find the conflicted link)."""
link, node1, node2 = await _cross_compute_link(project)
await link._prepare()
link._bind_members()
link._created = True
return link, node1, node2
def _vni_conflict(node, link):
"""The compute's 409 when the kernel refuses the link's VNI on the
shared dstport socket — the wording KernelDatapathMixin emits."""
device = vxlan_device_name(link.id, node.id)
return ComputeConflictError(
"http://compute", {"message": f"Could not create VXLAN device {device} to 192.168.1.2 via eth0: File exists"}
)
@pytest.mark.asyncio
async def test_node_start_self_heals_a_vni_conflict(project):
"""
A link wired while its nodes were stopped hits its VNI collision at
node start (the kernel's refusal is the first time anyone asks). The
start stops the half-started node, re-wires the link on a fresh VNI —
both ends, through the per-compute batch — and retries.
"""
with _cross_compute_enabled():
link, node1, node2 = await _prepared_vxlan_link(project)
salt = link._vxlan_salt
starts = []
async def post_callback(path, data=None, **kwargs):
if path.endswith("/start"):
starts.append(path)
if len(starts) == 1:
raise _vni_conflict(node1, link)
response = MagicMock()
response.json = {"udp_port": 1024}
return response
node1.compute.post.side_effect = post_callback
await node1.start()
assert len(starts) == 2
assert link._vxlan_downgraded is False
assert link._vxlan_salt == salt + 1
# the half-started node was stopped before the retry (a start on a
# running container early-returns without wiring)
assert any(call.args[0].endswith("/stop") for call in node1.compute.post.call_args_list)
# the fresh-VNI NIOs were re-dispatched to both computes
for compute in (node1.compute, node2.compute):
batches = [c for c in compute.post.call_args_list if str(c.args[0]).endswith("/nios/batch")]
assert len(batches) == 1
@pytest.mark.asyncio
async def test_node_start_degrades_the_link_after_a_second_conflict(project):
"""
A conflict that survives the fresh VNI degrades the link to the relay
— the start must succeed no later than that round, the same guarantee
create() and the project-open batch path give.
"""
with _cross_compute_enabled():
link, node1, node2 = await _prepared_vxlan_link(project)
starts = []
async def post_callback(path, data=None, **kwargs):
if path.endswith("/start"):
starts.append(path)
if len(starts) <= 2:
raise _vni_conflict(node1, link)
response = MagicMock()
response.json = {"udp_port": 1024}
return response
node1.compute.post.side_effect = post_callback
await node1.start()
assert len(starts) == 3
assert link._vxlan_downgraded is True
assert all(nio["type"] == "nio_udp" for nio in link._link_data)
@pytest.mark.asyncio
async def test_node_start_does_not_retry_other_failures(project):
"""
Only the VXLAN device conflict heals: any other start failure surfaces
unchanged, with a single attempt and no stop/redispatch side effects.
"""
with _cross_compute_enabled():
link, node1, node2 = await _prepared_vxlan_link(project)
salt = link._vxlan_salt
async def post_callback(path, data=None, **kwargs):
if path.endswith("/start"):
raise ComputeError("DockerError: image not found")
response = MagicMock()
response.json = {}
return response
node1.compute.post.side_effect = post_callback
with pytest.raises(ComputeError):
await node1.start()
starts = [c for c in node1.compute.post.call_args_list if str(c.args[0]).endswith("/start")]
assert len(starts) == 1
assert not any(call.args[0].endswith("/stop") for call in node1.compute.post.call_args_list)
assert link._vxlan_salt == salt
assert link._vxlan_downgraded is False
@pytest.mark.asyncio
async def test_open_heal_tries_the_full_vni_candidate_count(project):
"""
The project-open heal gives a refused link the same VNI candidate count
an interactive creation gets (fresh-salt rounds), degrading to the
relay only after they run out.
"""
link, node1, node2 = await _cross_compute_link(project)
rounds = []
async def redispatch(links, downgrade):
rounds.append(downgrade)
return links if not downgrade else []
with patch.object(project, "_redispatch_vxlan_links", redispatch):
await project._heal_refused_vxlan_links([link])
assert rounds == [False, False, True]
@pytest.mark.asyncio
async def test_open_heal_stops_at_the_first_successful_vni(project):
"""
A link healed by the first fresh-VNI round needs no further rounds —
no pointless second redispatch, no relay degradation.
"""
link, node1, node2 = await _cross_compute_link(project)
rounds = []
async def redispatch(links, downgrade):
rounds.append(downgrade)
return []
with patch.object(project, "_redispatch_vxlan_links", redispatch):
await project._heal_refused_vxlan_links([link])
assert rounds == [False]