Skip to content

Commit ec28def

Browse files
authored
NAS-134791 / 25.10 / Add CDROM device type (#16483)
## Problem Currently users need to repack windows iso because it is not supported out of the box by incus as there are missing drivers. ## Solution While this gets fixed upstream, we have decided to use `raw.qemu` and specify relevant CDROM directly so it works as expected with windows. However while doing this, we see boot priority does not take effect for CDROM entry as incus first initiates qemu process with qemu cmd where this CDROM entry is specified and then using QMP, it dynamically handles devices for the virt instance which overrides boot entry. So to get that to work, we still need to specify the CDROM as a disk device in incus and everything else falls into place nicely. It has been confirmed that this works as expected with windows and that it boots nicely.
1 parent 46a2e5a commit ec28def

File tree

5 files changed

+416
-52
lines changed

5 files changed

+416
-52
lines changed

src/middlewared/middlewared/api/v25_10_0/virt_device.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,14 @@ class PCI(Device):
126126
address: NonEmptyString
127127

128128

129+
class CDROM(Device):
130+
dev_type: Literal['CDROM']
131+
source: NonEmptyString
132+
boot_priority: int | None = Field(default=None, ge=0)
133+
134+
129135
DeviceType: TypeAlias = Annotated[
130-
Disk | GPU | Proxy | TPM | USB | NIC | PCI,
136+
Disk | GPU | Proxy | TPM | USB | NIC | PCI | CDROM,
131137
Field(discriminator='dev_type')
132138
]
133139

src/middlewared/middlewared/plugins/virt/instance.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,8 +25,9 @@
2525

2626
from .utils import (
2727
create_vnc_password_file, get_max_boot_priority_device, get_root_device_dict, get_vnc_info_from_config,
28-
get_vnc_password_file_path, incus_call, incus_call_and_wait, incus_pool_to_storage_pool,
29-
root_device_pool_from_raw, VirtGlobalStatus, storage_pool_to_incus_pool, VNC_BASE_PORT,
28+
VirtGlobalStatus, incus_call, incus_call_and_wait, incus_pool_to_storage_pool, root_device_pool_from_raw,
29+
storage_pool_to_incus_pool, validate_device_name, generate_qemu_cmd, generate_qemu_cdrom_metadata,
30+
INCUS_METADATA_CDROM_KEY,
3031
)
3132

3233

@@ -255,7 +256,9 @@ async def validate(self, new, schema_name, verrors, old=None):
255256
if any(new['vnc_port'] in v for v in port_mapping.values()):
256257
verrors.add(f'{schema_name}.vnc_port', 'VNC port is already in use by another virt instance')
257258

258-
def __data_to_config(self, instance_name: str, data: dict, raw: dict = None, instance_type=None):
259+
def __data_to_config(
260+
self, instance_name: str, data: dict, devices: dict, raw: dict = None, instance_type=None
261+
):
259262
config = {}
260263
if 'environment' in data:
261264
# If we are updating environment we need to remove current values
@@ -291,17 +294,10 @@ def __data_to_config(self, instance_name: str, data: dict, raw: dict = None, ins
291294
'vnc_port': data['vnc_port'],
292295
'vnc_password': data['vnc_password'],
293296
}),
297+
INCUS_METADATA_CDROM_KEY: generate_qemu_cdrom_metadata(devices),
294298
})
295299

