1
0
mirror of https://github.com/GNS3/gns3-server synced 2024-11-28 11:18:11 +00:00

Refactor template management to use database.

This commit is contained in:
grossmj 2021-03-28 11:15:08 +10:30
parent b417bc4dec
commit d730c591b3
20 changed files with 1704 additions and 1535 deletions

View File

@ -21,18 +21,25 @@ API routes for templates.
import hashlib
import json
import pydantic
import logging
log = logging.getLogger(__name__)
from fastapi import APIRouter, Request, Response, HTTPException, status
from fastapi.encoders import jsonable_encoder
from fastapi import APIRouter, Request, Response, HTTPException, Depends, status
from typing import List
from uuid import UUID
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.controller.controller_error import (
ControllerBadRequestError,
ControllerNotFoundError,
ControllerForbiddenError
)
from .dependencies.database import get_repository
router = APIRouter()
@ -41,107 +48,141 @@ responses = {
}
@router.post("/templates",
status_code=status.HTTP_201_CREATED,
response_model=schemas.Template)
def create_template(template_data: schemas.TemplateCreate):
@router.post("/templates", response_model=schemas.Template, status_code=status.HTTP_201_CREATED)
async def create_template(
new_template: schemas.TemplateCreate,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> dict:
"""
Create a new template.
"""
controller = Controller.instance()
template = controller.template_manager.add_template(jsonable_encoder(template_data, exclude_unset=True))
# Reset the symbol list
controller.symbols.list()
return template.__json__()
try:
return await template_repo.create_template(new_template)
except pydantic.ValidationError as e:
raise ControllerBadRequestError(f"JSON schema error received while creating new template: {e}")
@router.get("/templates/{template_id}",
response_model=schemas.Template,
response_model_exclude_unset=True,
responses=responses)
def get_template(template_id: UUID, request: Request, response: Response):
async def get_template(
template_id: UUID,
request: Request,
response: Response,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> dict:
"""
Return a template.
"""
request_etag = request.headers.get("If-None-Match", "")
controller = Controller.instance()
template = controller.template_manager.get_template(str(template_id))
data = json.dumps(template.__json__())
template = await template_repo.get_template(template_id)
if not template:
raise ControllerNotFoundError(f"Template '{template_id}' not found")
data = json.dumps(template)
template_etag = '"' + hashlib.md5(data.encode()).hexdigest() + '"'
if template_etag == request_etag:
raise HTTPException(status_code=status.HTTP_304_NOT_MODIFIED)
else:
response.headers["ETag"] = template_etag
return template.__json__()
return template
@router.put("/templates/{template_id}",
response_model=schemas.Template,
response_model_exclude_unset=True,
responses=responses)
def update_template(template_id: UUID, template_data: schemas.TemplateUpdate):
async def update_template(
template_id: UUID,
template_data: schemas.TemplateUpdate,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> dict:
"""
Update a template.
"""
controller = Controller.instance()
template = controller.template_manager.get_template(str(template_id))
template.update(**jsonable_encoder(template_data, exclude_unset=True))
return template.__json__()
if template_repo.get_builtin_template(template_id):
raise ControllerForbiddenError(f"Template '{template_id}' cannot be updated because it is built-in")
template = await template_repo.update_template(template_id, template_data)
if not template:
raise ControllerNotFoundError(f"Template '{template_id}' not found")
return template
@router.delete("/templates/{template_id}",
status_code=status.HTTP_204_NO_CONTENT,
responses=responses)
def delete_template(template_id: UUID):
async def delete_template(
template_id: UUID,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> None:
"""
Delete a template.
"""
controller = Controller.instance()
controller.template_manager.delete_template(str(template_id))
if template_repo.get_builtin_template(template_id):
raise ControllerForbiddenError(f"Template '{template_id}' cannot be deleted because it is built-in")
success = await template_repo.delete_template(template_id)
if not success:
raise ControllerNotFoundError(f"Template '{template_id}' not found")
@router.get("/templates",
response_model=List[schemas.Template],
response_model_exclude_unset=True)
def get_templates():
async def get_templates(
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> List[dict]:
"""
Return all templates.
"""
controller = Controller.instance()
return [c.__json__() for c in controller.template_manager.templates.values()]
templates = await template_repo.get_templates()
return templates
@router.post("/templates/{template_id}/duplicate",
response_model=schemas.Template,
status_code=status.HTTP_201_CREATED,
responses=responses)
async def duplicate_template(template_id: UUID):
async def duplicate_template(
template_id: UUID,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> dict:
"""
Duplicate a template.
"""
controller = Controller.instance()
template = controller.template_manager.duplicate_template(str(template_id))
return template.__json__()
if template_repo.get_builtin_template(template_id):
raise ControllerForbiddenError(f"Template '{template_id}' cannot be duplicated because it is built-in")
template = await template_repo.duplicate_template(template_id)
if not template:
raise ControllerNotFoundError(f"Template '{template_id}' not found")
return template
@router.post("/projects/{project_id}/templates/{template_id}",
response_model=schemas.Node,
status_code=status.HTTP_201_CREATED,
responses={404: {"model": schemas.ErrorMessage, "description": "Could not find project or template"}})
async def create_node_from_template(project_id: UUID, template_id: UUID, template_usage: schemas.TemplateUsage):
async def create_node_from_template(
project_id: UUID,
template_id: UUID,
template_usage: schemas.TemplateUsage,
template_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository))
) -> schemas.Node:
"""
Create a new node from a template.
"""
template = await template_repo.get_template(template_id)
if not template:
raise ControllerNotFoundError(f"Template '{template_id}' not found")
controller = Controller.instance()
project = controller.get_project(str(project_id))
node = await project.add_node_from_template(str(template_id),
node = await project.add_node_from_template(template,
x=template_usage.x,
y=template_usage.y,
compute_id=template_usage.compute_id)

View File

@ -25,10 +25,8 @@ import asyncio
from ..config import Config
from .project import Project
from .template import Template
from .appliance import Appliance
from .appliance_manager import ApplianceManager
from .template_manager import TemplateManager
from .compute import Compute, ComputeError
from .notification import Notification
from .symbols import Symbols
@ -55,7 +53,6 @@ class Controller:
self.gns3vm = GNS3VM(self)
self.symbols = Symbols()
self._appliance_manager = ApplianceManager()
self._template_manager = TemplateManager()
self._iou_license_settings = {"iourc_content": "",
"license_check": True}
self._config_loaded = False
@ -208,10 +205,6 @@ class Controller:
"appliances_etag": self._appliance_manager.appliances_etag,
"version": __version__}
for template in self._template_manager.templates.values():
if not template.builtin:
controller_settings["templates"].append(template.__json__())
for compute in self._computes.values():
if compute.id != "local" and compute.id != "vm":
controller_settings["computes"].append({"host": compute.host,
@ -259,7 +252,6 @@ class Controller:
self._appliance_manager.appliances_etag = controller_settings.get("appliances_etag")
self._appliance_manager.load_appliances()
self._template_manager.load_templates(controller_settings.get("templates"))
self._config_loaded = True
return controller_settings.get("computes", [])
@ -546,14 +538,6 @@ class Controller:
return self._appliance_manager
@property
def template_manager(self):
"""
:returns: Template Manager instance
"""
return self._template_manager
@property
def iou_license(self):
"""

View File

@ -612,7 +612,6 @@ class Node:
if the image exists
"""
print("UPLOAD MISSING IMAGE")
for directory in images_directories(type):
image = os.path.join(directory, img)
if os.path.exists(image):

View File

@ -500,16 +500,11 @@ class Project:
return new_name
@open_required
async def add_node_from_template(self, template_id, x=0, y=0, name=None, compute_id=None):
async def add_node_from_template(self, template, x=0, y=0, name=None, compute_id=None):
"""
Create a node from a template.
"""
try:
template = copy.deepcopy(self.controller.template_manager.templates[template_id].settings)
except KeyError:
msg = "Template {} doesn't exist".format(template_id)
log.error(msg)
raise ControllerNotFoundError(msg)
template["x"] = x
template["y"] = y
node_type = template.pop("template_type")

View File

@ -1,168 +0,0 @@
#!/usr/bin/env python
#
# Copyright (C) 2020 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 copy
import uuid
from pydantic import ValidationError
from fastapi.encoders import jsonable_encoder
from gns3server import schemas
import logging
log = logging.getLogger(__name__)
ID_TO_CATEGORY = {
3: "firewall",
2: "guest",
1: "switch",
0: "router"
}
TEMPLATE_TYPE_TO_SHEMA = {
"cloud": schemas.CloudTemplate,
"ethernet_hub": schemas.EthernetHubTemplate,
"ethernet_switch": schemas.EthernetSwitchTemplate,
"docker": schemas.DockerTemplate,
"dynamips": schemas.DynamipsTemplate,
"vpcs": schemas.VPCSTemplate,
"virtualbox": schemas.VirtualBoxTemplate,
"vmware": schemas.VMwareTemplate,
"iou": schemas.IOUTemplate,
"qemu": schemas.QemuTemplate
}
DYNAMIPS_PLATFORM_TO_SHEMA = {
"c7200": schemas.C7200DynamipsTemplate,
"c3745": schemas.C3745DynamipsTemplate,
"c3725": schemas.C3725DynamipsTemplate,
"c3600": schemas.C3600DynamipsTemplate,
"c2691": schemas.C2691DynamipsTemplate,
"c2600": schemas.C2600DynamipsTemplate,
"c1700": schemas.C1700DynamipsTemplate
}
class Template:
def __init__(self, template_id, settings, builtin=False):
if template_id is None:
self._id = str(uuid.uuid4())
elif isinstance(template_id, uuid.UUID):
self._id = str(template_id)
else:
self._id = template_id
self._settings = copy.deepcopy(settings)
# Version of the gui before 2.1 use linked_base
# and the server linked_clone
if "linked_base" in self.settings:
linked_base = self._settings.pop("linked_base")
if "linked_clone" not in self._settings:
self._settings["linked_clone"] = linked_base
# Convert old GUI category to text category
try:
self._settings["category"] = ID_TO_CATEGORY[self._settings["category"]]
except KeyError:
pass
# The "server" setting has been replaced by "compute_id" setting in version 2.2
if "server" in self._settings:
self._settings["compute_id"] = self._settings.pop("server")
# The "node_type" setting has been replaced by "template_type" setting in version 2.2
if "node_type" in self._settings:
self._settings["template_type"] = self._settings.pop("node_type")
# Remove an old IOU setting
if self._settings["template_type"] == "iou" and "image" in self._settings:
del self._settings["image"]
self._builtin = builtin
if builtin is False:
try:
template_schema = TEMPLATE_TYPE_TO_SHEMA[self.template_type]
template_settings_with_defaults = template_schema.parse_obj(self.__json__())
self._settings = jsonable_encoder(template_settings_with_defaults.dict())
if self.template_type == "dynamips":
# special case for Dynamips to cover all platform types that contain specific settings
dynamips_template_schema = DYNAMIPS_PLATFORM_TO_SHEMA[self._settings["platform"]]
dynamips_template_settings_with_defaults = dynamips_template_schema.parse_obj(self.__json__())
self._settings = jsonable_encoder(dynamips_template_settings_with_defaults.dict())
except ValidationError as e:
print(e) #TODO: handle errors
raise
log.debug('Template "{name}" [{id}] loaded'.format(name=self.name, id=self._id))
@property
def id(self):
return self._id
@property
def settings(self):
return self._settings
@settings.setter
def settings(self, settings):
self._settings.update(settings)
@property
def name(self):
return self._settings["name"]
@property
def compute_id(self):
return self._settings["compute_id"]
@property
def template_type(self):
return self._settings["template_type"]
@property
def builtin(self):
return self._builtin
def update(self, **kwargs):
from gns3server.controller import Controller
controller = Controller.instance()
Controller.instance().check_can_write_config()
self._settings.update(kwargs)
controller.notification.controller_emit("template.updated", self.__json__())
controller.save()
def __json__(self):
"""
Template settings.
"""
settings = self._settings
settings.update({"template_id": self._id,
"builtin": self.builtin})
if self.builtin:
# builin templates have compute_id set to None to tell clients
# to select a compute
settings["compute_id"] = None
else:
settings["compute_id"] = self.compute_id
return settings

View File

@ -1,148 +0,0 @@
#!/usr/bin/env python
#
# Copyright (C) 2019 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 copy
import uuid
import pydantic
from .controller_error import ControllerError, ControllerNotFoundError
from .template import Template
import logging
log = logging.getLogger(__name__)
class TemplateManager:
"""
Manages templates.
"""
def __init__(self):
self._templates = {}
@property
def templates(self):
"""
:returns: The dictionary of templates managed by GNS3
"""
return self._templates
def load_templates(self, template_settings=None):
"""
Loads templates from controller settings.
"""
if template_settings:
for template_settings in template_settings:
try:
template = Template(template_settings.get("template_id"), template_settings)
self._templates[template.id] = template
except pydantic.ValidationError as e:
message = "Cannot load template with JSON data '{}': {}".format(template_settings, e)
log.warning(message)
continue
# Add builtins
builtins = []
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "cloud"), {"template_type": "cloud", "name": "Cloud", "default_name_format": "Cloud{0}", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "nat"), {"template_type": "nat", "name": "NAT", "default_name_format": "NAT{0}", "category": 2, "symbol": ":/symbols/cloud.svg"}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "vpcs"), {"template_type": "vpcs", "name": "VPCS", "default_name_format": "PC{0}", "category": 2, "symbol": ":/symbols/vpcs_guest.svg", "properties": {"base_script_file": "vpcs_base_config.txt"}}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_switch"), {"template_type": "ethernet_switch", "console_type": "none", "name": "Ethernet switch", "default_name_format": "Switch{0}", "category": 1, "symbol": ":/symbols/ethernet_switch.svg"}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_hub"), {"template_type": "ethernet_hub", "name": "Ethernet hub", "default_name_format": "Hub{0}", "category": 1, "symbol": ":/symbols/hub.svg"}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "frame_relay_switch"), {"template_type": "frame_relay_switch", "name": "Frame Relay switch", "default_name_format": "FRSW{0}", "category": 1, "symbol": ":/symbols/frame_relay_switch.svg"}, builtin=True))
builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "atm_switch"), {"template_type": "atm_switch", "name": "ATM switch", "default_name_format": "ATMSW{0}", "category": 1, "symbol": ":/symbols/atm_switch.svg"}, builtin=True))
#FIXME: disable TraceNG
#if sys.platform.startswith("win"):
# builtins.append(Template(uuid.uuid3(uuid.NAMESPACE_DNS, "traceng"), {"template_type": "traceng", "name": "TraceNG", "default_name_format": "TraceNG-{0}", "category": 2, "symbol": ":/symbols/traceng.svg", "properties": {}}, builtin=True))
for b in builtins:
self._templates[b.id] = b
def add_template(self, settings):
"""
Adds a new template.
:param settings: template settings
:returns: Template object
"""
template_id = settings.get("template_id", "")
if template_id in self._templates:
raise ControllerError("Template ID '{}' already exists".format(template_id))
else:
template_id = settings.setdefault("template_id", str(uuid.uuid4()))
try:
template = Template(template_id, settings)
except pydantic.ValidationError as e:
message = "JSON schema error adding template with JSON data '{}': {}".format(settings, e)
raise ControllerError(message)
from . import Controller
Controller.instance().check_can_write_config()
self._templates[template.id] = template
Controller.instance().save()
Controller.instance().notification.controller_emit("template.created", template.__json__())
return template
def get_template(self, template_id):
"""
Gets a template.
:param template_id: template identifier
:returns: Template object
"""
template = self._templates.get(template_id)
if not template:
raise ControllerNotFoundError("Template ID {} doesn't exist".format(template_id))
return template
def delete_template(self, template_id):
"""
Deletes a template.
:param template_id: template identifier
"""
template = self.get_template(template_id)
if template.builtin:
raise ControllerError("Template ID {} cannot be deleted because it is a builtin".format(template_id))
from . import Controller
Controller.instance().check_can_write_config()
self._templates.pop(template_id)
Controller.instance().save()
Controller.instance().notification.controller_emit("template.deleted", template.__json__())
def duplicate_template(self, template_id):
"""
Duplicates a template.
:param template_id: template identifier
"""
template = self.get_template(template_id)
if template.builtin:
raise ControllerError("Template ID {} cannot be duplicated because it is a builtin".format(template_id))
template_settings = copy.deepcopy(template.settings)
del template_settings["template_id"]
return self.add_template(template_settings)

