Merge branch '3.1' into fix/duplicate-device-configs-merge

Resolve conflicts against the upstream wave (PRs #2952-#2965, incl. the
typed-response/schema hardening and the drop of Python 3.10):

- controller/node.py: keep upstream's _transitioning/_confirm_status
  lifecycle guard; the kernel-anchor link wiring (node_started) runs
  after the transition completes.
- controller/link.py: keep upstream's add_node rollback (release_ports
  on create failure) and delete via release_ports; release_ports now
  resolves ports live (same staleness reason as _bind_members) and
  discards the node link unconditionally.
- schemas/controller/links.py: extend LinkFilterType with the eight
  kernel-datapath filter types and LinkFilterParameter.type with "str"
  so the newly typed /available_filters response validates; Link.filters
  stays a free-form dict — the vocabulary is capability-gated per link
  and validated by the controller (409), not by a strict request schema.
- schemas/controller/topology.py: drop the type: ignore made redundant
  by the dict filters field.
- tests: route-level filter validation now expects the controller's 409
  (unknown types, out-of-range values); packet_loss correlation is a
  valid kernel parameter (positive test added); OpenAPI assertions match
  the free-form filters property.
This commit is contained in:
YueGuobin committed 2026-10-09 10:03:50 +08:00
commit 1e0d123f4b
60 files changed
+2654 -271

No files matched your search

