diff --git a/gns3server/controller/appliance_to_template.py b/gns3server/controller/appliance_to_template.py index a07f3a807..3602a7a7e 100644 --- a/gns3server/controller/appliance_to_template.py +++ b/gns3server/controller/appliance_to_template.py @@ -23,6 +23,27 @@ from .controller_error import ControllerError log = logging.getLogger(__name__) +# appliance fields that describe the appliance (vendor information, default +# credentials...) and are kept on the template as metadata instead of being +# dropped at installation time +_APPLIANCE_METADATA_FIELDS = ( + "description", + "vendor_name", + "vendor_url", + "vendor_logo_url", + "documentation_url", + "product_name", + "product_url", + "status", + "availability", + "maintainer", + "maintainer_email", + "installation_instructions", + "default_username", + "default_password", +) + + class ApplianceToTemplate: """ Appliance installation. @@ -61,6 +82,10 @@ class ApplianceToTemplate: if appliance_config.get("netmiko_device_type"): new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + appliance_metadata = self._build_appliance_metadata(appliance_config, version) + if appliance_metadata: + new_template["appliance_metadata"] = appliance_metadata + if new_template.get("symbol") is None: if appliance_config["category"] == "guest": if "docker" in appliance_config: @@ -192,6 +217,10 @@ class ApplianceToTemplate: if appliance_config.get("netmiko_device_type"): new_template["netmiko_device_type"] = appliance_config["netmiko_device_type"] + appliance_metadata = self._build_appliance_metadata(appliance_config, version) + if appliance_metadata: + new_template["appliance_metadata"] = appliance_metadata + if not new_template.get("symbol"): # apply a default symbol based on the effective category and template type if category_before_remap == "guest": @@ -221,6 +250,26 @@ class ApplianceToTemplate: return new_template + def _build_appliance_metadata(self, appliance_config, version): + """ + Builds the appliance metadata kept on the template: the fields that + describe the appliance, with version level values (e.g. credentials + specific to the installed version) overriding the appliance level ones. + """ + + version = version or {} + metadata = {} + for field in _APPLIANCE_METADATA_FIELDS: + value = version.get(field) + if value is None: + value = appliance_config.get(field) + if value is not None: + metadata[field] = value + appliance_id = appliance_config.get("appliance_id") + if appliance_id: + metadata["appliance_id"] = str(appliance_id) + return metadata or None + def get_template_type(self, appliance_config, version): """ Returns the template type of the settings set used to install the given diff --git a/gns3server/db/models/templates.py b/gns3server/db/models/templates.py index 6afb4f7cd..759cc0fc8 100644 --- a/gns3server/db/models/templates.py +++ b/gns3server/db/models/templates.py @@ -36,6 +36,7 @@ class Template(BaseTable): builtin = Column(Boolean, default=False) usage = Column(String) netmiko_device_type = Column(String) + appliance_metadata = Column(JSON) template_type = Column(String) tags = Column(JSON) compute_id = Column(String) diff --git a/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py new file mode 100644 index 000000000..7c7b18fbb --- /dev/null +++ b/gns3server/db_migrations/versions/c7e4a9f1d2b6_add_appliance_metadata_to_templates.py @@ -0,0 +1,26 @@ +"""add appliance_metadata to templates table + +Revision ID: c7e4a9f1d2b6 +Revises: b3c7e2a91d4f +Create Date: 2026-08-16 12:00:00.000000 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c7e4a9f1d2b6' +down_revision = 'b3c7e2a91d4f' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + + op.add_column('templates', sa.Column('appliance_metadata', sa.JSON())) + + +def downgrade() -> None: + + op.drop_column('templates', 'appliance_metadata') diff --git a/gns3server/schemas/controller/templates/__init__.py b/gns3server/schemas/controller/templates/__init__.py index f2d5dfd13..3c75c6b1f 100644 --- a/gns3server/schemas/controller/templates/__init__.py +++ b/gns3server/schemas/controller/templates/__init__.py @@ -34,6 +34,34 @@ class Category(str, Enum): firewall = "firewall" +class ApplianceMetadata(BaseModel): + """ + Metadata kept on a template installed from an appliance: vendor + information, default credentials and other fields that describe + the appliance but are not node properties. + """ + + model_config = ConfigDict(extra="allow") + + appliance_id: Optional[str] = Field( + None, description="ID of the appliance the template was installed from" + ) + description: Optional[str] = None + vendor_name: Optional[str] = None + vendor_url: Optional[str] = None + vendor_logo_url: Optional[str] = None + documentation_url: Optional[str] = None + product_name: Optional[str] = None + product_url: Optional[str] = None + status: Optional[str] = None + availability: Optional[str] = None + maintainer: Optional[str] = None + maintainer_email: Optional[str] = None + installation_instructions: Optional[str] = None + default_username: Optional[str] = None + default_password: Optional[str] = None + + class TemplateBase(BaseModel): """ Common template properties. @@ -57,6 +85,10 @@ class TemplateBase(BaseModel): default_factory=list, description="User-defined metadata tags (e.g. 'vendor:cisco' or 'model:7200')" ) + appliance_metadata: Optional[ApplianceMetadata] = Field( + None, + description="Metadata inherited from the appliance the template was installed from" + ) class TemplateCreate(TemplateBase): diff --git a/tests/api/routes/controller/test_templates.py b/tests/api/routes/controller/test_templates.py index 21bc5c82c..75cae55d5 100644 --- a/tests/api/routes/controller/test_templates.py +++ b/tests/api/routes/controller/test_templates.py @@ -173,6 +173,44 @@ class TestTemplateRoutes: assert response.status_code == status.HTTP_200_OK assert response.json()["netmiko_device_type"] == "" + async def test_template_appliance_metadata_roundtrip(self, app: FastAPI, client: AsyncClient) -> None: + """ + Appliance metadata persists with the template: create, read back, + and replace on update. Unknown fields are kept (extra=allow) so that + future appliance registry fields do not vanish. + """ + + template_id = str(uuid.uuid4()) + params = { + "template_id": template_id, + "name": "VPCS_METADATA", + "compute_id": "local", + "template_type": "vpcs", + "appliance_metadata": { + "vendor_name": "Test vendor", + "default_username": "admin", + "future_field": "kept", + }, + } + + response = await client.post(app.url_path_for("create_template"), json=params) + assert response.status_code == status.HTTP_201_CREATED + metadata = response.json()["appliance_metadata"] + assert metadata["vendor_name"] == "Test vendor" + assert metadata["default_username"] == "admin" + assert metadata["future_field"] == "kept" + + # read back from the database + response = await client.get(app.url_path_for("get_template", template_id=template_id)) + assert response.status_code == status.HTTP_200_OK + assert response.json()["appliance_metadata"] == metadata + + # the metadata object is replaced as a whole on update + params = {"appliance_metadata": {"default_username": "root"}} + 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()["appliance_metadata"] == {"default_username": "root"} + async def test_template_delete(self, app: FastAPI, client: AsyncClient) -> None: template_id = str(uuid.uuid4()) diff --git a/tests/controller/test_appliance_manager.py b/tests/controller/test_appliance_manager.py index c69a10999..2df0cedc4 100644 --- a/tests/controller/test_appliance_manager.py +++ b/tests/controller/test_appliance_manager.py @@ -97,6 +97,10 @@ async def test_install_docker_version_skips_image_resolution(monkeypatch): assert template["template_type"] == "docker" # the version image name is injected into the template assert template["image"] == "xrd:1.0" + # appliance metadata survives the install and validates through TemplateCreate + metadata = template["appliance_metadata"] + assert metadata["vendor_name"] == "Test vendor" + assert metadata["appliance_id"] == appliance.id @pytest.mark.asyncio diff --git a/tests/controller/test_appliance_to_template.py b/tests/controller/test_appliance_to_template.py index e00e7c885..0ce40cef4 100644 --- a/tests/controller/test_appliance_to_template.py +++ b/tests/controller/test_appliance_to_template.py @@ -555,3 +555,69 @@ def test_v8_get_template_type(): v6 = {"registry_version": 6, "docker": {"image": "test:latest"}} assert converter.get_template_type(v6, None) == "docker" + + +def test_v8_appliance_metadata_copied_to_template(): + """ + Appliance metadata (vendor information, default credentials...) is kept + on the template instead of being dropped, with version level values + overriding the appliance level ones. + """ + + appliance = dict(VYOS_V8, default_username="vyos", default_password="vyospass") + version = dict(VYOS_V8["versions"][1], default_username="vyos145") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + metadata = template["appliance_metadata"] + assert metadata["appliance_id"] == VYOS_V8["appliance_id"] + assert metadata["vendor_name"] == "VyOS Inc." + assert metadata["status"] == "stable" + # the version level default_username overrides the appliance level one + assert metadata["default_username"] == "vyos145" + assert metadata["default_password"] == "vyospass" + + +def test_v8_appliance_metadata_version_level_overrides(): + appliance = dict(VYOS_V8, installation_instructions="appliance instructions") + version = dict(VYOS_V8["versions"][1], installation_instructions="version instructions") + + template = ApplianceToTemplate().new_template(appliance, version, "local") + + assert template["appliance_metadata"]["installation_instructions"] == "version instructions" + + +def test_v6_appliance_metadata_copied_to_template(): + """ + Registry versions 1-6 appliances keep their metadata too (the fields + they have: the v8-only ones are simply absent). + """ + + appliance = { + "registry_version": 6, + "name": "SRLinux", + "category": "router", + "description": "Nokia SR Linux", + "vendor_name": "Nokia", + "docker": {"adapters": 35, "image": "ghcr.io/nokia/srlinux:latest"}, + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + metadata = template["appliance_metadata"] + assert metadata["description"] == "Nokia SR Linux" + assert metadata["vendor_name"] == "Nokia" + assert "default_username" not in metadata + + +def test_no_appliance_metadata_when_appliance_has_none(): + appliance = { + "registry_version": 8, + "name": "Test", + "category": "guest", + "settings": [{"name": "only", "template_type": "qemu", "template_properties": {"ram": 512}}], + } + + template = ApplianceToTemplate().new_template(appliance, None, "local") + + assert "appliance_metadata" not in template