mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-12 21:45:41 +03:00
Merge branch '3.1' into fix/duplicate-device-configs-merge
This commit is contained in:
commit
5238bd3b39
@ -80,7 +80,7 @@ def _iter_route_paths(routes: Sequence[BaseRoute], prefix: str = "", include_mou
|
||||
yield from _iter_route_paths(mounted_routes, _join_paths(prefix, route.path), include_mounted_routes)
|
||||
|
||||
|
||||
@router.get("/endpoints", status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("ACE.Audit"))])
|
||||
@router.get("/endpoints", dependencies=[Depends(has_privilege("ACE.Audit"))])
|
||||
async def endpoints(
|
||||
users_repo: UsersRepository = Depends(get_repository(UsersRepository)),
|
||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||
|
||||
@ -21,7 +21,7 @@ API routes for computes.
|
||||
from typing import Any, List, Optional, Union
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, status
|
||||
from fastapi import APIRouter, Body, Depends, Query, status
|
||||
|
||||
import gns3server.db.models as models
|
||||
from gns3server import schemas
|
||||
@ -106,14 +106,21 @@ async def get_compute(
|
||||
)
|
||||
async def get_computes(
|
||||
computes_repo: ComputesRepository = Depends(get_repository(ComputesRepository)),
|
||||
name: Optional[str] = Query(None, description="Return only computes whose name exactly matches (case-sensitive)"),
|
||||
) -> List[models.Compute]:
|
||||
"""
|
||||
Return all computes known by the controller.
|
||||
|
||||
Required privilege: Compute.Audit
|
||||
|
||||
Query Parameters:
|
||||
- name: Exact, case-sensitive match on the compute name.
|
||||
"""
|
||||
|
||||
return await ComputesService(computes_repo).get_computes()
|
||||
computes = await ComputesService(computes_repo).get_computes()
|
||||
if name is not None:
|
||||
computes = [compute for compute in computes if compute.name == name]
|
||||
return computes
|
||||
|
||||
|
||||
@router.put(
|
||||
|
||||
@ -146,7 +146,7 @@ def get_iou_license() -> schemas.IOULicense:
|
||||
@router.put(
|
||||
"/iou_license",
|
||||
dependencies=[Depends(get_current_active_user)],
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=schemas.IOULicense,
|
||||
)
|
||||
async def update_iou_license(iou_license: schemas.IOULicense) -> schemas.IOULicense:
|
||||
|
||||
@ -195,14 +195,18 @@ async def get_images(
|
||||
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||
image_type: Optional[schemas.ImageType] = None,
|
||||
availability: Optional[Literal["unknown", "available", "missing", "unavailable", "invalid"]] = None,
|
||||
name: Optional[str] = Query(None, description="Return only images whose filename exactly matches (case-sensitive)"),
|
||||
) -> List[models.Image]:
|
||||
"""
|
||||
Return all images.
|
||||
|
||||
Required privilege: Image.Audit
|
||||
|
||||
Query Parameters:
|
||||
- name: Exact, case-sensitive match on the image filename.
|
||||
"""
|
||||
|
||||
return await images_repo.get_images(image_type, availability)
|
||||
return await images_repo.get_images(image_type, availability, name)
|
||||
|
||||
|
||||
@router.post(
|
||||
|
||||
@ -151,6 +151,7 @@ async def create_node(node_create: schemas.NodeCreate, project: Project = Depend
|
||||
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)"),
|
||||
name: Optional[str] = Query(None, description="Return only nodes whose name exactly matches (case-sensitive)"),
|
||||
) -> List[schemas.Node]:
|
||||
"""
|
||||
Return all nodes belonging to a given project.
|
||||
@ -160,6 +161,7 @@ def get_nodes(
|
||||
Query Parameters:
|
||||
- tags: Filter by tags. Multiple tags are ANDed together.
|
||||
Example: ?tags=vendor:cisco&tags=model:7200
|
||||
- name: Exact, case-sensitive match on the node name. Combined with other filters using AND.
|
||||
"""
|
||||
|
||||
if project.status == "closed":
|
||||
@ -168,6 +170,9 @@ def get_nodes(
|
||||
else:
|
||||
nodes = [v.asdict() for v in project.nodes.values()]
|
||||
|
||||
if name is not None:
|
||||
nodes = [node for node in nodes if node.get("name") == name]
|
||||
|
||||
# Filter by tags if provided (all filter tags have to match the node tags)
|
||||
if tags:
|
||||
filtered_nodes = []
|
||||
|
||||
@ -34,7 +34,7 @@ log = logging.getLogger()
|
||||
from typing import Any, List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from websockets.exceptions import ConnectionClosed, WebSocketException
|
||||
@ -78,11 +78,15 @@ def dep_project(project_id: UUID) -> Project:
|
||||
async def get_projects(
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
|
||||
name: Optional[str] = Query(None, description="Return only projects whose name exactly matches (case-sensitive)"),
|
||||
) -> List[schemas.Project]:
|
||||
"""
|
||||
Return all projects.
|
||||
|
||||
Required privilege: Project.Audit
|
||||
|
||||
Query Parameters:
|
||||
- name: Exact, case-sensitive match on the project name.
|
||||
"""
|
||||
|
||||
controller = Controller.instance()
|
||||
@ -91,7 +95,7 @@ async def get_projects(
|
||||
|
||||
if current_user.is_superadmin:
|
||||
# super admin sees all projects
|
||||
return [p.asdict() for p in controller.projects.values()]
|
||||
return [p.asdict() for p in controller.projects.values() if name is None or p.name == name]
|
||||
|
||||
# Batch ACE + resource pool check (3 DB queries regardless of project count)
|
||||
all_project_ids = list(controller.projects.keys())
|
||||
@ -102,6 +106,8 @@ async def get_projects(
|
||||
# Step 2: Filter direct ACE projects by created_by
|
||||
# Direct project sharing is only available through resource pools
|
||||
for p in controller.projects.values():
|
||||
if name is not None and p.name != name:
|
||||
continue
|
||||
if p.id in direct_ace_ids and p.created_by == current_user.username:
|
||||
if p.id not in seen_project_ids:
|
||||
projects.append(p.asdict())
|
||||
@ -109,6 +115,8 @@ async def get_projects(
|
||||
|
||||
# Step 3: Resource pool projects (no created_by filter)
|
||||
for p in controller.projects.values():
|
||||
if name is not None and p.name != name:
|
||||
continue
|
||||
if p.id in pool_accessible_ids:
|
||||
if p.id not in seen_project_ids:
|
||||
projects.append(p.asdict())
|
||||
@ -487,7 +495,7 @@ async def close_project(project: Project = Depends(dep_project)) -> None:
|
||||
|
||||
@router.post(
|
||||
"/{project_id}/open",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=schemas.Project,
|
||||
responses={**responses, 409: {"model": schemas.ErrorMessage, "description": "Could not open project"}},
|
||||
dependencies=[Depends(has_privilege("Project.Allocate"))],
|
||||
|
||||
@ -106,7 +106,7 @@ async def delete_snapshot(
|
||||
|
||||
@router.post(
|
||||
"/{snapshot_id}/restore",
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
status_code=status.HTTP_200_OK,
|
||||
response_model=schemas.Project,
|
||||
dependencies=[Depends(has_privilege("Snapshot.Restore"))],
|
||||
)
|
||||
|
||||
@ -195,6 +195,7 @@ async def get_templates(
|
||||
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
|
||||
current_user: schemas.User = Depends(get_current_active_user),
|
||||
tags: Optional[List[str]] = Query(None, description="Filter by tags (e.g. tags=vendor:cisco&tags=model:7200)"),
|
||||
name: Optional[str] = Query(None, description="Return only templates whose name exactly matches (case-sensitive)"),
|
||||
) -> List[dict]:
|
||||
"""
|
||||
Return all templates.
|
||||
@ -204,10 +205,14 @@ async def get_templates(
|
||||
Query Parameters:
|
||||
- tags: Filter by tags. Multiple tags are ANDed together.
|
||||
Example: ?tags=vendor:cisco&tags=model:7200
|
||||
- name: Exact, case-sensitive match on the template name. Combined with other filters using AND.
|
||||
"""
|
||||
|
||||
templates = await TemplatesService(templates_repo).get_templates()
|
||||
|
||||
if name is not None:
|
||||
templates = [template for template in templates if template.get("name") == name]
|
||||
|
||||
# Filter by tags if provided (all filter tags have to match the node tags)
|
||||
if tags:
|
||||
filtered_templates = []
|
||||
|
||||
@ -83,7 +83,7 @@ class ImagesRepository(BaseRepository):
|
||||
except (OSError, InvalidImageError):
|
||||
return False
|
||||
|
||||
async def get_images(self, image_type=None, availability=None) -> List[models.Image]:
|
||||
async def get_images(self, image_type=None, availability=None, name=None) -> List[models.Image]:
|
||||
"""
|
||||
Get all images.
|
||||
"""
|
||||
@ -94,6 +94,8 @@ class ImagesRepository(BaseRepository):
|
||||
query = select(models.Image)
|
||||
if availability:
|
||||
query = query.where(models.Image.availability == availability)
|
||||
if name is not None:
|
||||
query = query.where(models.Image.filename == name)
|
||||
result = await self._db_session.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
@ -117,7 +117,11 @@ class AsyncioSSHServer:
|
||||
|
||||
try:
|
||||
while True:
|
||||
data = await process.stdin.read(READ_SIZE)
|
||||
try:
|
||||
data = await process.stdin.read(READ_SIZE)
|
||||
except asyncssh.TerminalSizeChanged:
|
||||
# The upstream serial stream has no terminal size to update.
|
||||
continue
|
||||
if not data:
|
||||
break
|
||||
|
||||
|
||||
@ -161,3 +161,8 @@ class TestACLRoutes:
|
||||
|
||||
# the ACE should have been deleted after deleting the project
|
||||
assert not await rbac_repo.get_ace_by_path(path)
|
||||
|
||||
async def test_get_endpoints(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("endpoints"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
@ -68,6 +68,31 @@ class TestComputeRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) > 0
|
||||
|
||||
async def test_compute_list_filter_by_name(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
for name, port in (("compute-a", 85), ("compute-b", 86)):
|
||||
params = {
|
||||
"name": name,
|
||||
"protocol": "http",
|
||||
"host": "localhost",
|
||||
"port": port,
|
||||
"user": "julien",
|
||||
"password": "secure",
|
||||
}
|
||||
response = await client.post(app.url_path_for("create_compute"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"), params={"name": "compute-a"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [c["name"] for c in response.json()] == ["compute-a"]
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"), params={"name": "COMPUTE-A"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_computes"))
|
||||
assert len(response.json()) >= 2
|
||||
|
||||
async def test_compute_get(self, app: FastAPI, client: AsyncClient, test_compute: Compute) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("get_compute", compute_id=test_compute.compute_id))
|
||||
|
||||
@ -70,3 +70,10 @@ class TestControllerRoutes:
|
||||
|
||||
response = await client.get(app.url_path_for("statistics"))
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
|
||||
async def test_update_iou_license(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
license_settings = {"iourc_content": "[license]\ngns3vm = 1234567890abcdef;", "license_check": False}
|
||||
response = await client.put(app.url_path_for("update_iou_license"), json=license_settings)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == license_settings
|
||||
|
||||
@ -168,6 +168,21 @@ class TestImageRoutes:
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert len(response.json()) == 5 # 4 valid images uploaded before + 1 created
|
||||
|
||||
async def test_image_list_filter_by_name(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
||||
|
||||
image_name = os.path.basename(qcow2_image)
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [i["filename"] for i in response.json()] == [image_name]
|
||||
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name.upper()})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_images"), params={"name": image_name, "image_type": "iou"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
async def test_image_get(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
||||
|
||||
image_name = os.path.basename(qcow2_image)
|
||||
|
||||
@ -156,6 +156,54 @@ class TestNodeRoutes:
|
||||
else:
|
||||
assert len(response.json()) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, tags, expected_names",
|
||||
(
|
||||
(None, None, ["test", "test2"]),
|
||||
("test", None, ["test"]),
|
||||
("test2", None, ["test2"]),
|
||||
("TEST", None, []),
|
||||
("missing", None, []),
|
||||
("test", ["tag1"], ["test"]),
|
||||
("test", ["tag3"], []),
|
||||
("test2", ["tag3", "tag4"], ["test2"]),
|
||||
),
|
||||
)
|
||||
async def test_list_nodes_filter_by_name(
|
||||
self,
|
||||
app: FastAPI,
|
||||
client: AsyncClient,
|
||||
project: Project,
|
||||
compute: Compute,
|
||||
name: Optional[str],
|
||||
tags: Optional[list],
|
||||
expected_names: list,
|
||||
) -> None:
|
||||
response = MagicMock()
|
||||
response.json = {"console": 2048}
|
||||
compute.post = AsyncioMagicMock(return_value=response)
|
||||
|
||||
for node_name, node_tags in (("test", ["tag1", "tag2"]), ("test2", ["tag3", "tag4"])):
|
||||
await client.post(
|
||||
app.url_path_for("create_node", project_id=project.id),
|
||||
json={
|
||||
"name": node_name,
|
||||
"node_type": "vpcs",
|
||||
"compute_id": "example.com",
|
||||
"tags": node_tags,
|
||||
"properties": {"startup_script": "echo test"},
|
||||
},
|
||||
)
|
||||
|
||||
params = {}
|
||||
if name is not None:
|
||||
params["name"] = name
|
||||
if tags is not None:
|
||||
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
|
||||
assert sorted(n["name"] for n in response.json()) == expected_names
|
||||
|
||||
async def test_get_node(self, app: FastAPI, client: AsyncClient, project: Project, compute: Compute) -> None:
|
||||
|
||||
response = MagicMock()
|
||||
|
||||
@ -164,6 +164,26 @@ class TestControllerProjectRoutes:
|
||||
projects = response.json()
|
||||
assert projects[0]["name"] == "test"
|
||||
|
||||
async def test_list_projects_filter_by_name(
|
||||
self, app: FastAPI, client: AsyncClient, controller: Controller
|
||||
) -> None:
|
||||
|
||||
for name in ("test", "other"):
|
||||
params = {"name": name, "project_id": str(uuid.uuid4())}
|
||||
response = await client.post(app.url_path_for("create_project"), json=params)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"), params={"name": "test"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [p["name"] for p in response.json()] == ["test"]
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"), params={"name": "TEST"})
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json() == []
|
||||
|
||||
response = await client.get(app.url_path_for("get_projects"))
|
||||
assert len(response.json()) == 2
|
||||
|
||||
async def test_get_project(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
response = await client.get(app.url_path_for("get_project", project_id=project.id))
|
||||
@ -196,7 +216,7 @@ class TestControllerProjectRoutes:
|
||||
|
||||
with asyncio_patch("gns3server.controller.project.Project.open", return_value=True) as mock:
|
||||
response = await client.post(app.url_path_for("open_project", project_id=project.id))
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert mock.called
|
||||
|
||||
async def test_load_project(self, app: FastAPI, client: AsyncClient, project: Project, config) -> None:
|
||||
|
||||
@ -72,7 +72,7 @@ class TestSnapshotRoutes:
|
||||
response = await client.post(
|
||||
app.url_path_for("restore_snapshot", project_id=project.id, snapshot_id=snapshot.id)
|
||||
)
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.json()["name"] == project.name
|
||||
|
||||
async def test_create_snapshot(self, app: FastAPI, client: AsyncClient, project: Project) -> None:
|
||||
|
||||
@ -20,6 +20,7 @@ import os
|
||||
import unittest.mock
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI, status
|
||||
@ -89,6 +90,27 @@ class TestTemplateRoutes:
|
||||
else:
|
||||
assert len(response.json()) == 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"name, tags, expected_names",
|
||||
(
|
||||
("VPCS_TEST", None, ["VPCS_TEST"]),
|
||||
("vpcs_test", None, []),
|
||||
("missing", None, []),
|
||||
("VPCS_TEST", ["tag1"], ["VPCS_TEST"]),
|
||||
("VPCS_TEST", ["tag42"], []),
|
||||
),
|
||||
)
|
||||
async def test_template_list_filter_by_name(
|
||||
self, app: FastAPI, client: AsyncClient, name: str, tags: Optional[list], expected_names: list
|
||||
) -> None:
|
||||
|
||||
params = {"name": name}
|
||||
if tags is not None:
|
||||
params["tags"] = tags
|
||||
response = await client.get(app.url_path_for("get_templates"), params=params)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert [t["name"] for t in response.json()] == expected_names
|
||||
|
||||
async def test_template_get(self, app: FastAPI, client: AsyncClient) -> None:
|
||||
|
||||
template_id = str(uuid.uuid4())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user