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

+15 -1
View File
@@ -17,7 +17,7 @@
# General schemas
from .common import ErrorMessage
from .config import ServerConfig
from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionV8
from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionCreate, ApplianceVersionV8
from .controller.computes import (
AutoIdlePC,
Compute,
@@ -37,6 +37,11 @@ from .controller.links import (
Link,
LinkCapture,
LinkCreate,
LinkFilterDefinition,
LinkFilterParameter,
LinkFilters,
LinkFilterType,
LinkIfaceInfo,
LinkUpdate,
MarkerCreate,
MarkerDefinitionCreate,
@@ -119,6 +124,7 @@ from .compute.vpcs_nodes import VPCS, VPCSCreate, VPCSUpdate
from .controller.capabilities import Capabilities
from .controller.iou_license import IOULicense
from .controller.netmiko import NetmikoDeviceType, NetmikoDeviceTypeList
from .controller.notifications import Notification, NotificationAction
from .controller.pools import Resource, ResourceCreate, ResourcePool, ResourcePoolCreate, ResourcePoolUpdate
from .controller.rbac import ACE, ACECreate, ACEUpdate, Privilege, Role, RoleCreate, RoleUpdate
from .controller.settings import SettingsResponse, SettingsUpdate, SettingsUpdateResponse
@@ -173,6 +179,7 @@ __all__ = [
"ApiKeyCreate",
"Appliance",
"ApplianceVersion",
"ApplianceVersionCreate",
"ApplianceVersionV8",
"AutoIdlePC",
"BatchNIOCreate",
@@ -256,6 +263,11 @@ __all__ = [
"Link",
"LinkCapture",
"LinkCreate",
"LinkFilterDefinition",
"LinkFilterParameter",
"LinkFilterType",
"LinkFilters",
"LinkIfaceInfo",
"LinkUpdate",
"LoggedInUserUpdate",
"MarkerCreate",
@@ -273,6 +285,8 @@ __all__ = [
"NodeDuplicate",
"NodeFile",
"NodeUpdate",
"Notification",
"NotificationAction",
"OpenAIMessage",
"OpenAIToolCall",
"Privilege",
+8 -1
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum
from typing import Optional
from typing import Any, Optional
from pydantic import BaseModel, Field, field_validator
@@ -26,6 +26,11 @@ class ErrorMessage(BaseModel):
"""
message: str
code: Optional[str] = Field(
None,
description="Stable machine-readable error code, for example port_in_use or node_not_found",
)
details: Optional[dict[str, Any]] = Field(None, description="Optional structured data about the error")
class NodeStatus(str, Enum):
@@ -36,6 +41,8 @@ class NodeStatus(str, Enum):
stopped = "stopped"
started = "started"
suspended = "suspended"
starting = "starting"
stopping = "stopping"
class CustomAdapter(BaseModel):
+3
View File
@@ -155,6 +155,9 @@ class ServerSettings(BaseModel):
False, description="Local server mode, set by the --local command line argument (not meant to be set by hand)"
)
enable_http_auth: bool = Field(True, description="Enable compute HTTP authentication")
openapi_include_ai: bool = Field(
True, description="Include the AI Copilot, LLM and MCP routes in the generated OpenAPI specification"
)
name: str = Field(
f"{socket.gethostname()} (controller)",
description="Server name, default is what is returned by socket.gethostname()",
+34 -1
View File
@@ -20,7 +20,7 @@ from enum import Enum
from typing import Annotated, List, Literal, Optional, Union
from uuid import UUID
from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag
from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag
from ..common import ExtraConfig
@@ -648,6 +648,39 @@ class ApplianceVersionV8(BaseModel):
images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version")
_V8_VERSION_ONLY_FIELDS = frozenset(set(ApplianceVersionV8.model_fields) - set(ApplianceVersion.model_fields))
class ApplianceVersionCreateV1_6(ApplianceVersion):
"""Request body to add a version to an appliance with registry version 1-6"""
registry_version: Literal[1, 2, 3, 4, 5, 6] = Field(
1, title="Version of the registry compatible with the appliance receiving the version"
)
class ApplianceVersionCreateV8(ApplianceVersionV8):
"""Request body to add a version to an appliance with registry version 8"""
registry_version: Literal[8] = Field(
8, title="Version of the registry compatible with the appliance receiving the version"
)
def _infer_version_registry_version(data):
if isinstance(data, dict) and "registry_version" not in data:
data = dict(data)
data["registry_version"] = 8 if _V8_VERSION_ONLY_FIELDS.intersection(data) else 1
return data
ApplianceVersionCreate = Annotated[
Union[ApplianceVersionCreateV1_6, ApplianceVersionCreateV8],
BeforeValidator(_infer_version_registry_version),
Discriminator("registry_version"),
]
# ============================================================================
# Child Models with Discriminated Union
# ============================================================================
+122 -4
View File
@@ -15,10 +15,10 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum
from typing import List, Optional, Tuple
from typing import Annotated, List, Literal, Optional, Tuple, Union
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator
from ..update import PartialUpdateModel
from .labels import Label
@@ -35,6 +35,32 @@ class LinkNode(BaseModel):
label: Optional[Label] = None
class LinkNodeCreate(BaseModel):
"""
Link node data for link creation.
The port is given either by adapter_number and port_number, by port_name
or by port set to "auto" (first free compatible port).
"""
node_id: UUID
adapter_number: Optional[int] = None
port_number: Optional[int] = None
port_name: Optional[str] = Field(None, description="Name of the port on the node")
port: Optional[Literal["auto"]] = Field(None, description='Use "auto" to select the first free compatible port')
label: Optional[Label] = None
@model_validator(mode="after")
def check_port_selector(self):
numbered = self.adapter_number is not None or self.port_number is not None
if numbered and (self.adapter_number is None or self.port_number is None):
raise ValueError("adapter_number and port_number must be given together")
forms = sum([numbered, self.port_name is not None, self.port is not None])
if forms != 1:
raise ValueError("exactly one of adapter_number and port_number, port_name or port must be given")
return self
class LinkType(str, Enum):
"""
Link type.
@@ -54,6 +80,87 @@ class LinkStyle(BaseModel):
control_offset: Optional[Tuple[float, float]] = None
class LinkFilterType(str, Enum):
"""
Packet filter type. The first five run on relay and kernel-datapath links
alike; the rest are kernel-datapath only (tc netem / cls_bpf / the eBPF
classifier on the host anchor).
"""
frequency_drop = "frequency_drop"
packet_loss = "packet_loss"
delay = "delay"
corrupt = "corrupt"
bpf = "bpf"
rate = "rate"
reorder = "reorder"
gemodel = "gemodel"
duplicate = "duplicate"
seed = "seed"
limit = "limit"
quota = "quota"
window_drop = "window_drop"
class LinkFilters(BaseModel):
"""
Packet filters applied on a link. Each filter is an array of positional values.
A filter set to an empty array or to its disabled value (0, 0 for delay, empty text for bpf) is not applied.
"""
model_config = ConfigDict(extra="forbid")
frequency_drop: Optional[List[Annotated[int, Field(ge=-1, le=32767)]]] = Field(
None,
max_length=1,
description="[frequency]: -1 drops every packet, N > 0 drops every Nth packet, 0 disables the filter",
)
packet_loss: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field(
None,
max_length=1,
description="[chance]: percentage chance for a packet to be lost, 0 disables the filter",
)
delay: Optional[List[Annotated[int, Field(ge=0, le=32767)]]] = Field(
None,
max_length=2,
description="[latency, jitter] in milliseconds: latency must be 1 to 32767 unless both values are 0, "
"which disables the filter; jitter is 0 to 32767",
)
corrupt: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field(
None,
max_length=1,
description="[chance]: percentage chance for a packet to be corrupted, 0 disables the filter",
)
bpf: Optional[List[str]] = Field(
None,
max_length=1,
description="[expressions]: BPF expressions, one per line, matching packets are dropped",
)
class LinkFilterParameter(BaseModel):
"""
Parameter of a packet filter.
"""
name: str
type: Literal["int", "text", "str"]
minimum: Optional[int] = None
maximum: Optional[int] = None
unit: Optional[str] = None
class LinkFilterDefinition(BaseModel):
"""
Packet filter available on a link.
"""
type: LinkFilterType
name: str
description: str
parameters: List[LinkFilterParameter]
class LinkBase(BaseModel):
"""
Link data.
@@ -62,7 +169,12 @@ class LinkBase(BaseModel):
nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2)
suspend: Optional[bool] = None
link_style: Optional[LinkStyle] = None
filters: Optional[dict] = None
filters: Optional[dict] = Field(
None,
description="Packet filters applied on a link: filter type → array of positional values. "
"The vocabulary is capability-gated per link (see /available_filters) and validated by the "
"controller, so it is not enumerated here",
)
markers: Optional[dict] = Field(
None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
)
@@ -71,7 +183,8 @@ class LinkBase(BaseModel):
class LinkCreate(LinkBase):
link_id: UUID = Field(default_factory=uuid4)
nodes: List[LinkNode] = Field(..., min_length=2, max_length=2)
# LinkNodeCreate narrows the port selector, so it cannot be a LinkNode subclass
nodes: List[LinkNodeCreate] = Field(..., min_length=2, max_length=2) # type: ignore[assignment]
class LinkUpdate(PartialUpdateModel, LinkBase):
@@ -113,6 +226,7 @@ class UDPPortInfo(BaseModel):
rhost: str
rport: int
type: str
kind: Literal["udp"]
class EthernetPortInfo(BaseModel):
@@ -123,6 +237,10 @@ class EthernetPortInfo(BaseModel):
node_id: UUID
interface: str
type: str
kind: Literal["ethernet"]
LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")]
class LinkCapture(BaseModel):
@@ -0,0 +1,154 @@
#
# Copyright (C) 2026 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/>.
from enum import Enum
from typing import Any, Dict, List, Optional, Union
from uuid import UUID
from pydantic import BaseModel, Field
from .computes import Compute
from .drawings import Drawing
from .links import Link
from .nodes import Node
from .projects import Project
from .snapshots import Snapshot
class NotificationAction(str, Enum):
"""
Values of the "action" field of a notification.
"""
ping = "ping"
log_info = "log.info"
log_warning = "log.warning"
log_error = "log.error"
node_created = "node.created"
node_updated = "node.updated"
node_deleted = "node.deleted"
node_interface_status = "node.interface_status"
link_created = "link.created"
link_updated = "link.updated"
link_deleted = "link.deleted"
link_web_wireshark_started = "link.web_wireshark_started"
drawing_created = "drawing.created"
drawing_updated = "drawing.updated"
drawing_deleted = "drawing.deleted"
marker_match = "marker.match"
snapshot_restored = "snapshot.restored"
project_created = "project.created"
project_updated = "project.updated"
project_opened = "project.opened"
project_closed = "project.closed"
project_deleted = "project.deleted"
compute_created = "compute.created"
compute_updated = "compute.updated"
compute_deleted = "compute.deleted"
template_created = "template.created"
template_updated = "template.updated"
template_deleted = "template.deleted"
settings_updated = "settings.updated"
class PingEvent(BaseModel):
"""
Event of a "ping" notification, sent when the stream is opened and then at least every 5 seconds.
"""
cpu_usage_percent: float = Field(..., description="CPU usage of the server in percent")
memory_usage_percent: float = Field(..., description="Memory usage of the server in percent")
disk_usage_percent: float = Field(..., description="Disk usage of the project directory in percent")
compute_id: Optional[str] = Field(None, description="Compute that sent the ping (project streams only)")
class LogEvent(BaseModel):
"""
Event of a "log.info", "log.warning" or "log.error" notification.
"""
message: str = Field(..., description="Log message")
class NodeInterfaceStatusEvent(BaseModel):
"""
Event of a "node.interface_status" notification.
"""
project_id: UUID
node_id: UUID
adapter_number: int
port_number: int
status: str = Field(..., description="Status of the interface")
class MarkerMatchEvent(BaseModel):
"""
Event of a "marker.match" notification, only sent on the markers WebSocket.
"""
project_id: UUID
node_id: UUID
link_id: Optional[UUID] = None
filter: str = Field(..., description="Name of the packet filter that matched")
tag: Optional[int] = Field(None, description="Tag of the marker")
ts: float = Field(..., description="Timestamp reported by uBridge (seconds since the epoch)")
len: int = Field(0, description="Length of the matching packet")
dir: Optional[str] = Field(None, description="Direction of the packet relative to the capture node")
class LinkWebWiresharkStartedEvent(BaseModel):
"""
Event of a "link.web_wireshark_started" notification.
"""
link_id: UUID
ws_url: Optional[str] = Field(None, description="URL of the Web Wireshark session")
class SettingsUpdatedEvent(BaseModel):
"""
Event of a "settings.updated" notification.
"""
changed: List[str] = Field(..., description="Names of the settings that changed (values are never sent)")
restart_required: List[str] = Field(..., description="Names of the changed settings that need a server restart")
class Notification(BaseModel):
"""
A message of the notification streams. HTTP streams send one JSON object per line (application/x-ndjson),
WebSocket streams send one JSON object per text frame.
"""
action: NotificationAction = Field(..., description="Type of the notification")
event: Union[
PingEvent,
LogEvent,
NodeInterfaceStatusEvent,
MarkerMatchEvent,
LinkWebWiresharkStartedEvent,
SettingsUpdatedEvent,
Node,
Link,
Drawing,
Project,
Compute,
Snapshot,
Dict[str, Any],
] = Field(..., description="Payload of the notification, its content depends on the action")
project_id: Optional[UUID] = Field(None, description="Project the notification belongs to, when set by the sender")
compute_id: Optional[str] = Field(None, description="Compute that emitted the notification, when set by the sender")
+3 -2
View File
@@ -15,6 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime
from enum import Enum
from typing import List, Optional
from uuid import UUID
@@ -111,8 +112,8 @@ class NodeFile(BaseModel):
path: str = Field(..., description="File name")
size: int = Field(..., description="File size in bytes")
created_at: str = Field(..., description="File creation time (ISO 8601)")
modified_at: str = Field(..., description="File modification time (ISO 8601)")
created_at: Optional[datetime] = Field(None, description="File creation time (ISO 8601)")
modified_at: Optional[datetime] = Field(None, description="File modification time (ISO 8601)")
file_type: str = Field(..., description="File type determined by the file command")
@@ -103,6 +103,7 @@ class ServerSettingsUpdate(BaseModel):
local: Optional[bool] = None
enable_http_auth: Optional[bool] = None
openapi_include_ai: Optional[bool] = None
name: Optional[str] = None
protocol: Optional[ServerProtocol] = None
host: Optional[str] = None
+13 -2
View File
@@ -15,10 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from datetime import datetime, timezone
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
class SnapshotBase(BaseModel):
@@ -44,4 +45,14 @@ class Snapshot(SnapshotBase):
name: str = Field(..., description="Name of the snapshot")
filename: str = Field(..., description="Filename of the snapshot")
description: str = Field(..., description="Description of the snapshot")
created_at: int = Field(..., description="Date of the snapshot (UTC timestamp)")
created_at: int = Field(
..., description="Date of the snapshot (UTC timestamp). Deprecated: use created", deprecated=True
)
created: datetime = Field(..., description="Date of the snapshot (ISO 8601, UTC)")
@model_validator(mode="before")
@classmethod
def _derive_created(cls, data):
if isinstance(data, dict) and "created" not in data and data.get("created_at") is not None:
data = {**data, "created": datetime.fromtimestamp(data["created_at"], tz=timezone.utc)}
return data
+2
View File
@@ -22,7 +22,9 @@ from pydantic import BaseModel
class Token(BaseModel):
access_token: str
token_type: str
expires_in: int
refresh_token: Optional[str] = None
refresh_expires_in: Optional[int] = None
class TokenData(BaseModel):
+6 -1
View File
@@ -32,6 +32,11 @@ from .nodes import Node
from .projects import Supplier, Variable
class TopologyLink(Link):
# Legacy topology files store filters as untyped dicts
filters: Optional[dict] = None
class TopologyType(str, Enum):
topology = "topology"
@@ -39,7 +44,7 @@ class TopologyType(str, Enum):
class TopologyContent(BaseModel):
computes: List[Compute] = Field(..., description="List of computes")
drawings: List[Drawing] = Field(..., description="List of drawings")
links: List[Link] = Field(..., description="List of links")
links: List[TopologyLink] = Field(..., description="List of links")
nodes: List[Node] = Field(..., description="List of nodes")