From 56ece7ce8831028df9a50132332b92f3dc74619d Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 20 Feb 2026 23:38:00 +0800 Subject: [PATCH 1/8] Add vendor, model and netmiko_device_type fields to templates Add three new optional fields to the template schema: - vendor: Device vendor (e.g., Cisco, Juniper, Huawei) - model: Device model (e.g., ISR4451-X, MX204, NE40E) - netmiko_device_type: Netmiko device type for automation (e.g., cisco_ios, juniper_junos, huawei) These fields enable AI/LLM integration to determine the appropriate Netmiko device type when automating network device configuration. Co-Authored-By: Claude Sonnet 4.5 --- gns3server/db/models/templates.py | 3 +++ gns3server/schemas/controller/templates/__init__.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index c43642295..a7ababe13 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -36,6 +36,9 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) + vendor = Column(String) + model = Column(String) + netmiko_device_type = Column(String) compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 78cd9cc25..04b4b9bf8 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -48,6 +48,9 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" + vendor: Optional[str] = Field(None, description="Device vendor (e.g., Cisco, Juniper, Huawei)") + model: Optional[str] = Field(None, description="Device model (e.g., ISR4451-X, MX204, NE40E)") + netmiko_device_type: Optional[str] = Field(None, description="Netmiko device type for automation (e.g., cisco_ios, juniper_junos, huawei)") class TemplateCreate(TemplateBase): From a3616e6d33cf9ae2598b1b2a1759e5f178f71191 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 22 Feb 2026 22:52:28 +0800 Subject: [PATCH 2/8] feat(api): add tag filtering to nodes and templates endpoints - Add optional `tags` query parameter to `/nodes` and `/templates` endpoints - Support filtering by tags in format "key:value" with multiple tags ANDed together - Example: `?tags=vendor:cisco&tags=model:7200` filters nodes/templates with both tags - Maintain backward compatibility for existing API usage without tags parameter --- gns3server/api/routes/controller/nodes.py | 43 ++++++++++++++++--- gns3server/api/routes/controller/templates.py | 32 +++++++++++++- gns3server/controller/node.py | 13 ++++++ gns3server/db/models/templates.py | 6 +-- gns3server/schemas/controller/nodes.py | 6 ++- .../schemas/controller/templates/__init__.py | 9 ++-- 6 files changed, 93 insertions(+), 16 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index f9cac15b1..c7d8668a5 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -22,10 +22,10 @@ import aiohttp import asyncio import ipaddress -from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status +from fastapi import APIRouter, Depends, WebSocket, WebSocketDisconnect, Request, Response, status, Query from fastapi.encoders import jsonable_encoder from fastapi.routing import APIRoute -from typing import List, Callable +from typing import List, Callable, Optional from uuid import UUID from gns3server.controller import Controller @@ -135,17 +135,50 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends( response_model_exclude_unset=True, dependencies=[Depends(has_privilege("Node.Audit"))] ) -def get_nodes(project: Project = Depends(dep_project)) -> List[schemas.Node]: +def get_nodes( + project: Project = Depends(dep_project), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") +) -> List[schemas.Node]: """ Return all nodes belonging to a given project. Required privilege: Node.Audit + + Query Parameters: + - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ if project.status == "closed": # allow to retrieve nodes from a closed project - return project.nodes.values() - return [v.asdict() for v in project.nodes.values()] + nodes = list(project.nodes.values()) + else: + nodes = [v.asdict() for v in project.nodes.values()] + + # Filter by tags if provided + if tags: + filtered_nodes = [] + for node in nodes: + node_dict = node.asdict() if hasattr(node, 'asdict') else node + node_tags = node_dict.get("tags") or {} + # Check if all tag filters match + match = True + for tag_filter in tags: + if ":" in tag_filter: + key, value = tag_filter.split(":", 1) + if node_tags.get(key) != value: + match = False + break + else: + # Check if key exists + if tag_filter not in node_tags: + match = False + break + if match: + filtered_nodes.append(node) + nodes = filtered_nodes + + return nodes @router.post("/start", status_code=status.HTTP_204_NO_CONTENT, dependencies=[Depends(has_privilege("Node.PowerMgmt"))]) diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index 801ea2975..4594fd635 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -26,7 +26,7 @@ import logging log = logging.getLogger(__name__) -from fastapi import APIRouter, Request, HTTPException, Depends, Response, status +from fastapi import APIRouter, Request, HTTPException, Depends, Response, status, Query from typing import List, Optional from uuid import UUID @@ -169,15 +169,43 @@ async def delete_template( async def get_templates( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)) + rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Template]: """ Return all templates. Required privilege: Template.Audit + + Query Parameters: + - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + Example: ?tags=vendor:cisco&tags=model:7200 """ templates = await TemplatesService(templates_repo).get_templates() + + # Filter by tags if provided + if tags: + filtered_templates = [] + for template in templates: + template_tags = template.get("tags") or {} + # Check if all tag filters match + match = True + for tag_filter in tags: + if ":" in tag_filter: + key, value = tag_filter.split(":", 1) + if template_tags.get(key) != value: + match = False + break + else: + # Check if key exists + if tag_filter not in template_tags: + match = False + break + if match: + filtered_templates.append(template) + templates = filtered_templates + if current_user.is_superadmin: return templates else: diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index b3d77aa54..3fa2fad50 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -97,6 +97,7 @@ class Node: self._y = 0 self._z = 1 # default z value is 1 self._locked = False + self._tags = {} self._ports = None self._symbol = None self._custom_adapters = [] @@ -218,6 +219,17 @@ class Node: def properties(self, val): self._properties = val + @property + def tags(self): + return self._tags + + @tags.setter + def tags(self, val): + if isinstance(val, dict): + self._tags = val + else: + self._tags = {} + def _base_config_file_content(self, path): if not os.path.isabs(path): path = os.path.join(self.project.controller.configs_path(), path) @@ -823,6 +835,7 @@ class Node: "port_segment_size": self._port_segment_size, "first_port_name": self._first_port_name, "custom_adapters": self._custom_adapters, + "tags": self._tags, } if topology_dump: diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index a7ababe13..699ef1c0e 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -16,7 +16,7 @@ # along with this program. If not, see . -from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType +from sqlalchemy import Boolean, Column, String, Integer, Float, ForeignKey, PickleType, JSON from sqlalchemy.orm import relationship from .base import BaseTable, generate_uuid, GUID @@ -36,9 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) - vendor = Column(String) - model = Column(String) - netmiko_device_type = Column(String) + tags = Column(JSON, default='{}') compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index d732c80a9..52d76595b 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import BaseModel, Field, model_validator -from typing import List, Optional, Union, Any +from typing import List, Optional, Union, Any, Dict from enum import Enum from uuid import UUID, uuid4 @@ -133,6 +133,10 @@ class NodeBase(BaseModel): port_segment_size: Optional[int] = Field(None, description="Size of the port segment") first_port_name: Optional[str] = Field(None, description="Name of the first port") custom_adapters: Optional[List[CustomAdapter]] = None + tags: Optional[Dict[str, str]] = Field( + default_factory=dict, + description="User-defined metadata tags inherited from template or custom" + ) class NodeCreate(NodeBase): diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 04b4b9bf8..ef1e2bae7 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import ConfigDict, BaseModel, Field -from typing import Optional, Union +from typing import Optional, Union, Dict from enum import Enum from uuid import UUID @@ -48,9 +48,10 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" - vendor: Optional[str] = Field(None, description="Device vendor (e.g., Cisco, Juniper, Huawei)") - model: Optional[str] = Field(None, description="Device model (e.g., ISR4451-X, MX204, NE40E)") - netmiko_device_type: Optional[str] = Field(None, description="Netmiko device type for automation (e.g., cisco_ios, juniper_junos, huawei)") + tags: Optional[Dict[str, str]] = Field( + default_factory=dict, + description="User-defined metadata tags as key-value pairs (e.g., {'vendor': 'cisco', 'model': '7200', 'netmiko_device_type': 'cisco_ios'})" + ) class TemplateCreate(TemplateBase): From e27ddf2cdf3c8b9d645de9d502a453abd5aef6b0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 22 Feb 2026 23:43:47 +0800 Subject: [PATCH 3/8] feat(api): fix ETag handling and JSON serialization in template endpoint - Use `default=str` in `json.dumps` to handle non-serializable objects - Return proper HTTP 304 response with ETag header instead of raising exception - Ensure consistent ETag generation for template caching --- gns3server/api/routes/controller/templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index 4594fd635..edaf9692d 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -88,10 +88,10 @@ async def get_template( request_etag = request.headers.get("If-None-Match", "") template = await TemplatesService(templates_repo).get_template(template_id) - data = json.dumps(template) + data = json.dumps(template, default=str) template_etag = '"' + hashlib.md5(data.encode()).hexdigest() + '"' if template_etag == request_etag: - raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED) + return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers={"ETag": template_etag}) else: response.headers["ETag"] = template_etag return template From c05f667f3d67c9a993925d2437e6d54bdb3841f7 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Mon, 23 Feb 2026 01:02:32 +0800 Subject: [PATCH 4/8] feat(templates): safely access settings dict keys Use dict.get() method to safely access 'image' and 'path' keys in settings dictionary to avoid KeyError exceptions when these keys are missing. This improves error handling and prevents server crashes when creating templates with incomplete settings. --- gns3server/services/templates.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gns3server/services/templates.py b/gns3server/services/templates.py index 0589f6797..2caf9fa78 100644 --- a/gns3server/services/templates.py +++ b/gns3server/services/templates.py @@ -197,7 +197,7 @@ class TemplatesService: images_to_add_to_template = [] if template_type == "dynamips": - if settings["image"]: + if settings.get("image"): image = await self._find_image(settings["image"]) if image.image_type != "ios": raise ControllerBadRequestError( @@ -205,7 +205,7 @@ class TemplatesService: ) images_to_add_to_template.append(image) elif template_type == "iou": - if settings["path"]: + if settings.get("path"): image = await self._find_image(settings["path"]) if image.image_type != "iou": raise ControllerBadRequestError( From bdcb6445c79d55334a8b258287a583db0dfd0c98 Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Feb 2026 23:26:42 +0800 Subject: [PATCH 5/8] Use lists for tags instead of dicts --- gns3server/api/routes/controller/nodes.py | 26 ++++++------------- gns3server/api/routes/controller/templates.py | 22 +++++----------- gns3server/controller/node.py | 7 ++--- gns3server/db/models/templates.py | 2 +- gns3server/schemas/controller/nodes.py | 12 ++++----- .../schemas/controller/templates/__init__.py | 8 +++--- 6 files changed, 28 insertions(+), 49 deletions(-) diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index c7d8668a5..d38384470 100644 --- a/gns3server/api/routes/controller/nodes.py +++ b/gns3server/api/routes/controller/nodes.py @@ -137,7 +137,7 @@ async def create_node(node_data: schemas.NodeCreate, project: Project = Depends( ) def get_nodes( project: Project = Depends(dep_project), - tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Node]: """ Return all nodes belonging to a given project. @@ -145,7 +145,7 @@ def get_nodes( Required privilege: Node.Audit Query Parameters: - - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + - tags: Filter by tags. Multiple tags are ANDed together. Example: ?tags=vendor:cisco&tags=model:7200 """ @@ -155,29 +155,19 @@ def get_nodes( else: nodes = [v.asdict() for v in project.nodes.values()] - # Filter by tags if provided + # Filter by tags if provided (all filter tags have to match the node tags) if tags: filtered_nodes = [] for node in nodes: - node_dict = node.asdict() if hasattr(node, 'asdict') else node - node_tags = node_dict.get("tags") or {} - # Check if all tag filters match + node_tags = node.get("tags") or [] match = True for tag_filter in tags: - if ":" in tag_filter: - key, value = tag_filter.split(":", 1) - if node_tags.get(key) != value: - match = False - break - else: - # Check if key exists - if tag_filter not in node_tags: - match = False - break + if tag_filter not in node_tags: + match = False + break if match: filtered_nodes.append(node) - nodes = filtered_nodes - + return filtered_nodes return nodes diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index edaf9692d..ee758eec6 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -170,7 +170,7 @@ async def get_templates( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), - tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g., tags=vendor:cisco&tags=model:7200)") + tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Template]: """ Return all templates. @@ -178,30 +178,22 @@ async def get_templates( Required privilege: Template.Audit Query Parameters: - - tags: Filter by tags in format "key:value". Multiple tags are ANDed together. + - tags: Filter by tags. Multiple tags are ANDed together. Example: ?tags=vendor:cisco&tags=model:7200 """ templates = await TemplatesService(templates_repo).get_templates() - # Filter by tags if provided + # Filter by tags if provided (all filter tags have to match the node tags) if tags: filtered_templates = [] for template in templates: - template_tags = template.get("tags") or {} - # Check if all tag filters match + template_tags = template.get("tags") or [] match = True for tag_filter in tags: - if ":" in tag_filter: - key, value = tag_filter.split(":", 1) - if template_tags.get(key) != value: - match = False - break - else: - # Check if key exists - if tag_filter not in template_tags: - match = False - break + if tag_filter not in template_tags: + match = False + break if match: filtered_templates.append(template) templates = filtered_templates diff --git a/gns3server/controller/node.py b/gns3server/controller/node.py index 3fa2fad50..8622b23b7 100644 --- a/gns3server/controller/node.py +++ b/gns3server/controller/node.py @@ -97,7 +97,7 @@ class Node: self._y = 0 self._z = 1 # default z value is 1 self._locked = False - self._tags = {} + self._tags = [] self._ports = None self._symbol = None self._custom_adapters = [] @@ -225,10 +225,7 @@ class Node: @tags.setter def tags(self, val): - if isinstance(val, dict): - self._tags = val - else: - self._tags = {} + self._tags = val def _base_config_file_content(self, path): if not os.path.isabs(path): diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index 699ef1c0e..ac85102cf 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -36,7 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) - tags = Column(JSON, default='{}') + tags = Column(JSON) compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index 52d76595b..840bfdce6 100644 --- a/gns3server/schemas/controller/nodes.py +++ b/gns3server/schemas/controller/nodes.py @@ -14,8 +14,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . -from pydantic import BaseModel, Field, model_validator -from typing import List, Optional, Union, Any, Dict +from pydantic import BaseModel, Field +from typing import List, Optional, Union from enum import Enum from uuid import UUID, uuid4 @@ -128,14 +128,14 @@ class NodeBase(BaseModel): z: Optional[int] = 1 locked: Optional[bool] = Field(False, description="Whether the element locked or not") port_name_format: Optional[str] = Field( - None, descript_port_name_formation="Formatting for port name {0} will be replace by port number" + None, description="Formatting for port name {0} will be replace by port number" ) port_segment_size: Optional[int] = Field(None, description="Size of the port segment") first_port_name: Optional[str] = Field(None, description="Name of the first port") custom_adapters: Optional[List[CustomAdapter]] = None - tags: Optional[Dict[str, str]] = Field( - default_factory=dict, - description="User-defined metadata tags inherited from template or custom" + tags: Optional[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index ef1e2bae7..dee74b7b9 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -15,7 +15,7 @@ # along with this program. If not, see . from pydantic import ConfigDict, BaseModel, Field -from typing import Optional, Union, Dict +from typing import Optional, List from enum import Enum from uuid import UUID @@ -48,9 +48,9 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" - tags: Optional[Dict[str, str]] = Field( - default_factory=dict, - description="User-defined metadata tags as key-value pairs (e.g., {'vendor': 'cisco', 'model': '7200', 'netmiko_device_type': 'cisco_ios'})" + tags: Optional[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) From a9525503c64cb3dfe34f76e034aedf136bb8d138 Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Feb 2026 23:27:10 +0800 Subject: [PATCH 6/8] Set up database schema migration --- ...8083573d011_add_tags_field_to_templates.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py diff --git a/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py new file mode 100644 index 000000000..eeafe3afd --- /dev/null +++ b/gns3server/db_migrations/versions/98083573d011_add_tags_field_to_templates.py @@ -0,0 +1,26 @@ +"""Add tags field to templates + +Revision ID: 98083573d011 +Revises: 9a5292aa4389 +Create Date: 2026-02-23 18:23:59.857607 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '98083573d011' +down_revision = '9a5292aa4389' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('templates', sa.Column('tags', sa.String())) + + +def downgrade() -> None: + + op.drop_column('templates', 'tags') From 4d35477e4651983f509a25053b1cb99ade5e9fce Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Feb 2026 23:27:36 +0800 Subject: [PATCH 7/8] Add/fix tests --- tests/api/routes/controller/test_nodes.py | 60 ++++++++++++++++- tests/api/routes/controller/test_templates.py | 65 +++++++++++++++---- tests/controller/test_node.py | 2 + 3 files changed, 110 insertions(+), 17 deletions(-) diff --git a/tests/api/routes/controller/test_nodes.py b/tests/api/routes/controller/test_nodes.py index 46f42e4af..32bc80918 100644 --- a/tests/api/routes/controller/test_nodes.py +++ b/tests/api/routes/controller/test_nodes.py @@ -86,7 +86,59 @@ class TestNodeRoutes: response = await client.get(app.url_path_for("get_nodes", project_id=project.id)) assert response.status_code == status.HTTP_200_OK assert response.json()[0]["name"] == "test" - + + + @pytest.mark.parametrize( + "tags, expected_match", + ( + ([], True), + (["tag1"], True), + (["tag1", "tag2"], True), + (["tag42"], False), + (["tag1", "tag3"], False), + ), + ) + async def test_list_nodes_with_tags( + self, + app: FastAPI, + client: AsyncClient, + project: Project, + compute: Compute, + tags: list, + expected_match: bool + ) -> None: + response = MagicMock() + response.json = {"console": 2048} + compute.post = AsyncioMagicMock(return_value=response) + + await client.post(app.url_path_for("create_node", project_id=project.id), json={ + "name": "test", + "node_type": "vpcs", + "compute_id": "example.com", + "tags": ["tag1", "tag2"], + "properties": { + "startup_script": "echo test" + } + }) + + await client.post(app.url_path_for("create_node", project_id=project.id), json={ + "name": "test2", + "node_type": "vpcs", + "compute_id": "example.com", + "tags": ["tag3", "tag4"], + "properties": { + "startup_script": "echo test" + } + }) + + params = {"tags": tags} + response = await client.get(app.url_path_for("get_nodes", project_id=project.id), params=params) + assert response.status_code == status.HTTP_200_OK + if expected_match: + assert len(response.json()) > 0 + else: + assert len(response.json()) == 0 + async def test_get_node( self, @@ -131,6 +183,7 @@ class TestNodeRoutes: "name": "test", "node_type": "vpcs", "compute_id": "example.com", + "tags": ["tag1", "tag2"], "properties": { "startup_script": "echo test" } @@ -139,8 +192,9 @@ class TestNodeRoutes: assert response.status_code == 200 assert response.json()["name"] == "test" assert "name" not in response.json()["properties"] - - + assert response.json()["tags"] == ["tag1", "tag2"] + + async def test_start_all_nodes( self, app: FastAPI, diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index 25ce4922d..38f01fb59 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -41,15 +41,18 @@ class TestTemplateRoutes: async def test_route_exist(self, app: FastAPI, client: AsyncClient) -> None: - new_template = {"base_script_file": "vpcs_base_config.txt", - "category": "guest", - "console_auto_start": False, - "console_type": "telnet", - "default_name_format": "PC{0}", - "name": "VPCS_TEST", - "compute_id": "local", - "symbol": ":/symbols/vpcs_guest.svg", - "template_type": "vpcs"} + new_template = { + "base_script_file": "vpcs_base_config.txt", + "category": "guest", + "console_auto_start": False, + "console_type": "telnet", + "default_name_format": "PC{0}", + "name": "VPCS_TEST", + "compute_id": "local", + "symbol": ":/symbols/vpcs_guest.svg", + "template_type": "vpcs", + "tags": ["tag1", "tag2"] + } response = await client.post(app.url_path_for("create_template"), json=new_template) assert response.status_code == status.HTTP_201_CREATED @@ -61,6 +64,36 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_200_OK assert len(response.json()) > 0 + @pytest.mark.parametrize( + "tags, expected_match", + ( + ([], True), + (["tag1"], True), + (["tag1", "tag2"], True), + (["tag42"], False), + (["tag1", "tag3"], False), + ), + ) + async def test_template_list_with_tags( + self, + app: FastAPI, + client: AsyncClient, + tags: list, + expected_match: bool + ) -> None: + + params = {"tags": tags} + response = await client.get(app.url_path_for("get_templates"), params=params) + assert response.status_code == status.HTTP_200_OK + if expected_match: + if not tags: + assert len(response.json()) == 8 + else: + assert response.json()[0]["name"] == "VPCS_TEST" + assert len(response.json()) == 1 + else: + assert len(response.json()) == 0 + async def test_template_get(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) @@ -105,11 +138,14 @@ class TestTemplateRoutes: async def test_template_update(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) - params = {"template_id": template_id, - "name": "VPCS_TEST", - "version": "3.0", - "compute_id": "local", - "template_type": "vpcs"} + params = { + "template_id": template_id, + "name": "VPCS_TEST", + "version": "3.0", + "compute_id": "local", + "template_type": "vpcs", + "tags": ["tag1", "tag2"] + } response = await client.post(app.url_path_for("create_template"), json=params) assert response.status_code == status.HTTP_201_CREATED @@ -117,6 +153,7 @@ class TestTemplateRoutes: response = await client.get(app.url_path_for("get_template", template_id=template_id)) assert response.status_code == status.HTTP_200_OK assert response.json()["template_id"] == template_id + assert response.json()["tags"] == ["tag1", "tag2"] params = {"name": "VPCS_TEST_RENAMED", "console_auto_start": True} response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params) diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py index 2192444b2..27a02d4d7 100644 --- a/tests/controller/test_node.py +++ b/tests/controller/test_node.py @@ -139,6 +139,7 @@ def test_json(node, compute): "port_name_format": "Ethernet{0}", "port_segment_size": 0, "first_port_name": None, + "tags": [], "custom_adapters": [], "console_auto_start": False, "ports": [ @@ -176,6 +177,7 @@ def test_json(node, compute): "port_segment_size": 0, "first_port_name": None, "custom_adapters": [], + "tags": [], "console_auto_start": False, } From 547e68fd2b635381f28b3b8d37f7a19713759bcc Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 23 Feb 2026 23:31:35 +0800 Subject: [PATCH 8/8] Remove unneeded rbac_repo in get_templates --- gns3server/api/routes/controller/templates.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gns3server/api/routes/controller/templates.py b/gns3server/api/routes/controller/templates.py index ee758eec6..a8d0be8a3 100644 --- a/gns3server/api/routes/controller/templates.py +++ b/gns3server/api/routes/controller/templates.py @@ -169,7 +169,6 @@ async def delete_template( async def get_templates( templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)), current_user: schemas.User = Depends(get_current_active_user), - rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)), tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)") ) -> List[schemas.Template]: """