feat(qemu): support two CD/DVD images and consistent ISO handling

This commit is contained in:
Cristi 2026-10-17 23:08:11 +03:00
parent d047ca0a5f
commit 84b4c05b9f
17 changed files with 462 additions and 41 deletions

View File

@ -158,6 +158,7 @@ class QemuVM(KernelDatapathMixin, BaseNode):
self._hdc_disk_interface = "none"
self._hdd_disk_interface = "none"
self._cdrom_image = ""
self._cdrom2_image = ""
self._bios_image = ""
self._boot_priority = "c"
self._mac_address = ""
@ -524,33 +525,74 @@ class QemuVM(KernelDatapathMixin, BaseNode):
else:
self._cdrom_image = ""
@property
def cdrom2_image(self):
return self._cdrom2_image
@cdrom2_image.setter
def cdrom2_image(self, image):
self._cdrom2_image = self.manager.get_abs_image_path(image, self.working_dir) if image else ""
async def update_property(self, name, value):
"""
Update Qemu VM properties.
"""
previous = getattr(self, name)
setattr(self, name, value)
if name == "cdrom_image":
# let the guest know about the new cdrom image
await self._update_cdrom_image()
try:
if name in {"cdrom_image", "cdrom2_image"}:
await self._update_cdrom_image(name)
except (Exception, asyncio.CancelledError):
# Old media may have been deleted while QEMU still has it open.
# Restore the stored path without resolving it again.
setattr(self, f"_{name}", previous)
raise
self._command_line_changed = True
async def _update_cdrom_image(self):
"""
Update the cdrom image path for the Qemu guest OS
"""
async def _update_cdrom_image(self, name="cdrom_image"):
"""Change media only on an existing drive, validating the affected slot."""
if not self.is_running():
return
image = getattr(self, name)
self._cdrom_option(name)
device = "ide1-cd0" if name == "cdrom_image" else "ide1-cd1"
reader, writer = await self._open_qemu_monitor_connection_vm()
if reader is None or writer is None:
raise QemuError("Could not connect to the QEMU monitor to change CD/DVD media")
if self.is_running():
if self._cdrom_image:
self._cdrom_option() # this will check the cdrom image is accessible
await self._control_vm("eject -f ide1-cd0")
await self._control_vm(f"change ide1-cd0 {self._cdrom_image}")
log.debug(
f'QEMU VM "{self._name}" [{self._id}] has changed the cdrom image path to {self._cdrom_image}'
)
async def command(text):
writer.write((text + "\n").encode("utf-8"))
await writer.drain()
# Consume the echo first: a filename may itself contain "(qemu) ".
await asyncio.wait_for(reader.readline(), timeout=3)
output = await asyncio.wait_for(reader.readuntil(b"(qemu) "), timeout=3)
response = output.decode("utf-8", errors="replace")
response = response.removesuffix("(qemu) ").strip()
if "Error:" in response or (text != "info block" and response):
raise QemuError(response)
return response
try:
await asyncio.wait_for(reader.readuntil(b"(qemu) "), timeout=3)
blocks = await command("info block")
if not re.search(rf"(?m)^{device}[: ]", blocks):
raise QemuError("Stop the VM before adding this CD/DVD drive")
if image:
filename = image
if not image.isascii() or any(ord(char) < 32 for char in image):
# HMP's line editor cannot reliably handle non-ASCII bytes.
# A JSON block filename carries them as ASCII escapes, which
# the block layer decodes after the HMP parser has run.
filename = "json:" + json.dumps({"driver": "raw", "file": {"driver": "file", "filename": image}})
await command(f"change {device} -f {json.dumps(filename)}")
else:
await self._control_vm("eject -f ide1-cd0")
log.debug(f'QEMU VM "{self._name}" [{self._id}] has ejected the cdrom image')
await command(f"eject -f {device}")
log.debug('QEMU VM "%s" has changed %s to %s', self._name, name, image)
except (asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as e:
raise QemuError(f"Could not change CD/DVD media: {e}") from e
finally:
writer.close()
@property
def bios_image(self):
@ -2387,10 +2429,10 @@ class QemuVM(KernelDatapathMixin, BaseNode):
return options
def _cdrom_option(self):
def _cdrom_option(self, name=None):
options = []
if self._cdrom_image:
if name in (None, "cdrom_image") and self._cdrom_image:
if not os.path.isfile(self._cdrom_image) or not os.path.exists(self._cdrom_image):
if os.path.islink(self._cdrom_image):
raise QemuError(
@ -2401,6 +2443,17 @@ class QemuVM(KernelDatapathMixin, BaseNode):
if self._hdc_disk_image:
raise QemuError("You cannot use a disk image on hdc disk and a CDROM image at the same time")
options.extend(["-cdrom", self._cdrom_image.replace(",", ",,")])
if name in (None, "cdrom2_image") and self._cdrom2_image:
if not os.path.isfile(self._cdrom2_image):
raise QemuError(f"cdrom image '{self._cdrom2_image}' is not accessible")
interface = self._hdd_disk_interface
if self._create_config_disk and interface == "none":
interface = self._hda_disk_interface
if (self._hdd_disk_image or self._create_config_disk) and interface == "ide":
raise QemuError("You cannot use an IDE hdd disk and a second CDROM image at the same time")
options.extend(
["-drive", f"file={self._cdrom2_image.replace(',', ',,')},if=ide,index=3,media=cdrom,readonly=on"]
)
return options
def _bios_option(self):
@ -2868,6 +2921,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
answer["cdrom_image"] = self.manager.get_relative_image_path(self._cdrom_image, self.working_dir)
answer["cdrom_image_md5sum"] = md5sum(self._cdrom_image, self.working_dir)
answer["cdrom2_image"] = self.manager.get_relative_image_path(self._cdrom2_image, self.working_dir)
answer["cdrom2_image_md5sum"] = md5sum(self._cdrom2_image, self.working_dir)
answer["bios_image"] = self.manager.get_relative_image_path(self._bios_image, self.working_dir)
answer["bios_image_md5sum"] = md5sum(self._bios_image, self.working_dir)
answer["initrd"] = self.manager.get_relative_image_path(self._initrd, self.working_dir)

View File

@ -208,6 +208,7 @@ async def _patch_project_file(
# image files that we need to include in the exported archive
images = []
portable_references = []
try:
with open(path) as f:
@ -249,32 +250,54 @@ async def _patch_project_file(
if value is None or value.strip() == "":
continue
if not keep_compute_ids: # If we keep the original compute we can keep the image path
if not keep_compute_ids:
node["properties"][prop] = os.path.basename(value)
if include_images is True:
images.append({"compute_id": compute_id, "image": value, "image_type": node["node_type"]})
if include_images:
image = {"compute_id": compute_id, "image": value, "image_type": node["node_type"]}
images.append(image)
if not keep_compute_ids:
portable_references.append((node["properties"], prop, image))
if not keep_compute_ids:
topology["topology"][
"computes"
] = [] # Strip compute information because could contain secret info like password
local_images = {i["image"] for i in images if i["compute_id"] == "local"}
destinations = _image_export_names(images)
for properties, prop, image in portable_references:
properties[prop] = destinations[(image["compute_id"], image["image_type"], image["image"])]
for image in local_images:
_export_local_image(image, zstream)
remote_images = {(i["compute_id"], i["image_type"], i["image"]) for i in images if i["compute_id"] != "local"}
for compute_id, image_type, image in remote_images:
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir)
for (compute_id, image_type, image), export_name in destinations.items():
if compute_id == "local":
_export_local_image(image, zstream, export_name, image_type)
else:
await _export_remote_images(project, compute_id, image_type, image, zstream, temporary_dir, export_name)
zstream.writestr("project.gns3", json.dumps(topology, indent=4, sort_keys=True).encode())
return images
def _export_local_image(image, zstream):
def _image_export_names(images):
"""Allocate distinct portable names without changing ordinary image names."""
destinations = {}
used = set()
for image in images:
key = (image["compute_id"], image["image_type"], image["image"])
if key in destinations:
continue
filename = os.path.basename(image["image"])
stem, extension = os.path.splitext(filename)
index = 1
while (image["image_type"], filename) in used:
index += 1
filename = f"{stem}-{index}{extension}"
used.add((image["image_type"], filename))
destinations[key] = filename
return destinations
def _export_local_image(image, zstream, export_name=None, image_type=None):
"""
Exports a local image to the zip file.
@ -285,6 +308,8 @@ def _export_local_image(image, zstream):
from ..compute import MODULES
for module in MODULES:
if image_type and getattr(module, "_NODE_TYPE", None) != image_type:
continue
try:
images_directory = module.instance().get_images_directory()
except NotImplementedError:
@ -298,13 +323,15 @@ def _export_local_image(image, zstream):
path = os.path.join(images_directory, image)
if os.path.exists(path):
arcname = os.path.join("images", directory, os.path.basename(image))
arcname = os.path.join("images", directory, export_name or os.path.basename(image))
_patch_mtime(path)
zstream.write(path, arcname)
return
async def _export_remote_images(project, compute_id, image_type, image, project_zipfile, temporary_dir):
async def _export_remote_images(
project, compute_id, image_type, image, project_zipfile, temporary_dir, export_name=None
):
"""
Export specific image from remote compute.
"""
@ -334,5 +361,6 @@ async def _export_remote_images(project, compute_id, image_type, image, project_
break
await f.write(data)
response.close()
arcname = os.path.join("images", image_type, image)
directory = "QEMU" if image_type == "qemu" else image_type
arcname = os.path.join("images", directory, export_name or image)
project_zipfile.write(temp_path, arcname=arcname, compress_type=zipfile.ZIP_DEFLATED)

View File

@ -58,6 +58,7 @@ IMAGE_PROPERTIES_BY_NODE_TYPE = {
"hdc_disk_image": "qemu",
"hdd_disk_image": "qemu",
"cdrom_image": "qemu",
"cdrom2_image": "qemu",
"initrd": "qemu",
"kernel_image": "qemu",
"bios_image": "qemu",
@ -900,6 +901,7 @@ class Node:
"hdc_disk_image",
"hdd_disk_image",
"cdrom_image",
"cdrom2_image",
"bios_image",
"initrd",
"kernel_image",

View File

@ -45,6 +45,23 @@ class Image(BaseTable):
last_error = Column(String)
templates = relationship("Template", secondary=image_template_map, back_populates="images")
@property
def reference(self):
"""An emulator-relative reference, or an absolute additional-root path."""
import os
from gns3server.utils import force_unix_path
from gns3server.utils.images import default_images_directory
root = os.path.abspath(default_images_directory(self.image_type))
path = os.path.abspath(self.path)
try:
if os.path.commonpath([root, path]) == root:
return force_unix_path(os.path.relpath(path, root))
except ValueError: # Different drives on Windows.
pass
return force_unix_path(path)
class ImageSyncJob(BaseTable):
__tablename__ = "image_sync_jobs"

View File

@ -201,6 +201,7 @@ class QemuTemplate(Template):
hdd_disk_image = Column(String)
hdd_disk_interface = Column(String)
cdrom_image = Column(String)
cdrom2_image = Column(String)
initrd = Column(String)
kernel_image = Column(String)
bios_image = Column(String)

View File

@ -140,12 +140,16 @@ async def connect_to_db(app: FastAPI) -> None:
def upgrade_inventory(connection):
from alembic.operations import Operations
from gns3server.db_migrations.versions.a6c8e1b2d490_add_second_qemu_cdrom import (
upgrade as upgrade_cdrom,
)
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import (
upgrade,
)
with Operations.context(MigrationContext.configure(connection)):
upgrade()
upgrade_cdrom()
await conn.run_sync(upgrade_inventory)
await conn.run_sync(run_stamp, alembic_cfg)

View File

@ -0,0 +1,23 @@
"""Add a second QEMU CD-ROM image.
Revision ID: a6c8e1b2d490
Revises: f7d3a9c2e5b1
"""
import sqlalchemy as sa
from alembic import op
revision = "a6c8e1b2d490"
down_revision = "f7d3a9c2e5b1"
branch_labels = None
depends_on = None
def upgrade():
existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("qemu_templates")}
if "cdrom2_image" not in existing:
op.add_column("qemu_templates", sa.Column("cdrom2_image", sa.String()))
def downgrade():
op.drop_column("qemu_templates", "cdrom2_image")

View File

@ -183,8 +183,10 @@ class QemuBase(BaseModel):
hdd_disk_image_backing_file: Optional[str] = Field(None, description="QEMU hdd backing file disk image path")
hdd_disk_image_md5sum: Optional[str] = Field(None, description="QEMU hdd disk image checksum")
hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(None, description="QEMU hdd interface")
cdrom_image: Optional[str] = Field(None, description="QEMU cdrom image path")
cdrom_image_md5sum: Optional[str] = Field(None, description="QEMU cdrom image checksum")
cdrom_image: Optional[str] = Field(None, description="QEMU first cdrom image path")
cdrom2_image: Optional[str] = Field(None, description="QEMU second cdrom image path")
cdrom_image_md5sum: Optional[str] = Field(None, description="QEMU first cdrom image checksum")
cdrom2_image_md5sum: Optional[str] = Field(None, description="QEMU second cdrom image checksum")
bios_image: Optional[str] = Field(None, description="QEMU bios image path")
bios_image_md5sum: Optional[str] = Field(None, description="QEMU bios image checksum")
initrd: Optional[str] = Field(None, description="QEMU initrd path")

View File

@ -425,7 +425,8 @@ class ApplianceVersionImages(BaseModel):
hdb_disk_image: Optional[str] = Field(None, title="Hdc disk image")
hdc_disk_image: Optional[str] = Field(None, title="Hdd disk image")
hdd_disk_image: Optional[str] = Field(None, title="Hdd diskimage")
cdrom_image: Optional[str] = Field(None, title="cdrom image")
cdrom_image: Optional[str] = Field(None, title="First cdrom image")
cdrom2_image: Optional[str] = Field(None, title="Second cdrom image")
class ApplianceVersion(BaseModel):

View File

@ -37,6 +37,9 @@ class ImageBase(BaseModel):
filename: str = Field(..., description="Image filename")
path: str = Field(..., description="Image path")
reference: Optional[str] = Field(
None, description="Image reference relative to its emulator directory, or an absolute additional-root path"
)
image_type: ImageType = Field(..., description="Image type")
image_size: int = Field(..., description="Image size in bytes")
checksum: str = Field(..., description="Checksum value")

View File

@ -79,7 +79,8 @@ class QemuTemplate(TemplateBase):
hdd_disk_interface: Optional[QemuDiskInterfaceType] = Field(
QemuDiskInterfaceType.none, description="QEMU hdd interface"
)
cdrom_image: Optional[str] = Field("", description="QEMU cdrom image path")
cdrom_image: Optional[str] = Field("", description="QEMU first cdrom image path")
cdrom2_image: Optional[str] = Field("", description="QEMU second cdrom image path")
initrd: Optional[str] = Field("", description="QEMU initrd path")
kernel_image: Optional[str] = Field("", description="QEMU kernel image path")
bios_image: Optional[str] = Field("", description="QEMU bios image path")

View File

@ -49,6 +49,9 @@ from . import force_unix_path
log = logging.getLogger(__name__)
# Include the first ISO 9660 volume descriptor in image type detection.
IMAGE_HEADER_SIZE = 16 * 2048 + 7
async def list_images(image_type):
"""
@ -145,7 +148,7 @@ def inspect_image_file(path, expected_image_type=None, allow_raw_image=False, st
info = os.fstat(f.fileno())
if not stat.S_ISREG(info.st_mode) or stat_fingerprint(info) != before:
raise ImageChangedError(f"Image changed while opening: {path}")
header = f.read(7)
header = f.read(IMAGE_HEADER_SIZE)
if len(header) < 7:
raise InvalidImageError(f"Image '{path}' is too small to be valid")
image_type = check_valid_image_header(path, header, allow_raw_image)
@ -359,6 +362,8 @@ def check_valid_image_header(path: str, data: bytes, allow_raw_image: bool = Fal
elif data[:4] == b"QFI\xfb" or data[:4] == b"KDMV":
# for Qemy images: file must be QCOW2 or VMDK
return "qemu"
elif data[16 * 2048 + 1 : 16 * 2048 + 6] == b"CD001":
return "qemu"
else:
if allow_raw_image is True:
return "qemu"
@ -380,12 +385,14 @@ async def write_image(
if subdirectory is not None:
validate_image_upload_name(image_filename)
subfolders = validate_image_subdirectory(subdirectory)
# HTTP chunk boundaries need not align with the seven-byte image header.
# HTTP chunk boundaries need not align with the image header or ISO descriptor.
iterator = stream.__aiter__()
prefix = bytearray()
async for chunk in iterator:
prefix.extend(chunk)
if len(prefix) >= 7:
# Known disk formats keep their original early detection behavior.
header_size = 7 if prefix.startswith((b"\x7fELF", b"QFI\xfb", b"KDMV")) else IMAGE_HEADER_SIZE
if len(prefix) >= header_size:
break
if len(prefix) < 7:
raise InvalidImageError("The image content is empty or too small to be valid")

View File

@ -476,3 +476,40 @@ class TestImageRoutes:
assert len(templates) == 1
assert templates[0].name == "Empty VM"
await templates_repo.delete_template(templates[0].template_id)
class TestIsoImages:
async def test_iso_uploads_can_be_used_together_in_a_template(self, app, client, config, images_dir):
config.settings.Server.allow_raw_images = False
references = []
for index, name in enumerate(("installer.ISO", "drivers.iso")):
content = bytes(16 * 2048) + b"\x01CD001\x01" + bytes([index]) * 2041
response = await client.post(
app.url_path_for("upload_image", image_path=name),
params={"subdirectory": "installers"} if index == 0 else {},
content=content,
)
assert response.status_code == status.HTTP_201_CREATED, response.text
assert response.json()["image_type"] == "qemu"
relative = "installers/" + name if index == 0 else name
assert response.json()["path"] == os.path.join(images_dir, "QEMU", relative)
assert response.json()["reference"] == relative
assert Qemu.instance().get_abs_image_path(relative) == response.json()["path"]
references.append(relative)
response = await client.post(
app.url_path_for("create_template"),
json={
"name": "Windows installer with drivers",
"template_type": "qemu",
"compute_id": "local",
"cdrom_image": references[0],
"cdrom2_image": references[1],
},
)
assert response.status_code == status.HTTP_201_CREATED, response.text
template_id = response.json()["template_id"]
response = await client.get(app.url_path_for("get_template", template_id=template_id))
assert response.status_code == status.HTTP_200_OK
assert response.json()["cdrom_image"] == references[0]
assert response.json()["cdrom2_image"] == references[1]

View File

@ -15,9 +15,11 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import asyncio
import json
import os
import stat
import sys
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock, patch
@ -1039,3 +1041,126 @@ async def test_run_with_kvm_linux_dev_kvm_missing(linux_platform, vm):
vm.manager.config.settings.Qemu.require_hardware_acceleration = True
with pytest.raises(QemuError):
await vm._run_with_hardware_acceleration("qemu-system-x86_64", "")
@pytest.mark.asyncio
async def test_two_cdrom_images(vm, images_dir):
installer = Path(images_dir) / "installer.iso"
drivers = Path(images_dir) / "virtio,drivers.iso"
installer.touch()
drivers.touch()
vm.cdrom_image = str(installer)
vm.cdrom2_image = str(drivers)
assert vm._cdrom_option() == [
"-cdrom",
str(installer),
"-drive",
f"file={str(drivers).replace(',', ',,')},if=ide,index=3,media=cdrom,readonly=on",
]
assert vm.asdict()["cdrom2_image"].endswith("virtio,drivers.iso")
vm.cdrom2_image = ""
assert vm._cdrom_option() == ["-cdrom", str(installer)]
@pytest.mark.asyncio
@pytest.mark.parametrize("config_disk", [False, True])
async def test_second_cdrom_ide_conflict(vm, images_dir, config_disk):
iso = Path(images_dir) / "drivers.iso"
iso.touch()
vm.cdrom2_image = str(iso)
vm._hdd_disk_interface = "ide"
vm._create_config_disk = config_disk
if not config_disk:
vm._hdd_disk_image = str(iso)
with pytest.raises(QemuError, match="second CDROM"):
vm._cdrom_option()
@pytest.fixture
def cdrom_monitor(vm):
reader = asyncio.StreamReader()
reader.feed_data(b"QEMU monitor\r\n(qemu) ")
writer = MagicMock()
writer.drain = AsyncioMagicMock()
state = {"blocks": "ide1-cd0: installer.iso\r\nide1-cd1: drivers.iso\r\n", "error": ""}
def respond(data):
command = data.decode("utf-8").strip()
output = state["blocks"] if command == "info block" else state["error"]
reader.feed_data(data.rstrip(b"\n") + b"\r\n" + output.encode("utf-8") + b"(qemu) ")
writer.write.side_effect = respond
with (
patch.object(vm, "is_running", return_value=True),
patch.object(
vm, "_open_qemu_monitor_connection_vm", new_callable=AsyncioMagicMock, return_value=(reader, writer)
),
):
yield state, writer
@pytest.mark.asyncio
@pytest.mark.parametrize("filename", ["drivers with spaces.iso", "drivers-雪.iso", "drivers (qemu) .iso"])
async def test_second_cdrom_update(vm, images_dir, cdrom_monitor, filename):
iso = Path(images_dir) / filename
iso.touch()
state, writer = cdrom_monitor
await vm.update_property("cdrom2_image", str(iso))
assert writer.write.call_args_list[0] == mock.call(b"info block\n")
command = writer.write.call_args_list[1].args[0].decode("ascii")
encoded_filename = json.loads(command.removeprefix("change ide1-cd1 -f ").strip())
if encoded_filename.startswith("json:"):
assert json.loads(encoded_filename[5:])["file"]["filename"] == str(iso)
else:
assert encoded_filename == str(iso)
assert vm.cdrom2_image == str(iso)
writer.close.assert_called_once()
@pytest.mark.asyncio
async def test_second_cdrom_eject(vm, cdrom_monitor):
state, writer = cdrom_monitor
await vm.update_property("cdrom2_image", "")
writer.write.assert_called_with(b"eject -f ide1-cd1\n")
@pytest.mark.asyncio
async def test_second_cdrom_hot_add_requires_restart(vm, images_dir, cdrom_monitor):
iso = Path(images_dir) / "drivers.iso"
iso.touch()
state, writer = cdrom_monitor
state["blocks"] = "ide1-cd0: installer.iso\r\n"
vm._command_line_changed = False
with pytest.raises(QemuError, match="Stop the VM"):
await vm.update_property("cdrom2_image", str(iso))
assert vm.cdrom2_image == ""
assert not vm._command_line_changed
assert writer.write.call_count == 1
@pytest.mark.asyncio
async def test_missing_other_iso_does_not_block_media_swap(vm, images_dir, cdrom_monitor):
iso = Path(images_dir) / "installer.iso"
iso.touch()
vm._cdrom2_image = str(Path(images_dir) / "deleted.iso")
await vm.update_property("cdrom_image", str(iso))
assert vm.cdrom_image == str(iso)
@pytest.mark.asyncio
@pytest.mark.parametrize("error", ["Error: media change failed", "change: filename expected"])
@pytest.mark.parametrize("original_deleted", [False, True])
async def test_cdrom_monitor_error_preserves_previous_image(vm, images_dir, cdrom_monitor, error, original_deleted):
original = Path(images_dir) / "original.iso"
original.touch()
replacement = Path(images_dir) / "replacement.iso"
replacement.touch()
vm.cdrom2_image = str(original)
if original_deleted:
original.unlink()
state, writer = cdrom_monitor
state["error"] = error + "\r\n"
with pytest.raises(QemuError, match=error):
await vm.update_property("cdrom2_image", str(replacement))
assert vm.cdrom2_image == str(original)
writer.close.assert_called_once()

View File

@ -628,3 +628,22 @@ def test_no_appliance_metadata_when_appliance_has_none():
template = ApplianceToTemplate().new_template(appliance, None, "local")
assert "appliance_metadata" not in template
@pytest.mark.parametrize("version", [6, 8])
def test_second_cdrom_survives_appliance_conversion(version):
from copy import deepcopy
from gns3server.schemas.controller.appliances import ApplianceVersionImages
slots = {"cdrom_image": "installer.iso", "cdrom2_image": "drivers.iso"}
validated = ApplianceVersionImages.model_validate(slots).model_dump(exclude_none=True)
assert validated == slots
appliance = deepcopy(VYOS_V8)
appliance["versions"][0]["images"] = validated
if version == 6:
appliance["registry_version"] = 6
appliance["qemu"] = {"arch": "x86_64", "ram": 512, "adapters": 1, "kvm": "allow"}
converted = ApplianceToTemplate().new_template(appliance, appliance["versions"][0], "local")
assert converted["cdrom_image"] == "installer.iso"
assert converted["cdrom2_image"] == "drivers.iso"

View File

@ -413,3 +413,49 @@ async def test_export_with_ignoring_snapshots(tmpdir, project):
with zipfile.ZipFile(str(tmpdir / "zipfile.zip")) as myzip:
assert os.path.join("snapshots", "snap.gns3project") not in [f.filename for f in myzip.filelist]
@pytest.mark.asyncio
async def test_export_two_isos_with_identical_filenames(tmpdir, project):
from gns3server.compute.qemu import Qemu
from gns3server.controller.import_project import _import_images
root = Path(str(tmpdir)) / "QEMU"
installer = root / "windows/install.iso"
drivers = root / "drivers/install.iso"
for image, data in ((installer, b"installer"), (drivers, b"drivers")):
image.parent.mkdir(parents=True)
image.write_bytes(data)
topology = {
"topology": {
"nodes": [
{
"node_type": "qemu",
"properties": {
"cdrom_image": str(installer),
"cdrom2_image": str(drivers),
},
}
]
}
}
Path(project.path, "test.gns3").write_text(json.dumps(topology))
archive = str(tmpdir / "iso-project.zip")
with aiozipstream.ZipFile() as stream:
with patch.object(Qemu, "get_images_directory", return_value=str(root)):
await export_project(stream, project, str(tmpdir), include_images=True)
await write_file(archive, stream)
with zipfile.ZipFile(archive) as stream:
properties = json.loads(stream.read("project.gns3"))["topology"]["nodes"][0]["properties"]
assert properties["cdrom_image"] != properties["cdrom2_image"]
assert stream.read("images/QEMU/" + properties["cdrom_image"]) == b"installer"
assert stream.read("images/QEMU/" + properties["cdrom2_image"]) == b"drivers"
assert len(stream.namelist()) == len(set(stream.namelist()))
destination = Path(str(tmpdir)) / "unpacked"
stream.extractall(destination)
controller = MagicMock()
imported_images = Path(str(tmpdir)) / "imported-images"
controller.images_path.return_value = str(imported_images)
await _import_images(controller, str(destination / "images"))
assert (imported_images / "QEMU" / properties["cdrom_image"]).read_bytes() == b"installer"
assert (imported_images / "QEMU" / properties["cdrom2_image"]).read_bytes() == b"drivers"

View File

@ -187,3 +187,53 @@ async def test_list_images(tmpdir, config):
"path": "qemu_image.qcow2",
}
]
@pytest.mark.asyncio
@pytest.mark.parametrize("subdirectory", [None, "installers/linux"])
async def test_iso_upload_and_discovery(tmp_path, config, subdirectory):
from unittest.mock import AsyncMock
from gns3server.utils.images import discover_images, inspect_image_file, write_image
config.settings.Server.images_path = str(tmp_path)
config.settings.Server.additional_images_paths = []
# ISO 9660 primary volume descriptor; no raw-image fallback is needed.
content = b"\0" * (16 * 2048) + b"\x01CD001\x01" + b"\0" * 2041
async def stream():
# Exercise a descriptor split across upload chunks.
for offset in range(0, len(content), 13):
yield content[offset : offset + 13]
repo = AsyncMock()
repo.get_image_by_checksum.return_value = None
repo.save_verified_image.side_effect = lambda image: image
result = await write_image(
"installer.ISO", str(tmp_path / "installer.ISO"), stream(), repo, subdirectory=subdirectory
)
expected = tmp_path / "QEMU" / (subdirectory or "") / "installer.ISO"
assert result["path"] == str(expected)
assert result["image_type"] == "qemu"
assert expected.read_bytes() == content
assert inspect_image_file(expected)["checksum"] == result["checksum"]
assert [image["path"] for image in await discover_images("qemu")] == [str(expected)]
def test_iso_extension_alone_is_not_a_valid_image():
from gns3server.utils.images import InvalidImageError, check_valid_image_header
with pytest.raises(InvalidImageError):
check_valid_image_header("fake.iso", b"not an ISO image")
@pytest.mark.parametrize("images_root", ["images", "QEMU/images"])
def test_image_reference_uses_configured_root(tmp_path, config, images_root):
from gns3server.db.models.images import Image
root = tmp_path / images_root
config.settings.Server.images_path = str(root)
image = Image(image_type="qemu", path=str(root / "QEMU/installers/linux.iso"))
assert image.reference == "installers/linux.iso"
image.path = str(tmp_path / "extra/QEMU/linux.iso")
assert image.reference == image.path