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,
}