From 1ae6d13022478db89192be0e9a24487503df9c12 Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 25 Sep 2023 17:51:14 +1000 Subject: [PATCH 1/2] Support to create empty disk images on the controller --- gns3server/api/routes/controller/images.py | 51 ++++++++++++++++++- gns3server/compute/qemu/__init__.py | 59 +++++++++++++++------- gns3server/schemas/__init__.py | 2 +- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index f4780db0..e27d4c53 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -23,13 +23,15 @@ import logging import urllib.parse from fastapi import APIRouter, Request, Depends, status +from fastapi.encoders import jsonable_encoder from starlette.requests import ClientDisconnect from sqlalchemy.orm.exc import MultipleResultsFound from typing import List, Optional from gns3server import schemas 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.templates import TemplatesRepository from gns3server.db.repositories.rbac import RbacRepository @@ -50,6 +52,53 @@ log = logging.getLogger(__name__) router = APIRouter() +@router.post( + "/{image_path:path}", + response_model=schemas.Image, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(has_privilege("Image.Allocate"))] +) +async def create_image( + image_path: str, + image_data: schemas.QemuDiskImageCreate, + images_repo: ImagesRepository = Depends(get_repository(ImagesRepository)), + +) -> schemas.Image: + """ + Create a new blank 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 + 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( "", response_model=List[schemas.Image], diff --git a/gns3server/compute/qemu/__init__.py b/gns3server/compute/qemu/__init__.py index e98bb9a8..a9fe0553 100644 --- a/gns3server/compute/qemu/__init__.py +++ b/gns3server/compute/qemu/__init__.py @@ -21,6 +21,8 @@ Qemu server module. import asyncio import os import platform +import shutil +import shlex import sys import re import subprocess @@ -159,6 +161,44 @@ class Qemu(BaseManager): 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 async def get_qemu_version(qemu_path): """ @@ -178,25 +218,6 @@ class Qemu(BaseManager): except (OSError, subprocess.SubprocessError) as 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 async def get_swtpm_version(swtpm_path): """ diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index d38b31d3..d331de54 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -82,4 +82,4 @@ from .compute.vmware_nodes import VMwareCreate, VMwareUpdate, VMware from .compute.virtualbox_nodes import VirtualBoxCreate, VirtualBoxUpdate, VirtualBox # Schemas for both controller and compute -from .qemu_disk_image import QemuDiskImageCreate, QemuDiskImageUpdate +from .qemu_disk_image import QemuDiskImageFormat, QemuDiskImageCreate, QemuDiskImageUpdate From 674381f1be035bce8d8e19ab0b88e9b31e003a7d Mon Sep 17 00:00:00 2001 From: grossmj Date: Mon, 25 Sep 2023 21:08:23 +1000 Subject: [PATCH 2/2] Fix tests --- gns3server/api/routes/controller/images.py | 8 ++++---- tests/api/routes/controller/test_images.py | 15 ++++++++++++++- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/gns3server/api/routes/controller/images.py b/gns3server/api/routes/controller/images.py index e27d4c53..afd592de 100644 --- a/gns3server/api/routes/controller/images.py +++ b/gns3server/api/routes/controller/images.py @@ -53,19 +53,19 @@ router = APIRouter() @router.post( - "/{image_path:path}", + "/qemu/{image_path:path}", response_model=schemas.Image, status_code=status.HTTP_201_CREATED, dependencies=[Depends(has_privilege("Image.Allocate"))] ) -async def create_image( +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 image. + Create a new blank Qemu image. Required privilege: Image.Allocate """ @@ -83,7 +83,7 @@ async def create_image( raise ControllerForbiddenError(f"Cannot write disk image, '{disk_image_path}' is forbidden") if not image_dir: - # put the image in the default images directory + # 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)) diff --git a/tests/api/routes/controller/test_images.py b/tests/api/routes/controller/test_images.py index 7aa09f22..015ea014 100644 --- a/tests/api/routes/controller/test_images.py +++ b/tests/api/routes/controller/test_images.py @@ -22,10 +22,12 @@ import hashlib from sqlalchemy.ext.asyncio import AsyncSession from fastapi import FastAPI, status from httpx import AsyncClient +from tests.utils import AsyncioMagicMock from gns3server.controller import Controller from gns3server.db.repositories.images import ImagesRepository from gns3server.db.repositories.templates import TemplatesRepository +from gns3server.compute.qemu import Qemu pytestmark = pytest.mark.asyncio @@ -104,6 +106,17 @@ def empty_image(tmpdir) -> str: 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( "image_type, fixture_name, valid_request", ( @@ -151,7 +164,7 @@ class TestImageRoutes: response = await client.get(app.url_path_for("get_images")) 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: