diff --git a/gns3server/api/routes/controller/nodes.py b/gns3server/api/routes/controller/nodes.py index f9cac15b1..d38384470 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,40 @@ 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. 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 (all filter tags have to match the node tags) + if tags: + filtered_nodes = [] + for node in nodes: + node_tags = node.get("tags") or [] + match = True + for tag_filter in tags: + if tag_filter not in node_tags: + match = False + break + if match: + filtered_nodes.append(node) + return 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..a8d0be8a3 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 @@ -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 @@ -169,15 +169,34 @@ 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]: """ Return all templates. Required privilege: Template.Audit + + Query Parameters: + - 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 (all filter tags have to match the node tags) + if tags: + filtered_templates = [] + for template in templates: + template_tags = template.get("tags") or [] + match = True + for tag_filter in tags: + 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..8622b23b7 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,14 @@ class Node: def properties(self, val): self._properties = val + @property + def tags(self): + return self._tags + + @tags.setter + def tags(self, val): + self._tags = val + 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 +832,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 c43642295..ac85102cf 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,6 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) template_type = Column(String) + tags = Column(JSON) compute_id = Column(String) images = relationship("Image", secondary=image_template_map, back_populates="templates") 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') diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py index d732c80a9..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 +from pydantic import BaseModel, Field +from typing import List, Optional, Union from enum import Enum from uuid import UUID, uuid4 @@ -128,11 +128,15 @@ 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[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + ) class NodeCreate(NodeBase): diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index 78cd9cc25..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 +from typing import Optional, List from enum import Enum from uuid import UUID @@ -48,6 +48,10 @@ class TemplateBase(BaseModel): template_type: Optional[NodeType] = None compute_id: Optional[str] = None usage: Optional[str] = "" + tags: Optional[List[str]] = Field( + default_factory=list, + description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" + ) class TemplateCreate(TemplateBase): 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( 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, }