296-
if data.get('enable_vnc') and data.get('vnc_port'):
297-
vnc_config = f'-vnc :{data["vnc_port"] - VNC_BASE_PORT}'
298-
if data.get('vnc_password'):
299-
vnc_config = (f'-object secret,id=vnc0,file={get_vnc_password_file_path(instance_name)} '
300-
f'{vnc_config},password-secret=vnc0')
301-
302-
config['raw.qemu'] = vnc_config
303-
if data.get('enable_vnc') is False:
304-
config['raw.qemu'] = ''
300+
config['raw.qemu'] = generate_qemu_cmd(config, instance_name)
305301
else:
306302
config.update({
307303
'security.privileged': 'true' if data.get('privileged_mode') else 'false',
@@ -424,6 +420,7 @@ async def do_create(self, job, data):
424420
}
425421
}
426422
for i in data_devices:
423+
validate_device_name(i, verrors)
427424
await self.middleware.call(
428425
'virt.instance.validate_device', i, 'virt_instance_create', verrors, data['name'], data['instance_type']
429426
)
@@ -469,7 +466,7 @@ async def running_cb(data):
469466
await incus_call_and_wait('1.0/instances', 'post', {'json': {
470467
'name': data['name'],
471468
'ephemeral': False,
472-
'config': self.__data_to_config(data['name'], data, instance_type=data['instance_type']),
469+
'config': self.__data_to_config(data['name'], data, devices, instance_type=data['instance_type']),
473470
'devices': devices,
474471
'source': source,
475472
'profiles': ['default'],
@@ -527,7 +524,9 @@ async def do_update(self, job, oid, data):
527524

528525
verrors.check()
529526

530-
instance['raw']['config'].update(self.__data_to_config(oid, data, instance['raw']['config'], instance['type']))
527+
instance['raw']['config'].update(
528+
self.__data_to_config(oid, data, instance['raw']['devices'], instance['raw']['config'], instance['type'])
529+
)
531530
if data.get('root_disk_size') or data.get('root_disk_io_bus'):
532531
if (pool := root_device_pool_from_raw(instance['raw'])) is None:
533532
raise CallError(f'{oid}: instance does not have a configured pool')

src/middlewared/middlewared/plugins/virt/instance_device.py

Lines changed: 94 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515
VirtInstanceBootableDiskArgs, VirtInstanceBootableDiskResult,
1616
)
1717
from middlewared.async_validators import check_path_resides_within_volume
18-
from .utils import get_max_boot_priority_device, incus_call_and_wait, incus_pool_to_storage_pool, storage_pool_to_incus_pool
18+
from .utils import (
19+
get_max_boot_priority_device, incus_call_and_wait, incus_pool_to_storage_pool, storage_pool_to_incus_pool,
20+
validate_device_name, CDROM_PREFIX, update_instance_metadata_and_qemu_cmd_on_device_change,
21+
)
1922

2023

2124
class VirtInstanceDeviceService(Service):
@@ -62,15 +65,23 @@ async def incus_to_device(self, name: str, incus: dict[str, Any], context: dict)
6265

6366
match incus['type']:
6467
case 'disk':
65-
device.update({
66-
'dev_type': 'DISK',
67-
'source': incus.get('source'),
68-
'storage_pool': incus_pool_to_storage_pool(incus.get('pool')),
69-
'destination': incus.get('path'),
70-
'description': f'{incus.get("source")} -> {incus.get("path")}',
71-
'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None,
72-
'io_bus': incus['io.bus'].upper() if incus.get('io.bus') else None,
73-
})
68+
if name.startswith(CDROM_PREFIX):
69+
device.update({
70+
'dev_type': 'CDROM',
71+
'source': incus.get('source'),
72+
'description': f'{incus.get("source")!r} CDROM device source',
73+
'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None,
74+
})
75+
else:
76+
device.update({
77+
'dev_type': 'DISK',
78+
'source': incus.get('source'),
79+
'storage_pool': incus_pool_to_storage_pool(incus.get('pool')),
80+
'destination': incus.get('path'),
81+
'description': f'{incus.get("source")} -> {incus.get("path")}',
82+
'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None,
83+
'io_bus': incus['io.bus'].upper() if incus.get('io.bus') else None,
84+
})
7485
case 'nic':
7586
device.update({
7687
'dev_type': 'NIC',
@@ -195,7 +206,14 @@ async def device_to_incus(self, instance_type: str, device: dict[str, Any]) -> d
195206
new['pool'] = None
196207
if device.get('io_bus'):
197208
new['io.bus'] = device['io_bus'].lower()
198-
209+
case 'CDROM':
210+
new |= {
211+
'type': 'disk',
212+
'source': device['source'],
213+
'path': None,
214+
}
215+
if device['boot_priority'] is not None:
216+
new['boot.priority'] = str(device['boot_priority'])
199217
case 'NIC':
200218
new.update({
201219
'type': 'nic',
@@ -266,6 +284,9 @@ async def generate_device_name(self, device_names: list[str], device_type: str)
266284
name = device_type.lower()
267285
if name == 'nic':
268286
name = 'eth'
287+
elif name == 'cdrom':
288+
name = CDROM_PREFIX
289+
269290
i = 0
270291
while True:
271292
new_name = f'{name}{i}'
@@ -323,6 +344,16 @@ async def validate_device(
323344
)
324345
verrors.extend(verror)
325346
break
347+
case 'CDROM':
348+
source = device['source']
349+
if os.path.isabs(source) is False:
350+
verrors.add(schema, 'Source must be an absolute path')
351+
if await self.middleware.run_in_thread(os.path.exists, source) is False:
352+
verrors.add(schema, 'Specified source path does not exist')
353+
elif await self.middleware.run_in_thread(os.path.isfile, source) is False:
354+
verrors.add(schema, 'Specified source path is not a file')
355+
if instance_type == 'CONTAINER':
356+
verrors.add(schema, 'Container instance type is not supported')
326357
case 'DISK':
327358
source = device['source'] or ''
328359
if source == '' and device['name'] != 'root':
@@ -464,71 +495,93 @@ async def get_all_disk_sources_of_instance(self, instance_name):
464495
VirtInstanceDeviceAddArgs,
465496
VirtInstanceDeviceAddResult,
466497
audit='Virt: Adding device',
467-
audit_extended=lambda id, device: f'{device["dev_type"]!r} to {id!r} instance',
498+
audit_extended=lambda i, device: f'{device["dev_type"]!r} to {i!r} instance',
468499
roles=['VIRT_INSTANCE_WRITE']
469500
)
470-
async def device_add(self, id, device):
501+
async def device_add(self, oid, device):
471502
"""
472503
Add a device to an instance.
473504
"""
474-
instance = await self.middleware.call('virt.instance.get_instance', id, {'extra': {'raw': True}})
505+
instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}})
475506
data = instance['raw']
507+
verrors = ValidationErrors()
508+
validate_device_name(device, verrors)
476509
if device['name'] is None:
477510
device['name'] = await self.generate_device_name(data['devices'].keys(), device['dev_type'])
478511

479-
verrors = ValidationErrors()
480-
await self.validate_device(device, 'virt_device_add', verrors, id, instance['type'], instance_config=instance)
512+
await self.validate_device(device, 'virt_device_add', verrors, oid, instance['type'], instance_config=instance)
481513
verrors.check()
482514

483515
data['devices'][device['name']] = await self.device_to_incus(instance['type'], device)
484-
await incus_call_and_wait(f'1.0/instances/{id}', 'put', {'json': data})
516+
if device['dev_type'] == 'CDROM':
517+
# We want to update qemu config here and make sure we keep track of which
518+
# devices we have added as cdroms here
519+
data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change(
520+
oid, data['config'], data['devices']
521+
))
522+
523+
await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data})
485524
return True
486525

