mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-11-03 05:51:15 +02:00
fix(links): reserve ports on add, accept port name and auto, add port_in_use code
This commit is contained in:
10 files changed
+286
-21
No files matched your search
@@ -32,7 +32,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
from gns3server import schemas
|
||||
from gns3server.agent.web_wireshark.manager import WebWiresharkManager
|
||||
from gns3server.controller import Controller
|
||||
from gns3server.controller.controller_error import ControllerError
|
||||
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError
|
||||
from gns3server.controller.link import _UNSET, Link
|
||||
from gns3server.db.repositories.rbac import RbacRepository
|
||||
from gns3server.utils.http_client import HTTPClient
|
||||
@@ -51,6 +51,29 @@ responses: dict[int | str, dict[str, Any]] = {
|
||||
router = APIRouter(responses=responses)
|
||||
|
||||
|
||||
def _select_port(node, endpoint, other_port):
|
||||
if endpoint.get("port_name") is not None:
|
||||
port = node.get_port_by_name(endpoint["port_name"])
|
||||
if port is None:
|
||||
raise ControllerNotFoundError(f"Port named {endpoint['port_name']} for {node.name} not found")
|
||||
return port
|
||||
if endpoint.get("port") == "auto":
|
||||
port = node.get_free_port(other_port.link_type if other_port else None)
|
||||
if port is None:
|
||||
raise ControllerError(f"No free port available on {node.name}", code="no_free_port")
|
||||
return port
|
||||
return None
|
||||
|
||||
|
||||
def _peer_port(project, endpoint):
|
||||
if endpoint.get("port") == "auto":
|
||||
return None
|
||||
node = project.get_node(endpoint["node_id"])
|
||||
if endpoint.get("port_name") is not None:
|
||||
return node.get_port_by_name(endpoint["port_name"])
|
||||
return node.get_port(endpoint["adapter_number"], endpoint["port_number"])
|
||||
|
||||
|
||||
async def dep_link(project_id: UUID, link_id: UUID) -> Link:
|
||||
"""
|
||||
Dependency to retrieve a link.
|
||||
@@ -110,14 +133,18 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche
|
||||
if "show_filters_icon" in link_data:
|
||||
await link.update_show_filters_icon(link_data["show_filters_icon"])
|
||||
try:
|
||||
for node in link_data["nodes"]:
|
||||
await link.add_node(
|
||||
project.get_node(node["node_id"]),
|
||||
node.get("adapter_number", 0),
|
||||
node.get("port_number", 0),
|
||||
label=node.get("label"),
|
||||
)
|
||||
endpoints = link_data["nodes"]
|
||||
attached_port = None
|
||||
for index, endpoint in enumerate(endpoints):
|
||||
node = project.get_node(endpoint["node_id"])
|
||||
peer_port = attached_port or _peer_port(project, endpoints[1 - index])
|
||||
port = _select_port(node, endpoint, peer_port)
|
||||
adapter_number = port.adapter_number if port else endpoint["adapter_number"]
|
||||
port_number = port.port_number if port else endpoint["port_number"]
|
||||
await link.add_node(node, adapter_number, port_number, label=endpoint.get("label"))
|
||||
attached_port = node.get_port(adapter_number, port_number)
|
||||
except ControllerError as e:
|
||||
link.release_ports()
|
||||
await project.delete_link(link.id)
|
||||
raise e
|
||||
return link.asdict()
|
||||
|
||||
@@ -151,9 +151,12 @@ async def redoc_html():
|
||||
@app.exception_handler(ControllerError)
|
||||
async def controller_error_handler(request: Request, exc: ControllerError):
|
||||
log.error(f"Controller error in {request.url.path} ({request.method}): {exc}")
|
||||
content = {"message": str(exc)}
|
||||
if exc.code:
|
||||
content["code"] = exc.code
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
content={"message": str(exc)},
|
||||
content=content,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -17,9 +17,14 @@
|
||||
|
||||
|
||||
class ControllerError(Exception):
|
||||
def __init__(self, message: str):
|
||||
def __init__(self, message: str, code: str | None = None):
|
||||
super().__init__()
|
||||
self._message = message
|
||||
self._code = code
|
||||
|
||||
@property
|
||||
def code(self):
|
||||
return self._code
|
||||
|
||||
def __repr__(self):
|
||||
return self._message
|
||||
|
||||
@@ -276,7 +276,7 @@ class Link:
|
||||
if port is None:
|
||||
raise ControllerNotFoundError(f"Port {adapter_number}/{port_number} for {node.name} not found")
|
||||
if port.link is not None:
|
||||
raise ControllerError("Port is already used")
|
||||
raise ControllerError("Port is already used", code="port_in_use")
|
||||
|
||||
self._link_type = port.link_type
|
||||
|
||||
@@ -313,17 +313,29 @@ 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
|
||||
for n in self._nodes:
|
||||
n["node"].add_link(self)
|
||||
n["port"].link = self
|
||||
self._created = True
|
||||
self._project.emit_notification("link.created", self.asdict())
|
||||
|
||||
if dump:
|
||||
self._project.dump()
|
||||
|
||||
def release_ports(self):
|
||||
for n in self._nodes:
|
||||
if n["port"].link == self:
|
||||
n["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"])
|
||||
@@ -352,11 +364,7 @@ class Link:
|
||||
"""
|
||||
Delete the link
|
||||
"""
|
||||
for n in self._nodes:
|
||||
# It could be different of self if we rollback an already existing link
|
||||
if n["port"].link == self:
|
||||
n["port"].link = None
|
||||
n["node"].remove_link(self)
|
||||
self.release_ports()
|
||||
|
||||
async def reset(self):
|
||||
"""
|
||||
|
||||
@@ -1142,6 +1142,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):
|
||||
"""
|
||||
Generate the list of port display in the client
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,6 +26,7 @@ class ErrorMessage(BaseModel):
|
||||
"""
|
||||
|
||||
message: str
|
||||
code: Optional[str] = None
|
||||
|
||||
|
||||
class NodeStatus(str, Enum):
|
||||
|
||||
@@ -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 List, Literal, Optional, Tuple
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, 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.
|
||||
@@ -71,7 +97,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):
|
||||
|
||||
Reference in new issue
Block a user