YueGuobin 1db9f171ca
fix(controller): re-arm window_drop and expose kernel_datapath
Three fixes from live-calibrating the kernel impairment filters on an
isolated instance (Docker-to-Docker veth link, per-sequence evidence):

- update_filters skipped an unchanged filters dict as a no-op, so a
  one-shot window_drop outage that had already passed could never be
  re-armed: re-applying the same values produced no datapath command at
  all. A PUT still carrying window_drop now reconciles, restarting the
  schedule from the apply as documented. Measured: identical re-apply
  moves the outage window from seq 50-70 to 70-91 (arm at +8s of the
  new apply).

- gemodel parameter semantics were documented backwards in the
  validation rules, available_filters, the MCP/copilot descriptions and
  the feature docs. Measured against the kernel (per-direction counters,
  offered = sent + dropped): p is the good-to-bad transition chance, r
  the bad-to-good transition, 1-h the loss chance in the bad state (the
  good state loses nothing) - mean one-way loss p/(p+r)*(1-h), e.g.
  [10,30,100] -> 24.8%/25.7% measured vs 25% predicted, [5,45,100] ->
  10.8% both ways vs 10%.

- kernel_datapath was silently stripped from every REST/MCP link payload
  by the response model (LinkBase carried no such field), so clients
  could not tell a kernel link from a relay one.

Also verified on the same setup: the 3-argument window_drop form works
exactly as specified ([3000,5000,100] -> a single seq 31-55 outage,
[20000,6000,100] -> seq 115-144), and both generations of the command
builder emit the parameters unpadded.

Adds an API test for the field and controller tests for the no-op skip
and the window_drop re-arm exemption.
2026-10-06 00:21:08 +08:00

436 lines
17 KiB
Python

