diff --git a/src/middlewared/middlewared/api/v25_04_0/__init__.py b/src/middlewared/middlewared/api/v25_04_0/__init__.py index 915435f7f61c7..5b9fca4d24fb7 100644 --- a/src/middlewared/middlewared/api/v25_04_0/__init__.py +++ b/src/middlewared/middlewared/api/v25_04_0/__init__.py @@ -51,5 +51,6 @@ from .virt_device import * # noqa from .virt_global import * # noqa from .virt_instance import * # noqa +from .virt_volume import * # noqa from .vm import * # noqa from .vm_device import * # noqa diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_device.py b/src/middlewared/middlewared/api/v25_04_0/virt_device.py index 4683b82dfbb94..1a1472308c9b8 100644 --- a/src/middlewared/middlewared/api/v25_04_0/virt_device.py +++ b/src/middlewared/middlewared/api/v25_04_0/virt_device.py @@ -1,6 +1,6 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import Field +from pydantic import Field, field_validator from middlewared.api.base import BaseModel, LocalGID, LocalUID, NonEmptyString @@ -23,8 +23,28 @@ class Device(BaseModel): class Disk(Device): dev_type: Literal['DISK'] - source: str | None = None + source: NonEmptyString | None = None + ''' + For CONTAINER instances, this would be a valid pool path. For VM instances, it + can be a valid zvol path or an incus storage volume name + ''' destination: str | None = None + boot_priority: int | None = Field(default=None, ge=0) + + @field_validator('source') + @classmethod + def validate_source(cls, source): + if source is None or '/' not in source: + return source + + # Source must be an absolute path now + if not source.startswith(('/dev/zvol/', '/mnt/')): + raise ValueError('Only pool paths are allowed') + + if source.startswith('/mnt/.ix-apps'): + raise ValueError('Invalid source') + + return source NicType: TypeAlias = Literal['BRIDGED', 'MACVLAN'] @@ -94,8 +114,8 @@ class USBChoice(BaseModel): product_id: str bus: int dev: int - product: str - manufacturer: str + product: str | None + manufacturer: str | None class VirtDeviceUSBChoicesResult(BaseModel): @@ -103,7 +123,6 @@ class VirtDeviceUSBChoicesResult(BaseModel): class VirtDeviceGPUChoicesArgs(BaseModel): - instance_type: InstanceType gpu_type: GPUType diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_instance.py b/src/middlewared/middlewared/api/v25_04_0/virt_instance.py index 0d374d2fa7d64..02840ac1b886a 100644 --- a/src/middlewared/middlewared/api/v25_04_0/virt_instance.py +++ b/src/middlewared/middlewared/api/v25_04_0/virt_instance.py @@ -1,6 +1,6 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import Field, StringConstraints +from pydantic import Field, model_validator, StringConstraints from middlewared.api.base import BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args @@ -15,7 +15,6 @@ 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceListArgs', 'VirtInstanceDeviceListResult', 'VirtInstanceDeviceAddArgs', 'VirtInstanceDeviceAddResult', 'VirtInstanceDeviceUpdateArgs', 'VirtInstanceDeviceUpdateResult', 'VirtInstanceDeviceDeleteArgs', 'VirtInstanceDeviceDeleteResult', - ] @@ -49,7 +48,9 @@ class VirtInstanceEntry(BaseModel): environment: dict[str, str] aliases: list[VirtInstanceAlias] image: Image - raw: dict + raw: dict | None + vnc_enabled: bool + vnc_port: int | None # Lets require at least 32MiB of reserved memory @@ -62,7 +63,8 @@ class VirtInstanceEntry(BaseModel): @single_argument_args('virt_instance_create') class VirtInstanceCreateArgs(BaseModel): name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] + source_type: Literal[None, 'IMAGE'] = 'IMAGE' + image: Annotated[NonEmptyString, StringConstraints(max_length=200)] | None = None remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' instance_type: InstanceType = 'CONTAINER' environment: dict[str, str] | None = None @@ -70,6 +72,24 @@ class VirtInstanceCreateArgs(BaseModel): cpu: str | None = None devices: list[DeviceType] | None = None memory: MemoryType | None = None + enable_vnc: bool = False + vnc_port: int | None = Field(ge=5900, le=65535, default=None) + + @model_validator(mode='after') + def validate_attrs(self): + if self.instance_type == 'CONTAINER': + if self.source_type != 'IMAGE': + raise ValueError('Source type must be set to "IMAGE" when instance type is CONTAINER') + if self.enable_vnc: + raise ValueError('VNC is not supported for containers and `enable_vnc` should be unset') + else: + if self.enable_vnc and self.vnc_port is None: + raise ValueError('VNC port must be set when VNC is enabled') + + if self.source_type == 'IMAGE' and self.image is None: + raise ValueError('Image must be set when source type is "IMAGE"') + + return self class VirtInstanceCreateResult(BaseModel): @@ -81,6 +101,7 @@ class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): autostart: bool | None = None cpu: str | None = None memory: MemoryType | None = None + vnc_port: int | None = Field(ge=5900, le=65535) class VirtInstanceUpdateArgs(BaseModel): @@ -115,7 +136,7 @@ class StopArgs(BaseModel): class VirtInstanceStopArgs(BaseModel): id: str - stop_args: StopArgs + stop_args: StopArgs = StopArgs() class VirtInstanceStopResult(BaseModel): @@ -124,18 +145,21 @@ class VirtInstanceStopResult(BaseModel): class VirtInstanceRestartArgs(BaseModel): id: str - stop_args: StopArgs + stop_args: StopArgs = StopArgs() class VirtInstanceRestartResult(BaseModel): result: bool -@single_argument_args('virt_instances_image_choices') -class VirtInstanceImageChoicesArgs(BaseModel): +class VirtInstanceImageChoices(BaseModel): remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' +class VirtInstanceImageChoicesArgs(BaseModel): + virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() + + class ImageChoiceItem(BaseModel): label: str os: str diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_volume.py b/src/middlewared/middlewared/api/v25_04_0/virt_volume.py new file mode 100644 index 0000000000000..890ab4043910a --- /dev/null +++ b/src/middlewared/middlewared/api/v25_04_0/virt_volume.py @@ -0,0 +1,76 @@ +import os +from typing import Literal + +from pydantic import Field, field_validator + +from middlewared.api.base import ( + BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, +) + +__all__ = [ + 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', + 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', + 'VirtVolumeDeleteResult', 'VirtVolumeImportISOArgs', 'VirtVolumeImportISOResult', +] + + +class VirtVolumeEntry(BaseModel): + id: NonEmptyString + name: NonEmptyString + content_type: NonEmptyString + created_at: str + type: NonEmptyString + config: dict + used_by: list[NonEmptyString] + + +@single_argument_args('virt_volume_create') +class VirtVolumeCreateArgs(BaseModel): + name: NonEmptyString + content_type: Literal['BLOCK'] = 'BLOCK' + size: int = Field(ge=512, default=1024) # 1 gb default + '''Size of volume in MB and it should at least be 512 MB''' + + +class VirtVolumeCreateResult(BaseModel): + result: VirtVolumeEntry + + +class VirtVolumeUpdate(BaseModel, metaclass=ForUpdateMetaclass): + size: int = Field(ge=512) + + +class VirtVolumeUpdateArgs(BaseModel): + id: NonEmptyString + virt_volume_update: VirtVolumeUpdate + + +class VirtVolumeUpdateResult(BaseModel): + result: VirtVolumeEntry + + +class VirtVolumeDeleteArgs(BaseModel): + id: NonEmptyString + + +class VirtVolumeDeleteResult(BaseModel): + result: Literal[True] + + +@single_argument_args('virt_volume_import_iso') +class VirtVolumeImportISOArgs(BaseModel): + name: NonEmptyString + '''Specify name of the newly created volume from the ISO specified''' + iso_location: NonEmptyString | None = None + upload_iso: bool = False + + @field_validator('iso_location') + @classmethod + def validate_iso_location(cls, v): + if v and not os.path.exists(v): + raise ValueError('Specified ISO location does not exist') + return v + + +class VirtVolumeImportISOResult(BaseModel): + result: VirtVolumeEntry diff --git a/src/middlewared/middlewared/logger.py b/src/middlewared/middlewared/logger.py index f7083669245a2..5f00cf1b39edb 100644 --- a/src/middlewared/middlewared/logger.py +++ b/src/middlewared/middlewared/logger.py @@ -38,6 +38,8 @@ # Prevent debug docker logs logging.getLogger('docker.utils.config').setLevel(logging.ERROR) logging.getLogger('docker.auth').setLevel(logging.ERROR) +# Prevent httpx debug spam +logging.getLogger('httpx._client').setLevel(logging.ERROR) # /usr/lib/python3/dist-packages/pydantic/json_schema.py:2158: PydanticJsonSchemaWarning: # Default value is not JSON serializable; excluding default from JSON schema diff --git a/src/middlewared/middlewared/plugins/virt/attachments.py b/src/middlewared/middlewared/plugins/virt/attachments.py index 99446a6eac85f..318eac48636a6 100644 --- a/src/middlewared/middlewared/plugins/virt/attachments.py +++ b/src/middlewared/middlewared/plugins/virt/attachments.py @@ -1,4 +1,6 @@ +from itertools import product from typing import TYPE_CHECKING + from middlewared.common.attachment import FSAttachmentDelegate from middlewared.common.ports import PortDelegate @@ -58,24 +60,18 @@ async def start(self, attachments): class VirtPortDelegate(PortDelegate): - name = 'virt devices' - namespace = 'virt.device' + name = 'virt instances' + namespace = 'virt' title = 'Virtualization Device' async def get_ports(self): ports = [] - for instance in await self.middleware.call('virt.instance.query'): - instance_ports = [] - for device in await self.middleware.call('virt.instance.device_list', instance['id']): - if device['dev_type'] != 'PROXY': - continue - instance_ports.append(('0.0.0.0', device['source_port'])) - instance_ports.append(('::', device['source_port'])) - if instance_ports: + for instance_id, instance_ports in (await self.middleware.call('virt.instance.get_ports_mapping')).items(): + if instance_ports := list(product(['0.0.0.0', '::'], instance_ports)): ports.append({ - 'description': f'{instance["id"]!r} instance', + 'description': f'{instance_id!r} instance', 'ports': instance_ports, - 'instance': instance['id'], + 'instance': instance_id, }) return ports diff --git a/src/middlewared/middlewared/plugins/virt/device.py b/src/middlewared/middlewared/plugins/virt/device.py index 36129ef60ef75..e0c1be0ed87a7 100644 --- a/src/middlewared/middlewared/plugins/virt/device.py +++ b/src/middlewared/middlewared/plugins/virt/device.py @@ -36,7 +36,7 @@ def usb_choices(self): return choices @api_method(VirtDeviceGPUChoicesArgs, VirtDeviceGPUChoicesResult, roles=['VIRT_INSTANCE_READ']) - async def gpu_choices(self, instance_type, gpu_type): + async def gpu_choices(self, gpu_type): """ Provide choices for GPU devices. """ @@ -45,9 +45,6 @@ async def gpu_choices(self, instance_type, gpu_type): if gpu_type != 'PHYSICAL': raise CallError('Only PHYSICAL type is supported for now.') - if instance_type != 'CONTAINER': - raise CallError('Only CONTAINER supported for now.') - for i in await self.middleware.call('device.get_gpus'): if not i['available_to_host'] or i['uses_system_critical_devices']: continue diff --git a/src/middlewared/middlewared/plugins/virt/instance.py b/src/middlewared/middlewared/plugins/virt/instance.py index 590acd26f7245..fc7cf81f8531c 100644 --- a/src/middlewared/middlewared/plugins/virt/instance.py +++ b/src/middlewared/middlewared/plugins/virt/instance.py @@ -1,8 +1,10 @@ -import aiohttp +import collections import json import os import platform +import aiohttp + from middlewared.service import ( CallError, CRUDService, ValidationErrors, filterable, job, private ) @@ -19,7 +21,7 @@ VirtInstanceRestartArgs, VirtInstanceRestartResult, VirtInstanceImageChoicesArgs, VirtInstanceImageChoicesResult, ) -from .utils import Status, incus_call, incus_call_and_wait +from .utils import get_vnc_info_from_config, Status, incus_call, incus_call_and_wait, VNC_BASE_PORT LC_IMAGES_SERVER = 'https://images.linuxcontainers.org' @@ -73,7 +75,9 @@ async def query(self, filters, options): 'serial': i['config'].get('image.serial'), 'type': i['config'].get('image.type'), 'variant': i['config'].get('image.variant'), - } + }, + **get_vnc_info_from_config(i['config']), + 'raw': None, # Default required by pydantic } if options['extra'].get('raw'): @@ -122,7 +126,39 @@ async def validate(self, new, schema_name, verrors, old=None): if int(new['cpu']) > cpuinfo['core_count']: verrors.add(f'{schema_name}.cpu', 'Cannot reserve more than system cores') - def __data_to_config(self, data: dict, raw: dict = None): + if old: + if new.get('vnc_port'): + # If in update case, user specifies a vnc port, we automatically assume he wants to enable vnc + # this makes it easier to change existing vnc port and set it as well + new['enable_vnc'] = True + elif 'vnc_port' in new and new['vnc_port'] is None: + # This is the case to handle when we want to disable VNC + new['enable_vnc'] = False + elif 'vnc_port' not in new and old['vnc_enabled'] and old['vnc_port']: + # We want to handle the case where nothing has been changed on vnc attrs + new.update({ + 'enable_vnc': True, + 'vnc_port': old['vnc_port'], + }) + + if ( + new.get('instance_type') == 'VM' or (old and old['type'] == 'VM') + ) and new.get('enable_vnc'): + if not new.get('vnc_port'): + verrors.add(f'{schema_name}.vnc_port', 'VNC port is required when VNC is enabled') + else: + port_verrors = await self.middleware.call( + 'port.validate_port', + f'{schema_name}.vnc_port', + new['vnc_port'], '0.0.0.0', 'virt', + ) + verrors.extend(port_verrors) + if not port_verrors: + port_mapping = await self.get_ports_mapping([['id', '!=', old['id']]] if old else []) + if any(new['vnc_port'] in v for v in port_mapping.values()): + verrors.add(f'{schema_name}.vnc_port', 'VNC port is already in use by another virt instance') + + def __data_to_config(self, data: dict, raw: dict = None, instance_type=None): config = {} if 'environment' in data: # If we are updating environment we need to remove current values @@ -144,12 +180,21 @@ def __data_to_config(self, data: dict, raw: dict = None): if data.get('autostart') is not None: config['boot.autostart'] = str(data['autostart']).lower() + + if instance_type == 'VM': + if data.get('enable_vnc') and data.get('vnc_port'): + config['user.ix_old_raw_qemu_config'] = raw.get('raw.qemu', '') if raw else '' + config['raw.qemu'] = f'-vnc :{data["vnc_port"] - VNC_BASE_PORT}' + if data.get('enable_vnc') is False: + config['user.ix_old_raw_qemu_config'] = raw['raw.qemu'] if raw else '' + config['raw.qemu'] = '' + return config @api_method(VirtInstanceImageChoicesArgs, VirtInstanceImageChoicesResult, roles=['VIRT_INSTANCE_READ']) async def image_choices(self, data): """ - Provice choices for instance image from a remote repository. + Provide choices for instance image from a remote repository. """ choices = {} if data['remote'] == 'LINUX_CONTAINERS': @@ -188,16 +233,17 @@ async def image_choices(self, data): @job() async def do_create(self, job, data): """ - Create a new virtualizated instance. + Create a new virtualized instance. """ - await self.middleware.call('virt.global.check_initialized') verrors = ValidationErrors() await self.validate(data, 'virt_instance_create', verrors) devices = {} for i in (data['devices'] or []): - await self.middleware.call('virt.instance.validate_device', i, 'virt_instance_create', verrors) + await self.middleware.call( + 'virt.instance.validate_device', i, 'virt_instance_create', verrors, data['instance_type'], + ) if i['name'] is None: i['name'] = await self.middleware.call('virt.instance.generate_device_name', devices.keys(), i['dev_type']) devices[i['name']] = await self.middleware.call('virt.instance.device_to_incus', data['instance_type'], i) @@ -218,7 +264,7 @@ async def running_cb(data): url = LC_IMAGES_SERVER source = { - 'type': 'image', + 'type': (data['source_type'] or 'none').lower(), } result = await incus_call(f'1.0/images/{data["image"]}', 'get') @@ -235,7 +281,7 @@ async def running_cb(data): await incus_call_and_wait('1.0/instances', 'post', {'json': { 'name': data['name'], 'ephemeral': False, - 'config': self.__data_to_config(data), + 'config': self.__data_to_config(data, instance_type=data['instance_type']), 'devices': devices, 'source': source, 'type': 'container' if data['instance_type'] == 'CONTAINER' else 'virtual-machine', @@ -255,9 +301,12 @@ async def do_update(self, job, id, data): verrors = ValidationErrors() await self.validate(data, 'virt_instance_update', verrors, old=instance) + if instance['type'] == 'CONTAINER' and data.get('enable_vnc'): + verrors.add('virt_instance_update.vnc_port', 'VNC is not supported for containers') + verrors.check() - instance['raw']['config'].update(self.__data_to_config(data, instance['raw']['config'])) + instance['raw']['config'].update(self.__data_to_config(data, instance['raw']['config'], instance['type'])) await incus_call_and_wait(f'1.0/instances/{id}', 'put', {'json': instance['raw']}) return await self.middleware.call('virt.instance.get_instance', id) @@ -367,10 +416,26 @@ def get_shell(self, id): if instance['status'] != 'RUNNING': raise CallError('Container must be running.') config = self.middleware.call_sync('virt.global.config') - mount_info = self.middleware.call_sync('filesystem.mount_info', [['mount_source', '=', f'{config["dataset"]}/containers/{id}']]) + mount_info = self.middleware.call_sync( + 'filesystem.mount_info', [['mount_source', '=', f'{config["dataset"]}/containers/{id}']] + ) if not mount_info: return None rootfs = f'{mount_info[0]["mountpoint"]}/rootfs' for i in ('/bin/bash', '/bin/zsh', '/bin/csh', '/bin/sh'): if os.path.exists(f'{rootfs}{i}'): return i + + @private + async def get_ports_mapping(self, filters=None): + ports = collections.defaultdict(list) + for instance in await self.middleware.call('virt.instance.query', filters or []): + if instance['vnc_enabled']: + ports[instance['id']].append(instance['vnc_port']) + for device in await self.middleware.call('virt.instance.device_list', instance['id']): + if device['dev_type'] != 'PROXY': + continue + + ports[instance['id']].append(device['source_port']) + + return ports diff --git a/src/middlewared/middlewared/plugins/virt/instance_device.py b/src/middlewared/middlewared/plugins/virt/instance_device.py index 3c9393b999357..51273c03cc93c 100644 --- a/src/middlewared/middlewared/plugins/virt/instance_device.py +++ b/src/middlewared/middlewared/plugins/virt/instance_device.py @@ -1,4 +1,5 @@ import errno +import os from typing import Any from middlewared.service import ( @@ -61,10 +62,13 @@ async def incus_to_device(self, name: str, incus: dict[str, Any], context: dict) match incus['type']: case 'disk': - device['dev_type'] = 'DISK' - device['source'] = incus.get('source') - device['destination'] = incus.get('path') - device['description'] = f'{device["source"]} -> {device["destination"]}' + device.update({ + 'dev_type': 'DISK', + 'source': incus.get('source'), + 'destination': incus.get('path'), + 'description': f'{incus.get("source")} -> {incus.get("destination")}', + 'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None, + }) case 'nic': device['dev_type'] = 'NIC' device['network'] = incus.get('network') @@ -130,15 +134,13 @@ async def incus_to_device(self, name: str, incus: dict[str, Any], context: dict) else: device['description'] = 'Unknown' case 'gpu': - device['dev_type'] = 'USB' + device['dev_type'] = 'GPU' device['gpu_type'] = incus['gputype'].upper() match incus['gputype']: case 'physical': device['pci'] = incus['pci'] if 'gpu_choices' not in context: - context['gpu_choices'] = await self.middleware.call( - 'virt.device.gpu_choices', 'CONTAINER', 'PHYSICAL', - ) + context['gpu_choices'] = await self.middleware.call('virt.device.gpu_choices', 'PHYSICAL') for key, choice in context['gpu_choices'].items(): if key == incus['pci']: device['description'] = choice['description'] @@ -158,19 +160,17 @@ async def device_to_incus(self, instance_type: str, device: dict[str, Any]) -> d match device['dev_type']: case 'DISK': - new['type'] = 'disk' - source = device.get('source') or '' - if not source.startswith(('/dev/zvol/', '/mnt/')): - raise CallError('Only pool paths are allowed.') - new['source'] = device['source'] - if source.startswith('/mnt/'): - if source.startswith('/mnt/.ix-apps'): - raise CallError('Invalid source') - if not device.get('destination'): - raise CallError('Destination is required for filesystem paths.') - if instance_type == 'VM': - raise CallError('Destination is not valid for VM') - new['path'] = device['destination'] + new.update({ + 'type': 'disk', + 'source': device['source'], + 'path': device['destination'], + }) + if device['boot_priority'] is not None: + new['boot.priority'] = str(device['boot_priority']) + if '/' not in new['source']: + # When incus volumes are used, we need to specify that incus needs to look in the default + # already configured pool + new['pool'] = 'default' case 'NIC': new['type'] = 'nic' new['network'] = device['network'] @@ -262,7 +262,10 @@ async def validate_devices(self, devices, schema, verrors: ValidationErrors): unique_dst_proxies.append(dst) @private - async def validate_device(self, device, schema, verrors: ValidationErrors, old: dict = None, instance: str = None): + async def validate_device( + self, device, schema, verrors: ValidationErrors, instance_type: str, old: dict = None, + instance_config: dict = None, + ): match device['dev_type']: case 'PROXY': # Skip validation if we are updating and port has not changed @@ -272,16 +275,55 @@ async def validate_device(self, device, schema, verrors: ValidationErrors, old: ports = await self.middleware.call('port.ports_mapping') for attachment in ports.get(device['source_port'], {}).values(): # Only add error if the port is not in use by current instance - if instance is None or attachment['namespace'] != 'virt.device' or any(True for i in attachment['port_details'] if i['instance'] != instance): + if instance_config is None or attachment['namespace'] != 'virt' or any( + True for i in attachment['port_details'] if i['instance'] != instance_config['name'] + ): verror = await self.middleware.call( 'port.validate_port', schema, device['source_port'], ) verrors.extend(verror) break case 'DISK': - if device['source'] and device['source'].startswith('/dev/zvol/'): - if device['source'] not in await self.middleware.call('virt.device.disk_choices'): - verrors.add(schema, 'Invalid ZVOL choice.') + source = device['source'] or '' + if source == '': + verrors.add(schema, 'Source is required.') + elif source.startswith('/'): + if instance_type == 'CONTAINER': + if device['boot_priority'] is not None: + verrors.add(schema, 'Boot priority is not valid for filesystem paths.') + + if not source.startswith('/mnt/'): + verrors.add(schema, 'Source must be a path starting with /mnt/ for container.') + elif await self.middleware.run_in_thread(os.path.exists, source) is False: + verrors.add(schema, 'Source path does not exist.') + if not device.get('destination'): + verrors.add(schema, 'Destination is required for filesystem paths.') + else: + if device['destination'].startswith('/') is False: + verrors.add(schema, 'Destination must be an absolute path.') + else: + if source.startswith('/dev/zvol/') is False: + verrors.add( + schema, 'Source must be a path starting with /dev/zvol/ for VM or a virt volume name.' + ) + elif source not in await self.middleware.call('virt.device.disk_choices'): + verrors.add(schema, 'Invalid ZVOL choice.') + else: + if instance_type == 'CONTAINER': + verrors.add(schema, 'Source must be a filesystem path for CONTAINER') + else: + available_volumes = {v['id']: v for v in await self.middleware.call('virt.volume.query')} + if source not in available_volumes: + verrors.add(schema, f'No {source!r} incus volume found which can be used for source') + elif available_volumes[source]['content_type'] == 'ISO' and device['boot_priority'] is None: + verrors.add(schema, 'Boot priority is required for ISO volumes.') + + destination = device.get('destination') + if destination == '/': + verrors.add(schema, 'Destination cannot be /') + if destination and instance_type == 'VM': + verrors.add(schema, 'Destination is not valid for VM') + case 'NIC': if await self.middleware.call('interface.has_pending_changes'): raise CallError('There are pending network changes, please resolve before proceeding.') @@ -291,6 +333,9 @@ async def validate_device(self, device, schema, verrors: ValidationErrors, old: choices = await self.middleware.call('virt.device.nic_choices', device['nic_type']) if device['parent'] not in choices: verrors.add(schema, 'Invalid parent interface') + case 'GPU': + if instance_config and instance_type == 'VM' and instance_config['status'] == 'RUNNING': + verrors.add('virt.device.gpu_choices', 'VM must be stopped before adding a GPU device') @api_method(VirtInstanceDeviceAddArgs, VirtInstanceDeviceAddResult, roles=['VIRT_INSTANCE_WRITE']) async def device_add(self, id, device): @@ -303,7 +348,7 @@ async def device_add(self, id, device): device['name'] = await self.generate_device_name(data['devices'].keys(), device['dev_type']) verrors = ValidationErrors() - await self.validate_device(device, 'virt_device_add', verrors) + await self.validate_device(device, 'virt_device_add', verrors, instance['type'], instance_config=instance) verrors.check() data['devices'][device['name']] = await self.device_to_incus(instance['type'], device) @@ -325,7 +370,7 @@ async def device_update(self, id, device): raise CallError('Device does not exist.', errno.ENOENT) verrors = ValidationErrors() - await self.validate_device(device, 'virt_device_update', verrors, old, instance['name']) + await self.validate_device(device, 'virt_device_update', verrors, instance['type'], old, instance) verrors.check() data['devices'][device['name']] = await self.device_to_incus(instance['type'], device) diff --git a/src/middlewared/middlewared/plugins/virt/utils.py b/src/middlewared/middlewared/plugins/virt/utils.py index 86bfa3c2e05c7..aad7accc8c5cf 100644 --- a/src/middlewared/middlewared/plugins/virt/utils.py +++ b/src/middlewared/middlewared/plugins/virt/utils.py @@ -1,14 +1,19 @@ import asyncio import aiohttp import enum +import httpx +import re from collections.abc import Callable from .websocket import IncusWS from middlewared.service import CallError + SOCKET = '/var/lib/incus/unix.socket' HTTP_URI = 'http://unix.socket' +RE_VNC_PORT = re.compile(r'vnc.*?:(\d+)\s*') +VNC_BASE_PORT = 5900 class Status(enum.Enum): @@ -19,6 +24,34 @@ class Status(enum.Enum): ERROR = 'ERROR' +def incus_call_sync(path: str, method: str, request_kwargs: dict = None, json: bool = True): + request_kwargs = request_kwargs or {} + headers = request_kwargs.get('headers', {}) + data = request_kwargs.get('data', None) + files = request_kwargs.get('files', None) + + url = f'{HTTP_URI}/{path.lstrip("/")}' + + transport = httpx.HTTPTransport(uds=SOCKET) + with httpx.Client( + transport=transport, timeout=httpx.Timeout(connect=5.0, read=300.0, write=300.0, pool=None) + ) as client: + response = client.request( + method.upper(), + url, + headers=headers, + data=data, + files=files, + ) + + response.raise_for_status() + + if json: + return response.json() + else: + return response.content + + async def incus_call(path: str, method: str, request_kwargs: dict = None, json: bool = True): async with aiohttp.UnixConnector(path=SOCKET) as conn: async with aiohttp.ClientSession(connector=conn) as session: @@ -30,24 +63,16 @@ async def incus_call(path: str, method: str, request_kwargs: dict = None, json: return r.content -async def incus_call_and_wait( - path: str, method: str, request_kwargs: dict = None, - running_cb: Callable[[dict], None] = None, timeout: int = 300, -): - result = await incus_call(path, method, request_kwargs) - - if result.get('type') == 'error': - raise CallError(result['error']) - +async def incus_wait(result, running_cb: Callable[[dict], None] = None, timeout: int = 300): async def callback(data): if data['metadata']['status'] == 'Failure': - return ('ERROR', data['metadata']['err']) + return 'ERROR', data['metadata']['err'] if data['metadata']['status'] == 'Success': - return ('SUCCESS', data['metadata']['metadata']) + return 'SUCCESS', data['metadata']['metadata'] if data['metadata']['status'] == 'Running': if running_cb: await running_cb(data) - return ('RUNNING', None) + return 'RUNNING', None task = asyncio.ensure_future(IncusWS().wait(result['metadata']['id'], callback)) try: @@ -55,3 +80,33 @@ async def callback(data): except asyncio.TimeoutError: raise CallError('Timed out') return task.result() + + +async def incus_call_and_wait( + path: str, method: str, request_kwargs: dict = None, + running_cb: Callable[[dict], None] = None, timeout: int = 300, +): + result = await incus_call(path, method, request_kwargs) + + if result.get('type') == 'error': + raise CallError(result['error']) + + return await incus_wait(result, running_cb, timeout) + + +def get_vnc_info_from_config(config: dict): + vnc_config = { + 'vnc_enabled': False, + 'vnc_port': None, + } + if not (raw_qemu_config := config.get('raw.qemu')) or 'vnc' not in raw_qemu_config: + return vnc_config + + for flag in raw_qemu_config.split('-'): + if port := RE_VNC_PORT.findall(flag): + return { + 'vnc_enabled': True, + 'vnc_port': int(port[0]) + VNC_BASE_PORT, + } + + return vnc_config diff --git a/src/middlewared/middlewared/plugins/virt/volume.py b/src/middlewared/middlewared/plugins/virt/volume.py new file mode 100644 index 0000000000000..a3982fd991ac0 --- /dev/null +++ b/src/middlewared/middlewared/plugins/virt/volume.py @@ -0,0 +1,136 @@ +from middlewared.api import api_method +from middlewared.api.current import ( + VirtVolumeEntry, VirtVolumeCreateArgs, VirtVolumeCreateResult, VirtVolumeUpdateArgs, + VirtVolumeUpdateResult, VirtVolumeDeleteArgs, VirtVolumeDeleteResult, VirtVolumeImportISOArgs, + VirtVolumeImportISOResult, +) +from middlewared.service import CallError, CRUDService, job +from middlewared.utils import filter_list + +from .utils import incus_call, incus_call_sync, Status, incus_wait + + +class VirtVolumeService(CRUDService): + + class Config: + namespace = 'virt.volume' + cli_namespace = 'virt.volume' + entry = VirtVolumeEntry + + async def query(self, filters, options): + config = await self.middleware.call('virt.global.config') + if config['state'] != Status.INITIALIZED.value: + return [] + + storage_devices = await incus_call('1.0/storage-pools/default/volumes/custom?recursion=2', 'get') + if storage_devices.get('status_code') != 200: + return [] + + entries = [] + for storage_device in storage_devices['metadata']: + entries.append({ + 'id': storage_device['name'], + 'name': storage_device['name'], + 'content_type': storage_device['content_type'].upper(), + 'created_at': storage_device['created_at'], + 'type': storage_device['type'], + 'config': storage_device['config'], + 'used_by': [instance.replace('/1.0/instances/', '') for instance in storage_device['used_by']] + }) + if storage_device['config'].get('size'): + entries[-1]['config']['size'] = int(storage_device['config']['size']) // (1024 * 1024) + return filter_list(entries, filters, options) + + @api_method(VirtVolumeCreateArgs, VirtVolumeCreateResult) + async def do_create(self, data): + await self.middleware.call('virt.global.check_initialized') + + result = await incus_call('1.0/storage-pools/default/volumes/custom', 'post', { + 'json': { + 'name': data['name'], + 'content_type': data['content_type'].lower(), + 'config': { + 'size': str(data['size'] * 1024 * 1024), # Convert MB to bytes + }, + }, + }) + if result.get('error') != '': + raise CallError(f'Failed to create volume: {result["error"]}') + + return await self.get_instance(data['name']) + + @api_method(VirtVolumeUpdateArgs, VirtVolumeUpdateResult) + async def do_update(self, name, data): + volume = await self.get_instance(name) + if data.get('size') is None: + return volume + + result = await incus_call(f'1.0/storage-pools/default/volumes/custom/{name}', 'patch', { + 'json': { + 'config': { + 'size': str(data['size'] * 1024 * 1024) + }, + }, + }) + if result.get('error') != '': + raise CallError(f'Failed to update volume: {result["error"]}') + + return await self.get_instance(name) + + @api_method(VirtVolumeDeleteArgs, VirtVolumeDeleteResult) + async def do_delete(self, name): + volume = await self.get_instance(name) + if volume['used_by']: + raise CallError(f'Volume {name!r} is in use by instances: {", ".join(volume["used_by"])}') + + result = await incus_call(f'1.0/storage-pools/default/volumes/custom/{name}', 'delete') + if result.get('status_code') != 200: + raise CallError(f'Failed to delete volume: {result["error"]}') + + return True + + @api_method(VirtVolumeImportISOArgs, VirtVolumeImportISOResult) + @job(lock=lambda args: f'virt_volume_import_iso_{args[0]}', pipes=['input'], check_pipes=False) + async def import_iso(self, job, data): + await self.middleware.call('virt.global.check_initialized') + + if data['upload_iso']: + job.check_pipe('input') + elif data['iso_location'] is None: + raise CallError('Either upload iso or provide iso_location') + + request_kwargs = { + 'headers': { + 'X-Incus-type': 'iso', + 'X-Incus-name': data['name'], + 'Content-Type': 'application/octet-stream', + } + } + + def read_input_stream(): + for stream in job.pipes.input.r: + yield stream + + def upload_file(): + job.set_progress(25, 'Importing ISO as incus volume') + if data['upload_iso']: + return incus_call_sync( + '1.0/storage-pools/default/volumes/custom', + 'post', + request_kwargs=request_kwargs | {'data': read_input_stream()}, + ) + else: + with open(data['iso_location'], 'rb') as f: + return incus_call_sync( + '1.0/storage-pools/default/volumes/custom', + 'post', + request_kwargs=request_kwargs | {'data': f}, + ) + + response = await self.middleware.run_in_thread(upload_file) + job.set_progress(70, 'ISO copied over to incus volume') + await incus_wait(response) + + job.set_progress(95, 'ISO successfully imported as incus volume') + return await self.get_instance(data['name']) + diff --git a/src/middlewared/middlewared/test/integration/assets/virt.py b/src/middlewared/middlewared/test/integration/assets/virt.py new file mode 100644 index 0000000000000..fc420f7eed876 --- /dev/null +++ b/src/middlewared/middlewared/test/integration/assets/virt.py @@ -0,0 +1,44 @@ +import contextlib +import os.path +import uuid + +from middlewared.test.integration.utils import call, ssh + + +@contextlib.contextmanager +def virt(pool: dict): + virt_config = call('virt.global.update', {'pool': pool['name']}, job=True) + assert virt_config['pool'] == pool['name'], virt_config + try: + yield virt_config + finally: + virt_config = call('virt.global.update', {'pool': None}, job=True) + assert virt_config['pool'] is None, virt_config + + +@contextlib.contextmanager +def import_iso_as_volume(volume_name: str, pool_name: str, size: int): + iso_path = os.path.join('/mnt', pool_name, f'virt_iso-{uuid.uuid4()}.iso') + try: + ssh(f'dd if=/dev/urandom of={iso_path} bs=1M count={size} oflag=sync') + yield call('virt.volume.import_iso', {'name': volume_name, 'iso_location': iso_path}, job=True) + finally: + ssh(f'rm {iso_path}') + call('virt.volume.delete', volume_name) + + +@contextlib.contextmanager +def volume(volume_name: str, size: int): + vol = call('virt.volume.create', {'name': volume_name, 'size': size, 'content_type': 'BLOCK'}) + try: + yield vol + finally: + call('virt.volume.delete', volume_name) + + +@contextlib.contextmanager +def virt_device(instance_name: str, device_name: str, payload: dict): + try: + yield call('virt.instance.device_add', instance_name, {'name': device_name, **payload}) + finally: + call('virt.instance.device_delete', instance_name, device_name) diff --git a/tests/api2/test_virt_vm.py b/tests/api2/test_virt_vm.py new file mode 100644 index 0000000000000..8ac085836d84f --- /dev/null +++ b/tests/api2/test_virt_vm.py @@ -0,0 +1,205 @@ +import json +import os +import tempfile +import uuid + +import pytest + +from truenas_api_client import ValidationErrors + +from middlewared.test.integration.assets.pool import another_pool +from middlewared.test.integration.assets.virt import virt, import_iso_as_volume, volume, virt_device +from middlewared.test.integration.utils import call +from middlewared.service_exception import ValidationErrors as ClientValidationErrors + +from functions import POST, wait_on_job + + +ISO_VOLUME_NAME = 'testiso' +VM_NAME = 'virt-vm' +VNC_PORT = 6900 + + +@pytest.fixture(scope='module') +def virt_pool(): + with another_pool() as pool: + with virt(pool) as virt_config: + yield virt_config + + +@pytest.fixture(scope='module') +def vm(virt_pool): + call('virt.instance.create', { + 'name': VM_NAME, + 'source_type': None, + 'vnc_port': VNC_PORT, + 'enable_vnc': True, + 'instance_type': 'VM', + }, job=True) + call('virt.instance.stop', VM_NAME, {'force': True, 'timeout': 1}, job=True) + try: + yield call('virt.instance.get_instance', VM_NAME) + finally: + call('virt.instance.delete', VM_NAME, job=True) + + +@pytest.fixture(scope='module') +def iso_volume(virt_pool): + with import_iso_as_volume(ISO_VOLUME_NAME, virt_pool['pool'], 1024) as vol: + yield vol + + +def test_virt_volume(virt_pool): + vol_name = 'test_volume' + with volume(vol_name, 1024) as vol: + assert vol['name'] == 'test_volume' + assert vol['config']['size'] == 1024 + assert vol['content_type'] == 'BLOCK' + + vol = call('virt.volume.update', vol['id'], {'size': 2048}) + assert vol['config']['size'] == 2048 + + assert call('virt.volume.query', [['id', '=', vol_name]]) == [] + + +def test_iso_import_as_volume(virt_pool): + with import_iso_as_volume('test_iso', virt_pool['pool'], 1024) as vol: + assert vol['name'] == 'test_iso' + assert vol['config']['size'] == 1024 + assert vol['content_type'] == 'ISO' + + +def test_upload_iso_file(virt_pool): + vol_name = 'test_uploaded_iso' + with tempfile.TemporaryDirectory() as tmpdir: + test_iso_file = os.path.join(tmpdir, f'virt_iso-{uuid.uuid4()}.iso') + data = { + 'method': 'virt.volume.import_iso', + 'params': [ + { + 'name': vol_name, + 'iso_location': None, + 'upload_iso': True + } + ] + } + os.system(f'dd if=/dev/urandom of={test_iso_file} bs=1M count=50 oflag=sync') + with open(test_iso_file, 'rb') as f: + response = POST( + '/_upload/', + files={'data': json.dumps(data), 'file': f}, + use_ip_only=True, + force_new_headers=True, + ) + + wait_on_job(json.loads(response.text)['job_id'], 600) + + vol = call('virt.volume.get_instance', 'test_uploaded_iso') + assert vol['name'] == vol_name + assert vol['config']['size'] == 50 + assert vol['content_type'] == 'ISO' + + call('virt.volume.delete', vol_name) + + +def test_vm_props(vm): + instance = call('virt.instance.get_instance', VM_NAME) + + # An empty VM was created, so it's image details should be none + assert instance['image'] == { + 'architecture': None, + 'description': None, + 'os': None, + 'release': None, + 'type': None, + 'serial': None, + 'variant': None, + } + + # Testing VNC specific bits + assert instance['vnc_enabled'] is True, instance + assert instance['vnc_port'] == VNC_PORT, instance + + # Going to unset VNC + call('virt.instance.update', VM_NAME, {'vnc_port': None}, job=True) + instance = call('virt.instance.get_instance', VM_NAME, {'extra': {'raw': True}}) + assert instance['raw']['config']['user.ix_old_raw_qemu_config'] == f'-vnc :{VNC_PORT - 5900}' + assert instance['vnc_enabled'] is False, instance + assert instance['vnc_port'] is None, instance + + # Going to update port + call('virt.instance.update', VM_NAME, {'vnc_port': 6901}, job=True) + instance = call('virt.instance.get_instance', VM_NAME, {'extra': {'raw': True}}) + assert instance['raw']['config'].get('user.ix_old_raw_qemu_config') is None + assert instance['raw']['config']['raw.qemu'] == f'-vnc :{1001}' + assert instance['vnc_port'] == 6901, instance + + # Changing nothing + instance = call('virt.instance.update', VM_NAME, {}, job=True) + assert instance['vnc_port'] == 6901, instance + + +def test_vm_iso_volume(vm, iso_volume): + device_name = 'iso_device' + with virt_device(VM_NAME, device_name, {'dev_type': 'DISK', 'source': ISO_VOLUME_NAME, 'boot_priority': 1}): + vm_devices = call('virt.instance.device_list', VM_NAME) + assert any(device['name'] == device_name for device in vm_devices), vm_devices + + iso_vol = call('virt.volume.get_instance', ISO_VOLUME_NAME) + assert iso_vol['used_by'] == [VM_NAME], iso_vol + + +@pytest.mark.parametrize('enable_vnc,vnc_port,error_msg', [ + (True, None, 'Value error, VNC port must be set when VNC is enabled'), + (True, 6901, 'VNC port is already in use by another virt instance'), + (True, 23, 'Input should be greater than or equal to 5900'), +]) +def test_vnc_validation_on_vm_create(virt_pool, enable_vnc, vnc_port, error_msg): + with pytest.raises(ValidationErrors) as ve: + call('virt.instance.create', { + 'name': 'test-vnc-vm', + 'instance_type': 'VM', + 'source_type': None, + 'vnc_port': vnc_port, + 'enable_vnc': enable_vnc, + }, job=True) + + assert ve.value.errors[0].errmsg == error_msg + + +@pytest.mark.parametrize('source,boot_priority,destination,error_msg', [ + (ISO_VOLUME_NAME, None, None, 'Boot priority is required for ISO volumes.'), + (ISO_VOLUME_NAME, 1, '/mnt', 'Destination is not valid for VM'), + (ISO_VOLUME_NAME, 1, '/', 'Destination cannot be /'), + ('some_iso', 1, None, 'No \'some_iso\' incus volume found which can be used for source'), + (None, 1, '/mnt/', 'Source is required.'), + ('/mnt/', 1, None, 'Source must be a path starting with /dev/zvol/ for VM or a virt volume name.'), +]) +def test_disk_device_attachment_validation(vm, iso_volume, source, boot_priority, destination, error_msg): + with pytest.raises(ClientValidationErrors) as ve: + call('virt.instance.device_add', VM_NAME, { + 'dev_type': 'DISK', + 'source': source, + 'boot_priority': boot_priority, + 'destination': destination, + }) + + assert ve.value.errors[0].errmsg == error_msg + + +@pytest.mark.parametrize('enable_vnc,vnc_port,source_type,error_msg', [ + (True, None, None, 'Value error, Source type must be set to "IMAGE" when instance type is CONTAINER'), + (True, 5902, 'IMAGE', 'Value error, VNC is not supported for containers and `enable_vnc` should be unset'), + (False, 5902, 'IMAGE', 'Value error, Image must be set when source type is "IMAGE"'), +]) +def test_vnc_validation_on_container_create(virt_pool, enable_vnc, vnc_port, source_type, error_msg): + with pytest.raises(ValidationErrors) as ve: + call('virt.instance.create', { + 'name': 'testcontainervalidation', + 'instance_type': 'CONTAINER', + 'source_type': source_type, + 'vnc_port': vnc_port, + 'enable_vnc': enable_vnc, + }, job=True) + + assert ve.value.errors[0].errmsg == error_msg