Merge pull request #2292 from GNS3/fix/3422

Support to create empty disk images on the controller
This commit is contained in:
Jeremy Grossmann 2023-09-25 21:29:24 +10:00 committed by GitHub
commit 7215b150dd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 105 additions and 22 deletions

View File

@ -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],

View File

@ -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):
""" """

View File

@ -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

View File

@ -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: