From 57fe54977333b1f6dbcae498c9c53737a1d9b346 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Sun, 16 Aug 2026 12:26:55 +0800 Subject: [PATCH] appliance: implement install support for registry version 8 new_template() now converts the v8 settings[] format per the spec in gns3-registry#734: settings selection (version name reference, then the default set, then a single set), inherit_default_properties merging, and template_properties expansion with category/usage/symbol resolved from template_properties > version > appliance levels. Undefined properties are left out so controller template defaults apply. Registry versions 1-6 keep the existing top-level emulator block path. --- .../controller/appliance_to_template.py | 106 +++++++ .../controller/test_appliance_to_template.py | 285 ++++++++++++++++++ 2 files changed, 391 insertions(+) create mode 100644 tests/controller/test_appliance_to_template.py diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index 658dc42c7..79a9fb37e 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -18,6 +18,8 @@ import logging +from .controller_error import ControllerError + log = logging.getLogger(__name__) @@ -31,6 +33,9 @@ class ApplianceToTemplate: Creates a new template from an appliance. """ + if appliance_config.get("registry_version", 0) >= 8: + return self._new_template_v8(appliance_config, version, server) + new_template = { "compute_id": server, "name": appliance_config["name"], @@ -133,3 +138,104 @@ class ApplianceToTemplate: new_config.update(appliance_config["iou"]) new_config["path"] = version.get("images").get("image") + + def _new_template_v8(self, appliance_config, version, server): + """ + Creates a new template from an appliance using the registry version 8 format. + """ + + settings = self._select_v8_settings(appliance_config, version) + properties = self._merge_v8_properties(settings, appliance_config) + + new_template = { + "compute_id": server, + "template_type": settings["template_type"], + "name": appliance_config["name"], + } + + if version: + new_template["version"] = version.get("name") + + # category/usage/symbol can be defined in template_properties (already merged above), + # otherwise at the version level, otherwise at the appliance level + for prop in ("category", "usage", "symbol"): + if prop not in properties: + if version and version.get(prop) is not None: + properties[prop] = version[prop] + elif appliance_config.get(prop) is not None: + properties[prop] = appliance_config[prop] + + if properties.get("category") == "multilayer_switch": + properties["category"] = "switch" + + new_template.update(properties) + if "tags" in appliance_config: + new_template["tags"] = appliance_config.get("tags") + + if not new_template.get("symbol"): + # apply a default symbol based on the category and template type + if appliance_config["category"] == "guest": + if settings["template_type"] == "docker": + new_template["symbol"] = ":/symbols/docker_guest.svg" + else: + new_template["symbol"] = ":/symbols/qemu_guest.svg" + else: + symbols = { + "router": ":/symbols/router.svg", + "switch": ":/symbols/ethernet_switch.svg", + "multilayer_switch": ":/symbols/multilayer_switch.svg", + "firewall": ":/symbols/firewall.svg", + } + new_template["symbol"] = symbols.get(appliance_config["category"]) + + if version and version.get("images"): + new_template.update(version["images"]) + + return new_template + + def _select_v8_settings(self, appliance_config, version): + """ + Selects the settings set to use: the one referenced by the version, + otherwise the default set, otherwise the only set present. + """ + + settings_list = appliance_config.get("settings") or [] + if not settings_list: + raise ControllerError(f"Appliance '{appliance_config['name']}' has no settings") + + if version and version.get("settings"): + settings_name = version["settings"] + for settings in settings_list: + if settings.get("name") == settings_name: + return settings + raise ControllerError( + f"Could not find settings '{settings_name}' referenced by " + f"version '{version.get('name')}' in appliance '{appliance_config['name']}'" + ) + + for settings in settings_list: + if settings.get("default"): + return settings + + if len(settings_list) == 1: + return settings_list[0] + + raise ControllerError( + f"Appliance '{appliance_config['name']}' has multiple settings " + f"but none is marked as default" + ) + + def _merge_v8_properties(self, settings, appliance_config): + """ + Merges the template properties of the selected settings with the default + settings properties, unless inheritance is disabled or the default set + is selected. + """ + + 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"): + properties.update(other_settings.get("template_properties") or {}) + properties.update(settings.get("template_properties") or {}) + return properties diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py new file mode 100644 index 000000000..4a06b27d8 --- /dev/null +++ b/tests/controller/test_appliance_to_template.py @@ -0,0 +1,285 @@ +#!/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 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 pytest + +from gns3server.controller.appliance_to_template import ApplianceToTemplate +from gns3server.controller.controller_error import ControllerError + + +# reduced mirror of the upstream vyos.gns3a (registry version 8, qemu, 2 settings sets) +VYOS_V8 = { + "registry_version": 8, + "appliance_id": "f82b74c4-0f30-456f-a582-63daca528502", + "name": "VyOS Universal Router", + "category": "router", + "description": "VyOS", + "vendor_name": "VyOS Inc.", + "vendor_url": "https://vyos.io/", + "product_name": "VyOS Universal Router", + "status": "stable", + "maintainer": "VyOS Inc.", + "maintainer_email": "support@vyos.io", + "usage": "appliance usage", + "symbol": "vyos.svg", + "settings": [ + { + "name": "default x86_64", + "default": True, + "template_type": "qemu", + "template_properties": { + "adapter_type": "virtio-net-pci", + "adapters": 10, + "port_name_format": "eth{0}", + "ram": 2048, + "cpus": 4, + "hda_disk_interface": "virtio", + "platform": "x86_64", + "console_type": "telnet", + "boot_priority": "c", + "uefi": False, + "on_close": "shutdown_signal", + }, + }, + { + "name": "1.5 x86_64", + "inherit_default_properties": True, + "template_type": "qemu", + "template_properties": { + "ram": 8192, + "cpus": 4, + }, + }, + ], + "images": [ + { + "filename": "vyos-1.5.1-kvm-amd64.qcow2", + "version": "1.5.1", + "md5sum": "816ec7c3699a9e4f19e2b8765fd3d7eb", + "filesize": 667549696, + }, + { + "filename": "vyos-1.4.5-kvm-amd64.qcow2", + "version": "1.4.5", + "md5sum": "06ccf7e3ed3f948a23c995133b5fbfce", + "filesize": 557645824, + }, + ], + "versions": [ + { + "name": "1.5.1", + "settings": "1.5 x86_64", + "images": {"hda_disk_image": "vyos-1.5.1-kvm-amd64.qcow2"}, + }, + { + "name": "1.4.5", + "images": {"hda_disk_image": "vyos-1.4.5-kvm-amd64.qcow2"}, + }, + ], +} + + +def test_v8_version_referenced_settings_with_inheritance(): + """ + A version referencing a named settings set must select it and inherit + the default set properties (vyos 1.5.1 -> "1.5 x86_64", ram overridden to 8192). + """ + + version = VYOS_V8["versions"][0] + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["template_type"] == "qemu" + assert template["version"] == "1.5.1" + # inherited from the default settings + assert template["adapters"] == 10 + assert template["adapter_type"] == "virtio-net-pci" + assert template["platform"] == "x86_64" + # overridden by the selected settings + assert template["ram"] == 8192 + # appliance level fields + assert template["name"] == "VyOS Universal Router" + assert template["category"] == "router" + assert template["usage"] == "appliance usage" + assert template["symbol"] == "vyos.svg" + # version images are injected + assert template["hda_disk_image"] == "vyos-1.5.1-kvm-amd64.qcow2" + + +def test_v8_default_settings_selected_when_version_has_no_reference(): + """ + A version without a settings reference falls back to the default settings set. + """ + + version = VYOS_V8["versions"][1] + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["version"] == "1.4.5" + assert template["ram"] == 2048 + assert template["hda_disk_image"] == "vyos-1.4.5-kvm-amd64.qcow2" + + +def test_v8_version_level_overrides(): + """ + category/usage/symbol defined at the version level override the appliance level. + """ + + version = dict(VYOS_V8["versions"][1], category="firewall", usage="version usage", symbol="firewall.svg") + template = ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + assert template["category"] == "firewall" + assert template["usage"] == "version usage" + assert template["symbol"] == "firewall.svg" + + +def test_v8_properties_take_precedence_over_version_and_appliance(): + """ + Fields defined in template_properties win over the version and appliance levels. + """ + + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"], usage="settings usage"), + ) + appliance = dict(VYOS_V8, settings=[settings]) + version = dict(VYOS_V8["versions"][1], usage="version usage") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + assert template["usage"] == "settings usage" + + +def test_v8_template_properties_name_and_category(): + """ + name/category defined in template_properties are used for the template. + """ + + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"], name="VyOS 1.4", category="guest"), + ) + appliance = dict(VYOS_V8, settings=[settings]) + + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["name"] == "VyOS 1.4" + assert template["category"] == "guest" + + +def test_v8_multilayer_switch_category_mapping(): + settings = dict( + VYOS_V8["settings"][0], + template_properties=dict(VYOS_V8["settings"][0]["template_properties"]), + ) + settings["template_properties"].pop("name", None) + appliance = dict(VYOS_V8, category="multilayer_switch", settings=[settings]) + + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["category"] == "switch" + + +def test_v8_default_symbol_fallback(): + """ + Without any symbol, a docker guest gets the docker symbol, other guests the qemu one. + """ + + appliance = { + "registry_version": 8, + "name": "Test", + "category": "guest", + "settings": [{"name": "only", "template_type": "docker", "template_properties": {"image": "test:latest"}}], + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["symbol"] == ":/symbols/docker_guest.svg" + assert template["template_type"] == "docker" + assert template["image"] == "test:latest" + + appliance["settings"][0]["template_type"] = "qemu" + appliance["settings"][0]["template_properties"] = {"ram": 512} + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["symbol"] == ":/symbols/qemu_guest.svg" + + +def test_v8_no_inheritance_when_disabled(): + settings = dict( + VYOS_V8["settings"][1], + inherit_default_properties=False, + template_properties={"ram": 4096, "adapters": 2}, + ) + appliance = dict(VYOS_V8, settings=[VYOS_V8["settings"][0], settings]) + version = dict(VYOS_V8["versions"][0], settings="1.5 x86_64") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + assert template["ram"] == 4096 + assert template["adapters"] == 2 + # not inherited + assert "adapter_type" not in template + assert "platform" not in template + + +def test_v8_unknown_settings_reference_raises(): + version = dict(VYOS_V8["versions"][0], settings="does not exist") + + with pytest.raises(ControllerError, match="Could not find settings 'does not exist'"): + ApplianceToTemplate().new_template(VYOS_V8, version, "local") + + +def test_v8_multiple_settings_without_default_raises(): + appliance = dict(VYOS_V8) + appliance["settings"] = [ + dict(VYOS_V8["settings"][0], default=None), + dict(VYOS_V8["settings"][1]), + ] + + with pytest.raises(ControllerError, match="none is marked as default"): + ApplianceToTemplate().new_template(appliance, VYOS_V8["versions"][1], "local") + + +def test_v8_single_settings_selected_without_default_flag(): + appliance = { + "registry_version": 8, + "name": "Test", + "category": "router", + "settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 1024}}], + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + assert template["ram"] == 1024 + assert template["symbol"] == ":/symbols/router.svg" + + +def test_v6_path_unchanged(): + """ + Registry versions 1-6 keep using the top-level emulator blocks (regression check). + """ + + appliance = { + "registry_version": 6, + "name": "SRLinux", + "category": "router", + "symbol": ":/symbols/router.svg", + "usage": "v6 usage", + "docker": { + "adapters": 35, + "image": "ghcr.io/nokia/srlinux:latest", + "console_type": "docker_exec", + "environment": "GNS3_SKIP_INIT=1", + }, + } + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert template["template_type"] == "docker" + assert template["image"] == "ghcr.io/nokia/srlinux:latest" + assert template["console_type"] == "docker_exec" + assert template["adapters"] == 35 + assert template["usage"] == "v6 usage"