mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-09-02 08:05:14 +03:00
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:
parent
435aa4c257
commit
1c68a52856
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 == ""
|
||||
|
||||
150
tests/controller/test_appliance_manager.py
Normal file
150
tests/controller/test_appliance_manager.py
Normal 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)
|
||||
@ -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"
|
||||
|
||||
@ -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):
|
||||
"""
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user