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