#
# Copyright (C) 2020 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import uuid
from typing import Tuple
from unittest.mock import MagicMock, patch
import pytest
import pytest_asyncio
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller.compute import Compute
from gns3server.controller.link import FILTERS, Link
from gns3server.controller.node import Node
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.project import Project
from gns3server.controller.udp_link import UDPLink
from tests.utils import AsyncioMagicMock, asyncio_patch
pytestmark = pytest.mark.asyncio
class TestLinkRoutes:
@pytest_asyncio.fixture
async def nodes(self, compute: Compute, project: Project) -> Tuple[Node, Node]:
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node1 = await project.add_node(compute, "node1", None, node_type="qemu")
node1._ports = [EthernetPort("E0", 0, 0, 3)]
node2 = await project.add_node(compute, "node2", None, node_type="qemu")
node2._ports = [EthernetPort("E0", 0, 2, 4)]
return node1, node2
async def test_create_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
filters = {"delay": [10, 0], "frequency_drop": [50]}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock:
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Text", "x": 42, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
],
"filters": filters,
},
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
assert response.json()["link_id"] is not None
assert len(response.json()["nodes"]) == 2
assert response.json()["nodes"][0]["label"]["x"] == 42
assert len(project.links) == 1
assert list(project.links.values())[0].filters == filters
async def test_create_link_failure(
self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project
) -> None:
"""
Make sure the link is deleted if we failed to create it.
The failure is triggered by connecting the link to itself
"""
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node1 = await project.add_node(compute, "node1", None, node_type="qemu")
node1._ports = [EthernetPort("E0", 0, 0, 3), EthernetPort("E0", 0, 0, 4)]
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Text", "x": 42, "y": 0},
},
{"node_id": node1.id, "adapter_number": 0, "port_number": 4},
]
},
)
assert response.status_code == status.HTTP_409_CONFLICT
assert len(project.links) == 0
async def test_get_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock:
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Text", "x": 42, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert mock.called
link_id = response.json()["link_id"]
assert response.json()["nodes"][0]["label"]["x"] == 42
# the runtime datapath fact survives the response model on both POST and GET
assert response.json()["kernel_datapath"] is False
response = await client.get(app.url_path_for("get_link", project_id=project.id, link_id=link_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["nodes"][0]["label"]["x"] == 42
assert response.json()["kernel_datapath"] is False
async def test_get_link_kernel_datapath(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
"""A kernel-wired link reports kernel_datapath true (was dropped by the schema)."""
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create"):
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
link_id = response.json()["link_id"]
link = project.get_link(link_id)
link._link_data = [{"type": "nio_bridge"}]
response = await client.get(app.url_path_for("get_link", project_id=project.id, link_id=link_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["kernel_datapath"] is True
async def test_update_link_suspend(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock:
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Text", "x": 42, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert mock.called
link_id = response.json()["link_id"]
assert response.json()["nodes"][0]["label"]["x"] == 42
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link_id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Hello", "x": 64, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
],
"suspend": True,
},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["nodes"][0]["label"]["x"] == 64
assert response.json()["suspend"]
assert response.json()["filters"] == {}
async def test_update_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
filters = {"delay": [10, 0], "frequency_drop": [50]}
node1, node2 = nodes
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock:
response = await client.post(
app.url_path_for("create_link", project_id=project.id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Text", "x": 42, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
},
)
assert mock.called
link_id = response.json()["link_id"]
assert response.json()["nodes"][0]["label"]["x"] == 42
response = await client.put(
app.url_path_for("update_link", project_id=project.id, link_id=link_id),
json={
"nodes": [
{
"node_id": node1.id,
"adapter_number": 0,
"port_number": 3,
"label": {"text": "Hello", "x": 64, "y": 0},
},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
],
"filters": filters,
},
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["nodes"][0]["label"]["x"] == 64
assert list(project.links.values())[0].filters == filters
async def test_list_link(
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
) -> None:
filters = {"delay": [10, 0], "frequency_drop": [50]}
node1, node2 = nodes
nodes = [
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
]
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as mock:
await client.post(
app.url_path_for("create_link", project_id=project.id), json={"nodes": nodes, "filters": filters}
)
assert mock.called
response = await client.get(app.url_path_for("get_links", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
assert response.json()[0]["filters"] == filters
# test listing links from a closed project
await project.close(ignore_notification=True)
response = await client.get(app.url_path_for("get_links", project_id=project.id))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) == 1
assert response.json()[0]["filters"] == filters
async def test_reset_link(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = UDPLink(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.udp_link.UDPLink.delete") as delete_mock:
with asyncio_patch("gns3server.controller.udp_link.UDPLink.create") as create_mock:
response = await client.post(app.url_path_for("reset_link", project_id=project.id, link_id=link.id))
assert delete_mock.called
assert create_mock.called
assert response.status_code == status.HTTP_200_OK
async def test_start_capture(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.link.Link.start_capture") as mock:
response = await client.post(
app.url_path_for("start_capture", project_id=project.id, link_id=link.id), json={}
)
assert mock.called
assert response.status_code == status.HTTP_201_CREATED
async def test_stop_capture(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.link.Link.stop_capture") as mock:
response = await client.post(app.url_path_for("stop_capture", project_id=project.id, link_id=link.id))
assert mock.called
assert response.status_code == status.HTTP_204_NO_CONTENT
# async def test_pcap(controller_api, http_client, project):
#
# async def pcap_capture():
# async with http_client.get(controller_api.get_url("/projects/{}/links/{}/pcap".format(project.id, link.id))) as response:
# response.body = await response.content.read(5)
# print("READ", response.body)
# return response
#
# with asyncio_patch("gns3server.controller.link.Link.capture_node") as mock:
# link = Link(project)
# link._capture_file_name = "test"
# link._capturing = True
# with open(link.capture_file_path, "w+") as f:
# f.write("hello")
# project._links = {link.id: link}
# response = await pcap_capture()
# assert mock.called
# assert response.status_code == 200
# assert b'hello' == response.body
async def test_delete_link(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
with asyncio_patch("gns3server.controller.link.Link.delete") as mock:
response = await client.delete(app.url_path_for("delete_link", project_id=project.id, link_id=link.id))
assert mock.called
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_list_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
link = Link(project)
project._links = {link.id: link}
with patch("gns3server.controller.link.Link.available_filters", return_value=FILTERS) as mock:
response = await client.get(app.url_path_for("get_filters", project_id=project.id, link_id=link.id))
assert mock.called
assert response.status_code == status.HTTP_200_OK
assert response.json() == FILTERS
async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
Test getting UDP tunnel interface information from a link.
"""
link = Link(project)
project._links = {link.id: link}
cloud_node = MagicMock()
cloud_node.node_type = "cloud"
cloud_node.id = str(uuid.uuid4())
cloud_node.name = "Cloud1"
compute = MagicMock()
response = MagicMock()
response.json = {
"ports_mapping": [
{
"port_number": 1,
"type": "udp",
"lport": 20000,
"rhost": "127.0.0.1",
"rport": 30000,
"name": "UDP tunnel 1",
}
]
}
compute.get = AsyncioMagicMock(return_value=response)
cloud_node.compute = compute
link._nodes = [{"node": cloud_node, "port_number": 1}]
response = await client.get(app.url_path_for("get_iface", project_id=project.id, link_id=link.id))
assert response.status_code == status.HTTP_200_OK
result = response.json()
assert result["node_id"] == cloud_node.id
assert result["lport"] == 20000
assert result["rhost"] == "127.0.0.1"
assert result["rport"] == 30000
assert result["type"] == "udp"
async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
"""
Test getting ethernet interface information from a link.
"""
link = Link(project)
project._links = {link.id: link}
cloud_node = MagicMock()
cloud_node.node_type = "cloud"
cloud_node.id = str(uuid.uuid4())
cloud_node.name = "Cloud1"
compute = MagicMock()
response = MagicMock()
response.json = {
"ports_mapping": [{"port_number": 1, "type": "ethernet", "interface": "eth0", "name": "Ethernet 1"}]
}
compute.get = AsyncioMagicMock(return_value=response)
cloud_node.compute = compute
link._nodes = [{"node": cloud_node, "port_number": 1}]
response = await client.get(app.url_path_for("get_iface", project_id=project.id, link_id=link.id))
assert response.status_code == status.HTTP_200_OK
result = response.json()
assert result["node_id"] == cloud_node.id
assert result["interface"] == "eth0"
assert result["type"] == "ethernet"