From a4f20e0966429883e76cf473f599526fc2a7c503 Mon Sep 17 00:00:00 2001 From: Mark Paronyan Date: Wed, 7 Oct 2026 10:39:43 +0000 Subject: [PATCH 1/2] feat(api): add discriminators to link iface, appliance version and template settings schemas --- .../api/routes/controller/appliances.py | 8 +-- gns3server/api/routes/controller/links.py | 6 +- gns3server/schemas/__init__.py | 5 +- gns3server/schemas/controller/appliances.py | 35 +++++++++- gns3server/schemas/controller/links.py | 9 ++- .../api/routes/controller/test_appliances.py | 30 +++++++++ tests/api/routes/controller/test_links.py | 2 + tests/api/test_openapi_discriminators.py | 65 +++++++++++++++++++ tests/controller/test_appliance.py | 54 ++++++++++++++- 9 files changed, 202 insertions(+), 12 deletions(-) create mode 100644 tests/api/test_openapi_discriminators.py diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py index ad8645554..1d3f0e413 100644 --- a/gns3server/api/routes/controller/appliances.py +++ b/gns3server/api/routes/controller/appliances.py @@ -19,7 +19,7 @@ API routes for appliances. """ import logging -from typing import List, Optional, Union +from typing import List, Optional from uuid import UUID from fastapi import APIRouter, Depends, status @@ -85,9 +85,7 @@ def get_appliance(appliance_id: UUID) -> schemas.Appliance: status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Appliance.Allocate"))], ) -def add_appliance_version( - appliance_id: UUID, appliance_version: Union[schemas.ApplianceVersion, schemas.ApplianceVersionV8] -) -> dict: +def add_appliance_version(appliance_id: UUID, appliance_version: schemas.ApplianceVersionCreate) -> dict: """ Add a version to an appliance. @@ -109,7 +107,7 @@ def add_appliance_version( if version.get("name") == appliance_version.name: raise ControllerError(message=f"Appliance '{appliance_id}' already has version '{appliance_version.name}'") - appliance.versions.append(appliance_version.model_dump(exclude_unset=True)) + appliance.versions.append(appliance_version.model_dump(exclude_unset=True, exclude={"registry_version"})) return appliance.asdict() diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b7fa2b40d..f824f75ca 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,7 +20,7 @@ API routes for links. import logging import os -from typing import Any, List, Union +from typing import Any, List from uuid import UUID, uuid4 import aiohttp @@ -484,7 +484,7 @@ async def update_marker(marker_name: str, marker_data: schemas.MarkerUpdate, lin @router.get( "/{link_id}/iface", - response_model=Union[schemas.UDPPortInfo, schemas.EthernetPortInfo], + response_model=schemas.LinkIfaceInfo, dependencies=[Depends(has_privilege("Link.Audit"))], ) async def get_iface(link: Link = Depends(dep_link)) -> dict: @@ -515,6 +515,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict: port_type = port.get("type", "") if "udp" in port_type.lower(): ifaces_info = { + "kind": "udp", "node_id": node.id, "type": f"{port_type}", "lport": port["lport"], @@ -523,6 +524,7 @@ async def get_iface(link: Link = Depends(dep_link)) -> dict: } else: ifaces_info = { + "kind": "ethernet", "node_id": node.id, "type": f"{port_type}", "interface": port["interface"], diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a5eb8406c..53d75d4a4 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -17,7 +17,7 @@ # General schemas from .common import ErrorMessage from .config import ServerConfig -from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionV8 +from .controller.appliances import Appliance, ApplianceVersion, ApplianceVersionCreate, ApplianceVersionV8 from .controller.computes import ( AutoIdlePC, Compute, @@ -37,6 +37,7 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -162,6 +163,7 @@ __all__ = [ "ApiKeyCreate", "Appliance", "ApplianceVersion", + "ApplianceVersionCreate", "ApplianceVersionV8", "AutoIdlePC", "BatchNIOCreate", @@ -244,6 +246,7 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py index 4a5b48da1..ce1a71b98 100644 --- a/gns3server/schemas/controller/appliances.py +++ b/gns3server/schemas/controller/appliances.py @@ -20,7 +20,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Union from uuid import UUID -from pydantic import AnyUrl, BaseModel, Discriminator, EmailStr, Field, Tag +from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag from ..common import ExtraConfig @@ -648,6 +648,39 @@ class ApplianceVersionV8(BaseModel): images: Optional[ApplianceVersionImages] = Field(None, title="Images used for this version") +_V8_VERSION_ONLY_FIELDS = frozenset(set(ApplianceVersionV8.model_fields) - set(ApplianceVersion.model_fields)) + + +class ApplianceVersionCreateV1_6(ApplianceVersion): + """Request body to add a version to an appliance with registry version 1-6""" + + registry_version: Literal[1, 2, 3, 4, 5, 6] = Field( + 1, title="Version of the registry compatible with the appliance receiving the version" + ) + + +class ApplianceVersionCreateV8(ApplianceVersionV8): + """Request body to add a version to an appliance with registry version 8""" + + registry_version: Literal[8] = Field( + 8, title="Version of the registry compatible with the appliance receiving the version" + ) + + +def _infer_version_registry_version(data): + if isinstance(data, dict) and "registry_version" not in data: + data = dict(data) + data["registry_version"] = 8 if _V8_VERSION_ONLY_FIELDS.intersection(data) else 1 + return data + + +ApplianceVersionCreate = Annotated[ + Union[ApplianceVersionCreateV1_6, ApplianceVersionCreateV8], + BeforeValidator(_infer_version_registry_version), + Discriminator("registry_version"), +] + + # ============================================================================ # Child Models with Discriminated Union # ============================================================================ diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index ccb85d424..8dd9b7096 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,10 +15,10 @@ # along with this program. If not, see . from enum import Enum -from typing import List, Optional, Tuple +from typing import Annotated, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, Discriminator, Field, field_validator from ..update import PartialUpdateModel from .labels import Label @@ -107,6 +107,7 @@ class UDPPortInfo(BaseModel): rhost: str rport: int type: str + kind: Literal["udp"] class EthernetPortInfo(BaseModel): @@ -117,6 +118,10 @@ class EthernetPortInfo(BaseModel): node_id: UUID interface: str type: str + kind: Literal["ethernet"] + + +LinkIfaceInfo = Annotated[Union[UDPPortInfo, EthernetPortInfo], Discriminator("kind")] class LinkCapture(BaseModel): diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py index 94e7673f7..193557e35 100644 --- a/tests/api/routes/controller/test_appliances.py +++ b/tests/api/routes/controller/test_appliances.py @@ -101,3 +101,33 @@ class TestApplianceRoutes: app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version ) assert response.status_code == status.HTTP_409_CONFLICT + + async def test_add_v8_version_appliance(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + new_version = {"name": "v8-99G", "category": "guest", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), json=new_version + ) + assert response.status_code == status.HTTP_201_CREATED + assert new_version in response.json()["versions"] + + async def test_add_version_appliance_with_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + stored_version = {"name": "explicit-99G", "images": {"hda_disk_image": "empty99G.qcow2"}} + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json=dict(stored_version, registry_version=3), + ) + assert response.status_code == status.HTTP_201_CREATED + assert stored_version in response.json()["versions"] + + async def test_add_version_appliance_unknown_registry_version(self, app: FastAPI, client: AsyncClient) -> None: + + appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance + response = await client.post( + app.url_path_for("add_appliance_version", appliance_id=appliance_id), + json={"name": "bad", "registry_version": 7, "images": {"hda_disk_image": "empty99G.qcow2"}}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 518eb22f9..4b642c83d 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -376,6 +376,7 @@ class TestLinkRoutes: assert result["rhost"] == "127.0.0.1" assert result["rport"] == 30000 assert result["type"] == "udp" + assert result["kind"] == "udp" async def test_get_ethernet_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ @@ -406,3 +407,4 @@ class TestLinkRoutes: assert result["node_id"] == cloud_node.id assert result["interface"] == "eth0" assert result["type"] == "ethernet" + assert result["kind"] == "ethernet" diff --git a/tests/api/test_openapi_discriminators.py b/tests/api/test_openapi_discriminators.py new file mode 100644 index 000000000..2a1bca86a --- /dev/null +++ b/tests/api/test_openapi_discriminators.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python +# +# Copyright (C) 2026 GNS3 Technologies Inc. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import pytest +from fastapi import FastAPI + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def openapi(app: FastAPI) -> dict: + return app.openapi() + + +def _ref(name: str) -> str: + return f"#/components/schemas/{name}" + + +def _assert_discriminated(schema: dict, property_name: str, mapping: dict) -> None: + assert [item["$ref"] for item in schema["oneOf"]] == [_ref(name) for name in dict.fromkeys(mapping.values())] + assert schema["discriminator"]["propertyName"] == property_name + assert schema["discriminator"]["mapping"] == {key: _ref(name) for key, name in mapping.items()} + + +async def test_link_iface_response_is_discriminated(openapi: dict) -> None: + path = "/v3/projects/{project_id}/links/{link_id}/iface" + schema = openapi["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + _assert_discriminated(schema, "kind", {"udp": "UDPPortInfo", "ethernet": "EthernetPortInfo"}) + + +async def test_appliance_version_request_is_discriminated(openapi: dict) -> None: + schema = openapi["paths"]["/v3/appliances/{appliance_id}/version"]["post"]["requestBody"]["content"][ + "application/json" + ]["schema"] + mapping = {str(version): "ApplianceVersionCreateV1_6" for version in range(1, 7)} + mapping["8"] = "ApplianceVersionCreateV8" + _assert_discriminated(schema, "registry_version", mapping) + + +async def test_appliance_settings_are_discriminated(openapi: dict) -> None: + schema = openapi["components"]["schemas"]["ApplianceV8"]["properties"]["settings"]["items"] + _assert_discriminated( + schema, + "template_type", + { + "qemu": "QemuTemplateSetting", + "dynamips": "DynamipsTemplateSetting", + "iou": "IouTemplateSetting", + "docker": "DockerTemplateSetting", + }, + ) diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py index 0ec004e20..89f58f802 100644 --- a/tests/controller/test_appliance.py +++ b/tests/controller/test_appliance.py @@ -17,10 +17,21 @@ import pydantic import pytest +from pydantic import TypeAdapter from gns3server.controller.appliance import Appliance from gns3server.controller.appliance_to_template import ApplianceToTemplate -from gns3server.schemas.controller.appliances import ApplianceModel +from gns3server.schemas.controller.appliances import ( + ApplianceModel, + ApplianceVersionCreate, + ApplianceVersionCreateV1_6, + ApplianceVersionCreateV8, + DockerTemplateSetting, + DynamipsTemplateSetting, + IouTemplateSetting, + QemuTemplateSetting, + TemplateSetting, +) # v8 mirror of the XRd Control Plane appliance shape (docker, custom_adapters, no versions) XRD_V8 = { @@ -242,3 +253,44 @@ def test_v8_netmiko_device_type_empty_string_clears(): appliance = dict(XRD_V8, netmiko_device_type="") model = ApplianceModel.model_validate(appliance) assert model.netmiko_device_type == "" + + +@pytest.mark.parametrize( + "template_type, properties, expected", + [ + ("qemu", {"ram": 512}, QemuTemplateSetting), + ("dynamips", {"platform": "c7200", "ram": 256}, DynamipsTemplateSetting), + ("iou", {"ram": 256}, IouTemplateSetting), + ("docker", {"image": "alpine:latest"}, DockerTemplateSetting), + ], +) +def test_template_setting_decoded_through_discriminator(template_type, properties, expected): + setting = TypeAdapter(TemplateSetting).validate_python( + {"name": "a", "template_type": template_type, "template_properties": properties} + ) + assert type(setting) is expected + assert setting.template_type == template_type + + +def test_template_setting_unknown_template_type_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(TemplateSetting).validate_python({"template_type": "vpcs", "template_properties": {}}) + + +@pytest.mark.parametrize( + "data, expected", + [ + ({"name": "1", "registry_version": 1}, ApplianceVersionCreateV1_6), + ({"name": "6", "registry_version": 6}, ApplianceVersionCreateV1_6), + ({"name": "8", "registry_version": 8}, ApplianceVersionCreateV8), + ({"name": "plain", "images": {"hda_disk_image": "a.qcow2"}}, ApplianceVersionCreateV1_6), + ({"name": "v8", "category": "guest"}, ApplianceVersionCreateV8), + ], +) +def test_appliance_version_decoded_through_discriminator(data, expected): + assert type(TypeAdapter(ApplianceVersionCreate).validate_python(data)) is expected + + +def test_appliance_version_unknown_registry_version_rejected(): + with pytest.raises(pydantic.ValidationError): + TypeAdapter(ApplianceVersionCreate).validate_python({"name": "x", "registry_version": 7}) From f50653107b4e4a7e86400a57acfdc3c068661fc0 Mon Sep 17 00:00:00 2001 From: grossmj Date: Thu, 8 Oct 2026 16:57:41 +0200 Subject: [PATCH 2/2] style: fix import and lint errors after merging --- gns3server/api/routes/controller/links.py | 2 +- gns3server/schemas/__init__.py | 4 ++-- gns3server/schemas/controller/links.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index 9750ad29e..d8625b9ad 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -20,7 +20,7 @@ API routes for links. import logging import os -from typing import Any, List, Optional, Union +from typing import Any, List, Optional from uuid import UUID, uuid4 import aiohttp diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index b02151be1..5338f5b57 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -37,11 +37,11 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, - LinkIfaceInfo, LinkFilterDefinition, LinkFilterParameter, LinkFilters, LinkFilterType, + LinkIfaceInfo, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -251,11 +251,11 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", - "LinkIfaceInfo", "LinkFilterDefinition", "LinkFilterParameter", "LinkFilterType", "LinkFilters", + "LinkIfaceInfo", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index 3aa66df9f..f74635b8c 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -18,7 +18,7 @@ from enum import Enum from typing import Annotated, List, Literal, Optional, Tuple, Union from uuid import UUID, uuid4 -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Discriminator, Field, field_validator, model_validator from ..update import PartialUpdateModel from .labels import Label