From 31991fe359ef38d7a75c57bfdb44c605ab8c133b Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 13 Jul 2026 15:37:07 +0800 Subject: [PATCH] feat(marker): add project-level marker definition inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project-level marker definitions fan out to every link (existing and new). A definition is stored once on Project._marker_definitions; when applied to a link the marker is named "global-{def_name}" — the "global" prefix was pre-reserved in the schema, so inherited and per-link markers can never collide, nor will their registry keys. Key behavior: - POST /projects/{pid}/marker-definitions → fan out to all existing links - PUT /projects/{pid}/marker-definitions/{name} → sync all inherited copies - DELETE → remove every inherited copy from every link - New links auto-inherit all active defs (hook in UDPLink.create) - Per-link DELETE/PUT of a "global-*" marker is rejected (409) - Inherited markers are NOT persisted in the topology; they are re-created from _marker_definitions on project load - Compute side is untouched — the marker reaches uBridge via the existing start_marker→update→NIO→ubridge pipeline Files: - controller/project.py — _marker_definitions + CRUD + fanout + topology load - controller/link.py — Link.inherit_marker() + asdict() filter - controller/udp_link.py — guards on stop/update + create() inheritance hook - controller/topology.py — persist marker_definitions in project topology - schemas/controller/links.py — MarkerDefinitionCreate schema - api/routes/controller/projects.py — REST endpoints (marker-definitions) --- gns3server/api/routes/controller/projects.py | 94 ++++++++++++++ gns3server/controller/link.py | 30 ++++- gns3server/controller/project.py | 121 +++++++++++++++++++ gns3server/controller/topology.py | 1 + gns3server/controller/udp_link.py | 26 +++- gns3server/schemas/controller/links.py | 23 ++++ 6 files changed, 291 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/controller/projects.py b/gns3server/api/routes/controller/projects.py index 39c225594..6bfb84ba6 100644 --- a/gns3server/api/routes/controller/projects.py +++ b/gns3server/api/routes/controller/projects.py @@ -218,6 +218,100 @@ def get_project_markers(project: Project = Depends(dep_project)) -> dict: return project.markers +# --------------------------------------------------------------------------- +# Project-level marker definitions (global rules inherited by every link) +# --------------------------------------------------------------------------- + +@router.get( + "/{project_id}/marker-definitions", + dependencies=[Depends(has_privilege("Project.Audit"))] +) +def get_marker_definitions(project: Project = Depends(dep_project)) -> dict: + """ + Return all project-level marker definitions with their bound link IDs. + + Required privilege: Project.Audit + """ + + result = {} + for name, d in project.marker_definitions.items(): + # Collect which links currently carry an inherited copy. + bound = [ + lid for lid, link in project.links.items() + if f"global-{name}" in link.markers + and link.markers[f"global-{name}"].get("inherited_from") == name + ] + result[name] = {**d, "link_ids": bound} + return result + + +@router.post( + "/{project_id}/marker-definitions", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def create_marker_definition( + def_data: schemas.MarkerDefinitionCreate, + project: Project = Depends(dep_project) +) -> dict: + """ + Create a project-level marker definition and fan out to every link. + + Required privilege: Project.Modify + """ + + name = def_data.name or f"def-{project.id[:8]}" + await project.create_marker_definition( + name=name, + bpf=def_data.bpf, + tag=def_data.tag, + color=def_data.color, + ) + return project.marker_definitions.get(name, {}) + + +@router.put( + "/{project_id}/marker-definitions/{def_name}", + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def update_marker_definition( + def_name: str, + def_data: schemas.MarkerDefinitionCreate, + project: Project = Depends(dep_project) +) -> dict: + """ + Update a marker definition and sync all inherited copies on every link. + + Required privilege: Project.Modify + """ + + await project.update_marker_definition( + name=def_name, + bpf=def_data.bpf if def_data.bpf else None, + tag=def_data.tag, + color=def_data.color, + ) + return project.marker_definitions.get(def_name, {}) + + +@router.delete( + "/{project_id}/marker-definitions/{def_name}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(has_privilege("Project.Modify"))] +) +async def delete_marker_definition( + def_name: str, + project: Project = Depends(dep_project) +) -> None: + """ + Delete a marker definition and remove all inherited copies from every link. + + Required privilege: Project.Modify + """ + + await project.delete_marker_definition(def_name) + + @router.post( "/{project_id}/close", status_code=status.HTTP_204_NO_CONTENT, diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 0e959cef8..ac1e17c94 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -107,6 +107,32 @@ class Link: """ return self._markers + async def inherit_marker(self, def_name, marker_def): + """ + Apply a project-level marker definition to this link. + + The marker is stored under ``global-{def_name}`` so it cannot collide + with a per-link private marker of the same name. It carries an + ``inherited_from`` back-reference that (a) guards against per-link + edits and (b) lets the project sync changes to every copy at once. + """ + + await self.start_marker( + name=f"global-{def_name}", + bpf=marker_def["bpf"], + tag=marker_def.get("tag"), + color=marker_def.get("color"), + inherited_from=def_name, + ) + + def _persist_markers(self): + """ + Return only the per-link (non-inherited) markers suitable for + persistence in a topology dump. Inherited markers are re-created from + ``project._marker_definitions`` on load so they do not need to be saved. + """ + return {k: v for k, v in self._markers.items() if not v.get("inherited_from")} + @property def show_filters_icon(self): """ @@ -600,7 +626,7 @@ class Link: "nodes": res, "link_id": self._id, "filters": self._filters, - "markers": self._markers, + "markers": self._persist_markers(), "link_style": self._link_style, "suspend": self._suspended, "show_filters_icon": getattr(self, '_show_filters_icon', True), @@ -615,7 +641,7 @@ class Link: "capture_compute_id": self.capture_compute_id, "link_type": self._link_type, "filters": self._filters, - "markers": self._markers, + "markers": self._persist_markers(), "suspend": self._suspended, "link_style": self._link_style, "wireshark": self._wireshark, diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index 1c446082a..3eeeda20a 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -212,6 +212,7 @@ class Project: self._allocated_node_names = set() self._nodes = {} self._links = {} + self._marker_definitions = {} # name → {bpf, tag, color} self._drawings = {} self._snapshots = {} self._computes = [] @@ -922,6 +923,119 @@ class Project: } return result + @property + def marker_definitions(self): + """ + :returns: dict of project-level marker definitions (name → {bpf, tag, color}) + """ + return self._marker_definitions + + async def create_marker_definition(self, name, bpf, tag=None, color=None): + """ + Create a project-level marker definition and fan out to every existing + link that has a capable node. Links without a capable node are silently + skipped. + """ + + if name in self._marker_definitions: + raise ControllerError( + f"Marker definition '{name}' already exists in this project" + ) + + self._marker_definitions[name] = {"bpf": bpf, "tag": tag, "color": color} + await self._apply_def_to_all_links(name) + self.dump() + self.emit_notification("project.updated", self.asdict()) + + async def update_marker_definition(self, name, bpf=None, tag=None, color=None): + """ + Update a marker definition and sync every inherited copy on every link. + """ + + if name not in self._marker_definitions: + raise ControllerNotFoundError( + f"Marker definition '{name}' not found in this project" + ) + + d = self._marker_definitions[name] + if bpf is not None: + d["bpf"] = bpf + if tag is not None: + d["tag"] = tag + if color is not None: + d["color"] = color + + # Sync: update every inherited copy across all links. + for link in list(self._links.values()): + marker_name = f"global-{name}" + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + await link.update_marker( + marker_name, bpf=d["bpf"], tag=d.get("tag"), color=d.get("color") + ) + self.dump() + self.emit_notification("project.updated", self.asdict()) + + async def delete_marker_definition(self, name): + """ + Delete a marker definition and remove every inherited copy from every link. + """ + + if name not in self._marker_definitions: + raise ControllerNotFoundError( + f"Marker definition '{name}' not found in this project" + ) + + del self._marker_definitions[name] + + for link in list(self._links.values()): + marker_name = f"global-{name}" + if marker_name in link.markers and link.markers[marker_name].get("inherited_from") == name: + try: + await link.stop_marker(marker_name) + except ControllerError: + # A missing compute or broken link shouldn't block the delete. + log.warning( + "Failed to remove inherited marker %s from link %s", + marker_name, link.id + ) + + self.dump() + self.emit_notification("project.updated", self.asdict()) + + async def _apply_def_to_all_links(self, def_name): + """ + Fan out a single marker definition to every existing link in the project. + Links that have no capable node (``_MARKER_CAPABLE_TYPES``) are silently + skipped — the marker can only live on a uBridge bridge. + """ + + d = self._marker_definitions[def_name] + for link in list(self._links.values()): + try: + await link.inherit_marker(def_name, d) + except ControllerError as e: + # Per-link failures (e.g. no capable node) shouldn't block the + # definition from serving the rest. + log.warning( + "Marker definition '%s' could not be applied to link %s: %s", + def_name, link.id, e + ) + + async def apply_defs_to_new_link(self, link): + """ + Apply every active marker definition to a newly created link so it + inherits project-level rules automatically. + """ + + for def_name, d in self._marker_definitions.items(): + try: + await link.inherit_marker(def_name, d) + except ControllerError as e: + log.warning( + "Marker definition '%s' could not be applied to new link %s: %s", + def_name, link.id, e + ) + @property def snapshots(self): """ @@ -1296,6 +1410,7 @@ class Project: "auto_start", "auto_close", "auto_open", + "marker_definitions", "scene_height", "scene_width", "zoom", @@ -1378,6 +1493,11 @@ class Project: for drawing_data in topology.get("drawings", []): await self.add_drawing(dump=False, **drawing_data) + # After every link is loaded, apply project-level marker definitions + # so inherited markers are present from the start. + for link in list(self._links.values()): + await self.apply_defs_to_new_link(link) + self.dump() # We catch all error to be able to roll back the .gns3 to the previous state except Exception as e: @@ -1759,6 +1879,7 @@ class Project: "supplier": self._supplier, "variables": self._variables, "created_by": self._created_by, + "marker_definitions": self._marker_definitions, } def __repr__(self): diff --git a/gns3server/controller/topology.py b/gns3server/controller/topology.py index 150283c39..a71b88dd6 100644 --- a/gns3server/controller/topology.py +++ b/gns3server/controller/topology.py @@ -88,6 +88,7 @@ def project_to_topology(project): "variables": project.variables, "supplier": project.supplier, "created_by": project.created_by, + "marker_definitions": project.marker_definitions, "topology": {"nodes": [], "links": [], "computes": [], "drawings": []}, "type": "topology", "revision": GNS3_FILE_FORMAT_REVISION, diff --git a/gns3server/controller/udp_link.py b/gns3server/controller/udp_link.py index c52ec650f..dedb92b8e 100644 --- a/gns3server/controller/udp_link.py +++ b/gns3server/controller/udp_link.py @@ -145,6 +145,9 @@ class UDPLink(Link): await node1.delete(f"/adapters/{adapter_number1}/ports/{port_number1}/nio", timeout=120) raise e self._created = True + # New links automatically inherit every active project-level marker + # definition so the user doesn't have to reconfigure. + self._project.apply_defs_to_new_link(self) async def update(self): """ @@ -319,7 +322,7 @@ class UDPLink(Link): # explicitly deletes a marker via the REST API, and a marker is torn # down automatically only when its link is deleted. - async def start_marker(self, name, bpf, tag=None, color=None): + async def start_marker(self, name, bpf, tag=None, color=None, inherited_from=None): """ Attach a traffic-insight marker to this link. @@ -334,6 +337,8 @@ class UDPLink(Link): :param tag: optional correlation id :param color: optional hex color for the Web UI (e.g. '#ff5722'); stored with the link and persisted in the topology, never sent to uBridge + :param inherited_from: def name when this marker is a project-level + inheritance copy; set automatically, never exposed to REST callers """ if name in self._markers: @@ -344,13 +349,16 @@ class UDPLink(Link): raise ControllerError(f"Invalid BPF expression: {result.get('error', 'unknown error')}") marker_side = self._choose_marker_side() - self._markers[name] = { + marker_entry = { "bpf": bpf, "tag": tag, "enabled": True, "color": color, "capture_node_id": marker_side["node"].id, } + if inherited_from: + marker_entry["inherited_from"] = inherited_from + self._markers[name] = marker_entry if self._created: await self.update() self._project.emit_notification("link.updated", self.asdict()) @@ -370,6 +378,13 @@ class UDPLink(Link): if name not in self._markers: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + if self._markers[name].get("inherited_from"): + raise ControllerError( + f"Marker '{name}' is inherited from the project-level " + f"definition '{self._markers[name]['inherited_from']}'. " + "Delete or update it via the marker-definitions API instead." + ) + del self._markers[name] if self._created: await self.update() @@ -393,6 +408,13 @@ class UDPLink(Link): if not marker_info: raise ControllerNotFoundError(f"Marker '{name}' not found on link {self._id}") + if marker_info.get("inherited_from"): + raise ControllerError( + f"Marker '{name}' is inherited from the project-level " + f"definition '{marker_info['inherited_from']}'. " + "Update it via the marker-definitions API instead." + ) + if bpf is not None and bpf != marker_info["bpf"]: result = validate_bpf_syntax(bpf) if not result.get("valid"): diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index a3733c9ea..0f5b10235 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -168,3 +168,26 @@ class MarkerCreate(BaseModel): ) +class MarkerDefinitionCreate(BaseModel): + """ + Body for creating / updating a project-level marker definition. + + The definition is a template — when applied to a link the marker name is + prefixed with ``global-`` (e.g. ``arp`` → ``global-arp``) so it can never + collide with a per-link private marker. + """ + + name: Optional[str] = Field( + None, + pattern=r"^(?i)(?!global)[A-Za-z0-9][A-Za-z0-9_.-]*$", + max_length=128, + description="Unique definition name. Auto-generated when absent.", + ) + bpf: str + tag: Optional[int] = None + color: Optional[str] = Field( + None, + description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'", + ) + +