feat(qemu): support SATA CD-ROM interfaces

- Add per-drive IDE/SATA selection with backward-compatible IDE defaults
- Persist interfaces in nodes, templates, and appliance imports
- Add database migration support for versioned and unversioned databases
- Prevent partial updates when rejecting live interface changes
- Validate interfaces and support SATA media changes through the monitor
This commit is contained in:
Cristi committed 2026-10-18 22:07:37 +03:00
1 parent 84b4c05b9f
commit 58e5591935
13 files changed
+351 -26

No files matched your search

@@ -135,6 +135,10 @@ async def update_qemu_node(node_data: schemas.QemuUpdate, node: QemuVM = Depends
"""
data = jsonable_encoder(node_data, exclude_unset=True)
# Reject interface changes before applying any other property or changing live media.
for name in ("cdrom_interface", "cdrom2_interface"):
if name in data:
node.validate_cdrom_interface(name, data[name])
# update the console first to avoid issue if updating console type
node.console = data.pop("console", node.console)
for name, value in data.items():
+68 -25
View File
@@ -159,6 +159,8 @@ class QemuVM(KernelDatapathMixin, BaseNode):
self._hdd_disk_interface = "none"
self._cdrom_image = ""
self._cdrom2_image = ""
self._cdrom_interface = "ide"
self._cdrom2_interface = "ide"
self._bios_image = ""
self._boot_priority = "c"
self._mac_address = ""
@@ -533,6 +535,31 @@ class QemuVM(KernelDatapathMixin, BaseNode):
def cdrom2_image(self, image):
self._cdrom2_image = self.manager.get_abs_image_path(image, self.working_dir) if image else ""
@property
def cdrom_interface(self):
return self._cdrom_interface
@cdrom_interface.setter
def cdrom_interface(self, interface):
self.validate_cdrom_interface("cdrom_interface", interface)
self._cdrom_interface = interface
@property
def cdrom2_interface(self):
return self._cdrom2_interface
@cdrom2_interface.setter
def cdrom2_interface(self, interface):
self.validate_cdrom_interface("cdrom2_interface", interface)
self._cdrom2_interface = interface
def validate_cdrom_interface(self, name, interface):
"""Validate without mutating the VM, including during API preflight."""
if interface not in ("ide", "sata"):
raise QemuError("CD/DVD interface must be ide or sata")
if interface != getattr(self, name) and self.is_running():
raise QemuError("Stop the VM before changing a CD/DVD interface")
async def update_property(self, name, value):
"""
Update Qemu VM properties.
@@ -556,7 +583,9 @@ class QemuVM(KernelDatapathMixin, BaseNode):
return
image = getattr(self, name)
self._cdrom_option(name)
device = "ide1-cd0" if name == "cdrom_image" else "ide1-cd1"
slot = 0 if name == "cdrom_image" else 1
interface = self.cdrom_interface if slot == 0 else self.cdrom2_interface
device = f"sata-cdrom{slot}" if interface == "sata" else f"ide1-cd{slot}"
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")
@@ -576,7 +605,7 @@ class QemuVM(KernelDatapathMixin, BaseNode):
try:
await asyncio.wait_for(reader.readuntil(b"(qemu) "), timeout=3)
blocks = await command("info block")
if not re.search(rf"(?m)^{device}[: ]", blocks):
if not re.search(rf"(?m)^{re.escape(device)}(?:\s+\([^\r\n]*\))?:", blocks):
raise QemuError("Stop the VM before adding this CD/DVD drive")
if image:
filename = image
@@ -2430,30 +2459,44 @@ class QemuVM(KernelDatapathMixin, BaseNode):
return options
def _cdrom_option(self, name=None):
options = []
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(
f"cdrom image '{self._cdrom_image}' linked to '{os.path.realpath(self._cdrom_image)}' is not accessible"
)
else:
raise QemuError(f"cdrom image '{self._cdrom_image}' is not accessible")
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"]
)
for slot, image_name in enumerate(("cdrom_image", "cdrom2_image")):
image = getattr(self, image_name)
if name not in (None, image_name) or not image:
continue
if not os.path.isfile(image):
if os.path.islink(image):
raise QemuError(f"cdrom image '{image}' linked to '{os.path.realpath(image)}' is not accessible")
raise QemuError(f"cdrom image '{image}' is not accessible")
interface = self.cdrom_interface if slot == 0 else self.cdrom2_interface
if interface == "sata":
# Separate IDs avoid collisions with SATA hard disk controllers.
options.extend(
[
"-device",
f"ich9-ahci,id=cdrom-ahci{slot}",
"-drive",
f"file={image.replace(',', ',,')},if=none,id=sata-cdrom{slot},media=cdrom,readonly=on",
"-device",
f"ide-cd,drive=sata-cdrom{slot},bus=cdrom-ahci{slot}.0",
]
)
elif slot == 0:
if self._hdc_disk_image and self._hdc_disk_interface == "ide":
raise QemuError("You cannot use an IDE hdc disk and a CDROM image at the same time")
options.extend(["-cdrom", image.replace(",", ",,")])
else:
disk_interface = self._hdd_disk_interface
if self._create_config_disk and disk_interface == "none":
disk_interface = self._hda_disk_interface
if (self._hdd_disk_image or self._create_config_disk) and disk_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={image.replace(',', ',,')},if=ide,index=3,media=cdrom,readonly=on",
]
)
return options
def _bios_option(self):
+2
View File
@@ -202,6 +202,8 @@ class QemuTemplate(Template):
hdd_disk_interface = Column(String)
cdrom_image = Column(String)
cdrom2_image = Column(String)
cdrom_interface = Column(String, nullable=False, default="ide", server_default="ide")
cdrom2_interface = Column(String, nullable=False, default="ide", server_default="ide")
initrd = Column(String)
kernel_image = Column(String)
bios_image = Column(String)
+4
View File
@@ -146,10 +146,14 @@ async def connect_to_db(app: FastAPI) -> None:
from gns3server.db_migrations.versions.d9e8a2b7c401_image_inventory_reconciliation import (
upgrade,
)
from gns3server.db_migrations.versions.e2a7c9d4b601_add_cdrom_interfaces import (
upgrade as upgrade_cdrom_interfaces,
)
with Operations.context(MigrationContext.configure(connection)):
upgrade()
upgrade_cdrom()
upgrade_cdrom_interfaces()
await conn.run_sync(upgrade_inventory)
await conn.run_sync(run_stamp, alembic_cfg)
@@ -0,0 +1,22 @@
"""Add QEMU CD/DVD interfaces."""
import sqlalchemy as sa
from alembic import op
revision = "e2a7c9d4b601"
down_revision = "a6c8e1b2d490"
branch_labels = None
depends_on = None
def upgrade():
existing = {column["name"] for column in sa.inspect(op.get_bind()).get_columns("qemu_templates")}
for name in ("cdrom_interface", "cdrom2_interface"):
if name in existing:
continue
op.add_column("qemu_templates", sa.Column(name, sa.String(), nullable=False, server_default="ide"))
def downgrade():
op.drop_column("qemu_templates", "cdrom2_interface")
op.drop_column("qemu_templates", "cdrom_interface")
+6 -1
View File
@@ -15,7 +15,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum
from typing import List, Optional
from typing import List, Literal, Optional
from uuid import UUID
from pydantic import BaseModel, Field
@@ -152,6 +152,9 @@ class QemuDiskInterfaceType(str, Enum):
none = "none"
QemuCdromInterface = Literal["ide", "sata"]
class QemuBase(BaseModel):
"""
Common Qemu node properties.
@@ -184,6 +187,8 @@ class QemuBase(BaseModel):
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 first cdrom image path")
cdrom_interface: QemuCdromInterface = Field("ide", description="First CD/DVD interface")
cdrom2_interface: QemuCdromInterface = Field("ide", description="Second CD/DVD interface")
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")
@@ -23,6 +23,7 @@ from uuid import UUID
from pydantic import AnyUrl, BaseModel, BeforeValidator, Discriminator, EmailStr, Field, Tag
from ..common import ExtraConfig
from ..compute.qemu_nodes import QemuCdromInterface
# ============================================================================
# Shared Enums
@@ -398,6 +399,8 @@ class Qemu(BaseModel):
hdc_disk_interface: Optional[QemuDiskInterface] = Field(
None, title="Disk interface for the installed hdc_disk_image"
)
cdrom_interface: Optional[QemuCdromInterface] = Field(None, title="First CD/DVD interface")
cdrom2_interface: Optional[QemuCdromInterface] = Field(None, title="Second CD/DVD interface")
hdd_disk_interface: Optional[QemuDiskInterface] = Field(
None, title="Disk interface for the installed hdd_disk_image"
)
@@ -579,6 +582,8 @@ class QemuPropertiesV8(BaseModel):
hdc_disk_interface: Optional[QemuDiskInterface] = Field(
None, title="Disk interface for the installed hdc_disk_image"
)
cdrom_interface: Optional[QemuCdromInterface] = Field(None, title="First CD/DVD interface")
cdrom2_interface: Optional[QemuCdromInterface] = Field(None, title="Second CD/DVD interface")
hdd_disk_interface: Optional[QemuDiskInterface] = Field(
None, title="Disk interface for the installed hdd_disk_image"
)
@@ -23,6 +23,7 @@ from gns3server.schemas.compute.qemu_nodes import (
CustomAdapter,
QemuAdapterType,
QemuBootPriority,
QemuCdromInterface,
QemuConsoleType,
QemuDiskInterfaceType,
QemuOnCloseAction,
@@ -80,6 +81,8 @@ class QemuTemplate(TemplateBase):
QemuDiskInterfaceType.none, description="QEMU hdd interface"
)
cdrom_image: Optional[str] = Field("", description="QEMU first cdrom image path")
cdrom_interface: QemuCdromInterface = Field("ide", description="First CD/DVD interface")
cdrom2_interface: QemuCdromInterface = Field("ide", description="Second CD/DVD interface")
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")