diff --git a/gns3server/controller/appliance_manager.py b/gns3server/controller/appliance_manager.py
index 5f78196fb..25b0c366e 100644
--- a/gns3server/controller/appliance_manager.py
+++ b/gns3server/controller/appliance_manager.py
@@ -174,7 +174,7 @@ class ApplianceManager:
version_images = version.get("images")
if version_images:
for appliance_key, appliance_file in version_images.items():
- for image in appliance.images:
+ for image in appliance.images or []:
if appliance_file == image.get("filename"):
image_checksum = image.get("md5sum")
image_in_db = await images_repo.get_image_by_checksum(image_checksum)
@@ -225,12 +225,15 @@ class ApplianceManager:
from . import Controller
- # downloading missing custom symbol for this appliance
- if appliance.symbol and not appliance.symbol.startswith(":/symbols/"):
- destination_path = os.path.join(Controller.instance().symbols.symbols_path(), appliance.symbol)
+ template_data = ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
+ # download the custom symbol used by the template if it is missing;
+ # the symbol can be defined at the appliance, version or settings level
+ symbol = template_data.get("symbol")
+ if symbol and not symbol.startswith(":/symbols/"):
+ destination_path = os.path.join(Controller.instance().symbols.symbols_path(), symbol)
if not os.path.exists(destination_path):
- await self._download_symbol(appliance.symbol, destination_path)
- return ApplianceToTemplate().new_template(appliance.asdict(), version, "local") # FIXME: "local"
+ await self._download_symbol(symbol, destination_path)
+ return template_data
async def install_appliances_from_image(
self,
@@ -290,11 +293,14 @@ class ApplianceManager:
if not appliance.versions:
raise ControllerBadRequestError(message=f"Appliance '{appliance_id}' do not have versions")
- image_dir = default_images_directory(appliance.type)
for appliance_version_info in appliance.versions:
if appliance_version_info.get("name") == version:
try:
- await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
+ template_type = ApplianceToTemplate().get_template_type(appliance.asdict(), appliance_version_info)
+ if template_type != "docker":
+ # docker appliances have no image files to find or download
+ image_dir = default_images_directory(template_type)
+ await self._find_appliance_version_images(appliance, appliance_version_info, images_repo, image_dir)
except InvalidImageError as e:
raise ControllerError(message=f"Image error: {e}")
template_data = await self._appliance_to_template(appliance, appliance_version_info)
@@ -363,12 +369,14 @@ class ApplianceManager:
category = appliance["category"]
if category == "guest":
if appliance.get("registry_version", 0) >= 8:
- # registry version 8: look for a Docker settings set instead of a top-level block
- settings_types = [
- settings.get("template_type") for settings in appliance.get("settings") or []
- ]
- if "docker" in settings_types:
+ # registry version 8: the emulator type comes from the default
+ # settings set (or the only one present), not a top-level block
+ settings = appliance.get("settings") or []
+ selected = next((s for s in settings if s.get("default")), settings[0] if settings else None)
+ if selected and selected.get("template_type") == "docker":
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
+ if selected:
+ return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)
elif "docker" in appliance:
return controller.symbols.get_default_symbol("docker_guest", symbol_theme)
elif "qemu" in appliance:
diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py
index 6f7d74e76..a07f3a807 100644
--- a/gns3server/controller/appliance_to_template.py
+++ b/gns3server/controller/appliance_to_template.py
@@ -168,9 +168,23 @@ class ApplianceToTemplate:
elif appliance_config.get(prop) is not None:
properties[prop] = appliance_config[prop]
- if properties.get("category") == "multilayer_switch":
+ category_before_remap = properties.get("category")
+ if category_before_remap == "multilayer_switch":
properties["category"] = "switch"
+ if settings["template_type"] == "qemu":
+ # kvm is not a valid template property: convert it to the
+ # equivalent qemu options like for registry versions 1-6
+ kvm = properties.pop("kvm", None) or "allow"
+ options = properties.get("options") or ""
+ if kvm == "disable" and "-machine accel=tcg" not in options:
+ options += " -machine accel=tcg"
+ properties["options"] = options.strip()
+
+ # template_properties must not override the structural fields
+ for reserved in ("template_type", "compute_id", "version"):
+ properties.pop(reserved, None)
+
new_template.update(properties)
if "tags" in appliance_config:
new_template["tags"] = appliance_config.get("tags")
@@ -179,8 +193,8 @@ class ApplianceToTemplate:
new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"]
if not new_template.get("symbol"):
- # apply a default symbol based on the category and template type
- if appliance_config["category"] == "guest":
+ # apply a default symbol based on the effective category and template type
+ if category_before_remap == "guest":
if settings["template_type"] == "docker":
new_template["symbol"] = ":/symbols/docker_guest.svg"
else:
@@ -192,13 +206,38 @@ class ApplianceToTemplate:
"multilayer_switch": ":/symbols/multilayer_switch.svg",
"firewall": ":/symbols/firewall.svg",
}
- new_template["symbol"] = symbols.get(appliance_config["category"])
+ new_template["symbol"] = symbols.get(category_before_remap)
if version and version.get("images"):
- new_template.update(version["images"])
+ if settings["template_type"] == "iou":
+ # IOU templates take the image path, not an image name
+ new_template["path"] = version["images"].get("image")
+ else:
+ new_template.update(version["images"])
+
+ if version and settings["template_type"] == "dynamips" and version.get("idlepc"):
+ # settings level idlepc takes precedence over the version level
+ new_template.setdefault("idlepc", version["idlepc"])
return new_template
+ def get_template_type(self, appliance_config, version):
+ """
+ Returns the template type of the settings set used to install the given
+ version: for registry versions 1-6 it comes from the emulator block, for
+ version 8 from the settings set selected for the version.
+ """
+
+ if appliance_config.get("registry_version", 0) >= 8:
+ return self._select_v8_settings(appliance_config, version)["template_type"]
+ if "iou" in appliance_config:
+ return "iou"
+ if "dynamips" in appliance_config:
+ return "dynamips"
+ if "docker" in appliance_config:
+ return "docker"
+ return "qemu"
+
def _select_v8_settings(self, appliance_config, version):
"""
Selects the settings set to use: the one referenced by the version,
@@ -235,13 +274,15 @@ class ApplianceToTemplate:
"""
Merges the template properties of the selected settings with the default
settings properties, unless inheritance is disabled or the default set
- is selected.
+ is selected. Only a default set of the same emulator type is inherited
+ from, so properties of a different type never pollute the template.
"""
properties = {}
if not settings.get("default") and settings.get("inherit_default_properties", True):
for other_settings in appliance_config.get("settings") or []:
- if other_settings.get("default"):
+ if other_settings.get("default") and other_settings.get("template_type") == settings["template_type"]:
properties.update(other_settings.get("template_properties") or {})
+ break
properties.update(settings.get("template_properties") or {})
return properties
diff --git a/gns3server/schemas/controller/appliances.py b/gns3server/schemas/controller/appliances.py
index a98544a19..9fb818b8a 100644
--- a/gns3server/schemas/controller/appliances.py
+++ b/gns3server/schemas/controller/appliances.py
@@ -19,7 +19,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, Discriminator, EmailStr, Field, Tag, model_validator
from ..common import ExtraConfig
@@ -567,14 +567,23 @@ class QemuPropertiesV8(BaseModel):
title='Optional define the disk boot priory. 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: Optional[Kvm] = Field(None, title='KVM requirements')
options: Optional[str] = Field(None, title='Optional additional qemu command line options')
- cpu_throttling: Optional[Annotated[float, Field(ge=0.0, le=100.0)]] = Field(None, title='Throttle the CPU')
+ cpu_throttling: Optional[Annotated[int, Field(ge=0, le=800)]] = Field(None, title='Throttle the CPU')
tpm: Optional[bool] = Field(None, title='Enable the Trusted Platform Module (TPM)')
uefi: Optional[bool] = Field(None, title='Enable the UEFI boot mode')
on_close: Optional[QemuOnClose] = Field(None, title='Action to execute on the VM is closed')
process_priority: Optional[QemuProcessPriority] = Field(None, title='Process priority for QEMU')
+_V8_PROPERTIES_MODELS = {
+ TemplateType.qemu: QemuPropertiesV8,
+ TemplateType.dynamips: DynamipsPropertiesV8,
+ TemplateType.iou: IouPropertiesV8,
+ TemplateType.docker: DockerPropertiesV8,
+}
+
+
class TemplateSetting(BaseModel):
"""Emulator settings configuration (v8)"""
@@ -587,12 +596,34 @@ class TemplateSetting(BaseModel):
title='Properties for the template'
)
+ @model_validator(mode='before')
+ @classmethod
+ def _validate_template_properties(cls, data):
+ """
+ Validate template_properties against the model matching template_type.
+ The template_type discriminator lives at the settings level (not inside
+ template_properties), so the union cannot be discriminated by pydantic
+ alone and would misroute properties between the per-type models.
+ """
+
+ if isinstance(data, dict):
+ # work on a copy: replacing template_properties with the validated
+ # model must not mutate the caller's data
+ data = data.copy()
+ template_type = data.get("template_type")
+ template_properties = data.get("template_properties")
+ model = _V8_PROPERTIES_MODELS.get(template_type)
+ if model is not None and isinstance(template_properties, dict):
+ data["template_properties"] = model.model_validate(template_properties)
+ return data
+
class ApplianceVersionV8(BaseModel):
"""Appliance version definition (v8)"""
name: str = Field(..., title='Name of the version')
settings: Optional[str] = Field(None, title='Template settings to use to run the version')
+ idlepc: Optional[str] = Field(None, pattern=r'^0x[0-9a-f]{8}')
category: Optional[Category] = Field(None, title='Category of the version')
installation_instructions: Optional[str] = Field(None, title='Optional installation instructions for the version')
usage: Optional[str] = Field(None, title='Optional instructions about using the version')
@@ -633,7 +664,7 @@ class ApplianceV1_6(BaseModel):
usage: Optional[str] = Field(None, title='How to use the appliance')
symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
netmiko_device_type: Optional[str] = Field(
- None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$'
+ None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
)
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}')
@@ -689,7 +720,7 @@ class ApplianceV8(BaseModel):
default_password: Optional[str] = Field(None, title='Default password for the appliance')
symbol: Optional[str] = Field(None, title='An optional symbol for the appliance')
netmiko_device_type: Optional[str] = Field(
- None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$'
+ None, title='Device type for Netmiko-based automation tools', pattern=r'^[a-z0-9_]+$|^$'
)
tags: Optional[List[str]] = Field(None, title='User-defined metadata tags for the appliance')
settings: List[TemplateSetting] = Field(..., title='Settings for running the appliance')
diff --git a/gns3server/schemas/controller/nodes.py b/gns3server/schemas/controller/nodes.py
index 14bb81c9e..7347cacd7 100644
--- a/gns3server/schemas/controller/nodes.py
+++ b/gns3server/schemas/controller/nodes.py
@@ -119,7 +119,7 @@ class NodeBase(BaseModel):
netmiko_device_type: Optional[str] = Field(
None,
description="Device type for Netmiko-based automation tools, overrides the template value",
- pattern=r"^[a-z0-9_]+$",
+ pattern=r"^[a-z0-9_]+$|^$",
)
aux: Optional[int] = Field(None, gt=0, le=65535, description="Auxiliary console TCP port")
aux_type: Optional[ConsoleType] = None
diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py
index 2cd3b53c6..f2d5dfd13 100644
--- a/gns3server/schemas/controller/templates/__init__.py
+++ b/gns3server/schemas/controller/templates/__init__.py
@@ -51,7 +51,7 @@ class TemplateBase(BaseModel):
netmiko_device_type: Optional[str] = Field(
None,
description="Device type for Netmiko-based automation tools (e.g. 'cisco_xr' or 'nokia_srl')",
- pattern=r"^[a-z0-9_]+$",
+ pattern=r"^[a-z0-9_]+$|^$",
)
tags: Optional[List[str]] = Field(
default_factory=list,
diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py
index 773be1459..21bc5c82c 100644
--- a/tests/api/routes/controller/test_templates.py
+++ b/tests/api/routes/controller/test_templates.py
@@ -166,6 +166,13 @@ class TestTemplateRoutes:
assert response.json()["name"] == "VPCS_TEST_RENAMED"
assert response.json()["netmiko_device_type"] == "cisco_ios"
+ # the field can also be cleared with an empty string
+ params = {"netmiko_device_type": ""}
+ response = await client.put(app.url_path_for("update_template", template_id=template_id), json=params)
+
+ assert response.status_code == status.HTTP_200_OK
+ assert response.json()["netmiko_device_type"] == ""
+
async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None:
template_id = str(uuid.uuid4())
diff --git a/tests/controller/test_appliance.py b/tests/controller/test_appliance.py
index 98a1ca3b0..74f5c3aec 100644
--- a/tests/controller/test_appliance.py
+++ b/tests/controller/test_appliance.py
@@ -15,6 +15,9 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
+import pydantic
+import pytest
+
from gns3server.controller.appliance import Appliance
from gns3server.controller.appliance_to_template import ApplianceToTemplate
from gns3server.schemas.controller.appliances import ApplianceModel
@@ -77,25 +80,24 @@ def test_v8_docker_appliance_type():
def test_v8_type_from_default_settings():
# the default set wins over the other sets
- appliance = {
- "registry_version": 8,
- "name": "mixed",
- "status": "stable",
- "settings": [
- {"name": "a", "template_type": "docker", "template_properties": {"image": "x"}},
+ appliance = dict(
+ XRD_V8,
+ settings=[
+ {"name": "a", "template_type": "docker", "template_properties": {"image": "test:latest"}},
{"name": "b", "default": True, "template_type": "qemu", "template_properties": {"ram": 512}},
],
- }
+ )
+ # the fixture must be a loadable appliance, not an unreachable shape
+ ApplianceModel.model_validate(appliance)
assert _appliance(appliance).type == "qemu"
def test_v8_qemu_appliance_type_without_default():
- appliance = {
- "registry_version": 8,
- "name": "single",
- "status": "stable",
- "settings": [{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}],
- }
+ appliance = dict(
+ XRD_V8,
+ settings=[{"name": "a", "template_type": "qemu", "template_properties": {"ram": 512}}],
+ )
+ ApplianceModel.model_validate(appliance)
assert _appliance(appliance).type == "qemu"
@@ -127,3 +129,82 @@ def test_v8_docker_install_conversion():
{"adapter_number": 1, "port_name": "Gi0/0/0/0"},
]
assert template["extra_configs"] == [{"target": "/firstboot.cfg", "content": "!\nend\n"}]
+
+
+def test_v8_docker_settings_require_image():
+ """
+ template_properties must be validated against the model matching
+ template_type: a docker settings set without the required image is
+ rejected instead of being silently misrouted to another union member.
+ """
+
+ appliance = dict(
+ XRD_V8,
+ settings=[
+ {"name": "only", "default": True, "template_type": "docker",
+ "template_properties": {"adapters": 2}},
+ ],
+ )
+ with pytest.raises(pydantic.ValidationError):
+ ApplianceModel.model_validate(appliance)
+
+
+def test_v8_template_properties_validated_against_template_type():
+ """
+ Invalid enum values in qemu properties must be rejected at load time,
+ not silently discarded by a misrouted union member.
+ """
+
+ appliance = dict(
+ XRD_V8,
+ settings=[
+ {"name": "only", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 512, "boot_priority": "zzz"}},
+ ],
+ )
+ with pytest.raises(pydantic.ValidationError):
+ ApplianceModel.model_validate(appliance)
+
+
+def test_v8_qemu_kvm_property_validates():
+ appliance = dict(
+ XRD_V8,
+ settings=[
+ {"name": "only", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 512, "kvm": "disable"}},
+ ],
+ )
+ model = ApplianceModel.model_validate(appliance)
+ assert model.settings[0].template_properties.kvm == "disable"
+
+
+def test_v8_qemu_cpu_throttling_range():
+ """
+ cpu_throttling in v8 properties uses the same type and range as the
+ qemu template (int, 0-800).
+ """
+
+ settings = {"name": "only", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 512, "cpu_throttling": 500}}
+ model = ApplianceModel.model_validate(dict(XRD_V8, settings=[settings]))
+ assert model.settings[0].template_properties.cpu_throttling == 500
+
+ for bad in (150.5, 900):
+ bad_settings = dict(settings, template_properties={"ram": 512, "cpu_throttling": bad})
+ with pytest.raises(pydantic.ValidationError):
+ ApplianceModel.model_validate(dict(XRD_V8, settings=[bad_settings]))
+
+
+def test_v8_version_idlepc_validates():
+ appliance = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "0x613080c0"}])
+ ApplianceModel.model_validate(appliance)
+
+ bad = dict(XRD_V8, versions=[{"name": "1.0", "idlepc": "not-an-idlepc"}])
+ with pytest.raises(pydantic.ValidationError):
+ ApplianceModel.model_validate(bad)
+
+
+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 == ""
diff --git a/tests/controller/test_appliance_manager.py b/tests/controller/test_appliance_manager.py
new file mode 100644
index 000000000..c69a10999
--- /dev/null
+++ b/tests/controller/test_appliance_manager.py
@@ -0,0 +1,150 @@
+#!/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 uuid
+import pytest
+
+from gns3server.controller.appliance import Appliance
+from gns3server.controller.appliance_manager import ApplianceManager
+
+
+def _v8_appliance(settings, versions=None):
+ """
+ A minimal but fully valid registry version 8 appliance (it must pass
+ ApplianceModel validation, like appliances loaded by load_appliances).
+ """
+
+ data = {
+ "registry_version": 8,
+ "appliance_id": str(uuid.uuid4()),
+ "name": "Test appliance",
+ "category": "router",
+ "description": "Appliance description",
+ "vendor_name": "Test vendor",
+ "vendor_url": "https://example.com/",
+ "product_name": "Test product",
+ "status": "stable",
+ "maintainer": "Test maintainer",
+ "maintainer_email": "maintainer@example.com",
+ "symbol": ":/symbols/router.svg",
+ "settings": settings,
+ }
+ if versions:
+ data["versions"] = versions
+ return data
+
+
+class _FakeTemplatesService:
+ """
+ Stands in for TemplatesService so install_appliance can be exercised
+ without a controller instance or database.
+ """
+
+ created = []
+
+ def __init__(self, templates_repo):
+ self._templates_repo = templates_repo
+
+ async def create_template(self, template_create):
+ _FakeTemplatesService.created.append(template_create)
+ return {"name": template_create.name}
+
+
+@pytest.mark.asyncio
+async def test_install_docker_version_skips_image_resolution(monkeypatch):
+ """
+ v8 docker appliances have no image files: installing a version must not
+ resolve an image directory (default_images_directory does not support
+ docker) nor iterate the appliance images list.
+ """
+
+ _FakeTemplatesService.created = []
+ appliance_data = _v8_appliance(
+ [
+ {"name": "default", "default": True, "template_type": "docker",
+ "template_properties": {"image": "xrd:latest"}},
+ ],
+ versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
+ )
+ manager = ApplianceManager()
+ appliance = Appliance("test.gns3a", appliance_data)
+ manager._appliances[appliance.id] = appliance
+
+ def _boom(image_type):
+ raise AssertionError(f"default_images_directory must not be called for docker (got '{image_type}')")
+
+ monkeypatch.setattr("gns3server.controller.appliance_manager.default_images_directory", _boom)
+ monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
+
+ await manager.install_appliance(uuid.UUID(appliance.id), "1.0", None, None, None, None)
+
+ assert len(_FakeTemplatesService.created) == 1
+ template = _FakeTemplatesService.created[0].model_dump()
+ assert template["template_type"] == "docker"
+ # the version image name is injected into the template
+ assert template["image"] == "xrd:1.0"
+
+
+@pytest.mark.asyncio
+async def test_install_iou_version_maps_image_to_path(monkeypatch, tmp_path):
+ """
+ v8 IOU versions install: the version image is mapped to the template
+ path (an IOU template has no 'image' field).
+ """
+
+ _FakeTemplatesService.created = []
+ appliance_data = _v8_appliance(
+ [
+ {"name": "default", "default": True, "template_type": "iou",
+ "template_properties": {"ethernet_adapters": 4, "ram": 256}},
+ ],
+ versions=[{"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}],
+ )
+ manager = ApplianceManager()
+ appliance = Appliance("test.gns3a", appliance_data)
+ manager._appliances[appliance.id] = appliance
+
+ monkeypatch.setattr(
+ "gns3server.controller.appliance_manager.default_images_directory",
+ lambda image_type: str(tmp_path),
+ )
+ monkeypatch.setattr("gns3server.controller.appliance_manager.TemplatesService", _FakeTemplatesService)
+
+ await manager.install_appliance(uuid.UUID(appliance.id), "15.9", None, None, None, None)
+
+ assert len(_FakeTemplatesService.created) == 1
+ template = _FakeTemplatesService.created[0].model_dump()
+ assert template["template_type"] == "iou"
+ assert template["path"] == "i86bi-linux-l3-15.9.bin"
+ assert "image" not in template
+
+
+@pytest.mark.asyncio
+async def test_install_version_not_found(monkeypatch):
+ manager = ApplianceManager()
+ appliance_data = _v8_appliance(
+ [{"name": "only", "default": True, "template_type": "docker",
+ "template_properties": {"image": "xrd:latest"}}],
+ versions=[{"name": "1.0", "images": {"image": "xrd:1.0"}}],
+ )
+ appliance = Appliance("test.gns3a", appliance_data)
+ manager._appliances[appliance.id] = appliance
+
+ from gns3server.controller.controller_error import ControllerNotFoundError
+
+ with pytest.raises(ControllerNotFoundError):
+ await manager.install_appliance(uuid.UUID(appliance.id), "9.9", None, None, None, None)
diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py
index d3713d67b..e00e7c885 100644
--- a/tests/controller/test_appliance_to_template.py
+++ b/tests/controller/test_appliance_to_template.py
@@ -314,3 +314,244 @@ def test_v6_netmiko_device_type_copied_to_template():
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert template["netmiko_device_type"] == "nokia_srl"
+
+
+def test_v8_iou_version_images_mapped_to_path():
+ """
+ An IOU template takes the image as a path: the version image name must be
+ mapped to 'path', never to an 'image' key.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "IOU L3",
+ "category": "router",
+ "settings": [
+ {"name": "only", "template_type": "iou",
+ "template_properties": {"ethernet_adapters": 4, "ram": 256}},
+ ],
+ }
+ version = {"name": "15.9", "images": {"image": "i86bi-linux-l3-15.9.bin"}}
+
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+
+ assert template["template_type"] == "iou"
+ assert template["path"] == "i86bi-linux-l3-15.9.bin"
+ assert "image" not in template
+
+
+def test_v8_dynamips_idlepc_from_version():
+ appliance = {
+ "registry_version": 8,
+ "name": "Cisco 7200",
+ "category": "router",
+ "settings": [
+ {"name": "only", "template_type": "dynamips",
+ "template_properties": {"ram": 512, "platform": "c7200"}},
+ ],
+ }
+ version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
+
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+
+ assert template["idlepc"] == "0x613080c0"
+ assert template["image"] == "c7200.bin"
+
+
+def test_v8_dynamips_settings_idlepc_precedence():
+ """
+ An idlepc defined in template_properties wins over the version level.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Cisco 7200",
+ "category": "router",
+ "settings": [
+ {"name": "only", "template_type": "dynamips",
+ "template_properties": {"ram": 512, "idlepc": "0x6142da40"}},
+ ],
+ }
+ version = {"name": "12.4", "idlepc": "0x613080c0", "images": {"image": "c7200.bin"}}
+
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+
+ assert template["idlepc"] == "0x6142da40"
+
+
+def test_v8_qemu_kvm_disable_forces_accel_tcg():
+ """
+ kvm: disable is not a valid template property: it is converted to the
+ equivalent qemu options like for registry versions 1-6.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "QEMU VM",
+ "category": "guest",
+ "settings": [
+ {"name": "only", "template_type": "qemu",
+ "template_properties": {"ram": 512, "options": "-m 512", "kvm": "disable"}},
+ ],
+ }
+
+ template = ApplianceToTemplate().new_template(appliance, None, "local")
+
+ assert template["options"] == "-m 512 -machine accel=tcg"
+ assert "kvm" not in template
+
+
+def test_v8_qemu_kvm_allow_keeps_options():
+ appliance = {
+ "registry_version": 8,
+ "name": "QEMU VM",
+ "category": "guest",
+ "settings": [
+ {"name": "only", "template_type": "qemu",
+ "template_properties": {"ram": 512, "options": "-m 512", "kvm": "allow"}},
+ ],
+ }
+
+ template = ApplianceToTemplate().new_template(appliance, None, "local")
+
+ assert template["options"] == "-m 512"
+ assert "kvm" not in template
+
+
+def test_v8_no_cross_type_inheritance():
+ """
+ A version referencing a docker settings set must not inherit properties
+ from a default qemu settings set.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Mixed",
+ "category": "router",
+ "settings": [
+ {"name": "default qemu", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 2048, "adapters": 10}},
+ {"name": "docker alt", "template_type": "docker",
+ "template_properties": {"image": "xrd:latest", "adapters": 2}},
+ ],
+ }
+ version = {"name": "1.0", "settings": "docker alt", "images": {"image": "xrd:1.0"}}
+
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+
+ assert template["template_type"] == "docker"
+ assert template["adapters"] == 2
+ assert "ram" not in template
+ assert template["image"] == "xrd:1.0"
+
+
+def test_v8_multiple_defaults_inherit_first_same_type():
+ """
+ With several default settings sets of the same type, the first one is
+ inherited from, matching the selection rule.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Multi",
+ "category": "router",
+ "settings": [
+ {"name": "default one", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 1024}},
+ {"name": "default two", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 2048}},
+ {"name": "alt", "template_type": "qemu", "template_properties": {"cpus": 2}},
+ ],
+ }
+ version = {"name": "1.0", "settings": "alt"}
+
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+
+ assert template["ram"] == 1024
+ assert template["cpus"] == 2
+
+
+def test_v8_symbol_fallback_uses_effective_category():
+ """
+ The default symbol must reflect the effective category (template_properties,
+ then version, then appliance), not the appliance-level one.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Test",
+ "category": "router",
+ "settings": [
+ {"name": "only", "template_type": "qemu",
+ "template_properties": {"ram": 512, "category": "guest"}},
+ ],
+ }
+ template = ApplianceToTemplate().new_template(appliance, None, "local")
+ assert template["category"] == "guest"
+ assert template["symbol"] == ":/symbols/qemu_guest.svg"
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Test",
+ "category": "guest",
+ "settings": [
+ {"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}},
+ ],
+ }
+ version = {"name": "1.0", "category": "router"}
+ template = ApplianceToTemplate().new_template(appliance, version, "local")
+ assert template["category"] == "router"
+ assert template["symbol"] == ":/symbols/router.svg"
+
+
+def test_v8_reserved_keys_cannot_be_overridden():
+ """
+ template_properties must not override the structural template fields.
+ """
+
+ appliance = {
+ "registry_version": 8,
+ "name": "Test",
+ "category": "router",
+ "settings": [
+ {"name": "only", "template_type": "docker",
+ "template_properties": {
+ "image": "xrd:latest",
+ "template_type": "qemu",
+ "compute_id": "evil-compute",
+ "version": "9.9",
+ }},
+ ],
+ }
+
+ template = ApplianceToTemplate().new_template(appliance, None, "local")
+
+ assert template["template_type"] == "docker"
+ assert template["compute_id"] == "local"
+ assert "version" not in template
+
+
+def test_v8_get_template_type():
+ """
+ get_template_type resolves the emulator type from the settings set
+ selected for the version, like the install flow does.
+ """
+
+ converter = ApplianceToTemplate()
+ appliance = {
+ "registry_version": 8,
+ "name": "Mixed",
+ "category": "router",
+ "settings": [
+ {"name": "default", "default": True, "template_type": "qemu",
+ "template_properties": {"ram": 512}},
+ {"name": "alt", "template_type": "docker",
+ "template_properties": {"image": "xrd:latest"}},
+ ],
+ }
+
+ assert converter.get_template_type(appliance, {"name": "1.0", "settings": "alt"}) == "docker"
+ assert converter.get_template_type(appliance, None) == "qemu"
+
+ v6 = {"registry_version": 6, "docker": {"image": "test:latest"}}
+ assert converter.get_template_type(v6, None) == "docker"
diff --git a/tests/controller/test_node.py b/tests/controller/test_node.py
index 2b9b9659a..0edb4a0f9 100644
--- a/tests/controller/test_node.py
+++ b/tests/controller/test_node.py
@@ -401,6 +401,11 @@ async def test_update_netmiko_device_type(node, compute):
assert node.netmiko_device_type == "cisco_ios_telnet"
assert node.asdict()["netmiko_device_type"] == "cisco_ios_telnet"
+ # the field can also be cleared with an empty string
+ await node.update(netmiko_device_type="")
+ assert node.netmiko_device_type == ""
+ assert node.asdict()["netmiko_device_type"] == ""
+
def test_netmiko_device_type_from_template_kwargs(compute, project):
"""