487526
@api_method(
488527
VirtInstanceDeviceUpdateArgs,
489528
VirtInstanceDeviceUpdateResult,
490529
audit='Virt: Updating device',
491-
audit_extended=lambda id, device: f'{device["name"]!r} of {id!r} instance',
530+
audit_extended=lambda i, device: f'{device["name"]!r} of {i!r} instance',
492531
roles=['VIRT_INSTANCE_WRITE']
493532
)
494-
async def device_update(self, id, device):
533+
async def device_update(self, oid, device):
495534
"""
496535
Update a device in an instance.
497536
"""
498-
instance = await self.middleware.call('virt.instance.get_instance', id, {'extra': {'raw': True}})
537+
instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}})
499538
data = instance['raw']
500539

501-
for old in await self.device_list(id):
540+
for old in await self.device_list(oid):
502541
if old['name'] == device['name']:
503542
break
504543
else:
505544
raise CallError('Device does not exist.', errno.ENOENT)
506545

507546
verrors = ValidationErrors()
508-
await self.validate_device(device, 'virt_device_update', verrors, id, instance['type'], old, instance)
547+
await self.validate_device(device, 'virt_device_update', verrors, oid, instance['type'], old, instance)
509548
verrors.check()
510549

511550
data['devices'][device['name']] = await self.device_to_incus(instance['type'], device)
512-
await incus_call_and_wait(f'1.0/instances/{id}', 'put', {'json': data})
551+
if device['dev_type'] == 'CDROM':
552+
# We want to update qemu config here and make sure we keep track of which
553+
# devices we have added as cdroms here
554+
data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change(
555+
oid, data['config'], data['devices']
556+
))
557+
558+
await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data})
513559
return True
514560

515561
@api_method(
516562
VirtInstanceDeviceDeleteArgs,
517563
VirtInstanceDeviceDeleteResult,
518564
audit='Virt: Deleting device',
519-
audit_extended=lambda id, device: f'{device!r} from {id!r} instance',
565+
audit_extended=lambda i, device: f'{device!r} from {i!r} instance',
520566
roles=['VIRT_INSTANCE_DELETE']
521567
)
522-
async def device_delete(self, id, device):
568+
async def device_delete(self, oid, device):
523569
"""
524570
Delete a device from an instance.
525571
"""
526-
instance = await self.middleware.call('virt.instance.get_instance', id, {'extra': {'raw': True}})
572+
instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}})
527573
data = instance['raw']
528574
if device not in data['devices']:
529575
raise CallError('Device not found.', errno.ENOENT)
530576
data['devices'].pop(device)
531-
await incus_call_and_wait(f'1.0/instances/{id}', 'put', {'json': data})
577+
if device.startswith(CDROM_PREFIX):
578+
# We want to update qemu config here and make sure we keep track of which
579+
# devices we have added as cdroms here
580+
data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change(
581+
oid, data['config'], data['devices']
582+
))
583+
584+
await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data})
532585
return True
533586

