mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-09 11:35:21 +03:00
Merge pull request #2614 from yueguobin/3.0
Implement tags for nodes and templates
This commit is contained in:
commit
4519130a8c
@ -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"))])
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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')
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user