+6 -2
View File
@@ -710,7 +710,9 @@ class Controller:
except KeyError:
if compute_id == "vm":
raise ControllerNotFoundError("Cannot use a node on the GNS3 VM server with the GNS3 VM not configured")
raise ControllerNotFoundError(f"Compute ID {compute_id} doesn't exist")
raise ControllerNotFoundError(
f"Compute ID {compute_id} doesn't exist", code="compute_not_found", details={"compute_id": compute_id}
)
def has_compute(self, compute_id):
"""
@@ -748,7 +750,9 @@ class Controller:
try:
return self._projects[project_id]
except KeyError:
raise ControllerNotFoundError(f"Project ID {project_id} doesn't exist")
raise ControllerNotFoundError(
f"Project ID {project_id} doesn't exist", code="project_not_found", details={"project_id": project_id}
)
async def get_loaded_project(self, project_id):
"""
+2 -7
View File
@@ -19,19 +19,14 @@ import asyncio
import io
import ipaddress
import json
import logging
import socket
import sys
from asyncio import timeout as asynctimeout
import aiohttp
from fastapi import HTTPException
if sys.version_info >= (3, 11):
from asyncio import timeout as asynctimeout
else:
from async_timeout import timeout as asynctimeout
import logging
from ..controller.controller_error import (
ComputeConflictError,
ComputeError,
+23 -12
View File
@@ -15,11 +15,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 typing import Any
class ControllerError(Exception):
def __init__(self, message: str):
default_code = "conflict"
def __init__(self, message: str, code: str | None = None, details: dict[str, Any] | None = None):
super().__init__()
self._message = message
self._code = code or self.default_code
self._details = details
def __repr__(self):
return self._message
@@ -27,30 +33,33 @@ class ControllerError(Exception):
def __str__(self):
return self._message
@property
def code(self) -> str:
return self._code
@property
def details(self) -> dict[str, Any] | None:
return self._details
class ControllerNotFoundError(ControllerError):
def __init__(self, message: str):
super().__init__(message)
default_code = "not_found"
class ControllerBadRequestError(ControllerError):
def __init__(self, message: str):
super().__init__(message)
default_code = "bad_request"
class ControllerUnauthorizedError(ControllerError):
def __init__(self, message: str):
super().__init__(message)
default_code = "unauthorized"
class ControllerForbiddenError(ControllerError):
def __init__(self, message: str):
super().__init__(message)
default_code = "forbidden"
class ControllerTimeoutError(ControllerError):
def __init__(self, message: str):
super().__init__(message)
default_code = "timeout"
class ComputeError(ControllerError):
@@ -65,8 +74,10 @@ class ComputeConflictError(ComputeError):
:param response: compute JSON response
"""
default_code = "compute_conflict"
def __init__(self, url, response):
super().__init__(response["message"])
super().__init__(response["message"], code=response.get("code"), details=response.get("details"))
self._url = url
self._response = response
+34 -11
View File
@@ -399,9 +399,22 @@ class Link:
port = node.get_port(adapter_number, port_number)
if port is None:
raise ControllerNotFoundError(f"Port {adapter_number}/{port_number} for {node.name} not found")
raise ControllerNotFoundError(
f"Port {adapter_number}/{port_number} for {node.name} not found",
code="port_not_found",
details={"node_id": node.id, "adapter_number": adapter_number, "port_number": port_number},
)
if port.link is not None:
raise ControllerError("Port is already used")
raise ControllerError(
"Port is already used",
code="port_in_use",
details={
"node_id": node.id,
"adapter_number": adapter_number,
"port_number": port_number,
"link_id": port.link.id,
},
)
self._link_type = port.link_type
@@ -438,8 +451,15 @@ class Link:
{"node": node, "adapter_number": adapter_number, "port_number": port_number, "port": port, "label": label}
)
if not batch:
port.link = self
if len(self._nodes) == 2 and not batch:
await self.create()
try:
await self.create()
except BaseException:
self.release_ports()
raise
self._bind_members()
self._created = True
self._project.emit_notification("link.created", self.asdict())
@@ -447,6 +467,16 @@ class Link:
if dump:
self._project.dump()
def release_ports(self):
for n in self._nodes:
# The live port, for the same staleness reason as _bind_members;
# the link could be different from self if we rollback an
# already existing link
port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"]
if port.link == self:
port.link = None
n["node"].links.discard(self)
async def update_nodes(self, nodes):
for node_data in nodes:
node = self._project.get_node(node_data["node_id"])
@@ -490,14 +520,7 @@ class Link:
"""
Delete the link
"""
for n in self._nodes:
# The live port, for the same staleness reason as _bind_members;
# the link could be different from self if we rollback an
# already existing link
port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"]
if port.link == self:
port.link = None
n["node"].remove_link(self)
self.release_ports()
async def reset(self):
"""
+97 -23
View File
@@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import contextlib
import copy
import html
import logging
@@ -134,6 +135,8 @@ class Node:
self._command_line = None
self._node_directory = None
self._status = "stopped"
self._status_changed = asyncio.Event()
self._transition = None
self._template_id = template_id
self._x = 0
self._y = 0
@@ -785,7 +788,7 @@ class Node:
elif key == "command_line":
self._command_line = value
elif key == "status":
self._status = value
self._set_status(value)
elif key == "console_type":
self._console_type = value
elif key == "aux_type":
@@ -901,6 +904,30 @@ class Node:
async def destroy(self):
await self.delete()
@contextlib.asynccontextmanager
async def _transitioning(self, transition):
"""
Report a transitional status while a lifecycle call is in flight
"""
self._transition = transition
self.project.emit_notification("node.updated", self.asdict())
try:
yield
finally:
self._transition = None
async def wait_for_status(self, status):
"""
Wait until the controller side status of the node matches
:param status: expected status
"""
while self._status != status:
self._status_changed.clear()
await self._status_changed.wait()
async def start(self, data=None):
"""
Start a node
@@ -924,37 +951,61 @@ class Node:
f"Cannot start node '{self._name}': {len(failed_links)} deferred link(s) "
"could not be restored. Please try again."
)
try:
# For IOU: we need to send the licence everytime we start a node
if self.node_type == "iou":
license_check = self._project.controller.iou_license.get("license_check", True)
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
await self.post(
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
)
else:
await self.post("/start", data=data, timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
async with self._transitioning("starting"):
try:
# For IOU: we need to send the licence everytime we start a node
if self.node_type == "iou":
license_check = self._project.controller.iou_license.get("license_check", True)
iourc_content = self._project.controller.iou_license.get("iourc_content", None)
await self.post(
"/start", timeout=240, data={"license_check": license_check, "iourc_content": iourc_content}
)
else:
await self.post("/start", data=data, timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when starting {self._name}")
self._confirm_status("started")
# A started node may have brought kernel anchors into existence that
# a link needs to finish wiring (the Ethernet-switch fast path joins
# the peer's anchor on the switch side — see UDPLink.node_started).
for link in self._links:
await link.node_started(self)
async def stop(self):
async def stop(self, strict=False):
"""
Stop a node
:param strict: Raise the errors reported by the compute instead of ignoring them
"""
if self.missing_image:
return
try:
await self.post("/stop", timeout=240, dont_connect=True)
# We don't care if a node is down at this step
except (ComputeError, ControllerError):
pass
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when stopping {self._name}")
stopped = False
async with self._transitioning("stopping"):
try:
await self.post("/stop", timeout=240, dont_connect=True)
stopped = True
except (ComputeError, ControllerError):
# We don't care if a node is down at this step
if strict:
raise
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when stopping {self._name}")
if stopped:
self._confirm_status("stopped")
def _set_status(self, status):
self._status = status
self._status_changed.set()
def _confirm_status(self, status):
"""
Record the state reached once the compute accepted a lifecycle call,
without waiting for the asynchronous compute notification
"""
if not self.is_always_running():
self._set_status(status)
self.project.emit_notification("node.updated", self.asdict())
async def suspend(self):
"""
@@ -963,7 +1014,8 @@ class Node:
try:
await self.post("/suspend", timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reloading {self._name}")
raise ControllerTimeoutError(f"Timeout when suspending {self._name}")
self._confirm_status("suspended")
async def reload(self):
"""
@@ -973,6 +1025,7 @@ class Node:
await self.post("/reload", timeout=240)
except asyncio.TimeoutError:
raise ControllerTimeoutError(f"Timeout when reloading {self._name}")
self._confirm_status("started")
async def reset_console(self):
"""
@@ -1147,6 +1200,27 @@ class Node:
return port
return None
def get_port_by_name(self, name):
"""
Return the port with this name or None if the port is not found
"""
for port in self.ports:
if port.name == name:
return port
return None
def get_free_port(self, link_type=None):
"""
Return the lowest free port, optionally restricted to a link type,
or None if there is no such port
"""
free_ports = [
port for port in self.ports if port.link is None and (link_type is None or port.link_type == link_type)
]
if not free_ports:
return None
return min(free_ports, key=lambda port: (port.adapter_number, port.port_number))
def _list_ports(self):
"""
Rebuild the port list, preserving the link back-references.
@@ -1311,7 +1385,7 @@ class Node:
additional_data = {
"project_id": self._project.id,
"command_line": self._command_line,
"status": self._status,
"status": self._transition or self._status,
"console_host": str(self._compute.console_host),
"node_directory": self._node_directory,
"ports": [port.asdict() for port in self.ports],
+4
View File
@@ -41,6 +41,10 @@ class Port:
def link(self, val):
self._link = val
@property
def name(self):
return self._name
@property
def adapter_number(self):
return self._adapter_number
+14 -4
View File
@@ -748,7 +748,9 @@ class Project:
try:
return self._nodes[node_id]
except KeyError:
raise ControllerNotFoundError(f"Node ID {node_id} doesn't exist")
raise ControllerNotFoundError(
f"Node ID {node_id} doesn't exist", code="node_not_found", details={"node_id": node_id}
)
def _get_closed_data(self, section, id_key):
"""
@@ -817,7 +819,9 @@ class Project:
try:
return self._drawings[drawing_id]
except KeyError:
raise ControllerNotFoundError(f"Drawing ID {drawing_id} doesn't exist")
raise ControllerNotFoundError(
f"Drawing ID {drawing_id} doesn't exist", code="drawing_not_found", details={"drawing_id": drawing_id}
)
@open_required
async def delete_drawing(self, drawing_id):
@@ -1099,7 +1103,9 @@ class Project:
try:
return self._links[link_id]
except KeyError:
raise ControllerNotFoundError(f"Link ID {link_id} doesn't exist")
raise ControllerNotFoundError(
f"Link ID {link_id} doesn't exist", code="link_not_found", details={"link_id": link_id}
)
@property
def links(self):
@@ -1460,7 +1466,11 @@ class Project:
try:
return self._snapshots[snapshot_id]
except KeyError:
raise ControllerNotFoundError(f"Snapshot ID {snapshot_id} doesn't exist")
raise ControllerNotFoundError(
f"Snapshot ID {snapshot_id} doesn't exist",
code="snapshot_not_found",
details={"snapshot_id": snapshot_id},
)
def _load_snapshot_config(self):