View File

@ -0,0 +1,32 @@
#!/usr/bin/env python
#
# Copyright (C) 2021 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/>.
from .base import Base
from .users import User
from .templates import (
Template,
CloudTemplate,
DockerTemplate,
DynamipsTemplate,
EthernetHubTemplate,
EthernetSwitchTemplate,
IOUTemplate,
QemuTemplate,
VirtualBoxTemplate,
VMwareTemplate,
VPCSTemplate
)

View File

@ -17,21 +17,33 @@
import uuid
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, func
from sqlalchemy.orm import relationship
from fastapi.encoders import jsonable_encoder
from sqlalchemy import Column, DateTime, func, inspect
from sqlalchemy.types import TypeDecorator, CHAR
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import as_declarative
from sqlalchemy.orm import declarative_base
Base = declarative_base()
@as_declarative()
class Base:
def _asdict(self):
return {c.key: getattr(self, c.key)
for c in inspect(self).mapper.column_attrs}
def _asjson(self):
return jsonable_encoder(self._asdict())
class GUID(TypeDecorator):
"""Platform-independent GUID type.
"""
Platform-independent GUID type.
Uses PostgreSQL's UUID type, otherwise uses
CHAR(32), storing as stringified hex values.
"""
impl = CHAR
def load_dialect_impl(self, dialect):
@ -73,30 +85,3 @@ class BaseTable(Base):
def generate_uuid():
return str(uuid.uuid4())
class User(BaseTable):
__tablename__ = "users"
user_id = Column(GUID, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
full_name = Column(String)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# items = relationship("Item", back_populates="owner")
#
#
# class Item(Base):
# __tablename__ = "items"
#
# id = Column(Integer, primary_key=True, index=True)
# title = Column(String, index=True)
# description = Column(String, index=True)
# owner_id = Column(Integer, ForeignKey("users.id"))
#
# owner = relationship("User", back_populates="items")

View File

@ -0,0 +1,286 @@
#!/usr/bin/env python
#
# Copyright (C) 2021 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/>.
from sqlalchemy import Boolean, Column, String, Integer, ForeignKey, PickleType
from .base import BaseTable, generate_uuid, GUID
class Template(BaseTable):
__tablename__ = "templates"
template_id = Column(GUID, primary_key=True, default=generate_uuid)
name = Column(String, index=True)
category = Column(String)
default_name_format = Column(String)
symbol = Column(String)
builtin = Column(Boolean, default=False)
compute_id = Column(String)
usage = Column(String)
template_type = Column(String)
__mapper_args__ = {
"polymorphic_identity": "templates",
"polymorphic_on": template_type
}
class CloudTemplate(Template):
__tablename__ = "cloud_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
ports_mapping = Column(PickleType)
remote_console_host = Column(String)
remote_console_port = Column(Integer)
remote_console_type = Column(String)
remote_console_http_path = Column(String)
__mapper_args__ = {
"polymorphic_identity": "cloud"
}
class DockerTemplate(Template):
__tablename__ = "docker_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
image = Column(String)
adapters = Column(Integer)
start_command = Column(String)
environment = Column(String)
console_type = Column(String)
aux_type = Column(String)
console_auto_start = Column(Boolean)
console_http_port = Column(Integer)
console_http_path = Column(String)
console_resolution = Column(String)
extra_hosts = Column(String)
extra_volumes = Column(PickleType)
memory = Column(Integer)
cpus = Column(Integer)
custom_adapters = Column(PickleType)
__mapper_args__ = {
"polymorphic_identity": "docker"
}
class DynamipsTemplate(Template):
__tablename__ = "dynamips_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
platform = Column(String)
chassis = Column(String)
image = Column(String)
exec_area = Column(Integer)
mmap = Column(Boolean)
mac_addr = Column(String)
system_id = Column(String)
startup_config = Column(String)
private_config = Column(String)
idlepc = Column(String)
idlemax = Column(Integer)
idlesleep = Column(Integer)
disk0 = Column(Integer)
disk1 = Column(Integer)
auto_delete_disks = Column(Boolean)
console_type = Column(String)
console_auto_start = Column(Boolean)
aux_type = Column(String)
ram = Column(Integer)
nvram = Column(Integer)
npe = Column(String)
midplane = Column(String)
sparsemem = Column(Boolean)
iomem = Column(Integer)
slot0 = Column(String)
slot1 = Column(String)
slot2 = Column(String)
slot3 = Column(String)
slot4 = Column(String)
slot5 = Column(String)
slot6 = Column(String)
wic0 = Column(String)
wic1 = Column(String)
wic2 = Column(String)
__mapper_args__ = {
"polymorphic_identity": "dynamips"
}
class EthernetHubTemplate(Template):
__tablename__ = "ethernet_hub_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
ports_mapping = Column(PickleType)
__mapper_args__ = {
"polymorphic_identity": "ethernet_hub"
}
class EthernetSwitchTemplate(Template):
__tablename__ = "ethernet_switch_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
ports_mapping = Column(PickleType)
console_type = Column(String)
__mapper_args__ = {
"polymorphic_identity": "ethernet_switch"
}
class IOUTemplate(Template):
__tablename__ = "iou_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
path = Column(String)
ethernet_adapters = Column(Integer)
serial_adapters = Column(Integer)
ram = Column(Integer)
nvram = Column(Integer)
use_default_iou_values = Column(Boolean)
startup_config = Column(String)
private_config = Column(String)
l1_keepalives = Column(Boolean)
console_type = Column(String)
console_auto_start = Column(Boolean)
__mapper_args__ = {
"polymorphic_identity": "iou"
}
class QemuTemplate(Template):
__tablename__ = "qemu_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
qemu_path = Column(String)
platform = Column(String)
linked_clone = Column(Boolean)
ram = Column(Integer)
cpus = Column(Integer)
maxcpus = Column(Integer)
adapters = Column(Integer)
adapter_type = Column(String)
mac_address = Column(String)
first_port_name = Column(String)
port_name_format = Column(String)
port_segment_size = Column(Integer)
console_type = Column(String)
console_auto_start = Column(Boolean)
aux_type = Column(String)
boot_priority = Column(String)
hda_disk_image = Column(String)
hda_disk_interface = Column(String)
hdb_disk_image = Column(String)
hdb_disk_interface = Column(String)
hdc_disk_image = Column(String)
hdc_disk_interface = Column(String)
hdd_disk_image = Column(String)
hdd_disk_interface = Column(String)
cdrom_image = Column(String)
initrd = Column(String)
kernel_image = Column(String)
bios_image = Column(String)
kernel_command_line = Column(String)
legacy_networking = Column(Boolean)
replicate_network_connection_state = Column(Boolean)
create_config_disk = Column(Boolean)
on_close = Column(String)
cpu_throttling = Column(Integer)
process_priority = Column(String)
options = Column(String)
custom_adapters = Column(PickleType)
__mapper_args__ = {
"polymorphic_identity": "qemu"
}
class VirtualBoxTemplate(Template):
__tablename__ = "virtualbox_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
vmname = Column(String)
ram = Column(Integer)
linked_clone = Column(Boolean)
adapters = Column(Integer)
use_any_adapter = Column(Boolean)
adapter_type = Column(String)
first_port_name = Column(String)
port_name_format = Column(String)
port_segment_size = Column(Integer)
headless = Column(Boolean)
on_close = Column(String)
console_type = Column(String)
console_auto_start = Column(Boolean)
custom_adapters = Column(PickleType)
__mapper_args__ = {
"polymorphic_identity": "virtualbox"
}
class VMwareTemplate(Template):
__tablename__ = "vmware_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
vmx_path = Column(String)
linked_clone = Column(Boolean)
first_port_name = Column(String)
port_name_format = Column(String)
port_segment_size = Column(Integer)
adapters = Column(Integer)
adapter_type = Column(String)
use_any_adapter = Column(Boolean)
headless = Column(Boolean)
on_close = Column(String)
console_type = Column(String)
console_auto_start = Column(Boolean)
custom_adapters = Column(PickleType)
__mapper_args__ = {
"polymorphic_identity": "vmware"
}
class VPCSTemplate(Template):
__tablename__ = "vpcs_templates"
template_id = Column(GUID, ForeignKey("templates.template_id"), primary_key=True)
base_script_file = Column(String)
console_type = Column(String)
console_auto_start = Column(Boolean, default=False)
__mapper_args__ = {
"polymorphic_identity": "vpcs"
}

View File

@ -0,0 +1,48 @@
#!/usr/bin/env python
#
# Copyright (C) 2021 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/>.
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, func
from sqlalchemy.orm import relationship
from .base import BaseTable, generate_uuid, GUID
class User(BaseTable):
__tablename__ = "users"
user_id = Column(GUID, primary_key=True, default=generate_uuid)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
full_name = Column(String)
hashed_password = Column(String)
is_active = Column(Boolean, default=True)
is_superuser = Column(Boolean, default=False)
# items = relationship("Item", back_populates="owner")
#
#
# class Item(Base):
# __tablename__ = "items"
#
# id = Column(Integer, primary_key=True, index=True)
# title = Column(String, index=True)
# description = Column(String, index=True)
# owner_id = Column(Integer, ForeignKey("users.id"))
#
# owner = relationship("User", back_populates="items")

View File

@ -16,6 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from sqlalchemy.ext.asyncio import AsyncSession
from gns3server.controller import Controller
class BaseRepository:
@ -23,3 +24,4 @@ class BaseRepository:
def __init__(self, db_session: AsyncSession) -> None:
self._db_session = db_session
self._controller = Controller.instance()

View File

@ -0,0 +1,243 @@
#!/usr/bin/env python
#
# Copyright (C) 2021 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
from uuid import UUID
from typing import List
from fastapi.encoders import jsonable_encoder
from sqlalchemy import select, update, delete
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.session import make_transient
from .base import BaseRepository
import gns3server.db.models as models
from gns3server import schemas
TEMPLATE_TYPE_TO_SHEMA = {
"cloud": schemas.CloudTemplate,
"ethernet_hub": schemas.EthernetHubTemplate,
"ethernet_switch": schemas.EthernetSwitchTemplate,
"docker": schemas.DockerTemplate,
"dynamips": schemas.DynamipsTemplate,
"vpcs": schemas.VPCSTemplate,
"virtualbox": schemas.VirtualBoxTemplate,
"vmware": schemas.VMwareTemplate,
"iou": schemas.IOUTemplate,
"qemu": schemas.QemuTemplate
}
DYNAMIPS_PLATFORM_TO_SHEMA = {
"c7200": schemas.C7200DynamipsTemplate,
"c3745": schemas.C3745DynamipsTemplate,
"c3725": schemas.C3725DynamipsTemplate,
"c3600": schemas.C3600DynamipsTemplate,
"c2691": schemas.C2691DynamipsTemplate,
"c2600": schemas.C2600DynamipsTemplate,
"c1700": schemas.C1700DynamipsTemplate
}
TEMPLATE_TYPE_TO_MODEL = {
"cloud": models.CloudTemplate,
"docker": models.DockerTemplate,
"dynamips": models.DynamipsTemplate,
"ethernet_hub": models.EthernetHubTemplate,
"ethernet_switch": models.EthernetSwitchTemplate,
"iou": models.IOUTemplate,
"qemu": models.QemuTemplate,
"virtualbox": models.VirtualBoxTemplate,
"vmware": models.VMwareTemplate,
"vpcs": models.VPCSTemplate
}
# built-in templates have their compute_id set to None to tell clients to select a compute
BUILTIN_TEMPLATES = [
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "cloud"),
"template_type": "cloud",
"name": "Cloud",
"default_name_format": "Cloud{0}",
"category": "guest",
"symbol": ":/symbols/cloud.svg",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "nat"),
"template_type": "nat",
"name": "NAT",
"default_name_format": "NAT{0}",
"category": "guest",
"symbol": ":/symbols/cloud.svg",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "vpcs"),
"template_type": "vpcs",
"name": "VPCS",
"default_name_format": "PC{0}",
"category": "guest",
"symbol": ":/symbols/vpcs_guest.svg",
"base_script_file": "vpcs_base_config.txt",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_switch"),
"template_type": "ethernet_switch",
"name": "Ethernet switch",
"console_type": "none",
"default_name_format": "Switch{0}",
"category": "switch",
"symbol": ":/symbols/ethernet_switch.svg",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "ethernet_hub"),
"template_type": "ethernet_hub",
"name": "Ethernet hub",
"default_name_format": "Hub{0}",
"category": "switch",
"symbol": ":/symbols/hub.svg",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "frame_relay_switch"),
"template_type": "frame_relay_switch",
"name": "Frame Relay switch",
"default_name_format": "FRSW{0}",
"category": "switch",
"symbol": ":/symbols/frame_relay_switch.svg",
"compute_id": None,
"builtin": True
},
{
"template_id": uuid.uuid3(uuid.NAMESPACE_DNS, "atm_switch"),
"template_type": "atm_switch",
"name": "ATM switch",
"default_name_format": "ATMSW{0}",
"category": "switch",
"symbol": ":/symbols/atm_switch.svg",
"compute_id": None,
"builtin": True
},
]
class TemplatesRepository(BaseRepository):
def __init__(self, db_session: AsyncSession) -> None:
super().__init__(db_session)
def get_builtin_template(self, template_id: UUID) -> dict:
for builtin_template in BUILTIN_TEMPLATES:
if builtin_template["template_id"] == template_id:
return jsonable_encoder(builtin_template)
async def get_template(self, template_id: UUID) -> dict:
query = select(models.Template).where(models.Template.template_id == template_id)
result = (await self._db_session.execute(query)).scalars().first()
if result:
return result._asjson()
else:
return self.get_builtin_template(template_id)
async def get_templates(self) -> List[dict]:
templates = []
query = select(models.Template)
result = await self._db_session.execute(query)
for db_template in result.scalars().all():
templates.append(db_template._asjson())
for builtin_template in BUILTIN_TEMPLATES:
templates.append(jsonable_encoder(builtin_template))
return templates
async def create_template(self, template_create: schemas.TemplateCreate) -> dict:
# get the default template settings
template_settings = jsonable_encoder(template_create, exclude_unset=True)
template_schema = TEMPLATE_TYPE_TO_SHEMA[template_create.template_type]
template_settings_with_defaults = template_schema.parse_obj(template_settings)
settings = template_settings_with_defaults.dict()
if template_create.template_type == "dynamips":
# special case for Dynamips to cover all platform types that contain specific settings
dynamips_template_schema = DYNAMIPS_PLATFORM_TO_SHEMA[settings["platform"]]
dynamips_template_settings_with_defaults = dynamips_template_schema.parse_obj(template_settings)
settings = dynamips_template_settings_with_defaults.dict()
model = TEMPLATE_TYPE_TO_MODEL[template_create.template_type]
db_template = model(**settings)
self._db_session.add(db_template)
await self._db_session.commit()
await self._db_session.refresh(db_template)
template = db_template._asjson()
self._controller.notification.controller_emit("template.created", template)
return template
async def update_template(
self,
template_id: UUID,
template_update: schemas.TemplateUpdate) -> dict:
update_values = template_update.dict(exclude_unset=True)
query = update(models.Template) \
.where(models.Template.template_id == template_id) \
.values(update_values)
await self._db_session.execute(query)
await self._db_session.commit()
template = await self.get_template(template_id)
if template:
self._controller.notification.controller_emit("template.updated", template)
return template
async def delete_template(self, template_id: UUID) -> bool:
query = delete(models.Template).where(models.Template.template_id == template_id)
result = await self._db_session.execute(query)
await self._db_session.commit()
if result.rowcount > 0:
self._controller.notification.controller_emit("template.deleted", {"template_id": str(template_id)})
return True
return False
async def duplicate_template(self, template_id: UUID) -> dict:
query = select(models.Template).where(models.Template.template_id == template_id)
db_template = (await self._db_session.execute(query)).scalars().first()
if not db_template:
return db_template
# duplicate db object with new primary key (template_id)
self._db_session.expunge(db_template)
make_transient(db_template)
db_template.template_id = None
self._db_session.add(db_template)
await self._db_session.commit()
await self._db_session.refresh(db_template)
template = db_template._asjson()
self._controller.notification.controller_emit("template.created", template)
return template

View File

@ -36,22 +36,26 @@ class UsersRepository(BaseRepository):
async def get_user(self, user_id: UUID) -> Optional[models.User]:
result = await self._db_session.execute(select(models.User).where(models.User.user_id == user_id))
query = select(models.User).where(models.User.user_id == user_id)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_user_by_username(self, username: str) -> Optional[models.User]:
result = await self._db_session.execute(select(models.User).where(models.User.username == username))
query = select(models.User).where(models.User.username == username)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_user_by_email(self, email: str) -> Optional[models.User]:
result = await self._db_session.execute(select(models.User).where(models.User.email == email))
query = select(models.User).where(models.User.email == email)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_users(self) -> List[models.User]:
result = await self._db_session.execute(select(models.User))
query = select(models.User)
result = await self._db_session.execute(query)
return result.scalars().all()
async def create_user(self, user: schemas.UserCreate) -> models.User:

View File

@ -31,7 +31,7 @@ log = logging.getLogger(__name__)
async def connect_to_db(app: FastAPI) -> None:
db_path = os.path.join(Config.instance().config_dir, "gns3_controller.db")
db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite:///{db_path}")
db_url = os.environ.get("GNS3_DATABASE_URI", f"sqlite+pysqlite:///{db_path}")
engine = create_async_engine(db_url, connect_args={"check_same_thread": False}, future=True)
try:
async with engine.begin() as conn:

View File

@ -18,8 +18,10 @@
from pydantic import BaseModel, Field
from typing import Optional, Union
from enum import Enum
from uuid import UUID
from .nodes import NodeType
from .base import DateTimeModelMixin
class Category(str, Enum):
@ -38,7 +40,7 @@ class TemplateBase(BaseModel):
Common template properties.
"""
template_id: Optional[str] = None
template_id: Optional[UUID] = None
name: Optional[str] = None
category: Optional[Category] = None
default_name_format: Optional[str] = None
@ -50,6 +52,7 @@ class TemplateBase(BaseModel):
class Config:
extra = "allow"
orm_mode = True
class TemplateCreate(TemplateBase):
@ -67,9 +70,9 @@ class TemplateUpdate(TemplateBase):
pass
class Template(TemplateBase):
class Template(DateTimeModelMixin, TemplateBase):
template_id: str
template_id: UUID
name: str
category: Category
symbol: str

