Appliance management refactoring:

* Install an appliance based on selected version
* Each template have unique name and version
* Allow to download an appliance file
pull/1985/head
grossmj 3 years ago
parent 8a643cf4a4
commit 04934691df

@ -18,8 +18,25 @@
API routes for appliances.
"""
from fastapi import APIRouter
import os
import logging
from fastapi import APIRouter, Depends, Response, status
from fastapi.responses import FileResponse
from typing import Optional, List
from uuid import UUID
from gns3server import schemas
from gns3server.controller import Controller
from gns3server.controller.controller_error import ControllerNotFoundError
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.db.repositories.rbac import RbacRepository
from .dependencies.authentication import get_current_active_user
from .dependencies.database import get_repository
log = logging.getLogger(__name__)
router = APIRouter()
@ -30,10 +47,50 @@ async def get_appliances(update: Optional[bool] = False, symbol_theme: Optional[
Return all appliances known by the controller.
"""
from gns3server.controller import Controller
controller = Controller.instance()
if update:
await controller.appliance_manager.download_appliances()
controller.appliance_manager.load_appliances(symbol_theme=symbol_theme)
return [c.asdict() for c in controller.appliance_manager.appliances.values()]
@router.get("/{appliance_id}/download")
def download_appliance(appliance_id: UUID) -> FileResponse:
"""
Download an appliance file.
"""
controller = Controller.instance()
appliance = controller.appliance_manager.appliances.get(str(appliance_id))
if not appliance:
raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'")
if not os.path.exists(appliance.path):
raise ControllerNotFoundError(message=f"Could not find appliance file '{appliance.path}'")
return FileResponse(appliance.path, media_type="application/json")
@router.post("/{appliance_id}/install", status_code=status.HTTP_204_NO_CONTENT)
async def install_appliance(
appliance_id: UUID,
version: Optional[str] = None,
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
) -> Response:
"""
Install an appliance.
"""
controller = Controller.instance()
await controller.appliance_manager.install_appliance(
appliance_id,
version,
images_repo,
templates_repo,
rbac_repo,
current_user
)
return Response(status_code=status.HTTP_204_NO_CONTENT)

