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 committed 2026-08-21 21:41:42 +08:00
1 parent 435aa4c257
commit 1c68a52856
10 files changed
+603 -39

No files matched your search

+21 -13
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:
+48 -7
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
+35 -4
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')
+1 -1
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
@@ -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,