534587
@api_method(
@@ -559,16 +612,25 @@ async def set_bootable_disk(self, id, disk):
559612
if desired_disk is None:
560613
raise CallError(f'{disk!r} device does not exist.', errno.ENOENT)
561614

562-
if desired_disk['dev_type'] != 'DISK':
615+
if desired_disk['dev_type'] not in ('CDROM', 'DISK'):
563616
raise CallError(f'{disk!r} device type is not DISK.')
564617

565618
if max_boot_priority_device and max_boot_priority_device['name'] == disk:
566619
return True
567620

568-
return await self.device_update(id, {
569-
'dev_type': 'DISK',
621+
data = {
570622
'name': disk,
571623
'source': desired_disk.get('source'),
572-
'io_bus': desired_disk.get('io_bus'),
573624
'boot_priority': max_boot_priority_device['boot_priority'] + 1 if max_boot_priority_device else 1,
574-
} | ({'destination': desired_disk['destination']} if disk != 'root' else {}))
625+
}
626+
if desired_disk['dev_type'] == 'CDROM':
627+
data |= {
628+
'dev_type': 'CDROM',
629+
}
630+
else:
631+
data |= {
632+
'dev_type': 'DISK',
633+
'io_bus': desired_disk.get('io_bus'),
634+
} | ({'destination': desired_disk['destination']} if disk != 'root' else {})
635+
636+
return await self.device_update(id, data)

src/middlewared/middlewared/plugins/virt/utils.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,16 @@
99
from collections.abc import Callable
1010

1111
from middlewared.plugins.zfs_.utils import TNUserProp
12-
from middlewared.service import CallError
12+
from middlewared.service import CallError, ValidationErrors
1313
from middlewared.utils import MIDDLEWARE_RUN_DIR
1414

1515
from .websocket import IncusWS
1616

1717

18-
SOCKET = '/var/lib/incus/unix.socket'
18+
CDROM_PREFIX = 'ix_cdrom'
1919
HTTP_URI = 'http://unix.socket'
20+
INCUS_METADATA_CDROM_KEY = 'user.ix_cdrom_devices'
21+
SOCKET = '/var/lib/incus/unix.socket'
2022
VNC_BASE_PORT = 5900
2123
VNC_PASSWORD_DIR = os.path.join(MIDDLEWARE_RUN_DIR, 'incus/passwords')
2224
TRUENAS_STORAGE_PROP_STR = TNUserProp.INCUS_POOL.value
@@ -226,3 +228,44 @@ class PciEntry:
226228
reset_mechanism_defined: bool
227229
description: str
228230
error: str | None
231+
232+
233+
def generate_qemu_cmd(instance_config: dict, instance_name: str) -> str:
234+
vnc_config = json.loads(instance_config.get('user.ix_vnc_config', '{}'))
235+
cdrom_config = json.loads(instance_config.get(INCUS_METADATA_CDROM_KEY, '[]'))
236+
cmd = ''
237+
if vnc_config['vnc_enabled'] and vnc_config['vnc_port']:
238+
cmd = f'-vnc :{vnc_config["vnc_port"] - VNC_BASE_PORT}'
239+
if vnc_config.get('vnc_password'):
240+
cmd = (f'-object secret,id=vnc0,file={get_vnc_password_file_path(instance_name)} '
241+
f'{cmd},password-secret=vnc0')
242+
243+
for cdrom_file in cdrom_config:
244+
cmd += f'{" " if cmd else ""}-drive media=cdrom,if=ide,file={cdrom_file},file.locking=off'
245+
246+
return cmd
247+
248+
249+
def generate_qemu_cdrom_metadata(devices: dict) -> str:
250+
return json.dumps([
251+
d['source'] for name, d in devices.items() if name.startswith(CDROM_PREFIX)
252+
])
253+
254+
255+
def validate_device_name(device: dict, verrors: ValidationErrors):
256+
if device['dev_type'] == 'CDROM':
257+
if device['name'] and device['name'].startswith(CDROM_PREFIX) is False:
258+
verrors.add('virt_device_add.name', f'CDROM device name must start with {CDROM_PREFIX!r} prefix')
259+
elif device['name'] and device['name'].startswith(CDROM_PREFIX):
260+
verrors.add('virt_device_add.name', f'Device name must not start with {CDROM_PREFIX!r} prefix')
261+
262+
263+
def update_instance_metadata_and_qemu_cmd_on_device_change(
264+
instance_name: str, instance_config: dict, devices: dict
265+
) -> dict:
266+
data = {
267+
INCUS_METADATA_CDROM_KEY: generate_qemu_cdrom_metadata(devices)
268+
}
269+
data['raw.qemu'] = generate_qemu_cmd(instance_config | data, instance_name)
270+
data['user.ix_old_raw_qemu_config'] = instance_config.get('raw.qemu', '')
271+
return data

0 commit comments

Comments
 (0)