@ -24,14 +24,12 @@ import urllib.parse
from fastapi import APIRouter, Request, Response, Depends, status
from sqlalchemy.orm.exc import MultipleResultsFound
from typing import List
from typing import List, Optional
from gns3server import schemas
from pydantic import ValidationError
from gns3server.utils.images import InvalidImageError, default_images_directory, write_image
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.templates import TemplatesService
from gns3server.db.repositories.rbac import RbacRepository
from gns3server.controller import Controller
from gns3server.controller.controller_error import (
@ -68,7 +66,8 @@ async def upload_image(
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
templates_repo: TemplatesRepository = Depends(get_repository(TemplatesRepository)),
current_user: schemas.User = Depends(get_current_active_user),
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository))
rbac_repo: RbacRepository = Depends(get_repository(RbacRepository)),
install_appliances: Optional[bool] = True
) -> schemas.Image:
"""
Upload an image.
@ -92,25 +91,18 @@ async def upload_image(
except (OSError, InvalidImageError) as e:
raise ControllerError(f"Could not save {image_type} image '{image_path}': {e}")
try:
# attempt to automatically create a template based on image checksum
template = await Controller.instance().appliance_manager.install_appliance_from_image(
if install_appliances:
# attempt to automatically create templates based on image checksum
await Controller.instance().appliance_manager.install_appliances_from_image(
image_path,
image.checksum,
images_repo,
templates_repo,
rbac_repo,
current_user,
directory
)
if template:
template_create = schemas.TemplateCreate(**template)
template = await TemplatesService(templates_repo).create_template(template_create)
template_id = template.get("template_id")
await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
log.info(f"Template '{template.get('name')}' version {template.get('version')} "
f"has been created using image '{image_name}'")
except (ControllerError, ValidationError, InvalidImageError) as e:
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
return image
@ -150,8 +142,10 @@ async def delete_image(
if not image:
raise ControllerNotFoundError(f"Image '{image_path}' not found")
if await images_repo.get_image_templates(image.image_id):
raise ControllerError(f"Image '{image_path}' is used by one or more templates")
templates = await images_repo.get_image_templates(image.image_id)
if templates:
template_names = ", ".join([template.name for template in templates])
raise ControllerError(f"Image '{image_path}' is used by one or more templates: {template_names}")
try:
os.remove(image.path)

@ -24,15 +24,11 @@ log = logging.getLogger(__name__)
class Appliance:
def __init__(self, appliance_id, data, builtin=True):
def __init__(self, path, data, builtin=True):
if appliance_id is None:
self._id = str(uuid.uuid4())
elif isinstance(appliance_id, uuid.UUID):
self._id = str(appliance_id)
else:
self._id = appliance_id
self._data = data.copy()
self._id = data.get("appliance_id", uuid.uuid5(uuid.NAMESPACE_X500, path))
self._path = path
self._builtin = builtin
if "appliance_id" in self._data:
del self._data["appliance_id"]
@ -44,6 +40,10 @@ class Appliance:
def id(self):
return self._id
@property
def path(self):
return self._path
@property
def status(self):
return self._data["status"]
@ -75,6 +75,8 @@ class Appliance:
return "iou"
elif "dynamips" in self._data:
return "dynamips"
elif "docker" in self._data:
return "docker"
else:
return "qemu"

@ -17,22 +17,32 @@
import os
import json
import uuid
import asyncio
import aiofiles
from typing import Tuple, List
from aiohttp.client_exceptions import ClientError
from uuid import UUID
from pydantic import ValidationError
from .appliance import Appliance
from ..config import Config
from ..utils.asyncio import locking
from ..utils.get_resource import get_resource
from ..utils.http_client import HTTPClient
from .controller_error import ControllerError
from .controller_error import ControllerBadRequestError, ControllerNotFoundError, ControllerError
from .appliance_to_template import ApplianceToTemplate
from ..utils.images import InvalidImageError, write_image, md5sum
from ..utils.asyncio import wait_run_in_executor
from gns3server import schemas
from gns3server.utils.images import default_images_directory
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.services.templates import TemplatesService
from gns3server.db.repositories.rbac import RbacRepository
import logging
log = logging.getLogger(__name__)
@ -49,7 +59,7 @@ class ApplianceManager:
self._appliances_etag = None
@property
def appliances_etag(self):
def appliances_etag(self) -> str:
"""
:returns: ETag for downloaded appliances
"""
@ -65,14 +75,14 @@ class ApplianceManager:
self._appliances_etag = etag
@property
def appliances(self):
def appliances(self) -> dict:
"""
:returns: The dictionary of appliances managed by GNS3
"""
return self._appliances
def appliances_path(self):
def appliances_path(self) -> str:
"""
Get the image storage directory
"""
@ -82,18 +92,27 @@ class ApplianceManager:
os.makedirs(appliances_path, exist_ok=True)
return appliances_path
def _find_appliance_from_image_checksum(self, image_checksum):
def _find_appliances_from_image_checksum(self, image_checksum: str) -> List[Tuple[Appliance, str]]:
"""
Find an appliance and version that matches an image checksum.
Find appliances that matches an image checksum.
"""
appliances = []
for appliance in self._appliances.values():
if appliance.images:
for image in appliance.images:
if image.get("md5sum") == image_checksum:
return appliance, image.get("version")
async def _download_image(self, image_dir, image_name, image_type, image_url, images_repo):
appliances.append((appliance, image.get("version")))
return appliances
async def _download_image(
self,
image_dir: str,
image_name: str,
image_type: str,
image_url: str,
images_repo: ImagesRepository
) -> None:
"""
Download an image.
"""
@ -112,7 +131,13 @@ class ApplianceManager:
except asyncio.TimeoutError:
raise ControllerError(f"Timeout while downloading '{image_name}' from '{image_url}'")
async def _find_appliance_version_images(self, appliance, version, images_repo, image_dir):
async def _find_appliance_version_images(
self,
appliance: Appliance,
version: dict,
images_repo: ImagesRepository,
image_dir: str
) -> None:
"""
Find all the images belonging to a specific appliance version.
"""
@ -145,28 +170,103 @@ class ApplianceManager:
else:
raise ControllerError(f"Could not find '{appliance_file}'")
async def install_appliance_from_image(self, image_checksum, images_repo, image_dir):
async def _create_template(self, template_data, templates_repo, rbac_repo, current_user):
"""
Find the image checksum in appliance files
Create a new template
"""
try:
template_create = schemas.TemplateCreate(**template_data)
except ValidationError as e:
raise ControllerError(message=f"Could not validate template data: {e}")
template = await TemplatesService(templates_repo).create_template(template_create)
template_id = template.get("template_id")
await rbac_repo.add_permission_to_user_with_path(current_user.user_id, f"/templates/{template_id}/*")
log.info(f"Template '{template.get('name')}' has been created")
async def _appliance_to_template(self, appliance: Appliance, version: str = None) -> dict:
"""
Get template data from appliance
"""
from . import Controller
appliance_info = self._find_appliance_from_image_checksum(image_checksum)
if appliance_info:
appliance, image_version = appliance_info
# 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)
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"
async def install_appliances_from_image(
self,
image_path: str,
image_checksum: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User,
image_dir: str
) -> None:
"""
Install appliances using an image checksum
"""
appliances_info = self._find_appliances_from_image_checksum(image_checksum)
for appliance, image_version in appliances_info:
if appliance.versions:
for version in appliance.versions:
if version.get("name") == image_version:
await self._find_appliance_version_images(appliance, version, images_repo, image_dir)
# 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)
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"
try:
await self._find_appliance_version_images(appliance, version, images_repo, image_dir)
template_data = await self._appliance_to_template(appliance, version)
await self._create_template(template_data, templates_repo, rbac_repo, current_user)
except (ControllerError, InvalidImageError) as e:
log.warning(f"Could not automatically create template using image '{image_path}': {e}")
async def install_appliance(
self,
appliance_id: UUID,
version: str,
images_repo: ImagesRepository,
templates_repo: TemplatesRepository,
rbac_repo: RbacRepository,
current_user: schemas.User
) -> None:
"""
Install a new appliance
"""
appliance = self._appliances.get(str(appliance_id))
if not appliance:
raise ControllerNotFoundError(message=f"Could not find appliance '{appliance_id}'")
if version:
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)
except InvalidImageError as e:
raise ControllerError(message=f"Image error: {e}")
template_data = await self._appliance_to_template(appliance, appliance_version_info)
return await self._create_template(template_data, templates_repo, rbac_repo, current_user)
def load_appliances(self, symbol_theme="Classic"):
raise ControllerNotFoundError(message=f"Could not find version '{version}' in appliance '{appliance_id}'")
else:
if appliance.versions:
# TODO: install appliance versions based on available images
raise ControllerBadRequestError(message=f"Selecting a version is required to install "
f"appliance '{appliance_id}'")
template_data = await self._appliance_to_template(appliance)
await self._create_template(template_data, templates_repo, rbac_repo, current_user)
def load_appliances(self, symbol_theme: str = "Classic") -> None:
"""
Loads appliance files from disk.
"""
@ -187,27 +287,22 @@ class ApplianceManager:
if not file.endswith(".gns3a") and not file.endswith(".gns3appliance"):
continue
path = os.path.join(directory, file)
# Generate UUID from path to avoid change between reboots
appliance_id = uuid.uuid5(
uuid.NAMESPACE_X500,
path
)
try:
with open(path, encoding="utf-8") as f:
appliance = Appliance(appliance_id, json.load(f), builtin=builtin)
appliance = Appliance(path, json.load(f), builtin=builtin)
json_data = appliance.asdict() # Check if loaded without error
if appliance.status != "broken":
self._appliances[appliance.id] = appliance
self._appliances[appliance.id] = appliance
if not appliance.symbol or appliance.symbol.startswith(":/symbols/"):
# apply a default symbol if the appliance has none or a default symbol
default_symbol = self._get_default_symbol(json_data, symbol_theme)
if default_symbol:
appliance.symbol = default_symbol
except (ValueError, OSError, KeyError) as e:
log.warning("Cannot load appliance file '%s': %s", path, str(e))
log.warning(f"Cannot load appliance file '{path}': {e}")
continue
def _get_default_symbol(self, appliance, symbol_theme):
def _get_default_symbol(self, appliance: dict, symbol_theme: str) -> str:
"""
Returns the default symbol for a given appliance.
"""
@ -223,7 +318,7 @@ class ApplianceManager:
return controller.symbols.get_default_symbol("qemu_guest", symbol_theme)
return controller.symbols.get_default_symbol(category, symbol_theme)
async def download_custom_symbols(self):
async def download_custom_symbols(self) -> None:
"""
Download custom appliance symbols from our GitHub registry repository.
"""
@ -242,7 +337,7 @@ class ApplianceManager:
# refresh the symbol cache
Controller.instance().symbols.list()
async def _download_symbol(self, symbol, destination_path):
async def _download_symbol(self, symbol: str, destination_path: str) -> None:
"""
Download a custom appliance symbol from our GitHub registry repository.
"""
@ -266,7 +361,7 @@ class ApplianceManager:
log.warning(f"Could not write appliance symbol '{destination_path}': {e}")
@locking
async def download_appliances(self):
async def download_appliances(self) -> None:
"""
Downloads appliance files from GitHub registry repository.
"""

@ -34,9 +34,11 @@ class ApplianceToTemplate:
new_template = {
"compute_id": server,
"name": appliance_config["name"],
"version": version.get("name")
}
if version:
new_template["version"] = version.get("name")
if "usage" in appliance_config:
new_template["usage"] = appliance_config["usage"]

@ -57,6 +57,14 @@ class TemplatesRepository(BaseRepository):
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_template_by_name_and_version(self, name: str, version: str) -> Union[None, models.Template]:
query = select(models.Template).\
options(selectinload(models.Template.images)).\
where(models.Template.name == name, models.Template.version == version)
result = await self._db_session.execute(query)
return result.scalars().first()
async def get_templates(self) -> List[models.Template]:
query = select(models.Template).options(selectinload(models.Template.images))

@ -25,8 +25,8 @@ from typing import List
from gns3server import schemas
import gns3server.db.models as models
from gns3server.db.repositories.templates import TemplatesRepository
from gns3server.controller import Controller
from gns3server.controller.controller_error import (
ControllerError,
ControllerBadRequestError,
ControllerNotFoundError,
ControllerForbiddenError,
@ -137,6 +137,7 @@ class TemplatesService:
def __init__(self, templates_repo: TemplatesRepository):
self._templates_repo = templates_repo
from gns3server.controller import Controller
self._controller = Controller.instance()
def get_builtin_template(self, template_id: UUID) -> dict:
@ -195,6 +196,13 @@ class TemplatesService:
async def create_template(self, template_create: schemas.TemplateCreate) -> dict:
if await self._templates_repo.get_template_by_name_and_version(template_create.name, template_create.version):
if template_create.version:
raise ControllerError(f"A template with name '{template_create.name}' and "
f"version {template_create.version} already exists")
else:
raise ControllerError(f"A template with name '{template_create.name}' already exists")
try:
# get the default template settings
template_settings = jsonable_encoder(template_create, exclude_unset=True)

@ -1,6 +1,6 @@
#!/usr/bin/env python
#
# Copyright (C) 2020 GNS3 Technologies Inc.
# 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
@ -15,7 +15,9 @@
# 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 os
import pytest
import shutil
from fastapi import FastAPI, status
from httpx import AsyncClient
@ -23,8 +25,44 @@ from httpx import AsyncClient
pytestmark = pytest.mark.asyncio
async def test_appliances_list(app: FastAPI, client: AsyncClient) -> None:
class TestApplianceRoutes:
response = await client.get(app.url_path_for("get_appliances"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) > 0
async def test_appliances_list(self, app: FastAPI, client: AsyncClient) -> None:
response = await client.get(app.url_path_for("get_appliances"))
assert response.status_code == status.HTTP_200_OK
assert len(response.json()) > 0
async def test_appliance_download(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "3bf492b6-5717-4257-9bfd-b34617c6f133" # Cisco IOSv appliance
response = await client.get(app.url_path_for("download_appliance", appliance_id=appliance_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["appliance_id"] == appliance_id
async def test_docker_appliance_install(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "fc520ae2-a4e5-48c3-9a13-516bb2e94668" # Alpine Linux appliance
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id))
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_docker_appliance_install_with_version(self, app: FastAPI, client: AsyncClient) -> None:
appliance_id = "fc520ae2-a4e5-48c3-9a13-516bb2e94668" # Alpine Linux appliance
params = {"version": "123"}
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params)
assert response.status_code == status.HTTP_400_BAD_REQUEST
async def test_qemu_appliance_install_with_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None:
shutil.copy("tests/resources/empty8G.qcow2", os.path.join(images_dir, "QEMU", "empty8G.qcow2"))
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
params = {"version": "8G"}
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id), params=params)
assert response.status_code == status.HTTP_204_NO_CONTENT
async def test_qemu_appliance_install_without_version(self, app: FastAPI, client: AsyncClient, images_dir: str) -> None:
appliance_id = "1cfdf900-7c30-4cb7-8f03-3f61d2581633" # Empty VM appliance
response = await client.post(app.url_path_for("install_appliance", appliance_id=appliance_id))
assert response.status_code == status.HTTP_400_BAD_REQUEST

@ -23,7 +23,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from fastapi import FastAPI, status
from httpx import AsyncClient
from gns3server.controller import Controller
from gns3server.db.repositories.images import ImagesRepository
from gns3server.db.repositories.templates import TemplatesRepository
pytestmark = pytest.mark.asyncio
@ -256,3 +258,27 @@ class TestImageRoutes:
images_repo = ImagesRepository(db_session)
images_in_db = await images_repo.get_images()
assert len(images_in_db) == 0
async def test_image_upload_create_appliance(
self, app: FastAPI,
client: AsyncClient,
db_session: AsyncSession,
controller: Controller
) -> None:
controller.appliance_manager.load_appliances() # make sure appliances are loaded
image_path = "tests/resources/empty30G.qcow2"
image_name = os.path.basename(image_path)
with open(image_path, "rb") as f:
image_data = f.read()
response = await client.post(
app.url_path_for("upload_image", image_path=image_name),
params={"image_type": "qemu"},
content=image_data)
assert response.status_code == status.HTTP_201_CREATED
templates_repo = TemplatesRepository(db_session)
templates = await templates_repo.get_templates()
assert len(templates) == 1
assert templates[0].name == "Empty VM"
assert templates[0].version == "30G"

@ -62,6 +62,7 @@ class TestTemplateRoutes:
template_id = str(uuid.uuid4())
params = {"template_id": template_id,
"name": "VPCS_TEST",
"version": "1.0",
"compute_id": "local",
"template_type": "vpcs"}
@ -72,9 +73,25 @@ class TestTemplateRoutes:
assert response.status_code == status.HTTP_200_OK
assert response.json()["template_id"] == template_id
async def test_template_create_same_name_and_version(
self,
app: FastAPI,
client: AsyncClient,
controller: Controller
) -> None:
params = {"name": "VPCS_TEST",
"version": "1.0",
"compute_id": "local",
"template_type": "vpcs"}
response = await client.post(app.url_path_for("create_template"), json=params)
assert response.status_code == status.HTTP_409_CONFLICT
async def test_template_create_wrong_type(self, app: FastAPI, client: AsyncClient, controller: Controller) -> None:
params = {"name": "VPCS_TEST",
"version": "2.0",
"compute_id": "local",
"template_type": "invalid_template_type"}
@ -86,6 +103,7 @@ class TestTemplateRoutes:
template_id = str(uuid.uuid4())
params = {"template_id": template_id,
"name": "VPCS_TEST",
"version": "3.0",
"compute_id": "local",
"template_type": "vpcs"}
@ -107,6 +125,7 @@ class TestTemplateRoutes:
template_id = str(uuid.uuid4())
params = {"template_id": template_id,
"name": "VPCS_TEST",
"version": "4.0",
"compute_id": "local",
"template_type": "vpcs"}
@ -426,7 +445,7 @@ class TestDynamipsTemplate:
async def test_c3600_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None:
params = {"name": "Cisco c3600 template",
params = {"name": "Cisco c3600 template with wrong chassis",
"platform": "c3600",
"chassis": "3650",
"compute_id": "local",
@ -530,7 +549,7 @@ class TestDynamipsTemplate:
async def test_c2600_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None:
params = {"name": "Cisco c2600 template",
params = {"name": "Cisco c2600 template with wrong chassis",
"platform": "c2600",
"chassis": "2660XM",
"compute_id": "local",
@ -589,7 +608,7 @@ class TestDynamipsTemplate:
async def test_c1700_dynamips_template_create_wrong_chassis(self, app: FastAPI, client: AsyncClient) -> None:
params = {"name": "Cisco c1700 template",
params = {"name": "Cisco c1700 template with wrong chassis",
"platform": "c1700",
"chassis": "1770",
"compute_id": "local",
@ -1200,6 +1219,7 @@ class TestImageAssociationWithTemplate:
) -> None:
params = {"name": "Qemu template",
"version": "1.0",
"compute_id": "local",
"platform": "i386",
"hda_disk_image": "subdir/image.qcow2",
@ -1224,7 +1244,7 @@ class TestImageAssociationWithTemplate:
async def test_template_create_with_non_existing_image(self, app: FastAPI, client: AsyncClient) -> None:
params = {"name": "Qemu template",
params = {"name": "Qemu template with non existing image",
"compute_id": "local",
"platform": "i386",
"hda_disk_image": "unkown_image.qcow2",

Binary file not shown.
Loading…
Cancel
Save