Merge pull request #2614 from yueguobin/3.0

Implement tags for nodes and templates
This commit is contained in:
Jeremy Grossmann 2026-02-25 00:33:15 +08:00 committed by GitHub
commit 4519130a8c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 213 additions and 33 deletions

View File

@ -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"))])

View File

@ -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:

View File

@ -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:

View File

@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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")

View File

@ -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')

View File

@ -14,8 +14,8 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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):

View File

@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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):

View File

@ -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(

View File

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

View File

@ -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)

View File

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