fix: resolve the code-review findings on the kernel datapath

Compute:
- Track the anchor that owns uBridge's single AF_PACKET capture slot: a
  second kernel port's capture is refused with a clear error (its flag
  rolled back), a failed start no longer wedges the port, and only the
  owning port's stop issues the process-wide `capture stop_kernel`
  (docker/qemu/iou/dynamips/switch, via the mixin's slot helpers).
- Unwind failed starts instead of leaking uBridge and the anchor TAPs:
  QEMU's stop body runs under try/finally, QEMU and IOU start the datapath
  inside the launch try, Dynamips undoes a half-attached port (the retry
  used to be short-circuited into a silent black hole), and Docker deletes
  the veth pair when the namespace move fails.
- Restore the relay filter guard on IOU (kernel-only filters raise
  NodeError, an uncompilable BPF line degrades to a warning), validate the
  eBPF/quota/window/bpf filter shapes before touching the bridge (short or
  non-numeric values surfaced as 500s after partial state), and refuse a
  second NIO on an occupied Docker/QEMU port (the guard IOU and Dynamips
  already had).
- Probe fixes: `_probe_bridge_tap` survives a failing uBridge start, and
  the probe caches key on ctime too, so a chmod/setcap remedy re-probes.

Controller:
- Parse GNS3_UNIX_SOCKET_NIO like the compute does (line-anchored, truthy
  value) and mirror the compute's class selection in kernel_anchor_type.
- Read partial capability reports defensively; make BridgeNIO.bridge
  required-but-nullable so an omitted key is a 422 rather than a KeyError.
- Strip the packet_loss correlation slot even when it is zero: the relay
  filter takes exactly one argument and rejected `packet_loss 10 0`.

Ethernet switch: skip ports whose absorbed anchor does not exist yet (the
join is deferred by design) and roll the ports_mapping back when the VLAN
reconcile fails, so a retry still diffs against the old mapping.

Agent: reject device config entries without a device_name instead of
silently dropping them (a malformed batch used to execute partially while
reporting success).

Also split the netem builder to clear C901 and drop the two per-file ruff
ignores the PR had added, keeping the remove-only policy.
This commit is contained in:
YueGuobin committed 2026-10-06 19:55:21 +08:00
1 parent 87ca2138e3
commit c3c025ab2a
21 files changed
+654 -274

No files matched your search

@@ -200,7 +200,11 @@ class ExecuteMultipleDeviceConfigCommands(BaseTool):
return device_configs_list
# Merge same-device entries before anything keys by device_name
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="config_commands")
try:
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="config_commands")
except ValueError as e:
logger.error("Invalid device_configs input: %s", e)
return [{"status": "failed", "error": str(e)}]
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
@@ -203,7 +203,11 @@ class ExecuteMultipleDeviceCommands(BaseTool):
return device_configs_list
# Merge same-device entries before anything keys by device_name
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
try:
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
except ValueError as e:
logger.error("Invalid device_configs input: %s", e)
return [{"status": "failed", "error": str(e)}]
# Filter forbidden commands and store blocked commands info
device_configs_list, blocked_commands_map = self._filter_forbidden_commands_from_device_configs(
@@ -183,7 +183,11 @@ class VPCSCommands(BaseTool):
return device_configs_list
# Merge same-device entries before anything keys by device_name
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
try:
device_configs_list = merge_duplicate_device_configs(device_configs_list, commands_field="commands")
except ValueError as e:
logger.error("Invalid device_configs input: %s", e)
return [{"status": "failed", "error": str(e)}]
# Create a mapping of device names to their commands
device_configs_map = self._configs_map(device_configs_list)
@@ -34,7 +34,7 @@ from typing import Any
def merge_duplicate_device_configs(
device_configs: list[dict[str, Any]],
device_configs: list[Any],
commands_field: str,
) -> list[dict[str, Any]]:
"""
@@ -46,8 +46,9 @@ def merge_duplicate_device_configs(
every row would report that same output.
Args:
device_configs: Device config entries; each holds device_name and a
command list under commands_field.
device_configs: Device config entries (parsed tool input, validated
here); each holds device_name and a command list under
commands_field.
commands_field: Field carrying the commands ("commands" or
"config_commands" depending on the tool).
@@ -55,12 +56,18 @@ def merge_duplicate_device_configs(
One entry per device_name, in first-appearance order. The first
entry's extra fields are preserved; later duplicates only contribute
their commands.
Raises:
ValueError: An entry is not an object or has no usable device_name.
Such an entry cannot be addressed; dropping it silently would
let a malformed batch execute partially while every returned
row reports success.
"""
merged: dict[str, dict[str, Any]] = {}
for config in device_configs:
name = config.get("device_name")
if not name:
continue
for index, config in enumerate(device_configs):
if not isinstance(config, dict) or not config.get("device_name"):
raise ValueError(f"device_configs[{index}] must be an object with a non-empty 'device_name' field")
name = config["device_name"]
commands = list(config.get(commands_field, []))
if name in merged:
merged[name][commands_field].extend(commands)