Skip to content

Commit

Permalink
Complete VM support in virt plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
Qubad786 committed Dec 11, 2024
1 parent 0888305 commit d5413d2
Show file tree
Hide file tree
Showing 9 changed files with 411 additions and 52 deletions.
1 change: 1 addition & 0 deletions src/middlewared/middlewared/api/v25_04_0/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,5 +50,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
28 changes: 24 additions & 4 deletions src/middlewared/middlewared/api/v25_04_0/virt_device.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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']
Expand Down Expand Up @@ -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):
Expand Down
33 changes: 28 additions & 5 deletions src/middlewared/middlewared/api/v25_04_0/virt_instance.py
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -49,7 +49,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
Expand All @@ -62,14 +64,33 @@ 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
autostart: bool | None = True
cpu: str | None = None
devices: list[DeviceType] | None = None
memory: MemoryType | None = None
enable_vnc: bool = False
vnc_port: int | None = 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):
Expand All @@ -81,6 +102,8 @@ class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass):
autostart: bool | None = None
cpu: str | None = None
memory: MemoryType | None = None
enable_vnc: bool | None
vnc_port: int | None


class VirtInstanceUpdateArgs(BaseModel):
Expand Down Expand Up @@ -115,7 +138,7 @@ class StopArgs(BaseModel):

class VirtInstanceStopArgs(BaseModel):
id: str
stop_args: StopArgs
stop_args: StopArgs = StopArgs()


class VirtInstanceStopResult(BaseModel):
Expand All @@ -124,7 +147,7 @@ class VirtInstanceStopResult(BaseModel):

class VirtInstanceRestartArgs(BaseModel):
id: str
stop_args: StopArgs
stop_args: StopArgs = StopArgs()


class VirtInstanceRestartResult(BaseModel):
Expand Down
75 changes: 75 additions & 0 deletions src/middlewared/middlewared/api/v25_04_0/virt_volume.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
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

@field_validator('iso_location')
@classmethod
def validate_iso_location(cls, v):
if not os.path.exists(v):
raise ValueError('Specified ISO location does not exist')
return v


class VirtVolumeImportISOResult(BaseModel):
result: VirtVolumeEntry
15 changes: 12 additions & 3 deletions src/middlewared/middlewared/plugins/virt/attachments.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,19 +58,28 @@ 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 = []
if instance['vnc_enabled']:
instance_ports = [
('0.0.0.0', instance['vnc_port']),
('::', instance['vnc_port']),
]
else:
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:
ports.append({
'description': f'{instance["id"]!r} instance',
Expand Down
40 changes: 33 additions & 7 deletions src/middlewared/middlewared/plugins/virt/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,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


LC_IMAGES_SERVER = 'https://images.linuxcontainers.org'
Expand Down Expand Up @@ -72,7 +72,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'):
Expand Down Expand Up @@ -121,6 +123,19 @@ 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')

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:
# FIXME: Whitelist virt plugin and check no other virt instance is consuming this
verrors.extend(await self.middleware.call(
'port.validate_port',
f'{schema_name}.vnc_port',
new['vnc_port'], '0.0.0.0', 'virt',
))

def __data_to_config(self, data: dict, raw: dict = None):
config = {}
if 'environment' in data:
Expand All @@ -143,12 +158,18 @@ 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 data.get('enable_vnc') and data.get('vnc_port'):
config['raw.qemu'] = f'-vnc :{data["vnc_port"]}'
if data.get('enable_vnc') is False:
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':
Expand Down Expand Up @@ -181,16 +202,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)
Expand All @@ -211,7 +233,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')
Expand Down Expand Up @@ -248,6 +270,10 @@ 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.enable_vnc', 'VNC is not supported for containers')
# TODO: Handle change case

verrors.check()

instance['raw']['config'].update(self.__data_to_config(data, instance['raw']['config']))
Expand Down
Loading

0 comments on commit d5413d2

Please sign in to comment.