View File

@ -1,5 +1,5 @@
uvicorn==0.13.3
fastapi==0.62.0
fastapi==0.63.0
websockets==8.1
python-multipart==0.0.5
aiohttp==3.7.2
@ -10,7 +10,7 @@ psutil==5.7.3
async-timeout==3.0.1
distro==1.5.0
py-cpuinfo==7.0.0
sqlalchemy==1.4.0b1 # beta version with asyncio support
sqlalchemy==1.4.0b2 # beta version with asyncio support
passlib[bcrypt]==1.7.2
python-jose==3.2.0
email-validator==1.1.2

File diff suppressed because it is too large Load Diff

View File

@ -445,33 +445,6 @@ def test_appliances(controller, tmpdir):
elif j["name"] == "My Appliance":
assert not j["builtin"]
def test_load_templates(controller):
controller._settings = {}
controller.template_manager.load_templates()
assert "Cloud" in [template.name for template in controller.template_manager.templates.values()]
assert "VPCS" in [template.name for template in controller.template_manager.templates.values()]
for template in controller.template_manager.templates.values():
if template.name == "VPCS":
assert template._settings["properties"] == {"base_script_file": "vpcs_base_config.txt"}
# UUID should not change when you run again the function
for template in controller.template_manager.templates.values():
if template.name == "Test":
qemu_uuid = template.id
elif template.name == "Cloud":
cloud_uuid = template.id
controller.template_manager.load_templates()
for template in controller.template_manager.templates.values():
if template.name == "Test":
assert qemu_uuid == template.id
elif template.name == "Cloud":
assert cloud_uuid == template.id
@pytest.mark.asyncio
async def test_autoidlepc(controller):

View File

@ -26,7 +26,6 @@ from unittest.mock import patch
from uuid import uuid4
from gns3server.controller.project import Project
from gns3server.controller.template import Template
from gns3server.controller.node import Node
from gns3server.controller.ports.ethernet_port import EthernetPort
from gns3server.controller.controller_error import ControllerError, ControllerNotFoundError, ControllerForbiddenError
@ -343,72 +342,72 @@ async def test_add_node_iou_no_id_available(controller):
await project.add_node(compute, "test1", None, node_type="iou")
@pytest.mark.asyncio
async def test_add_node_from_template(controller):
"""
For a local server we send the project path
"""
compute = MagicMock()
compute.id = "local"
project = Project(controller=controller, name="Test")
project.emit_notification = MagicMock()
template = Template(str(uuid.uuid4()), {
"compute_id": "local",
"name": "Test",
"template_type": "vpcs",
"builtin": False,
})
controller.template_manager.templates[template.id] = template
controller._computes["local"] = compute
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node_from_template(template.id, x=23, y=12)
compute.post.assert_any_call('/projects', data={
"name": project._name,
"project_id": project._id,
"path": project._path
})
assert compute in project._project_created_on_compute
project.emit_notification.assert_any_call("node.created", node.__json__())
@pytest.mark.asyncio
async def test_add_builtin_node_from_template(controller):
"""
For a local server we send the project path
"""
compute = MagicMock()
compute.id = "local"
project = Project(controller=controller, name="Test")
project.emit_notification = MagicMock()
template = Template(str(uuid.uuid4()), {
"name": "Builtin-switch",
"template_type": "ethernet_switch",
}, builtin=True)
controller.template_manager.templates[template.id] = template
template.__json__()
controller._computes["local"] = compute
response = MagicMock()
response.json = {"console": 2048}
compute.post = AsyncioMagicMock(return_value=response)
node = await project.add_node_from_template(template.id, x=23, y=12, compute_id="local")
compute.post.assert_any_call('/projects', data={
"name": project._name,
"project_id": project._id,
"path": project._path
})
assert compute in project._project_created_on_compute
project.emit_notification.assert_any_call("node.created", node.__json__())
# @pytest.mark.asyncio
# async def test_add_node_from_template(controller):
# """
# For a local server we send the project path
# """
#
# compute = MagicMock()
# compute.id = "local"
# project = Project(controller=controller, name="Test")
# project.emit_notification = MagicMock()
# template = Template(str(uuid.uuid4()), {
# "compute_id": "local",
# "name": "Test",
# "template_type": "vpcs",
# "builtin": False,
# })
# controller.template_manager.templates[template.id] = template
# controller._computes["local"] = compute
#
# response = MagicMock()
# response.json = {"console": 2048}
# compute.post = AsyncioMagicMock(return_value=response)
#
# node = await project.add_node_from_template(template.id, x=23, y=12)
# compute.post.assert_any_call('/projects', data={
# "name": project._name,
# "project_id": project._id,
# "path": project._path
# })
#
# assert compute in project._project_created_on_compute
# project.emit_notification.assert_any_call("node.created", node.__json__())
#
#
# @pytest.mark.asyncio
# async def test_add_builtin_node_from_template(controller):
# """
# For a local server we send the project path
# """
#
# compute = MagicMock()
# compute.id = "local"
# project = Project(controller=controller, name="Test")
# project.emit_notification = MagicMock()
# template = Template(str(uuid.uuid4()), {
# "name": "Builtin-switch",
# "template_type": "ethernet_switch",
# }, builtin=True)
#
# controller.template_manager.templates[template.id] = template
# template.__json__()
# controller._computes["local"] = compute
#
# response = MagicMock()
# response.json = {"console": 2048}
# compute.post = AsyncioMagicMock(return_value=response)
#
# node = await project.add_node_from_template(template.id, x=23, y=12, compute_id="local")
# compute.post.assert_any_call('/projects', data={
# "name": project._name,
# "project_id": project._id,
# "path": project._path
# })
#
# assert compute in project._project_created_on_compute
# project.emit_notification.assert_any_call("node.created", node.__json__())
@pytest.mark.asyncio

