diff --git a/gns3server/api/routes/controller/appliances.py b/gns3server/api/routes/controller/appliances.py
index 944b91c81..18532e052 100644
--- a/gns3server/api/routes/controller/appliances.py
+++ b/gns3server/api/routes/controller/appliances.py
@@ -18,11 +18,9 @@
API routes for appliances.
"""
-import os
import logging
from fastapi import APIRouter, Depends, Response, status
-from fastapi.responses import FileResponse
from typing import Optional, List
from uuid import UUID
@@ -42,7 +40,10 @@ router = APIRouter()
@router.get("")
-async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[str] = "Classic") -> List[dict]:
+async def get_appliances(
+ update: Optional[bool] = False,
+ symbol_theme: Optional[str] = "Classic"
+) -> List[schemas.Appliance]:
"""
Return all appliances known by the controller.
"""
@@ -54,21 +55,17 @@ async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[
return [c.asdict() for c in controller.appliance_manager.appliances.values()]
-@router.get("/{appliance_id}/download")
-def download_appliance(appliance_id: UUID) -> FileResponse:
+@router.get("/{appliance_id}")
+def get_appliance(appliance_id: UUID) -> schemas.Appliance:
"""
- Download an appliance file.
+ Get an appliance file.
"""
controller = Controller.instance()
appliance = controller.appliance_manager.appliances.get(str(appliance_id))
if not appliance:
raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'")
-
- if not os.path.exists(appliance.path):
- raise ControllerNotFoundError(message=f"Could not find appliance file '{appliance.path}'")
-
- return FileResponse(appliance.path, media_type="application/json")
+ return appliance.asdict()
@router.post("/{appliance_id}/install", status_code=status.HTTP_204_NO_CONTENT)
diff --git a/gns3server/controller/appliance.py b/gns3server/controller/appliance.py
index c460e4e9f..9e93ee424 100644
--- a/gns3server/controller/appliance.py
+++ b/gns3server/controller/appliance.py
@@ -16,7 +16,6 @@
# along with this program. If not, see .
import copy
-import uuid
import logging
log = logging.getLogger(__name__)
@@ -27,12 +26,9 @@ class Appliance:
def __init__(self, path, data, builtin=True):
self._data = data.copy()
- self._id = data.get("appliance_id", uuid.uuid5(uuid.NAMESPACE_X500, path))
+ self._id = self._data.get("appliance_id")
self._path = path
self._builtin = builtin
- if "appliance_id" in self._data:
- del self._data["appliance_id"]
-
if self.status != "broken":
log.debug(f'Appliance "{self.name}" [{self._id}] loaded')
@@ -84,6 +80,7 @@ class Appliance:
"""
Appliance data (a hash)
"""
+
data = copy.deepcopy(self._data)
data["builtin"] = self._builtin
return data
diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py
index d837abe23..c00b26fd8 100644
--- a/gns3server/controller/appliance_manager.py
+++ b/gns3server/controller/appliance_manager.py
@@ -214,6 +214,10 @@ class ApplianceManager:
appliances_info = self._find_appliances_from_image_checksum(image_checksum)
for appliance, image_version in appliances_info:
+ try:
+ schemas.Appliance.parse_obj(appliance.asdict())
+ except ValidationError as e:
+ log.warning(message=f"Could not validate appliance '{appliance.id}': {e}")
if appliance.versions:
for version in appliance.versions:
if version.get("name") == image_version:
@@ -241,6 +245,11 @@ class ApplianceManager:
if not appliance:
raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'")
+ try:
+ schemas.Appliance.parse_obj(appliance.asdict())
+ except ValidationError as e:
+ raise ControllerError(message=f"Could not validate appliance '{appliance_id}': {e}")
+
if version:
if not appliance.versions:
raise ControllerBadRequestError(message=f"Appliance '{appliance_id}' do not have versions")
@@ -289,16 +298,18 @@ class ApplianceManager:
path = os.path.join(directory, file)
try:
with open(path, encoding="utf-8") as f:
- appliance = Appliance(path, json.load(f), builtin=builtin)
- json_data = appliance.asdict() # Check if loaded without error
+ json_data = json.load(f)
+ schemas.Appliance.parse_obj(json_data)
+ appliance = Appliance(path, json_data, builtin=builtin)
+ appliance_data = appliance.asdict() # Check if loaded without error
if appliance.status != "broken":
self._appliances[appliance.id] = appliance
if not appliance.symbol or appliance.symbol.startswith(":/symbols/"):
# apply a default symbol if the appliance has none or a default symbol
- default_symbol = self._get_default_symbol(json_data, symbol_theme)
+ default_symbol = self._get_default_symbol(appliance_data, symbol_theme)
if default_symbol:
appliance.symbol = default_symbol
- except (ValueError, OSError, KeyError) as e:
+ except (ValueError, OSError, KeyError, ValidationError) as e:
log.warning(f"Cannot load appliance file '{path}': {e}")
continue
diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py
index 477c9058d..f5c69e320 100644
--- a/gns3server/schemas/__init__.py
+++ b/gns3server/schemas/__init__.py
@@ -24,6 +24,7 @@ from .controller.links import LinkCreate, LinkUpdate, Link
from .controller.computes import ComputeCreate, ComputeUpdate, AutoIdlePC, Compute
from .controller.templates import TemplateCreate, TemplateUpdate, TemplateUsage, Template
from .controller.images import Image, ImageType
+from .controller.appliances import Appliance
from .controller.drawings import Drawing
from .controller.gns3vm import GNS3VM
from .controller.nodes import NodeCreate, NodeUpdate, NodeDuplicate, NodeCapture, Node
diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py
new file mode 100644
index 000000000..604af3f75
--- /dev/null
+++ b/gns3server/schemas/controller/appliances.py
@@ -0,0 +1,463 @@
+#
+# Copyright (C) 2021 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 .
+
+from enum import Enum
+from typing import List, Optional, Union
+from uuid import UUID
+
+from pydantic import AnyUrl, BaseModel, EmailStr, Field, confloat, conint, constr
+
+
+class Category(Enum):
+
+ router = 'router'
+ multilayer_switch = 'multilayer_switch'
+ switch = 'switch'
+ firewall = 'firewall'
+ guest = 'guest'
+
+
+class RegistryVersion(Enum):
+
+ version1 = 1
+ version2 = 2
+ version3 = 3
+ version4 = 4
+ version5 = 5
+ version6 = 6
+
+
+class Status(Enum):
+
+ stable = 'stable'
+ experimental = 'experimental'
+ broken = 'broken'
+
+
+class Availability(Enum):
+
+ free = 'free'
+ with_registration = 'with-registration'
+ free_to_try = 'free-to-try'
+ service_contract = 'service-contract'
+
+
+class ConsoleType(Enum):
+
+ telnet = 'telnet'
+ vnc = 'vnc'
+ http = 'http'
+ https = 'https'
+ none = 'none'
+
+
+class Docker(BaseModel):
+
+ adapters: int = Field(..., title='Number of ethernet adapters')
+ image: str = Field(..., title='Docker image in the Docker Hub')
+ start_command: Optional[str] = Field(
+ None,
+ title='Command executed when the container start. Empty will use the default',
+ )
+ environment: Optional[str] = Field(None, title='One KEY=VAR environment by line')
+ console_type: Optional[ConsoleType] = Field(
+ None, title='Type of console connection for the administration of the appliance'
+ )
+ console_http_port: Optional[int] = Field(
+ None, description='Internal port in the container of the HTTP server'
+ )
+ console_http_path: Optional[str] = Field(
+ None, description='Path of the web interface'
+ )
+ extra_hosts: Optional[str] = Field(
+ None, description='Hosts which will be written to /etc/hosts into container'
+ )
+ extra_volumes: Optional[List[str]] = Field(
+ None,
+ description='Additional directories to make persistent that are not included in the images VOLUME directive',
+ )
+
+
+class Iou(BaseModel):
+
+ ethernet_adapters: int = Field(..., title='Number of ethernet adapters')
+ serial_adapters: int = Field(..., title='Number of serial adapters')
+ nvram: int = Field(..., title='Host NVRAM')
+ ram: int = Field(..., title='Host RAM')
+ startup_config: str = Field(..., title='Config loaded at startup')
+
+
+class Chassis(Enum):
+
+ chassis_1720 = '1720'
+ chassis_1721 = '1721'
+ chassis_1750 = '1750'
+ chassis_1751 = '1751'
+ chassis_1760 = '1760'
+ chassis_2610 = '2610'
+ chassis_2620 = '2620'
+ chassis_2610XM = '2610XM'
+ chassis_2620XM = '2620XM'
+ chassis_2650XM = '2650XM'
+ chassis_2621 = '2621'
+ chassis_2611XM = '2611XM'
+ chassis_2621XM = '2621XM'
+ chassis_2651XM = '2651XM'
+ chassis_3620 = '3620'
+ chassis_3640 = '3640'
+ chassis_3660 = '3660'
+
+
+class Platform(Enum):
+
+ c1700 = 'c1700'
+ c2600 = 'c2600'
+ c2691 = 'c2691'
+ c3725 = 'c3725'
+ c3745 = 'c3745'
+ c3600 = 'c3600'
+ c7200 = 'c7200'
+
+
+class Midplane(Enum):
+
+ std = 'std'
+ vxr = 'vxr'
+
+
+class Npe(Enum):
+
+ npe_100 = 'npe-100'
+ npe_150 = 'npe-150'
+ npe_175 = 'npe-175'
+ npe_200 = 'npe-200'
+ npe_225 = 'npe-225'
+ npe_300 = 'npe-300'
+ npe_400 = 'npe-400'
+ npe_g2 = 'npe-g2'
+
+
+class AdapterType(Enum):
+
+ e1000 = 'e1000'
+ e1000_82544gc = 'e1000-82544gc'
+ e1000_82545em = 'e1000-82545em'
+ e1000e = 'e1000e'
+ i82550 = 'i82550'
+ i82551 = 'i82551'
+ i82557a = 'i82557a'
+ i82557b = 'i82557b'
+ i82557c = 'i82557c'
+ i82558a = 'i82558a'
+ i82558b = 'i82558b'
+ i82559a = 'i82559a'
+ i82559b = 'i82559b'
+ i82559c = 'i82559c'
+ i82559er = 'i82559er'
+ i82562 = 'i82562'
+ i82801 = 'i82801'
+ ne2k_pci = 'ne2k_pci'
+ pcnet = 'pcnet'
+ rocker = 'rocker'
+ rtl8139 = 'rtl8139'
+ virtio = 'virtio'
+ virtio_net_pci = 'virtio-net-pci'
+ vmxnet3 = 'vmxnet3'
+
+
+class DiskInterface(Enum):
+
+ ide = 'ide'
+ sata = 'sata'
+ nvme = 'nvme'
+ scsi = 'scsi'
+ sd = 'sd'
+ mtd = 'mtd'
+ floppy = 'floppy'
+ pflash = 'pflash'
+ virtio = 'virtio'
+ none = 'none'
+
+
+class Arch(Enum):
+
+ aarch64 = 'aarch64'
+ alpha = 'alpha'
+ arm = 'arm'
+ cris = 'cris'
+ i386 = 'i386'
+ lm32 = 'lm32'
+ m68k = 'm68k'
+ microblaze = 'microblaze'
+ microblazeel = 'microblazeel'
+ mips = 'mips'
+ mips64 = 'mips64'
+ mips64el = 'mips64el'
+ mipsel = 'mipsel'
+ moxie = 'moxie'
+ or32 = 'or32'
+ ppc = 'ppc'
+ ppc64 = 'ppc64'
+ ppcemb = 'ppcemb'
+ s390x = 's390x'
+ sh4 = 'sh4'
+ sh4eb = 'sh4eb'
+ sparc = 'sparc'
+ sparc64 = 'sparc64'
+ tricore = 'tricore'
+ unicore32 = 'unicore32'
+ x86_64 = 'x86_64'
+ xtensa = 'xtensa'
+ xtensaeb = 'xtensaeb'
+
+
+class ConsoleType1(Enum):
+
+ telnet = 'telnet'
+ vnc = 'vnc'
+ spice = 'spice'
+ spice_agent = 'spice+agent'
+ none = 'none'
+
+
+class BootPriority(Enum):
+
+ c = 'c'
+ d = 'd'
+ n = 'n'
+ cn = 'cn'
+ cd = 'cd'
+ dn = 'dn'
+ dc = 'dc'
+ nc = 'nc'
+ nd = 'nd'
+
+
+class Kvm(Enum):
+
+ require = 'require'
+ allow = 'allow'
+ disable = 'disable'
+
+
+class ProcessPriority(Enum):
+
+ realtime = 'realtime'
+ very_high = 'very high'
+ high = 'high'
+ normal = 'normal'
+ low = 'low'
+ very_low = 'very low'
+ null = 'null'
+
+
+class Qemu(BaseModel):
+
+ adapter_type: AdapterType = Field(..., title='Type of network adapter')
+ adapters: int = Field(..., title='Number of adapters')
+ ram: int = Field(..., title='Ram allocated to the appliance (MB)')
+ cpus: Optional[int] = Field(None, title='Number of Virtual CPU')
+ hda_disk_interface: Optional[DiskInterface] = Field(
+ None, title='Disk interface for the installed hda_disk_image'
+ )
+ hdb_disk_interface: Optional[DiskInterface] = Field(
+ None, title='Disk interface for the installed hdb_disk_image'
+ )
+ hdc_disk_interface: Optional[DiskInterface] = Field(
+ None, title='Disk interface for the installed hdc_disk_image'
+ )
+ hdd_disk_interface: Optional[DiskInterface] = Field(
+ None, title='Disk interface for the installed hdd_disk_image'
+ )
+ arch: Arch = Field(..., title='Architecture emulated')
+ console_type: ConsoleType1 = Field(
+ ..., title='Type of console connection for the administration of the appliance'
+ )
+ boot_priority: Optional[BootPriority] = Field(
+ None,
+ title='Disk boot priority. Refer to -boot option in qemu manual for more details.',
+ )
+ kernel_command_line: Optional[str] = Field(
+ None, title='Command line parameters send to the kernel'
+ )
+ kvm: Kvm = Field(..., title='KVM requirements')
+ options: Optional[str] = Field(
+ None, title='Optional additional qemu command line options'
+ )
+ cpu_throttling: Optional[confloat(ge=0.0, le=100.0)] = Field(
+ None, title='Throttle the CPU'
+ )
+ process_priority: Optional[ProcessPriority] = Field(
+ None, title='Process priority for QEMU'
+ )
+
+
+class Compression(Enum):
+
+ bzip2 = 'bzip2'
+ gzip = 'gzip'
+ lzma = 'lzma'
+ xz = 'xz'
+ rar = 'rar'
+ zip = 'zip'
+ field_7z = '7z'
+
+
+class Image(BaseModel):
+
+ filename: str = Field(..., title='Filename')
+ version: str = Field(..., title='Version of the file')
+ md5sum: constr(regex=r'^[a-f0-9]{32}$') = Field(..., title='md5sum of the file')
+ filesize: int = Field(..., title='File size in bytes')
+ download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
+ None, title='Download url where you can download the appliance from a browser'
+ )
+ direct_download_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
+ None,
+ title='Optional. Non authenticated url to the image file where you can download the image.',
+ )
+ compression: Optional[Compression] = Field(
+ None, title='Optional, compression type of direct download url image.'
+ )
+
+
+class Images(BaseModel):
+
+ kernel_image: Optional[str] = Field(None, title='Kernel image')
+ initrd: Optional[str] = Field(None, title='Initrd disk image')
+ image: Optional[str] = Field(None, title='OS image')
+ bios_image: Optional[str] = Field(None, title='Bios image')
+ hda_disk_image: Optional[str] = Field(None, title='Hda disk image')
+ hdb_disk_image: Optional[str] = Field(None, title='Hdc disk image')
+ hdc_disk_image: Optional[str] = Field(None, title='Hdd disk image')
+ hdd_disk_image: Optional[str] = Field(None, title='Hdd diskimage')
+ cdrom_image: Optional[str] = Field(None, title='cdrom image')
+
+
+class Version(BaseModel):
+
+ name: str = Field(..., title='Name of the version')
+ idlepc: Optional[constr(regex=r'^0x[0-9a-f]{8}')] = None
+ images: Optional[Images] = Field(None, title='Images used for this version')
+
+
+class DynamipsSlot(Enum):
+
+ C7200_IO_2FE = 'C7200-IO-2FE'
+ C7200_IO_FE = 'C7200-IO-FE'
+ C7200_IO_GE_E = 'C7200-IO-GE-E'
+ NM_16ESW = 'NM-16ESW'
+ NM_1E = 'NM-1E'
+ NM_1FE_TX = 'NM-1FE-TX'
+ NM_4E = 'NM-4E'
+ NM_4T = 'NM-4T'
+ PA_2FE_TX = 'PA-2FE-TX'
+ PA_4E = 'PA-4E'
+ PA_4T_ = 'PA-4T+'
+ PA_8E = 'PA-8E'
+ PA_8T = 'PA-8T'
+ PA_A1 = 'PA-A1'
+ PA_FE_TX = 'PA-FE-TX'
+ PA_GE = 'PA-GE'
+ PA_POS_OC3 = 'PA-POS-OC3'
+ C2600_MB_2FE = 'C2600-MB-2FE'
+ C2600_MB_1E = 'C2600-MB-1E'
+ C1700_MB_1FE = 'C1700-MB-1FE'
+ C2600_MB_2E = 'C2600-MB-2E'
+ C2600_MB_1FE = 'C2600-MB-1FE'
+ C1700_MB_WIC1 = 'C1700-MB-WIC1'
+ GT96100_FE = 'GT96100-FE'
+ Leopard_2FE = 'Leopard-2FE'
+ _ = ''
+
+
+class DynamipsWic(Enum):
+
+ WIC_1ENET = 'WIC-1ENET'
+ WIC_1T = 'WIC-1T'
+ WIC_2T = 'WIC-2T'
+
+
+class Dynamips(BaseModel):
+
+ chassis: Optional[Chassis] = Field(None, title='Chassis type')
+ platform: Platform = Field(..., title='Platform type')
+ ram: conint(ge=1) = Field(..., title='Amount of ram')
+ nvram: conint(ge=1) = Field(..., title='Amount of nvram')
+ startup_config: Optional[str] = Field(None, title='Config loaded at startup')
+ wic0: Optional[DynamipsWic] = None
+ wic1: Optional[DynamipsWic] = None
+ wic2: Optional[DynamipsWic] = None
+ slot0: Optional[DynamipsSlot] = None
+ slot1: Optional[DynamipsSlot] = None
+ slot2: Optional[DynamipsSlot] = None
+ slot3: Optional[DynamipsSlot] = None
+ slot4: Optional[DynamipsSlot] = None
+ slot5: Optional[DynamipsSlot] = None
+ slot6: Optional[DynamipsSlot] = None
+ midplane: Optional[Midplane] = None
+ npe: Optional[Npe] = None
+
+
+class Appliance(BaseModel):
+
+ appliance_id: UUID = Field(..., title='Appliance ID')
+ name: str = Field(..., title='Appliance name')
+ category: Category = Field(..., title='Category of the appliance')
+ description: str = Field(
+ ..., title='Description of the appliance. Could be a marketing description'
+ )
+ vendor_name: str = Field(..., title='Name of the vendor')
+ vendor_url: Union[AnyUrl, constr(max_length=0)] = Field(..., title='Website of the vendor')
+ documentation_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
+ None,
+ title='An optional documentation for using the appliance on vendor website',
+ )
+ product_name: str = Field(..., title='Product name')
+ product_url: Optional[Union[AnyUrl, constr(max_length=0)]] = Field(
+ None, title='An optional product url on vendor website'
+ )
+ registry_version: RegistryVersion = Field(
+ ..., title='Version of the registry compatible with this appliance'
+ )
+ status: Status = Field(..., title='Document if the appliance is working or not')
+ availability: Optional[Availability] = Field(
+ None,
+ title='About image availability: can be downloaded directly; download requires a free registration; paid but a trial version (time or feature limited) is available; not available publicly',
+ )
+ maintainer: str = Field(..., title='Maintainer name')
+ maintainer_email: Union[EmailStr, constr(max_length=0)] = Field(..., title='Maintainer email')
+ usage: Optional[str] = Field(None, title='How to use the appliance')
+ symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
+ first_port_name: Optional[str] = Field(
+ None, title='Optional name of the first networking port example: eth0'
+ )
+ port_name_format: Optional[str] = Field(
+ None, title='Optional formating of the networking port example: eth{0}'
+ )
+ port_segment_size: Optional[int] = Field(
+ None,
+ title='Optional port segment size. A port segment is a block of port. For example Ethernet0/0 Ethernet0/1 is the module 0 with a port segment size of 2',
+ )
+ linked_clone: Optional[bool] = Field(
+ None, title="False if you don't want to use a single image for all nodes"
+ )
+ docker: Optional[Docker] = Field(None, title='Docker specific options')
+ iou: Optional[Iou] = Field(None, title='IOU specific options')
+ dynamips: Optional[Dynamips] = Field(None, title='Dynamips specific options')
+ qemu: Optional[Qemu] = Field(None, title='Qemu specific options')
+ images: Optional[List[Image]] = Field(None, title='Images for this appliance')
+ versions: Optional[List[Version]] = Field(None, title='Versions of the appliance')
diff --git a/tests/api/routes/controller/test_appliances.py b/tests/api/routes/controller/test_appliances.py
index 53fcc73b8..a57b04adc 100644
--- a/tests/api/routes/controller/test_appliances.py
+++ b/tests/api/routes/controller/test_appliances.py
@@ -33,10 +33,10 @@ class TestApplianceRoutes:
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) > 0
- async def test_appliance_download(self, app: FastAPI, client: AsyncClient) -> None:
+ async def test_get_appliance(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "3bf492b6-5717-4257-9bfd-b34617c6f133" # Cisco IOSv appliance
- response = await client.get(app.url_path_for("download_appliance", appliance_id=appliance_id))
+ response = await client.get(app.url_path_for("get_appliance", appliance_id=appliance_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["appliance_id"] == appliance_id