mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
Merge branch '3.1' into fork/markparonyan/api-issue-10-0eztih
# Conflicts: # gns3server/schemas/__init__.py
This commit is contained in:
110 files changed
+21567
-701
No files matched your search
@@ -28,9 +28,22 @@ from gns3server.version import __version__
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
async def _no_tap_probe():
|
||||
"""The probes spawn uBridge — stub them out for payload assertions."""
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class TestCapabilitiesRoutes:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform) -> None:
|
||||
async def test_get(self, app: FastAPI, compute_client: AsyncClient, windows_platform, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@@ -54,10 +67,21 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
"ubridge_tap": None,
|
||||
"ubridge_iol_tap": None,
|
||||
"ubridge_bridge_tap": None,
|
||||
}
|
||||
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm) -> None:
|
||||
async def test_get_on_gns3vm(self, app: FastAPI, compute_client: AsyncClient, on_gns3vm, monkeypatch) -> None:
|
||||
|
||||
async def no_probe():
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", no_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == {
|
||||
@@ -81,4 +105,76 @@ class TestCapabilitiesRoutes:
|
||||
"cpus": psutil.cpu_count(logical=True),
|
||||
"memory": psutil.virtual_memory().total,
|
||||
"disk_size": psutil.disk_usage(get_default_project_directory()).total,
|
||||
"ubridge_tc": None,
|
||||
"ubridge_tap": None,
|
||||
"ubridge_iol_tap": None,
|
||||
"ubridge_bridge_tap": None,
|
||||
}
|
||||
|
||||
async def test_get_reports_ubridge_tc(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
|
||||
async def fake_probe():
|
||||
return {"netem": "delay,rate", "ebpf": "1", "cbpf": "0", "ebpf_modes": "nth,quota,window,flow"}
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tc_capabilities", fake_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["ubridge_tc"] == {
|
||||
"netem": ["delay", "rate"],
|
||||
"ebpf": True,
|
||||
"ebpf_modes": ["nth", "quota", "window", "flow"],
|
||||
"cbpf": False,
|
||||
}
|
||||
|
||||
async def test_get_reports_ubridge_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
"""
|
||||
The tap capability is what makes a QEMU node kernel-eligible, so it
|
||||
travels with the other compute capabilities.
|
||||
"""
|
||||
|
||||
async def fake_tap_probe():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", fake_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["ubridge_tap"] is True
|
||||
|
||||
async def test_get_reports_ubridge_iol_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
"""
|
||||
The IOL-port TAP capability is what makes an IOU node kernel-eligible,
|
||||
so it travels with the other compute capabilities.
|
||||
"""
|
||||
|
||||
async def fake_iol_tap_probe():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", fake_iol_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["ubridge_iol_tap"] is True
|
||||
|
||||
async def test_get_reports_ubridge_bridge_tap(self, app: FastAPI, compute_client: AsyncClient, monkeypatch) -> None:
|
||||
"""
|
||||
The swappable-bridge-TAP-leg capability is what makes an IOL runner
|
||||
container kernel-eligible, so it travels with the other compute
|
||||
capabilities.
|
||||
"""
|
||||
|
||||
async def fake_bridge_tap_probe():
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"gns3server.api.routes.compute.capabilities.probe_bridge_tap_support", fake_bridge_tap_probe
|
||||
)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_tap_support", _no_tap_probe)
|
||||
monkeypatch.setattr("gns3server.api.routes.compute.capabilities.probe_iol_tap_support", _no_tap_probe)
|
||||
response = await compute_client.get(app.url_path_for("compute:get_capabilities"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["ubridge_bridge_tap"] is True
|
||||
@@ -226,6 +226,31 @@ class TestDockerNodesRoutes:
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["type"] == "nio_udp"
|
||||
|
||||
async def test_docker_nio_create_bridge_requires_the_field_but_takes_null(
|
||||
self, app: FastAPI, compute_client: AsyncClient, vm: dict
|
||||
) -> None:
|
||||
"""
|
||||
BridgeNIO.bridge is required-but-nullable: an omitted key is a 422
|
||||
(the NIO factories read it, and the routes drop unset fields — an
|
||||
omitted key used to pass validation and die as a KeyError during
|
||||
binding), while an explicit null is the absorbed-link shape.
|
||||
"""
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_docker_node_nio",
|
||||
project_id=vm["project_id"],
|
||||
node_id=vm["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
|
||||
response = await compute_client.post(url, json={"type": "nio_bridge"})
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
|
||||
response = await compute_client.post(url, json={"type": "nio_bridge", "bridge": None})
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["bridge"] is None
|
||||
|
||||
async def test_docker_nio_batch_create(self, app: FastAPI, compute_client: AsyncClient, vm: dict) -> None:
|
||||
"""
|
||||
Exercise the project-wide batch NIO endpoint: bind two NIOs on the same
|
||||
|
||||
@@ -14,14 +14,17 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from unittest.mock import MagicMock, call
|
||||
import os
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from fastapi import FastAPI, status
|
||||
from httpx import AsyncClient
|
||||
|
||||
from gns3server.compute.error import NodeError
|
||||
from gns3server.compute.project import Project
|
||||
from gns3server.compute.ubridge.ubridge_error import UbridgeError
|
||||
from tests.utils import asyncio_patch
|
||||
|
||||
# The builtin Ethernet switch talks to uBridge (brctl/bridge modules) instead of
|
||||
@@ -535,3 +538,784 @@ class TestEthernetSwitchNodesRoutes:
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
node._ubridge_send.assert_any_call(f"bridge stop_capture {relay}")
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# kernel datapath: absorbed anchors (nio_anchor)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _anchor_params(anchor: str = "gv00010203e0p0", **extra) -> dict:
|
||||
params = {"type": "nio_anchor", "anchor": anchor}
|
||||
params.update(extra)
|
||||
return params
|
||||
|
||||
@staticmethod
|
||||
def _sysfs(anchor_exists: bool = True, bridge_member: bool = True):
|
||||
"""An ``os.path.exists`` that answers the /sys/class/net probes of the
|
||||
kernel-port wiring, delegating everything else to the real one."""
|
||||
real = os.path.exists
|
||||
|
||||
def fake(path):
|
||||
value = str(path)
|
||||
if "/brif/" in value:
|
||||
return bridge_member
|
||||
if value.startswith("/sys/class/net/"):
|
||||
return anchor_exists
|
||||
return real(path)
|
||||
|
||||
return patch("os.path.exists", new=fake)
|
||||
|
||||
async def test_ethernet_switch_create_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
A kernel link joins the peer's anchor straight to the switch bridge
|
||||
with the port's VLAN mode applied — no relay, no per-port TAP.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True):
|
||||
response = await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["type"] == "nio_anchor"
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
# access VLAN 1 (default): the anchor replaces the port TAP as the
|
||||
# bridge port, so it carries the port's membership
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl vlan_del "{br}" "{anchor}" 1')
|
||||
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
|
||||
# no relay was created for this port
|
||||
assert not any("bridge create" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
assert node._kernel_ports[0] == anchor
|
||||
assert node.get_nio(0).anchor == anchor
|
||||
|
||||
async def test_ethernet_switch_create_anchor_nio_defers_when_anchor_missing(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The peer's anchor only exists while the peer runs: a link created
|
||||
against a stopped peer must not fail (nor stop the switch's
|
||||
uBridge) — the join is deferred to the re-push after the peer
|
||||
starts.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=False):
|
||||
response = await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
assert node._kernel_ports[0] == anchor
|
||||
assert node.get_nio(0).anchor == anchor
|
||||
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
assert not any("brctl" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_update_anchor_nio_reconciles(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Updating a joined kernel port reconciles on the anchor: tc
|
||||
impairments, markers and the carrier the suspend flag drives.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
update_url = app.url_path_for(
|
||||
"compute:update_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True, bridge_member=True):
|
||||
response = await compute_client.put(
|
||||
update_url, json=self._anchor_params(anchor, filters={"delay": [10]}, suspend=True)
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["suspend"] is True
|
||||
|
||||
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'tc netem set "{anchor}" delay 10')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
|
||||
# no re-join on a plain reconcile
|
||||
assert not any("addif" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_update_anchor_nio_completes_deferred_join(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The controller re-pushes the switch NIO once the peer starts: a join
|
||||
deferred at link creation completes (the anchor exists but is not a
|
||||
bridge member), and a peer restart that replaced its anchor under
|
||||
the same name re-joins.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=False):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
update_url = app.url_path_for(
|
||||
"compute:update_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
# the anchor exists now (peer started) but is not a bridge member
|
||||
with self._sysfs(anchor_exists=True, bridge_member=False):
|
||||
response = await compute_client.put(update_url, json=self._anchor_params(anchor))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
|
||||
|
||||
async def test_ethernet_switch_delete_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Removing a kernel link resets the anchor's qdisc, takes it out of
|
||||
the switch bridge and drops its carrier — the anchor itself survives
|
||||
(it belongs to the peer).
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
with self._sysfs(anchor_exists=True):
|
||||
response = await compute_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" down')
|
||||
assert 0 not in node._kernel_ports
|
||||
assert 0 not in node._nios
|
||||
|
||||
async def test_ethernet_switch_capture_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Capture on a kernel port runs on the absorbed anchor (AF_PACKET),
|
||||
not on a relay.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:start_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
),
|
||||
json={"capture_file_name": "test.pcap", "data_link_type": "DLT_EN10MB"},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert any(
|
||||
c.args and str(c.args[0]).startswith(f'capture start_kernel {anchor} "')
|
||||
for c in node._ubridge_send.call_args_list
|
||||
), node._ubridge_send.call_args_list
|
||||
|
||||
response = await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:stop_ethernet_switch_capture",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
node._ubridge_send.assert_any_call("capture stop_kernel")
|
||||
|
||||
async def test_ethernet_switch_batch_create_anchor_nio(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The project-open batch endpoint accepts anchor NIOs (a reopen must
|
||||
not 422 the switch's kernel links).
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p3"
|
||||
params = {
|
||||
"nios": [
|
||||
{
|
||||
"node_id": ethernet_switch["node_id"],
|
||||
"adapter_number": 0,
|
||||
"port_number": 3,
|
||||
"nio": {"type": "nio_anchor", "anchor": anchor},
|
||||
}
|
||||
]
|
||||
}
|
||||
with self._sysfs(anchor_exists=False):
|
||||
response = await compute_client.post(
|
||||
app.url_path_for("compute:create_batch_nios", project_id=compute_project.id), json=params
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["added"] == 1
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
assert node._kernel_ports[3] == anchor
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# switch-to-switch cascade: one veth pair joins the two bridges
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@staticmethod
|
||||
def _cascade_params(anchor: str, peer: str) -> dict:
|
||||
return {"type": "nio_anchor", "anchor": anchor, "peer": peer}
|
||||
|
||||
def _cascade_sysfs(self, anchor: str, exists: dict):
|
||||
"""An ``os.path.exists`` whose answer for *one* interface flips with
|
||||
the ``exists`` flag — for the create-race test, where the pair comes
|
||||
into being between the pre-check and the failed create."""
|
||||
real = os.path.exists
|
||||
|
||||
def fake(path):
|
||||
value = str(path)
|
||||
if value == f"/sys/class/net/{anchor}":
|
||||
return exists["value"]
|
||||
if "/brif/" in value:
|
||||
return True
|
||||
if value.startswith("/sys/class/net/"):
|
||||
return True
|
||||
return real(value)
|
||||
|
||||
return patch("os.path.exists", new=fake)
|
||||
|
||||
async def test_ethernet_switch_create_cascade_nio_creates_the_pair(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
A cascade port is one end of a link-owned veth pair: the switch
|
||||
creates the pair (uBridge's docker create_veth — the same primitive
|
||||
every Docker adapter is born from) and enslaves its own end into its
|
||||
bridge with the port's VLAN mode, exactly like an absorbed anchor.
|
||||
"""
|
||||
|
||||
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
exists = {"value": False}
|
||||
|
||||
async def create_the_pair(command):
|
||||
if "create_veth" in str(command):
|
||||
exists["value"] = True
|
||||
|
||||
node._ubridge_send.side_effect = create_the_pair
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._cascade_sysfs(anchor, exists):
|
||||
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["peer"] == peer
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'docker create_veth "{anchor}" "{peer}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl vlan_add "{br}" "{anchor}" 1 pvid untagged')
|
||||
node._ubridge_send.assert_any_call(f'link set "{anchor}" up')
|
||||
assert node._kernel_ports[0] == anchor
|
||||
assert node._cascade_peers[0] == peer
|
||||
|
||||
async def test_ethernet_switch_create_cascade_nio_reuses_an_existing_pair(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
An existing end (this switch restarted, or a leftover from a previous
|
||||
life of this link id) is reused as-is: the other end may still be a
|
||||
live port of the peer switch, so the pair is never destroyed here —
|
||||
a stale tc qdisc is all a reused end can carry, and the reset clears
|
||||
it before the join.
|
||||
"""
|
||||
|
||||
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._cascade_sysfs(anchor, {"value": True}):
|
||||
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'tc reset "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
assert not any("create_veth" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_create_cascade_nio_lost_create_race(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Both cascade sides create the pair when their end is missing, so the
|
||||
two uBridge processes can race: the loser's create fails with
|
||||
"exists", the kernel re-check shows the pair is there, and the join
|
||||
proceeds — a lost race is not an error.
|
||||
"""
|
||||
|
||||
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
exists = {"value": False}
|
||||
|
||||
async def lose_the_race(command):
|
||||
if "create_veth" in str(command):
|
||||
# the peer switch's create won between our pre-check and now
|
||||
exists["value"] = True
|
||||
raise UbridgeError("202-interface already exists")
|
||||
|
||||
node._ubridge_send.side_effect = lose_the_race
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._cascade_sysfs(anchor, exists):
|
||||
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'brctl addif "{br}" "{anchor}"')
|
||||
assert node._kernel_ports[0] == anchor
|
||||
|
||||
async def test_ethernet_switch_create_cascade_nio_hardens_own_end_l2only(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
``docker create_veth`` hardens only its FIRST end (the Docker shape:
|
||||
host anchor plus a container leg that keeps its L3 life) — but a
|
||||
cascade end the peer's winning create minted is that second,
|
||||
unhardened end, and the kernel gives it an IPv6 link-local (a silent
|
||||
L3 identity on the switch fabric, ubridge-l2-anchor-spec §E). Each
|
||||
switch therefore hardens its OWN end once the pair exists, whatever
|
||||
branch brought it there (fresh create, lost race, reuse).
|
||||
"""
|
||||
|
||||
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
# pre-existing end: the branch whose end create_veth did NOT harden
|
||||
with self._cascade_sysfs(anchor, {"value": True}):
|
||||
response = await compute_client.post(url, json=self._cascade_params(anchor, peer))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.assert_any_call(f'link l2only "{anchor}" on')
|
||||
# only its own end — the peer's end is the peer switch's to harden
|
||||
assert not any(f'"{peer}"' in str(c) and "l2only" in str(c) for c in node._ubridge_send.call_args_list)
|
||||
|
||||
async def test_ethernet_switch_delete_cascade_nio_destroys_the_pair(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The cascade pair dies with the link: the teardown detaches the link
|
||||
state, delifs the end, and destroys the pair (deleting one veth end
|
||||
takes the peer end with it — either side's teardown converges on the
|
||||
same gone pair).
|
||||
"""
|
||||
|
||||
anchor, peer = "gs1a2b3c4d5e0", "gs1a2b3c4d5e1"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._cascade_sysfs(anchor, {"value": True}):
|
||||
await compute_client.post(url, json=self._cascade_params(anchor, peer))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
response = await compute_client.delete(url)
|
||||
assert response.status_code == status.HTTP_204_NO_CONTENT
|
||||
|
||||
br = node._bridge_name
|
||||
node._ubridge_send.assert_any_call(f'brctl delif "{br}" "{anchor}"')
|
||||
node._ubridge_send.assert_any_call(f'docker delete_veth "{anchor}"')
|
||||
assert node._kernel_ports == {}
|
||||
assert node._cascade_peers == {}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# in-place VLAN reconfiguration (no port ever leaves the bridge)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def test_ethernet_switch_update_ports_reconfigures_in_place(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Changing a wired port's VLAN edits its membership in place: delete the
|
||||
old membership, add the new one — the port is never detached from the
|
||||
bridge (a relay TAP here; the TAP is the cloud/relay-side port).
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
tap = f"{br}-0"
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{tap}" 1',
|
||||
f'brctl vlan_add "{br}" "{tap}" 10 pvid untagged',
|
||||
], commands
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_update_ports_leaves_untouched_ports_alone(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
Only ports whose mode/VLAN changed are touched: re-saving the mapping
|
||||
no longer re-programs every wired port.
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
for port_number in (0, 1):
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number=str(port_number),
|
||||
)
|
||||
await compute_client.post(url, json=self._udp_params())
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[1]["vlan"] = 20
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{br}-1" 1',
|
||||
f'brctl vlan_add "{br}" "{br}-1" 20 pvid untagged',
|
||||
], commands
|
||||
assert not any(f'"{br}-0"' in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_vlan_transition_table(self, compute_project: Project, ethernet_switch: dict) -> None:
|
||||
"""
|
||||
Every mode transition is expressed as in-place VLAN edits: the old
|
||||
mode's entries the new mode does not want, then the new mode's
|
||||
entries. The dot1q admits-all entry is one range operation in both
|
||||
directions, and same-to-same emits nothing.
|
||||
"""
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
br = node._bridge_name
|
||||
iface = "gvdeadbeef0p0"
|
||||
|
||||
cases = [
|
||||
(
|
||||
{"type": "access", "vlan": 10},
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 10',
|
||||
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 20 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
{"type": "access", "vlan": 30},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 20},
|
||||
{"type": "dot1q", "vlan": 30},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 20',
|
||||
f'brctl vlan_add "{br}" "{iface}" 30 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "dot1q", "vlan": 5},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 5 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "dot1q", "vlan": 5},
|
||||
{"type": "qinq", "vlan": 2},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 1 vid 4094',
|
||||
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "access", "vlan": 10},
|
||||
{"type": "qinq", "vlan": 2},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 10',
|
||||
f'brctl vlan_add "{br}" "{iface}" 2 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "access", "vlan": 10},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 10 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "qinq", "vlan": 2},
|
||||
{"type": "qinq", "vlan": 9},
|
||||
[
|
||||
f'brctl vlan_del "{br}" "{iface}" 2',
|
||||
f'brctl vlan_add "{br}" "{iface}" 9 pvid untagged',
|
||||
],
|
||||
),
|
||||
(
|
||||
{"type": "access", "vlan": 7},
|
||||
{"type": "access", "vlan": 7},
|
||||
[],
|
||||
),
|
||||
]
|
||||
for old_settings, new_settings, expected in cases:
|
||||
node._ubridge_send.reset_mock()
|
||||
await node._reconfigure_port_vlan(iface, old_settings, new_settings)
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == expected, (old_settings, new_settings, commands)
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_update_ports_reconfigures_absorbed_anchor_in_place(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
The same in-place reconcile applies to an absorbed kernel anchor (a
|
||||
peer node's interface): a ports_mapping update issues VLAN edits on
|
||||
it and never detaches it from the bridge.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=True):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
br = node._bridge_name
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert commands == [
|
||||
f'brctl vlan_del "{br}" "{anchor}" 1',
|
||||
f'brctl vlan_add "{br}" "{anchor}" 10 pvid untagged',
|
||||
], commands
|
||||
assert not any("delif" in c or "addif" in c for c in commands)
|
||||
|
||||
async def test_ethernet_switch_update_ports_skips_a_deferred_anchor(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
A kernel port whose peer is stopped has no interface yet (the join is
|
||||
deferred by design): a VLAN change must not send commands that would
|
||||
fail ENODEV and abort the whole reconcile — the port keeps the new
|
||||
settings in the mapping and they apply when the peer starts.
|
||||
"""
|
||||
|
||||
anchor = "gv00010203e0p0"
|
||||
url = app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
)
|
||||
with self._sysfs(anchor_exists=False):
|
||||
await compute_client.post(url, json=self._anchor_params(anchor))
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
assert node._kernel_ports.get(0) == anchor # deferred but registered
|
||||
node._ubridge_send.reset_mock()
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
with self._sysfs(anchor_exists=False):
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
commands = [c.args[0] for c in node._ubridge_send.call_args_list]
|
||||
assert not any("vlan_" in c for c in commands), commands
|
||||
|
||||
async def test_ethernet_switch_update_ports_rolls_the_mapping_back_on_failure(
|
||||
self, app: FastAPI, compute_client: AsyncClient, compute_project: Project, ethernet_switch: dict
|
||||
) -> None:
|
||||
"""
|
||||
A failed VLAN reconcile must not keep the new mapping: the retry would
|
||||
then diff it against itself and report success with the VLANs never
|
||||
applied.
|
||||
"""
|
||||
|
||||
await compute_client.post(
|
||||
app.url_path_for(
|
||||
"compute:create_ethernet_switch_nio",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
adapter_number="0",
|
||||
port_number="0",
|
||||
),
|
||||
json=self._udp_params(),
|
||||
)
|
||||
|
||||
node = compute_project.get_node(ethernet_switch["node_id"])
|
||||
before = [dict(port) for port in node.ports_mapping]
|
||||
node._ubridge_send.side_effect = NodeError("207-reconcile failed")
|
||||
|
||||
ports = [{"name": f"Ethernet{i}", "port_number": i, "type": "access", "vlan": 1} for i in range(8)]
|
||||
ports[0]["vlan"] = 10
|
||||
response = await compute_client.put(
|
||||
app.url_path_for(
|
||||
"compute:update_ethernet_switch",
|
||||
project_id=ethernet_switch["project_id"],
|
||||
node_id=ethernet_switch["node_id"],
|
||||
),
|
||||
json={"ports_mapping": ports},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert response.json()["message"] == "207-reconcile failed"
|
||||
assert [dict(port) for port in node.ports_mapping] == before
|
||||
@@ -297,6 +297,40 @@ class TestIOUNodesRoutes:
|
||||
assert response.json()["type"] == "nio_udp"
|
||||
assert response.json()["filters"] == {}
|
||||
|
||||
async def test_iou_nio_update_carries_suspend_and_bridge(
|
||||
self, app: FastAPI, compute_client: AsyncClient, vm: dict
|
||||
) -> None:
|
||||
"""
|
||||
A kernel-datapath NIO (nio_bridge) is accepted on the IOU routes, and
|
||||
the update body's suspend flag reaches the NIO — the compute turns it
|
||||
into an admin-down anchor, so a dropped suspend would leave a
|
||||
"suspended" link forwarding traffic.
|
||||
"""
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:create_iou_node_nio",
|
||||
project_id=vm["project_id"],
|
||||
node_id=vm["node_id"],
|
||||
adapter_number="1",
|
||||
port_number="0",
|
||||
)
|
||||
params = {"type": "nio_bridge", "bridge": "gns3a1b2c3d4e5f"}
|
||||
response = await compute_client.post(url, json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["type"] == "nio_bridge"
|
||||
|
||||
url = app.url_path_for(
|
||||
"compute:update_iou_node_nio",
|
||||
project_id=vm["project_id"],
|
||||
node_id=vm["node_id"],
|
||||
adapter_number="1",
|
||||
port_number="0",
|
||||
)
|
||||
params["suspend"] = True
|
||||
response = await compute_client.put(url, json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.json()["suspend"] is True
|
||||
|
||||
async def test_iou_nio_create_ethernet(
|
||||
self, app: FastAPI, compute_client: AsyncClient, vm: dict, ethernet_device: str
|
||||
) -> None:
|
||||
|
||||
@@ -99,6 +99,40 @@ class TestComputeRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["compute_id"] == str(test_compute.compute_id)
|
||||
|
||||
async def test_compute_get_reports_every_capability(self, app: FastAPI, client: AsyncClient, controller) -> None:
|
||||
"""
|
||||
The capabilities payload the compute reports must survive the
|
||||
response model intact. The local compute is the interesting one: it
|
||||
is the controller object serialized straight through, and a second,
|
||||
private Capabilities definition in the response schema silently
|
||||
stripped the kernel-datapath fields (ubridge_tap, ubridge_iol_tap)
|
||||
there — links were then built on a datapath clients could not see.
|
||||
"""
|
||||
|
||||
await controller.add_compute(
|
||||
compute_id="local", name="local", host="127.0.0.1", port=3080, force=True, connect=False
|
||||
)
|
||||
controller.get_compute("local")._capabilities.update(
|
||||
{
|
||||
"version": "3.1.0",
|
||||
"node_types": ["docker"],
|
||||
"platform": "linux",
|
||||
"cpus": 4,
|
||||
"memory": 1024,
|
||||
"disk_size": 2048,
|
||||
"ubridge_tc": {"netem": ["delay"], "ebpf": True, "ebpf_modes": ["nth"], "cbpf": True},
|
||||
"ubridge_tap": True,
|
||||
"ubridge_iol_tap": False,
|
||||
}
|
||||
)
|
||||
|
||||
response = await client.get(app.url_path_for("get_compute", compute_id="local"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
capabilities = response.json()["capabilities"]
|
||||
assert capabilities["ubridge_tap"] is True
|
||||
assert capabilities["ubridge_iol_tap"] is False
|
||||
assert capabilities["ubridge_tc"]["netem"] == ["delay"]
|
||||
|
||||
async def test_compute_get_local(self, app: FastAPI, client: AsyncClient, controller) -> None:
|
||||
|
||||
await controller.add_compute(
|
||||
|
||||
@@ -81,6 +81,45 @@ class TestLinkRoutes:
|
||||
assert len(project.links) == 1
|
||||
assert list(project.links.values())[0].filters == filters
|
||||
|
||||
async def test_create_links_batch(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
|
||||
) -> None:
|
||||
|
||||
node1, node2 = nodes
|
||||
valid = {
|
||||
"nodes": [
|
||||
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
|
||||
{"node_id": node2.id, "adapter_number": 2, "port_number": 4},
|
||||
]
|
||||
}
|
||||
same_node = {
|
||||
"nodes": [
|
||||
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
|
||||
{"node_id": node1.id, "adapter_number": 0, "port_number": 3},
|
||||
]
|
||||
}
|
||||
unknown_node = {
|
||||
"nodes": [
|
||||
{"node_id": str(uuid.uuid4()), "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"):
|
||||
response = await client.post(
|
||||
app.url_path_for("create_links", project_id=project.id), json=[same_node, unknown_node, valid]
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.json()
|
||||
assert [r["status_code"] for r in results] == [409, 404, 201]
|
||||
assert results[0]["link"] is None
|
||||
assert results[0]["error"]["message"]
|
||||
assert results[1]["error"]["message"]
|
||||
assert results[2]["error"] is None
|
||||
assert results[2]["link"]["link_id"] in project.links
|
||||
assert len(project.links) == 1
|
||||
|
||||
async def test_create_link_failure(
|
||||
self, app: FastAPI, client: AsyncClient, compute: Compute, project: Project
|
||||
) -> None:
|
||||
@@ -243,9 +282,36 @@ class TestLinkRoutes:
|
||||
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_link_etag_and_if_match(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node]
|
||||
@@ -515,7 +581,10 @@ class TestLinkRoutes:
|
||||
{"frequency_drop": [32768]},
|
||||
{"delay": [32768, 0]},
|
||||
{"delay": [10, -1]},
|
||||
{"packet_loss": [1, 2]},
|
||||
# a second packet_loss value (correlation) is valid — it is a
|
||||
# kernel-datapath parameter the controller accepts; gemodel out
|
||||
# of range stands in for the invalid slot instead
|
||||
{"gemodel": [101, 50, 50]},
|
||||
{"bpf": [1]},
|
||||
{"packet_loss": ["abc"]},
|
||||
],
|
||||
@@ -529,7 +598,9 @@ class TestLinkRoutes:
|
||||
response = await client.put(
|
||||
app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters}
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
# The filter vocabulary is capability-gated per link, so values are
|
||||
# validated by the controller (409), not by a strict request schema
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "message" in response.json()
|
||||
assert link.filters == {}
|
||||
|
||||
@@ -541,10 +612,24 @@ class TestLinkRoutes:
|
||||
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
|
||||
json={"filters": {"unknown": [1]}},
|
||||
)
|
||||
assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT
|
||||
assert response.status_code == status.HTTP_409_CONFLICT
|
||||
assert "message" in response.json()
|
||||
assert link.filters == {}
|
||||
|
||||
async def test_update_link_packet_loss_correlation(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project
|
||||
) -> None:
|
||||
# The optional packet_loss correlation (second value) is a
|
||||
# kernel-datapath parameter accepted by the controller
|
||||
link = Link(project)
|
||||
project._links = {link.id: link}
|
||||
response = await client.put(
|
||||
app.url_path_for("update_link", project_id=project.id, link_id=link.id),
|
||||
json={"filters": {"packet_loss": [1, 2]}},
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["filters"] == {"packet_loss": [1, 2]}
|
||||
|
||||
async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
link = Link(project)
|
||||
@@ -557,9 +642,12 @@ class TestLinkRoutes:
|
||||
async def test_openapi_link_filters(self, app: FastAPI) -> None:
|
||||
|
||||
schemas = app.openapi()["components"]["schemas"]
|
||||
link_filters = schemas["LinkFilters"]
|
||||
assert link_filters["additionalProperties"] is False
|
||||
assert set(link_filters["properties"]) == {"frequency_drop", "packet_loss", "delay", "corrupt", "bpf"}
|
||||
# The filters vocabulary is capability-gated per link and validated by
|
||||
# the controller, so the Link schema carries it as a free-form object
|
||||
# (no strict LinkFilters model on the request path)
|
||||
assert "LinkFilters" not in schemas
|
||||
filters_property = schemas["Link"]["properties"]["filters"]
|
||||
assert filters_property["anyOf"][0] == {"additionalProperties": True, "type": "object"}
|
||||
assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == {
|
||||
"$ref": "#/components/schemas/LinkFilterParameter"
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ from pydantic import SecretStr
|
||||
from gns3server.api.routes.controller.nodes import vnc_console, ws_console
|
||||
from gns3server.config import Config
|
||||
from gns3server.controller.compute import Compute
|
||||
from gns3server.controller.controller_error import ComputeError
|
||||
from gns3server.controller.controller_error import ComputeError, ControllerError
|
||||
from gns3server.controller.node import Node
|
||||
from gns3server.controller.project import Project
|
||||
from gns3server.services import auth_service
|
||||
@@ -73,6 +73,103 @@ class TestNodeRoutes:
|
||||
assert response.json()["name"] == "test"
|
||||
assert "name" not in response.json()["properties"]
|
||||
|
||||
async def test_create_node_taken_name_is_renamed(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
|
||||
) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com"}
|
||||
|
||||
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
|
||||
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
|
||||
|
||||
assert first.json()["name"] == "R1"
|
||||
assert second.status_code == status.HTTP_201_CREATED
|
||||
assert second.json()["name"] == "R2"
|
||||
|
||||
async def test_create_node_strict_names(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
|
||||
) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
payload = {"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}
|
||||
|
||||
first = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
|
||||
second = await client.post(app.url_path_for("create_node", project_id=project.id), json=payload)
|
||||
|
||||
assert first.status_code == status.HTTP_201_CREATED
|
||||
assert second.status_code == status.HTTP_409_CONFLICT
|
||||
assert "R1" in second.json()["message"]
|
||||
assert len(project.nodes) == 1
|
||||
|
||||
async def test_create_nodes_batch(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
|
||||
) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
nodes = [{"name": "R{0}", "node_type": "vpcs", "compute_id": "example.com"} for _ in range(3)]
|
||||
|
||||
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.json()
|
||||
assert [r["status_code"] for r in results] == [201, 201, 201]
|
||||
assert [r["node"]["name"] for r in results] == ["R1", "R2", "R3"]
|
||||
assert all(r["error"] is None for r in results)
|
||||
assert len(project.nodes) == 3
|
||||
|
||||
async def test_create_nodes_batch_item_failures_keep_order(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
|
||||
) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
nodes = [
|
||||
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
|
||||
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True},
|
||||
{"name": "R1", "node_type": "vpcs", "compute_id": "unknown"},
|
||||
{"name": "R1", "node_type": "vpcs", "compute_id": "example.com"},
|
||||
]
|
||||
|
||||
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
results = response.json()
|
||||
assert [r["status_code"] for r in results] == [201, 409, 404, 201]
|
||||
assert results[0]["node"]["name"] == "R1"
|
||||
assert results[1]["node"] is None
|
||||
assert "R1" in results[1]["error"]["message"]
|
||||
assert results[2]["error"]["message"]
|
||||
assert results[3]["node"]["name"] == "R2"
|
||||
assert len(project.nodes) == 2
|
||||
|
||||
async def test_create_nodes_batch_failed_node_releases_name(
|
||||
self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute
|
||||
) -> None:
|
||||
|
||||
compute.post = AsyncioMagicMock(side_effect=ControllerError("compute failure"))
|
||||
nodes = [{"name": "R1", "node_type": "vpcs", "compute_id": "example.com", "strict_names": True}]
|
||||
|
||||
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
|
||||
|
||||
assert response.json()[0]["status_code"] == status.HTTP_409_CONFLICT
|
||||
assert len(project.nodes) == 0
|
||||
|
||||
response_ok = MagicMock()
|
||||
response_ok.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response_ok)
|
||||
response = await client.post(app.url_path_for("create_nodes", project_id=project.id), json=nodes)
|
||||
|
||||
assert response.json()[0]["status_code"] == status.HTTP_201_CREATED
|
||||
assert response.json()[0]["node"]["name"] == "R1"
|
||||
|
||||
async def test_list_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
|
||||
Reference in new issue
Block a user