mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-08-27 12:30:13 +03:00
templates: persist appliance metadata on install
Appliance fields that describe the appliance (vendor information, default credentials, installation instructions...) were dropped when installing a template. Keep them in a new appliance_metadata JSON column on the templates table, filled by the appliance-to-template conversion for both registry v1-6 and v8 (version level values override the appliance level ones). The nested schema allows extra fields so future registry fields persist without a migration.
This commit is contained in:
parent
1c68a52856
commit
300c53e6fb
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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')
|
||||
@ -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):
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user