marker: add direction-clear, routing, and ubridge transport tests

Close the test gaps surfaced in review:

- controller (test_marker.py): direction clear vs preserve, definition-sync
  clear propagation, pinned-marker NIO routing, _markers_for_node carrying
  direction, and non-capable capture-node rejection.
- ubridge (new tests/compute/ubridge/test_hypervisor.py): unix/tcp __init__
  branches + socket-dir perms, _build_command -U/-H/debug, endpoint, stop
  socket unlink, and start() immediate-exit fail-fast.
- mcp (test_handlers.py): TestLinkMarker / TestMarkerDefinition covering
  direction tri-state (both/tx/omitted) for create and update.
This commit is contained in:
YueGuobin 2026-08-02 11:37:49 +08:00
parent 37cb9f0a9c
commit 7368e4a65b
No known key found for this signature in database
4 changed files with 429 additions and 0 deletions

View File

@ -359,3 +359,158 @@ class TestTemplate:
m.return_value = _mock_conn({})
result = delete_template_handler({"template_id": "t1"}, ctx)
assert "deleted" in str(result).lower()
# ── Marker (traffic-insight) ────────────────────────────────────────────
class TestLinkMarker:
"""link_marker_handler direction tri-state: omit=preserve, tx/rx=set, both=clear (→ null)."""
mod = "links"
def test_update_direction_both_clears(self, ctx):
from gns3server.api.routes.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "update",
"marker_name": "icmp", "direction": "both"}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
json_data={"direction": None},
)
def test_update_direction_tx_sets(self, ctx):
from gns3server.api.routes.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "update",
"marker_name": "icmp", "direction": "tx"}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
json_data={"direction": "tx"},
)
def test_update_direction_omitted_preserved(self, ctx):
from gns3server.api.routes.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "update",
"marker_name": "icmp", "tag": 1}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/links/l/markers/icmp",
json_data={"tag": 1},
)
def test_create_direction_both_omitted(self, ctx):
from gns3server.api.routes.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "create",
"bpf": "icmp", "direction": "both"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
json_data={"bpf": "icmp"},
)
def test_create_direction_tx(self, ctx):
from gns3server.api.routes.mcp.links import link_marker_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "icmp"})
m.return_value = conn
link_marker_handler(
{"project_id": "p", "link_id": "l", "action": "create",
"bpf": "icmp", "direction": "tx"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/links/l/markers",
json_data={"bpf": "icmp", "direction": "tx"},
)
class TestMarkerDefinition:
"""marker_definition_handler direction tri-state (same semantics as link markers)."""
mod = "links"
def test_update_direction_both_clears(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
{"project_id": "p", "action": "update",
"def_name": "arp", "direction": "both"}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp",
json_data={"direction": None},
)
def test_update_direction_tx_sets(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
{"project_id": "p", "action": "update",
"def_name": "arp", "direction": "tx"}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp",
json_data={"direction": "tx"},
)
def test_update_direction_omitted_preserved(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
{"project_id": "p", "action": "update",
"def_name": "arp", "tag": 1}, ctx,
)
conn.http_call.assert_called_with(
"put", "http://192.168.1.3:3080/v3/projects/p/marker-definitions/arp",
json_data={"tag": 1},
)
def test_create_direction_both_omitted(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
{"project_id": "p", "action": "create",
"bpf": "arp", "direction": "both"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions",
json_data={"bpf": "arp"},
)
def test_create_direction_tx(self, ctx):
from gns3server.api.routes.mcp.links import marker_definition_handler
with patch(f"{BASE}.{self.mod}._get_connector") as m:
conn = _mock_conn({"name": "arp"})
m.return_value = conn
marker_definition_handler(
{"project_id": "p", "action": "create",
"bpf": "arp", "direction": "tx"}, ctx,
)
conn.http_call.assert_called_with(
"post", "http://192.168.1.3:3080/v3/projects/p/marker-definitions",
json_data={"bpf": "arp", "direction": "tx"},
)

View File

View File

@ -0,0 +1,191 @@
#!/usr/bin/env python
#
# Copyright (C) 2025 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
"""
Tests for the uBridge ``Hypervisor`` wrapper the configurable control-channel
transport (AF_UNIX ``-U`` vs TCP ``-H``), command building, the human-readable
``endpoint``, socket cleanup on stop, and the fail-fast detection of an
immediately-exiting uBridge process (e.g. an old build that rejects ``-U``).
"""
import os
import re
import stat
import logging
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from gns3server.compute.ubridge.hypervisor import Hypervisor
from gns3server.compute.ubridge.ubridge_error import UbridgeError
def _make(transport, tmp_path, monkeypatch, node_id="abc123", host="127.0.0.1"):
"""Build a Hypervisor with ``XDG_RUNTIME_DIR`` pinned to ``tmp_path``.
The unix transport creates its socket dir under ``$XDG_RUNTIME_DIR/gns3``;
pinning it keeps creation predictable and avoids touching the real runtime
dir. ``host`` is unused for the unix transport but always accepted.
"""
monkeypatch.setenv("XDG_RUNTIME_DIR", str(tmp_path))
return Hypervisor(MagicMock(), "ubridge", str(tmp_path), transport, host=host, node_id=node_id)
# ---------------------------------------------------------------------------
# __init__: transport selection
# ---------------------------------------------------------------------------
def test_init_unix_creates_socket_dir_and_path(tmp_path, monkeypatch):
hyp = _make("unix", tmp_path, monkeypatch, node_id="abc123")
assert hyp._socket_path == str(tmp_path / "gns3" / "ubridge-abc123.sock")
socket_dir = os.path.dirname(hyp._socket_path)
assert os.path.isdir(socket_dir)
# 0o700 regardless of umask — __init__ chmods explicitly.
assert stat.S_IMODE(os.stat(socket_dir).st_mode) == 0o700
# TCP-only attributes are unused on the unix transport.
assert hyp._host is None
assert hyp._port is None
def test_init_unix_fallback_name_without_node_id(tmp_path, monkeypatch):
# node_id is normally always passed (one ubridge per node); the counter
# fallback only fires when it's missing. Match the numbered pattern so the
# assertion is independent of class-counter ordering across the suite.
hyp = _make("unix", tmp_path, monkeypatch, node_id=None)
assert re.search(r"ubridge-\d+\.sock$", hyp._socket_path)
def test_init_tcp_sets_host_port(tmp_path, monkeypatch):
hyp = _make("tcp", tmp_path, monkeypatch)
assert hyp._socket_path is None
assert hyp._host == "127.0.0.1"
assert isinstance(hyp._port, int) and hyp._port > 0
# ---------------------------------------------------------------------------
# _build_command + endpoint
# ---------------------------------------------------------------------------
def test_build_command_unix(tmp_path, monkeypatch):
hyp = _make("unix", tmp_path, monkeypatch)
cmd = hyp._build_command()
assert cmd[0] == "ubridge"
assert "-U" in cmd
assert hyp._socket_path in cmd
assert "-H" not in cmd
assert "-d" not in cmd # debug flag only at DEBUG level
def test_build_command_tcp(tmp_path, monkeypatch):
hyp = _make("tcp", tmp_path, monkeypatch)
cmd = hyp._build_command()
assert cmd[0] == "ubridge"
assert "-H" in cmd
assert f"{hyp._host}:{hyp._port}" in cmd
assert "-U" not in cmd
def test_build_command_debug_flag(tmp_path, monkeypatch):
hyp = _make("unix", tmp_path, monkeypatch)
logger = logging.getLogger("gns3server.compute.ubridge.hypervisor")
original = logger.level
logger.setLevel(logging.DEBUG)
try:
cmd = hyp._build_command()
assert "-d" in cmd and "1" in cmd
finally:
logger.setLevel(original)
def test_endpoint_unix(tmp_path, monkeypatch):
hyp = _make("unix", tmp_path, monkeypatch)
assert hyp.endpoint == hyp._socket_path
def test_endpoint_tcp(tmp_path, monkeypatch):
hyp = _make("tcp", tmp_path, monkeypatch)
assert hyp.endpoint == f"{hyp._host}:{hyp._port}"
# ---------------------------------------------------------------------------
# stop: AF_UNIX socket cleanup
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_stop_unlinks_unix_socket(tmp_path, monkeypatch):
hyp = _make("unix", tmp_path, monkeypatch)
# Simulate the socket file ubridge would have created.
open(hyp._socket_path, "w").close()
# Stopped process => is_running() is False => skips UBridgeHypervisor.stop (no send).
hyp._process = MagicMock()
hyp._process.returncode = 0
assert os.path.exists(hyp._socket_path)
await hyp.stop()
assert not os.path.exists(hyp._socket_path)
@pytest.mark.asyncio
async def test_stop_tcp_has_no_socket_to_unlink(tmp_path, monkeypatch):
# TCP transport: no socket_path, so stop must simply not raise.
hyp = _make("tcp", tmp_path, monkeypatch)
hyp._process = MagicMock()
hyp._process.returncode = 0
await hyp.stop()
# ---------------------------------------------------------------------------
# start: fail-fast on an immediately-exiting uBridge
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_start_detects_immediate_exit(tmp_path, monkeypatch):
# An unsupported flag (e.g. -U on an old ubridge) makes the process exit at
# once. start() must surface that from ubridge.log instead of timing out in
# connect() with a confusing "couldn't connect" error.
hyp = _make("unix", tmp_path, monkeypatch)
proc = MagicMock()
proc.pid = 1234
proc.returncode = 2 # already exited
with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc):
with pytest.raises(UbridgeError, match="exited immediately"):
await hyp.start()
@pytest.mark.asyncio
async def test_start_proceeds_when_process_keeps_running(tmp_path, monkeypatch):
# Healthy startup: the process stays up, so start() returns normally.
hyp = _make("unix", tmp_path, monkeypatch)
proc = MagicMock()
proc.pid = 1234
proc.returncode = None # still running
with patch.object(Hypervisor, "_check_ubridge_version", new_callable=AsyncMock), \
patch("asyncio.create_subprocess_exec", new_callable=AsyncMock, return_value=proc):
await hyp.start() # must NOT raise
assert hyp._process is proc

View File

@ -378,3 +378,86 @@ async def test_markers_aggregation(project):
assert agg[key]["highlight_duration"] == 800
assert agg[key]["link_id"] == link.id
assert agg[key]["node_id"] == agg[key]["capture_node_id"]
# ---------------------------------------------------------------------------
# Direction clear/preserve semantics (sentinel _UNSET vs explicit None)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_update_marker_clears_direction(project):
# Explicit direction=None clears the filter back to "both directions" —
# distinct from omitting the kwarg (which preserves the stored value).
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("m", "icmp", direction="tx")
assert link.markers["m"]["direction"] == "tx"
await link.update_marker("m", direction=None)
assert link.markers["m"]["direction"] is None
@pytest.mark.asyncio
async def test_update_marker_preserves_direction_when_omitted(project):
# Omitting direction entirely is a partial update: the stored value stays.
with _valid_bpf():
link = await _make_link(project)
await link.start_marker("m", "icmp", direction="tx")
await link.update_marker("m", tag=9)
assert link.markers["m"]["direction"] == "tx"
assert link.markers["m"]["tag"] == 9
@pytest.mark.asyncio
async def test_update_marker_definition_clears_direction(project):
# Clearing a definition's direction must propagate to every inherited copy.
with _valid_bpf():
link1 = await _make_link(project)
link2 = await _make_link(project)
await project.create_marker_definition("arp", "arp", direction="tx")
for link in (link1, link2):
assert link.markers["global-arp"]["direction"] == "tx"
await project.update_marker_definition("arp", direction=None)
assert project.marker_definitions["arp"]["direction"] is None
for link in (link1, link2):
assert link.markers["global-arp"]["direction"] is None
# ---------------------------------------------------------------------------
# Capture-node routing + capability validation
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_pinned_marker_routes_only_to_chosen_node(project):
# The marker rides only the pinned capture node's NIO; the far endpoint sees nothing.
with _valid_bpf():
link = await _make_link(project)
chosen = link._nodes[1]["node"]
other = link._nodes[0]["node"]
await link.start_marker("icmp", "icmp", capture_node_id=chosen.id)
assert "icmp" in link._markers_for_node(chosen)
assert "icmp" not in link._markers_for_node(other)
@pytest.mark.asyncio
async def test_markers_for_node_carries_direction(project):
# The NIO-bound marker spec forwards direction so uBridge gets the dir token.
with _valid_bpf():
link = await _make_link(project)
node = link._nodes[0]["node"] # auto-pick selects the first capable endpoint
await link.start_marker("m", "icmp", direction="rx")
assert link._markers_for_node(node)["m"]["direction"] == "rx"
@pytest.mark.asyncio
async def test_start_marker_rejects_non_capable_capture_node(project):
# A NAT endpoint has no uBridge bridge. Pinning to it must fail even though
# it IS a link endpoint (distinct from the not-an-endpoint -> 404 case).
with _valid_bpf():
link = await _make_link(project)
nat = Node(project, link._nodes[0]["node"].compute, "nat", node_type="nat")
link._nodes.append({"node": nat, "adapter_number": 0, "port_number": 0})
with pytest.raises(ControllerError):
await link.start_marker("m", "icmp", capture_node_id=nat.id)