View File

@ -1,89 +0,0 @@
#!/usr/bin/env python
#
# Copyright (C) 2016 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 pytest
import pydantic
from gns3server.controller.template import Template
def test_template_json():
a = Template(None, {
"node_type": "qemu",
"name": "Test",
"default_name_format": "{name}-{0}",
"category": 0,
"symbol": "qemu.svg",
"server": "local",
"platform": "i386"
})
settings = a.__json__()
assert settings["template_id"] == a.id
assert settings["template_type"] == "qemu"
assert settings["builtin"] == False
def test_template_json_with_not_known_category():
with pytest.raises(pydantic.ValidationError):
Template(None, {
"node_type": "qemu",
"name": "Test",
"default_name_format": "{name}-{0}",
"category": 'Not known',
"symbol": "qemu.svg",
"server": "local",
"platform": "i386"
})
def test_template_json_with_platform():
a = Template(None, {
"node_type": "dynamips",
"name": "Test",
"default_name_format": "{name}-{0}",
"category": 0,
"symbol": "dynamips.svg",
"image": "IOS_image.bin",
"server": "local",
"platform": "c3725"
})
settings = a.__json__()
assert settings["template_id"] == a.id
assert settings["template_type"] == "dynamips"
assert settings["builtin"] == False
assert settings["platform"] == "c3725"
def test_template_fix_linked_base():
"""
Version of the gui before 2.1 use linked_base and the server
linked_clone
"""
a = Template(None, {
"node_type": "qemu",
"name": "Test",
"default_name_format": "{name}-{0}",
"category": 0,
"symbol": "qemu.svg",
"server": "local",
"linked_base": True
})
assert a.settings["linked_clone"]
assert "linked_base" not in a.settings