diff --git a/docs/features/docker-kernel-datapath.md b/docs/features/docker-kernel-datapath.md index d560bee31..c1405a8c2 100644 --- a/docs/features/docker-kernel-datapath.md +++ b/docs/features/docker-kernel-datapath.md @@ -138,6 +138,11 @@ attach to running containers: `brctl addif` (kernel) or On project (re)open every link re-runs `_prepare`, so relay links whose endpoints became eligible are upgraded to the kernel datapath automatically. +Which datapath a link ended up on is visible in its REST payload (and the +`link_list` / `link_get` MCP tools) as the read-only boolean +`kernel_datapath`, recomputed from the NIO wiring — a relay link upgraded on +reopen flips the field. It is not persisted and never sent on create/update. + ## Adapter lifecycle * **Birth (container start)** — `_create_veth`: stale sweep of any leftover @@ -209,7 +214,7 @@ ends (uBridge `tc netem set`, raw netlink — no `tc` binary needed): | `duplicate [%, correl]` | netem `dup P [correl C]` | kernel-only type | | `rate ["512kbit"]` | netem `rate B` | tc-style integer+unit (bit/kbit/mbit/gbit/bps/kbps/mbps, ≤100gbit); kernel-only type | | `reorder [%, correl, gap]` | netem `reorder P [correl C] [gap G]` | requires `delay`; kernel-only type | -| `gemodel [p, r, 1-h]` | netem `loss gemodel P R H` | Gilbert-Elliot bursty loss; mutually exclusive with `packet_loss`; kernel-only type | +| `gemodel [p, r, 1-h]` | netem `loss gemodel P R H` | Gilbert-Elliot bursty loss: p = good→bad transition, r = bad→good transition, 1-h = loss chance in the bad state (good loses nothing) — mean one-way loss p/(p+r)×(1-h); mutually exclusive with `packet_loss`; kernel-only type | | `seed [u32]` | netem `seed N` | reproducible random draws; kernel-only type | | `limit [pkts]` | netem `limit N` | queue depth above the 1000 default (rate+delay BDP); kernel-only type | | `bpf` (one line per expression, OR) | `tc bpf_drop add ""` — cls_bpf on clsact egress + gact drop | needs uBridge `cbpf=1` (`tc capabilities`, probed once per uBridge process); a line that fails to compile on the compute is skipped with a warning, mirroring the relay | @@ -274,7 +279,11 @@ Semantics: controller — a round trip survives only when **both** directions are outside their windows. Note `start = 0` (outage begins immediately) is a valid, active configuration — unlike other filters, a zero first - parameter does not mean "disabled". + parameter does not mean "disabled". A PUT re-sending an unchanged filters + dict is normally a controller no-op, but one still carrying `window_drop` + is reconciled anyway, so re-applying the same window re-arms a one-shot + outage that has already passed (arm the filter immediately before + generating traffic, or use a period for a repeatable event). * **Validation** mirrors the tc grammar: `reorder` requires `delay`, `gemodel` and `packet_loss` are mutually exclusive (both map to the netem loss keyword), `distribution` requires jitter > 0, rate must be integer + diff --git a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py index e6aee5583..b643a26f0 100644 --- a/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py +++ b/gns3server/agent/gns3_copilot/tools_v2/gns3_packet_filter.py @@ -130,7 +130,8 @@ class GNS3PacketFilterTool(BaseTool): - "rate": Bandwidth cap, tc-style string like "512kbit" (kernel links only) - "reorder": Packet reordering (parameters: percentage, correlation, gap; needs delay; kernel links only) - - "gemodel": Gilbert-Elliot bursty loss (parameters: p, r, 1-h; kernel links only) + - "gemodel": Gilbert-Elliot bursty loss (parameters: p good-to-bad transition, r bad-to-good + transition, 1-h loss in the bad state; mean loss = p/(p+r)*(1-h); kernel links only) - "duplicate": Packet duplication percentage (kernel links only) - "seed": Reproducible netem random draws (kernel links only) - "limit": Impairment queue depth in packets (kernel links only) diff --git a/gns3server/agent/mcp/__init__.py b/gns3server/agent/mcp/__init__.py index 180087905..1c5398233 100644 --- a/gns3server/agent/mcp/__init__.py +++ b/gns3server/agent/mcp/__init__.py @@ -817,7 +817,10 @@ async def link_update( * rate (K): ["512kbit"] - Bandwidth cap, tc-style int+unit (bit/kbit/ mbit/gbit/bps/kbps/mbps, max 100gbit) * reorder (K): [pct, correl?, gap?] - Requires delay to also be set - * gemodel (K): [p, r, 1-h] - Gilbert-Elliot bursty loss; mutually + * gemodel (K): [p, r, 1-h] - Gilbert-Elliot bursty loss; p = chance + of moving from the good to the bad state, r = chance of moving back, + 1-h = loss chance in the bad state (the good state loses nothing); + steady-state mean loss = p/(p+r)*(1-h) per direction; mutually exclusive with packet_loss * duplicate (K): [pct, correl?] - Duplication percentage * seed (K): [u32] - Makes netem random draws reproducible @@ -830,7 +833,10 @@ async def link_update( outage (traffic passes before and after), 4 args = recurring flap (period >= outage), 5 args = per-cycle timing randomized within +/- jitter. start is measured from the moment this filter is applied and - ANY filter update / node restart restarts the schedule; start 0 = + ANY filter update (including re-sending the same values) or node + restart restarts the schedule — a one-shot window that already + passed will not fire again until re-applied, so arm the filter + immediately before generating traffic (or use a period); start 0 = outage begins immediately (active, not disabled) Example filters: diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 337df9e29..b531d91ec 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -105,12 +105,14 @@ FILTERS = [ { "type": "gemodel", "name": "Gilbert-Elliot loss", - "description": "Bursty loss model: p is the loss chance in the bad state, r in the good state, 1-h the " - "chance of moving from good to bad. Mutually exclusive with packet loss; kernel-datapath links only", + "description": "Bursty loss model: p is the chance of moving from the good to the bad state, r the chance " + "of moving back, and 1-h the loss chance while in the bad state (the good state loses nothing) — the " + "steady-state mean loss is p/(p+r) x (1-h). Mutually exclusive with packet loss; kernel-datapath links " + "only", "parameters": [ - {"name": "p (bad-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, - {"name": "r (good-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, - {"name": "1-h (good-to-bad)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, + {"name": "p (good-to-bad)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, + {"name": "r (bad-to-good)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, + {"name": "1-h (bad-state loss)", "minimum": 0, "maximum": 100, "type": "int", "unit": "%"}, ], }, { @@ -338,7 +340,12 @@ class Link: ) ) - if new_filters != self.filters: + # An unchanged filters dict is normally a no-op skip, but a PUT that + # still carries window_drop must reconcile: the outage schedule is + # measured from the apply, so re-sending the same window has to + # re-arm the one-shot outage (the documented contract is that any + # filter update restarts the schedule). + if new_filters != self.filters or "window_drop" in new_filters: self._filters = new_filters if self._created: await self.update() diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 5dd8f1752..dccc33063 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -81,6 +81,11 @@ class Link(LinkBase): link_id: UUID project_id: Optional[UUID] = None link_type: Optional[LinkType] = None + kernel_datapath: Optional[bool] = Field( + None, + description="Read only property. True when this link is wired through kernel veth/bridge interfaces " + "(currently Docker-to-Docker on one compute) instead of the uBridge UDP relay", + ) capturing: Optional[bool] = Field(None, description="Read only property. True if a capture running on the link") capture_file_name: Optional[str] = Field( None, description="Read only property. The name of the capture file if a capture is running" diff --git a/gns3server/utils/packet_filter_validation.py b/gns3server/utils/packet_filter_validation.py index 43f6a3fb2..c9c3a6d96 100644 --- a/gns3server/utils/packet_filter_validation.py +++ b/gns3server/utils/packet_filter_validation.py @@ -210,12 +210,15 @@ def validate_filter_parameters(filter_type: str, values: List[Any]) -> None: "units": ["%", "%", "packets"], }, "gemodel": { - # Gilbert-Elliot loss model, tc semantics: p = loss probability in - # the bad state, r = loss probability in the good state, 1-h = - # probability of moving from the good to the bad state. + # Gilbert-Elliot loss model, tc semantics (verified against the + # kernel on kernel-datapath links): p = probability of moving + # from the good to the bad state (PGAP), r = probability of + # moving back (PGBP), 1-h = loss probability in the bad state + # (the good state loses nothing). Steady-state mean loss is + # p/(p+r) * (1-h) per direction. "params_count": (1, 3), "ranges": [(0, 100), (0, 100), (0, 100)], - "names": ["p (bad-state loss)", "r (good-state loss)", "1-h (good-to-bad transition)"], + "names": ["p (good-to-bad transition)", "r (bad-to-good transition)", "1-h (bad-state loss)"], "units": ["%", "%", "%"], }, "seed": {"params_count": (1, 1), "ranges": [(0, 4294967295)], "names": ["Seed"], "units": [""]}, diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 518eb22f9..08c73a8f7 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -139,9 +139,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_update_link_suspend( self, app: FastAPI, client: AsyncClient, project: Project, nodes: Tuple[Node, Node] diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 66a94e708..186e4c332 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -361,6 +361,58 @@ async def test_update_filters(project, compute): assert link.update.called +@pytest.mark.asyncio +async def test_update_filters_unchanged_is_noop(project, compute): + """Re-sending an identical filters dict skips the datapath reconcile.""" + + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + node2 = Node(project, compute, "node2", node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 0, 4)] + + link = Link(project) + link.create = AsyncioMagicMock() + link._project.emit_notification = MagicMock() + project.dump = AsyncioMagicMock() + await link.add_node(node1, 0, 4) + await link.add_node(node2, 0, 4) + + link.update = AsyncioMagicMock() + await link.update_filters({"packet_loss": [10]}) + assert link.update.call_count == 1 + await link.update_filters({"packet_loss": [10]}) + assert link.update.call_count == 1 + + +@pytest.mark.asyncio +async def test_update_filters_unchanged_window_drop_still_reconciles(project, compute): + """ + window_drop's outage schedule is measured from the apply, so an + unchanged re-send must re-arm it instead of being skipped as a no-op. + """ + + node1 = Node(project, compute, "node1", node_type="qemu") + node1._ports = [EthernetPort("E0", 0, 0, 4)] + node2 = Node(project, compute, "node2", node_type="qemu") + node2._ports = [EthernetPort("E0", 0, 0, 4)] + + link = Link(project) + link.create = AsyncioMagicMock() + link._project.emit_notification = MagicMock() + project.dump = AsyncioMagicMock() + await link.add_node(node1, 0, 4) + await link.add_node(node2, 0, 4) + # window_drop is kernel-only: this synthetic link has no real wiring, + # so declare the kernel datapath the way _link_data does at runtime + link._link_data = [{"type": "nio_bridge"}] + + link.update = AsyncioMagicMock() + await link.update_filters({"window_drop": [3000, 5000, 100]}) + assert link.update.call_count == 1 + await link.update_filters({"window_drop": [3000, 5000, 100]}) + assert link.update.call_count == 2 + + @pytest.mark.asyncio async def test_available_filters(project, compute): diff --git a/tests/utils/test_packet_filter_validation.py b/tests/utils/test_packet_filter_validation.py index d196e9d3e..2bada6f13 100644 --- a/tests/utils/test_packet_filter_validation.py +++ b/tests/utils/test_packet_filter_validation.py @@ -308,7 +308,7 @@ class TestNetemExtensionFilters: validate_all_filters({"gemodel": [100, 0, 30], "packet_loss": [10]}) def test_gemodel_invalid_range(self): - with pytest.raises(FilterValidationError, match="bad-state"): + with pytest.raises(FilterValidationError, match="good-to-bad"): validate_filter_parameters("gemodel", [101, 0, 30]) def test_duplicate_valid(self):