mirror of
https://github.com/GNS3/gns3-server
synced 2024-12-26 16:58:28 +00:00
Merge pull request #2292 from GNS3/fix/3422
Support to create empty disk images on the controller
This commit is contained in:
commit
7215b150dd
@ -23,13 +23,15 @@ import logging
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, status
|
from fastapi import APIRouter, Request, Depends, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
from starlette.requests import ClientDisconnect
|
from starlette.requests import ClientDisconnect
|
||||||
from sqlalchemy.orm.exc import MultipleResultsFound
|
from sqlalchemy.orm.exc import MultipleResultsFound
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from gns3server import schemas
|
from gns3server import schemas
|
||||||
|
|
||||||
from gns3server.config import Config
|
from gns3server.config import Config
|
||||||
from gns3server.utils.images import InvalidImageError, write_image
|
from gns3server.compute.qemu import Qemu
|
||||||
|
from gns3server.utils.images import InvalidImageError, write_image, read_image_info, default_images_directory
|
||||||
from gns3server.db.repositories.images import ImagesRepository
|
from gns3server.db.repositories.images import ImagesRepository
|
||||||
from gns3server.db.repositories.templates import TemplatesRepository
|
from gns3server.db.repositories.templates import TemplatesRepository
|
||||||
from gns3server.db.repositories.rbac import RbacRepository
|
from gns3server.db.repositories.rbac import RbacRepository
|
||||||
@ -50,6 +52,53 @@ log = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/qemu/{image_path:path}",
|
||||||
|
response_model=schemas.Image,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(has_privilege("Image.Allocate"))]
|
||||||
|
)
|
||||||
|
async def create_qemu_image(
|
||||||
|
image_path: str,
|
||||||
|
image_data: schemas.QemuDiskImageCreate,
|
||||||
|
images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)),
|
||||||
|
|
||||||
|
) -> schemas.Image:
|
||||||
|
"""
|
||||||
|
Create a new blank Qemu image.
|
||||||
|
|
||||||
|
Required privilege: Image.Allocate
|
||||||
|
"""
|
||||||
|
|
||||||
|
allow_raw_image = Config.instance().settings.Server.allow_raw_images
|
||||||
|
if image_data.format == schemas.QemuDiskImageFormat.raw and not allow_raw_image:
|
||||||
|
raise ControllerBadRequestError("Raw images are not allowed")
|
||||||
|
|
||||||
|
disk_image_path = urllib.parse.unquote(image_path)
|
||||||
|
image_dir, image_name = os.path.split(disk_image_path)
|
||||||
|
# check if the path is within the default images directory
|
||||||
|
base_images_directory = os.path.expanduser(Config.instance().settings.Server.images_path)
|
||||||
|
full_path = os.path.abspath(os.path.join(base_images_directory, image_dir, image_name))
|
||||||
|
if os.path.commonprefix([base_images_directory, full_path]) != base_images_directory:
|
||||||
|
raise ControllerForbiddenError(f"Cannot write disk image, '{disk_image_path}' is forbidden")
|
||||||
|
|
||||||
|
if not image_dir:
|
||||||
|
# put the image in the default images directory for Qemu
|
||||||
|
directory = default_images_directory(image_type="qemu")
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
disk_image_path = os.path.abspath(os.path.join(directory, disk_image_path))
|
||||||
|
|
||||||
|
if await images_repo.get_image(disk_image_path):
|
||||||
|
raise ControllerBadRequestError(f"Disk image '{disk_image_path}' already exists")
|
||||||
|
|
||||||
|
options = jsonable_encoder(image_data, exclude_unset=True)
|
||||||
|
# FIXME: should we have the create_disk_image in the compute code since
|
||||||
|
# this code is used to create images on the controller?
|
||||||
|
await Qemu.instance().create_disk_image(disk_image_path, options)
|
||||||
|
|
||||||
|
image_info = await read_image_info(disk_image_path, "qemu")
|
||||||
|
return await images_repo.add_image(**image_info)
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"",
|
"",
|
||||||
response_model=List[schemas.Image],
|
response_model=List[schemas.Image],
|
||||||
|
@ -21,6 +21,8 @@ Qemu server module.
|
|||||||
import asyncio
|
import asyncio
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import shutil
|
||||||
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
@ -159,6 +161,44 @@ class Qemu(BaseManager):
|
|||||||
|
|
||||||
return qemus
|
return qemus
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def create_disk_image(disk_image_path, options):
|
||||||
|
"""
|
||||||
|
Create a Qemu disk (used by the controller to create empty disk images)
|
||||||
|
|
||||||
|
:param disk_image_path: disk image path
|
||||||
|
:param options: disk creation options
|
||||||
|
"""
|
||||||
|
|
||||||
|
qemu_img_path = shutil.which("qemu-img")
|
||||||
|
if not qemu_img_path:
|
||||||
|
raise QemuError(f"Could not find qemu-img binary")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.exists(disk_image_path):
|
||||||
|
raise QemuError(f"Could not create disk image '{disk_image_path}', file already exists")
|
||||||
|
except UnicodeEncodeError:
|
||||||
|
raise QemuError(
|
||||||
|
f"Could not create disk image '{disk_image_path}', "
|
||||||
|
"Disk image name contains characters not supported by the filesystem"
|
||||||
|
)
|
||||||
|
|
||||||
|
img_format = options.pop("format")
|
||||||
|
img_size = options.pop("size")
|
||||||
|
command = [qemu_img_path, "create", "-f", img_format]
|
||||||
|
for option in sorted(options.keys()):
|
||||||
|
command.extend(["-o", f"{option}={options[option]}"])
|
||||||
|
command.append(disk_image_path)
|
||||||
|
command.append(f"{img_size}M")
|
||||||
|
command_string = " ".join(shlex.quote(s) for s in command)
|
||||||
|
output = ""
|
||||||
|
try:
|
||||||
|
log.info(f"Executing qemu-img with: {command_string}")
|
||||||
|
output = await subprocess_check_output(*command, stderr=True)
|
||||||
|
log.info(f"Qemu disk image'{disk_image_path}' created")
|
||||||
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
|
raise QemuError(f"Could not create '{disk_image_path}' disk image: {e}\n{output}")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_qemu_version(qemu_path):
|
async def get_qemu_version(qemu_path):
|
||||||
"""
|
"""
|
||||||
@ -178,25 +218,6 @@ class Qemu(BaseManager):
|
|||||||
except (OSError, subprocess.SubprocessError) as e:
|
except (OSError, subprocess.SubprocessError) as e:
|
||||||
raise QemuError(f"Error while looking for the Qemu version: {e}")
|
raise QemuError(f"Error while looking for the Qemu version: {e}")
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def _get_qemu_img_version(qemu_img_path):
|
|
||||||
"""
|
|
||||||
Gets the Qemu-img version.
|
|
||||||
|
|
||||||
:param qemu_img_path: path to Qemu-img executable.
|
|
||||||
"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
output = await subprocess_check_output(qemu_img_path, "--version")
|
|
||||||
match = re.search(r"version\s+([0-9a-z\-\.]+)", output)
|
|
||||||
if match:
|
|
||||||
version = match.group(1)
|
|
||||||
return version
|
|
||||||
else:
|
|
||||||
raise QemuError("Could not determine the Qemu-img version for '{}'".format(qemu_img_path))
|
|
||||||
except (OSError, subprocess.SubprocessError) as e:
|
|
||||||
raise QemuError("Error while looking for the Qemu-img version: {}".format(e))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def get_swtpm_version(swtpm_path):
|
async def get_swtpm_version(swtpm_path):
|
||||||
"""
|
"""
|
||||||
|
@ -82,4 +82,4 @@ from .compute.vmware_nodes import VMwareCreate, VMwareUpdate, VMware
|
|||||||
from .compute.virtualbox_nodes import VirtualBoxCreate, VirtualBoxUpdate, VirtualBox
|
from .compute.virtualbox_nodes import VirtualBoxCreate, VirtualBoxUpdate, VirtualBox
|
||||||
|
|
||||||
# Schemas for both controller and compute
|
# Schemas for both controller and compute
|
||||||
from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageUpdate
|
from .qemu_disk_image import QemuDiskImageFormat, QemuDiskImageCreate, QemuDiskImageUpdate
|
||||||
|
@ -22,10 +22,12 @@ import hashlib
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from fastapi import FastAPI, status
|
from fastapi import FastAPI, status
|
||||||
from httpx import AsyncClient
|
from httpx import AsyncClient
|
||||||
|
from tests.utils import AsyncioMagicMock
|
||||||
|
|
||||||
from gns3server.controller import Controller
|
from gns3server.controller import Controller
|
||||||
from gns3server.db.repositories.images import ImagesRepository
|
from gns3server.db.repositories.images import ImagesRepository
|
||||||
from gns3server.db.repositories.templates import TemplatesRepository
|
from gns3server.db.repositories.templates import TemplatesRepository
|
||||||
|
from gns3server.compute.qemu import Qemu
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
pytestmark = pytest.mark.asyncio
|
||||||
|
|
||||||
@ -104,6 +106,17 @@ def empty_image(tmpdir) -> str:
|
|||||||
|
|
||||||
class TestImageRoutes:
|
class TestImageRoutes:
|
||||||
|
|
||||||
|
async def test_create_image(self, app: FastAPI, client: AsyncClient, images_dir) -> None:
|
||||||
|
|
||||||
|
Qemu.instance().create_disk_image = AsyncioMagicMock()
|
||||||
|
path = os.path.join(os.path.join(images_dir, "QEMU", "new_image.qcow2"))
|
||||||
|
with open(path, "wb+") as f:
|
||||||
|
f.write(b'QFI\xfb\x00\x00\x00')
|
||||||
|
image_name = os.path.basename(path)
|
||||||
|
response = await client.post(
|
||||||
|
app.url_path_for("create_qemu_image", image_path=image_name), json={"format": "qcow2", "size": 30})
|
||||||
|
assert response.status_code == status.HTTP_201_CREATED
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"image_type, fixture_name, valid_request",
|
"image_type, fixture_name, valid_request",
|
||||||
(
|
(
|
||||||
@ -151,7 +164,7 @@ class TestImageRoutes:
|
|||||||
|
|
||||||
response = await client.get(app.url_path_for("get_images"))
|
response = await client.get(app.url_path_for("get_images"))
|
||||||
assert response.status_code == status.HTTP_200_OK
|
assert response.status_code == status.HTTP_200_OK
|
||||||
assert len(response.json()) == 4 # 4 valid images uploaded before
|
assert len(response.json()) == 5 # 4 valid images uploaded before + 1 created
|
||||||
|
|
||||||
async def test_image_get(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
async def test_image_get(self, app: FastAPI, client: AsyncClient, qcow2_image: str) -> None:
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user