fix: address appliance v8 install review findings

- install: resolve the image directory from the version's settings type and
  skip image handling for docker appliances; guard appliance.images
- appliance schema: validate template_properties against template_type,
  align cpu_throttling with the qemu template, add kvm and version idlepc
- conversion: map IOU image to path, kvm disable to accel=tcg, inherit only
  same-type default settings, symbol fallback from the effective category,
  template_properties cannot override structural fields
- allow clearing netmiko_device_type with an empty string
- download the template symbol regardless of the level it is defined at and
  give qemu guests a default symbol
This commit is contained in:
YueGuobin 2026-08-16 16:52:29 +08:00
parent 435aa4c257
commit 1c68a52856
No known key found for this signature in database
10 changed files with 603 additions and 39 deletions

View File

@ -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:

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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,

View File

@ -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())

View File

@ -15,6 +15,9 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
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 == ""

View File

@ -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 <http://www.gnu.org/licenses/>.
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)

View File

@ -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"

View File

@ -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):
"""