diff --git a/docs/source/accounts/implementation.rst b/docs/source/accounts/implementation.rst index 60c4c6e083610..0a617f32c355c 100644 --- a/docs/source/accounts/implementation.rst +++ b/docs/source/accounts/implementation.rst @@ -291,4 +291,4 @@ lifetime of the SSH session. NOTE that not all NSS-provided accounts will be abl Apps and containers ------------------- -There is no interaction between the host identity providers and accounts in apps or incus containers. +There is no interaction between the host identity providers and accounts in apps containers. diff --git a/src/middlewared/middlewared/api/base/types/user.py b/src/middlewared/middlewared/api/base/types/user.py index 1774e736bd0e0..00a046732ff1d 100644 --- a/src/middlewared/middlewared/api/base/types/user.py +++ b/src/middlewared/middlewared/api/base/types/user.py @@ -10,15 +10,6 @@ XID_MAX = 2 ** 32 - 2 # uid_t -1 can have special meaning depending on context # TRUENAS_IDMAP_MAX + 1 -INCUS_IDMAP_MIN = 2147000001 -# Each unpriviliged container with isolated idmap will require at least 65536. -# Lets reserve enough so we can run at least 7 of these. -# Increasing this would go above signed 32 bits (>= 2147483648) which might -# cause problems for programs that do not expect it (e.g. filesystems like -# devpts and some syscalls like setfsuid()) -INCUS_MAX_ISOLATED_CONTAINER = 7 -INCUS_IDMAP_COUNT = 65536 * INCUS_MAX_ISOLATED_CONTAINER -INCUS_IDMAP_MAX = INCUS_IDMAP_MIN + INCUS_IDMAP_COUNT TRUENAS_IDMAP_DEFAULT_LOW = 90000001 DEFAULT_VALID_CHARS = string.ascii_letters + string.digits + '_' + '-' + '.' diff --git a/src/middlewared/middlewared/api/v25_04_0/__init__.py b/src/middlewared/middlewared/api/v25_04_0/__init__.py index b0d68ae873e97..ac462592e80b0 100644 --- a/src/middlewared/middlewared/api/v25_04_0/__init__.py +++ b/src/middlewared/middlewared/api/v25_04_0/__init__.py @@ -65,9 +65,5 @@ from .tn_connect import * # noqa from .truenas import * # noqa from .user import * # noqa -from .virt_device import * # noqa -from .virt_global import * # noqa -from .virt_instance import * # noqa -from .virt_volume import * # noqa from .webui_enclosure import * # noqa from .webui_main_dashboard import * # noqa diff --git a/src/middlewared/middlewared/api/v25_04_0/pool_dataset_details.py b/src/middlewared/middlewared/api/v25_04_0/pool_dataset_details.py index ec91d9cb89e82..cb08de0649164 100644 --- a/src/middlewared/middlewared/api/v25_04_0/pool_dataset_details.py +++ b/src/middlewared/middlewared/api/v25_04_0/pool_dataset_details.py @@ -71,7 +71,6 @@ class PoolDatasetDetailsModel(BaseModel): iscsi_shares: list[PDD_ISCSIEntry] vms: list[PDD_VMEntry] apps: list[PDD_AppEntry] - virt_instances: list[PDD_VirtEntry] replication_tasks_count: int snapshot_tasks_count: int cloudsync_tasks_count: int diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_device.py b/src/middlewared/middlewared/api/v25_04_0/virt_device.py deleted file mode 100644 index 14c92ea843c87..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_0/virt_device.py +++ /dev/null @@ -1,198 +0,0 @@ -from typing import Annotated, Literal, TypeAlias - -from pydantic import Field, field_validator - -from middlewared.api.base import BaseModel, LocalGID, LocalUID, NonEmptyString, single_argument_args - - -__all__ = [ - 'DeviceType', 'InstanceType', 'VirtDeviceUsbChoicesArgs', 'VirtDeviceUsbChoicesResult', - 'VirtDeviceGpuChoicesArgs', 'VirtDeviceGpuChoicesResult', 'VirtDeviceDiskChoicesArgs', - 'VirtDeviceDiskChoicesResult', 'VirtDeviceNicChoicesArgs', 'VirtDeviceNicChoicesResult', - 'VirtDeviceExportDiskImageArgs', 'VirtDeviceExportDiskImageResult', 'VirtDeviceImportDiskImageArgs', - 'VirtDeviceImportDiskImageResult', 'VirtDevicePciChoicesArgs', 'VirtDevicePciChoicesResult', -] - - -InstanceType: TypeAlias = Literal['CONTAINER', 'VM'] - - -class Device(BaseModel): - name: NonEmptyString | None = None - description: NonEmptyString | None = None - readonly: bool = False - - -class Disk(Device): - dev_type: Literal['DISK'] - 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) - io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which the device is located. This must be one - of the storage pools listed in virt.global.config output. - If this is set to None during device creation, then the default storage - pool defined in virt.global.config will be used. - ''' - - @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'] - - -class NIC(Device): - dev_type: Literal['NIC'] - network: NonEmptyString | None = None - nic_type: NicType | None = None - parent: NonEmptyString | None = None - - -class USB(Device): - dev_type: Literal['USB'] - bus: int | None = None - dev: int | None = None - product_id: str | None = None - vendor_id: str | None = None - - -Proto: TypeAlias = Literal['UDP', 'TCP'] - - -class Proxy(Device): - dev_type: Literal['PROXY'] - source_proto: Proto - source_port: int = Field(ge=1, le=65535) - dest_proto: Proto - dest_port: int = Field(ge=1, le=65535) - - -class TPM(Device): - dev_type: Literal['TPM'] - path: str | None = None - pathrm: str | None = None - - -GPUType: TypeAlias = Literal['PHYSICAL', 'MDEV', 'MIG', 'SRIOV'] - - -class GPU(Device): - dev_type: Literal['GPU'] - gpu_type: GPUType - id: str | None = None - gid: LocalGID | None = None - uid: LocalUID | None = None - mode: str | None = None - mdev: NonEmptyString | None = None - mig_uuid: NonEmptyString | None = None - pci: NonEmptyString | None = None - productid: NonEmptyString | None = None - vendorid: NonEmptyString | None = None - - -class PCI(Device): - dev_type: Literal['PCI'] - address: NonEmptyString - - -DeviceType: TypeAlias = Annotated[ - Disk | GPU | Proxy | TPM | USB | NIC | PCI, - Field(discriminator='dev_type') -] - - -class VirtDeviceUsbChoicesArgs(BaseModel): - pass - - -class USBChoice(BaseModel): - vendor_id: str - product_id: str - bus: int - dev: int - product: str | None - manufacturer: str | None - - -class VirtDeviceUsbChoicesResult(BaseModel): - result: dict[str, USBChoice] - - -class VirtDeviceGpuChoicesArgs(BaseModel): - gpu_type: GPUType - - -class GPUChoice(BaseModel): - bus: str - slot: str - description: str - vendor: str | None = None - pci: str - - -class VirtDeviceGpuChoicesResult(BaseModel): - result: dict[str, GPUChoice] - - -class VirtDeviceDiskChoicesArgs(BaseModel): - pass - - -class VirtDeviceDiskChoicesResult(BaseModel): - result: dict[str, str] - - -class VirtDeviceNicChoicesArgs(BaseModel): - nic_type: NicType - - -class VirtDeviceNicChoicesResult(BaseModel): - result: dict[str, str] - - -@single_argument_args('virt_device_import_disk_image') -class VirtDeviceImportDiskImageArgs(BaseModel): - diskimg: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceImportDiskImageResult(BaseModel): - result: bool - - -@single_argument_args('virt_device_export_disk_image') -class VirtDeviceExportDiskImageArgs(BaseModel): - format: NonEmptyString - directory: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceExportDiskImageResult(BaseModel): - result: bool - - -class VirtDevicePciChoicesArgs(BaseModel): - pass - - -class VirtDevicePciChoicesResult(BaseModel): - result: dict diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_global.py b/src/middlewared/middlewared/api/v25_04_0/virt_global.py deleted file mode 100644 index 935b06745e807..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_0/virt_global.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Literal - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, single_argument_result, -) - - -__all__ = [ - 'VirtGlobalEntry', 'VirtGlobalUpdateResult', 'VirtGlobalUpdateArgs', 'VirtGlobalBridgeChoicesArgs', - 'VirtGlobalBridgeChoicesResult', 'VirtGlobalPoolChoicesArgs', 'VirtGlobalPoolChoicesResult', - 'VirtGlobalGetNetworkArgs', 'VirtGlobalGetNetworkResult', -] - - -class VirtGlobalEntry(BaseModel): - id: int - pool: str | None = None - "Default storage pool when creating new instances and volumes" - dataset: str | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - bridge: str | None = None - v4_network: str | None = None - v6_network: str | None = None - state: Literal['INITIALIZING', 'INITIALIZED', 'NO_POOL', 'ERROR', 'LOCKED'] | None = None - - -class VirtGlobalUpdateResult(BaseModel): - result: VirtGlobalEntry - - -@single_argument_args('virt_global_update') -class VirtGlobalUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass): - pool: NonEmptyString | None = None - "Default storage pool when creating new instances and volumes" - bridge: NonEmptyString | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - v4_network: str | None = None - v6_network: str | None = None - - -class VirtGlobalBridgeChoicesArgs(BaseModel): - pass - - -class VirtGlobalBridgeChoicesResult(BaseModel): - result: dict - - -class VirtGlobalPoolChoicesArgs(BaseModel): - pass - - -class VirtGlobalPoolChoicesResult(BaseModel): - result: dict - - -class VirtGlobalGetNetworkArgs(BaseModel): - name: NonEmptyString - - -@single_argument_result -class VirtGlobalGetNetworkResult(BaseModel): - type: Literal['BRIDGE'] - managed: bool - ipv4_address: NonEmptyString - ipv4_nat: bool - ipv6_address: NonEmptyString - ipv6_nat: bool diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_instance.py b/src/middlewared/middlewared/api/v25_04_0/virt_instance.py deleted file mode 100644 index 612dece7121d7..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_0/virt_instance.py +++ /dev/null @@ -1,313 +0,0 @@ -import os -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, model_validator, Secret, StringConstraints - -from middlewared.api.base import BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args - -from .virt_device import DeviceType, InstanceType - - -__all__ = [ - 'VirtInstanceEntry', 'VirtInstanceCreateArgs', 'VirtInstanceCreateResult', 'VirtInstanceUpdateArgs', - 'VirtInstanceUpdateResult', 'VirtInstanceDeleteArgs', 'VirtInstanceDeleteResult', - 'VirtInstanceStartArgs', 'VirtInstanceStartResult', 'VirtInstanceStopArgs', 'VirtInstanceStopResult', - 'VirtInstanceRestartArgs', 'VirtInstanceRestartResult', 'VirtInstanceImageChoicesArgs', - 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceDeviceListArgs', 'VirtInstanceDeviceDeviceListResult', - 'VirtInstanceDeviceDeviceAddArgs', 'VirtInstanceDeviceDeviceAddResult', 'VirtInstanceDeviceDeviceUpdateArgs', - 'VirtInstanceDeviceDeviceUpdateResult', 'VirtInstanceDeviceDeviceDeleteArgs', 'VirtInstanceDeviceDeviceDeleteResult', -] - - -REMOTE_CHOICES: TypeAlias = Literal['LINUX_CONTAINERS'] -ENV_KEY: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^\w[\w/]*$'), - 'ENV_KEY must not be empty, should start with alphanumeric characters' - ', should not contain whitespaces, and can have _ and /' - ) - ) -] -ENV_VALUE: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^(?!\s*$).+'), - 'ENV_VALUE must have at least one non-whitespace character to be considered valid' - ) - ) -] - - -class VirtInstanceAlias(BaseModel): - type: Literal['INET', 'INET6'] - address: NonEmptyString - netmask: int | None - - -class Image(BaseModel): - architecture: str | None - description: str | None - os: str | None - release: str | None - serial: str | None - type: str | None - variant: str | None - secureboot: bool | None - - -class IdmapUserNsEntry(BaseModel): - hostid: int - maprange: int - nsid: int - - -class UserNsIdmap(BaseModel): - uid: IdmapUserNsEntry - gid: IdmapUserNsEntry - - -class VirtInstanceEntry(BaseModel): - id: str - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - type: InstanceType = 'CONTAINER' - status: Literal['RUNNING', 'STOPPED', 'UNKNOWN', 'ERROR', 'FROZEN', 'STARTING', 'STOPPING', 'FREEZING', 'THAWED', 'ABORTING'] - cpu: str | None - memory: int | None - autostart: bool - environment: dict[str, str] - aliases: list[VirtInstanceAlias] - image: Image - userns_idmap: UserNsIdmap | None - raw: Secret[dict | None] - vnc_enabled: bool - vnc_port: int | None - vnc_password: Secret[NonEmptyString | None] - secure_boot: bool | None - root_disk_size: int | None - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] - storage_pool: NonEmptyString - "Storage pool in which the root of the instance is located." - - -def validate_memory(value: int) -> int: - if value < 33554432: - raise ValueError('Value must be 32MiB or larger') - return value - - -# Lets require at least 32MiB of reserved memory -# This value is somewhat arbitrary but hard to think lower value would have to be used -# (would most likely be a typo). -# Running container with very low memory will probably cause it to be killed by the cgroup OOM -MemoryType: TypeAlias = Annotated[int, AfterValidator(validate_memory)] - - -@single_argument_args('virt_instance_create') -class VirtInstanceCreateArgs(BaseModel): - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - iso_volume: NonEmptyString | None = None - source_type: Literal[None, 'IMAGE', 'ZVOL', 'ISO', 'VOLUME'] = 'IMAGE' - storage_pool: NonEmptyString | None = None - ''' - Storage pool under which to allocate root filesystem. Must be one of the pools - listed in virt.global.config output under "storage_pools". If None (default) then the pool - specified in the global configuration will be used. - ''' - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] | None = None - root_disk_size: int = Field(ge=5, default=10) # In GBs - ''' - This can be specified when creating VMs so the root device's size can be configured. Root device for VMs - is a sparse zvol and the field specifies space in GBs and defaults to 10 GBs. - ''' - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI'] = 'NVME' - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - instance_type: InstanceType = 'CONTAINER' - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = True - cpu: str | None = None - devices: list[DeviceType] | None = None - memory: MemoryType | None = None - secure_boot: bool = False - enable_vnc: bool = False - vnc_port: int | None = Field(ge=5900, le=65535, default=None) - zvol_path: NonEmptyString | None = None - ''' - This is useful when a VM wants to be booted where a ZVOL already has a VM bootstrapped in it and needs - to be ported over to virt plugin. Virt will consume this zvol and add it as a DISK device to the instance - with boot priority set to 1 so the VM can be booted from it. - ''' - volume: NonEmptyString | None = None - ''' - This should be set when source type is "VOLUME" and should be the name of the virt volume which should - be used to boot the VM instance. - ''' - vnc_password: Secret[NonEmptyString | 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') - if self.zvol_path: - raise ValueError('Zvol path is only supported for VMs') - else: - if self.enable_vnc and self.vnc_port is None: - raise ValueError('VNC port must be set when VNC is enabled') - - if self.vnc_password is not None and not self.enable_vnc: - raise ValueError('VNC password can only be set when VNC is enabled') - - if self.source_type == 'ISO' and self.iso_volume is None: - raise ValueError('ISO volume must be set when source type is "ISO"') - - if self.source_type == 'VOLUME' and self.volume is None: - raise ValueError('volume must be set when source type is "VOLUME"') - - if self.source_type == 'ZVOL': - if self.zvol_path is None: - raise ValueError('Zvol path must be set when source type is "ZVOL"') - if self.zvol_path.startswith('/dev/zvol/') is False: - raise ValueError('Zvol path must be a valid zvol path') - elif not os.path.exists(self.zvol_path): - raise ValueError(f'Zvol path {self.zvol_path} does not exist') - - if self.source_type == 'IMAGE' and self.image is None: - raise ValueError('Image must be set when source type is "IMAGE"') - elif self.source_type != 'IMAGE' and self.image: - raise ValueError('Image must not be set when source type is not "IMAGE"') - - return self - - -class VirtInstanceCreateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = None - cpu: str | None = None - memory: MemoryType | None = None - vnc_port: int | None = Field(ge=5900, le=65535) - enable_vnc: bool - vnc_password: Secret[NonEmptyString | None] - '''Setting vnc_password to null will unset VNC password''' - secure_boot: bool = False - root_disk_size: int | None = Field(ge=5, default=None) - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - - -class VirtInstanceUpdateArgs(BaseModel): - id: str - virt_instance_update: VirtInstanceUpdate - - -class VirtInstanceUpdateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceDeleteArgs(BaseModel): - id: str - - -class VirtInstanceDeleteResult(BaseModel): - result: Literal[True] - - -class VirtInstanceStartArgs(BaseModel): - id: str - - -class VirtInstanceStartResult(BaseModel): - result: bool - - -class StopArgs(BaseModel): - timeout: int = -1 - force: bool = False - - -class VirtInstanceStopArgs(BaseModel): - id: str - stop_args: StopArgs = StopArgs() - - @model_validator(mode='after') - def validate_attrs(self): - if self.stop_args.force is False and self.stop_args.timeout == -1: - raise ValueError('Timeout should be set if force is disabled') - return self - - -class VirtInstanceStopResult(BaseModel): - result: bool - - -class VirtInstanceRestartArgs(VirtInstanceStopArgs): - pass - - -class VirtInstanceRestartResult(BaseModel): - result: bool - - -class VirtInstanceImageChoices(BaseModel): - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - - -class VirtInstanceImageChoicesArgs(BaseModel): - virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() - - -class ImageChoiceItem(BaseModel): - label: str - os: str - release: str - archs: list[str] - variant: str - instance_types: list[InstanceType] - secureboot: bool | None - - -class VirtInstanceImageChoicesResult(BaseModel): - result: dict[str, ImageChoiceItem] - - -class VirtInstanceDeviceDeviceListArgs(BaseModel): - id: str - - -class VirtInstanceDeviceDeviceListResult(BaseModel): - result: list[DeviceType] - - -class VirtInstanceDeviceDeviceAddArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceAddResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceUpdateArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceUpdateResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceDeleteArgs(BaseModel): - id: str - name: str - - -class VirtInstanceDeviceDeviceDeleteResult(BaseModel): - result: Literal[True] diff --git a/src/middlewared/middlewared/api/v25_04_0/virt_volume.py b/src/middlewared/middlewared/api/v25_04_0/virt_volume.py deleted file mode 100644 index 17b62eb78561a..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_0/virt_volume.py +++ /dev/null @@ -1,124 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator, StringConstraints - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args, -) - -__all__ = [ - 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', - 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', - 'VirtVolumeDeleteResult', 'VirtVolumeImportIsoArgs', 'VirtVolumeImportIsoResult', - 'VirtVolumeImportZvolArgs', 'VirtVolumeImportZvolResult', -] - - -RE_VOLUME_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9-._]*[A-Za-z0-9]$', re.IGNORECASE) -VOLUME_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - RE_VOLUME_NAME, - 'Name can contain only letters, numbers, dashes, underscores and dots. ' - 'Name must start with a letter, and must not end with a dash.' - ), - ), - StringConstraints(max_length=63), -] - - -class VirtVolumeEntry(BaseModel): - id: NonEmptyString - name: NonEmptyString - storage_pool: 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: VOLUME_NAME - 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''' - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -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: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - iso_location: NonEmptyString | None = None - upload_iso: bool = False - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -class VirtVolumeImportIsoResult(BaseModel): - result: VirtVolumeEntry - - -class ZvolImportEntry(BaseModel): - virt_volume_name: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - zvol_path: NonEmptyString - '''Specify path of zvol in /dev/zvol''' - - @field_validator('zvol_path') - @classmethod - def validate_source(cls, zvol_path): - if not zvol_path.startswith('/dev/zvol/'): - raise ValueError('Not a valid /dev/zvol path') - - return zvol_path - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportZvolArgs(BaseModel): - to_import: list[ZvolImportEntry] - '''List of zvols to import as volumes''' - clone: bool = False - '''Optionally clone and promote zvol''' - - -class VirtVolumeImportZvolResult(BaseModel): - result: VirtVolumeEntry diff --git a/src/middlewared/middlewared/api/v25_04_1/__init__.py b/src/middlewared/middlewared/api/v25_04_1/__init__.py index 937f783845dd6..74c081c2a0d8c 100644 --- a/src/middlewared/middlewared/api/v25_04_1/__init__.py +++ b/src/middlewared/middlewared/api/v25_04_1/__init__.py @@ -66,9 +66,5 @@ from .tn_connect import * # noqa from .truenas import * # noqa from .user import * # noqa -from .virt_device import * # noqa -from .virt_global import * # noqa -from .virt_instance import * # noqa -from .virt_volume import * # noqa from .webui_enclosure import * # noqa from .webui_main_dashboard import * # noqa diff --git a/src/middlewared/middlewared/api/v25_04_1/pool_dataset_details.py b/src/middlewared/middlewared/api/v25_04_1/pool_dataset_details.py index ec91d9cb89e82..cb08de0649164 100644 --- a/src/middlewared/middlewared/api/v25_04_1/pool_dataset_details.py +++ b/src/middlewared/middlewared/api/v25_04_1/pool_dataset_details.py @@ -71,7 +71,6 @@ class PoolDatasetDetailsModel(BaseModel): iscsi_shares: list[PDD_ISCSIEntry] vms: list[PDD_VMEntry] apps: list[PDD_AppEntry] - virt_instances: list[PDD_VirtEntry] replication_tasks_count: int snapshot_tasks_count: int cloudsync_tasks_count: int diff --git a/src/middlewared/middlewared/api/v25_04_1/virt_device.py b/src/middlewared/middlewared/api/v25_04_1/virt_device.py deleted file mode 100644 index 14c92ea843c87..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_1/virt_device.py +++ /dev/null @@ -1,198 +0,0 @@ -from typing import Annotated, Literal, TypeAlias - -from pydantic import Field, field_validator - -from middlewared.api.base import BaseModel, LocalGID, LocalUID, NonEmptyString, single_argument_args - - -__all__ = [ - 'DeviceType', 'InstanceType', 'VirtDeviceUsbChoicesArgs', 'VirtDeviceUsbChoicesResult', - 'VirtDeviceGpuChoicesArgs', 'VirtDeviceGpuChoicesResult', 'VirtDeviceDiskChoicesArgs', - 'VirtDeviceDiskChoicesResult', 'VirtDeviceNicChoicesArgs', 'VirtDeviceNicChoicesResult', - 'VirtDeviceExportDiskImageArgs', 'VirtDeviceExportDiskImageResult', 'VirtDeviceImportDiskImageArgs', - 'VirtDeviceImportDiskImageResult', 'VirtDevicePciChoicesArgs', 'VirtDevicePciChoicesResult', -] - - -InstanceType: TypeAlias = Literal['CONTAINER', 'VM'] - - -class Device(BaseModel): - name: NonEmptyString | None = None - description: NonEmptyString | None = None - readonly: bool = False - - -class Disk(Device): - dev_type: Literal['DISK'] - 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) - io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which the device is located. This must be one - of the storage pools listed in virt.global.config output. - If this is set to None during device creation, then the default storage - pool defined in virt.global.config will be used. - ''' - - @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'] - - -class NIC(Device): - dev_type: Literal['NIC'] - network: NonEmptyString | None = None - nic_type: NicType | None = None - parent: NonEmptyString | None = None - - -class USB(Device): - dev_type: Literal['USB'] - bus: int | None = None - dev: int | None = None - product_id: str | None = None - vendor_id: str | None = None - - -Proto: TypeAlias = Literal['UDP', 'TCP'] - - -class Proxy(Device): - dev_type: Literal['PROXY'] - source_proto: Proto - source_port: int = Field(ge=1, le=65535) - dest_proto: Proto - dest_port: int = Field(ge=1, le=65535) - - -class TPM(Device): - dev_type: Literal['TPM'] - path: str | None = None - pathrm: str | None = None - - -GPUType: TypeAlias = Literal['PHYSICAL', 'MDEV', 'MIG', 'SRIOV'] - - -class GPU(Device): - dev_type: Literal['GPU'] - gpu_type: GPUType - id: str | None = None - gid: LocalGID | None = None - uid: LocalUID | None = None - mode: str | None = None - mdev: NonEmptyString | None = None - mig_uuid: NonEmptyString | None = None - pci: NonEmptyString | None = None - productid: NonEmptyString | None = None - vendorid: NonEmptyString | None = None - - -class PCI(Device): - dev_type: Literal['PCI'] - address: NonEmptyString - - -DeviceType: TypeAlias = Annotated[ - Disk | GPU | Proxy | TPM | USB | NIC | PCI, - Field(discriminator='dev_type') -] - - -class VirtDeviceUsbChoicesArgs(BaseModel): - pass - - -class USBChoice(BaseModel): - vendor_id: str - product_id: str - bus: int - dev: int - product: str | None - manufacturer: str | None - - -class VirtDeviceUsbChoicesResult(BaseModel): - result: dict[str, USBChoice] - - -class VirtDeviceGpuChoicesArgs(BaseModel): - gpu_type: GPUType - - -class GPUChoice(BaseModel): - bus: str - slot: str - description: str - vendor: str | None = None - pci: str - - -class VirtDeviceGpuChoicesResult(BaseModel): - result: dict[str, GPUChoice] - - -class VirtDeviceDiskChoicesArgs(BaseModel): - pass - - -class VirtDeviceDiskChoicesResult(BaseModel): - result: dict[str, str] - - -class VirtDeviceNicChoicesArgs(BaseModel): - nic_type: NicType - - -class VirtDeviceNicChoicesResult(BaseModel): - result: dict[str, str] - - -@single_argument_args('virt_device_import_disk_image') -class VirtDeviceImportDiskImageArgs(BaseModel): - diskimg: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceImportDiskImageResult(BaseModel): - result: bool - - -@single_argument_args('virt_device_export_disk_image') -class VirtDeviceExportDiskImageArgs(BaseModel): - format: NonEmptyString - directory: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceExportDiskImageResult(BaseModel): - result: bool - - -class VirtDevicePciChoicesArgs(BaseModel): - pass - - -class VirtDevicePciChoicesResult(BaseModel): - result: dict diff --git a/src/middlewared/middlewared/api/v25_04_1/virt_global.py b/src/middlewared/middlewared/api/v25_04_1/virt_global.py deleted file mode 100644 index 935b06745e807..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_1/virt_global.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Literal - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, single_argument_result, -) - - -__all__ = [ - 'VirtGlobalEntry', 'VirtGlobalUpdateResult', 'VirtGlobalUpdateArgs', 'VirtGlobalBridgeChoicesArgs', - 'VirtGlobalBridgeChoicesResult', 'VirtGlobalPoolChoicesArgs', 'VirtGlobalPoolChoicesResult', - 'VirtGlobalGetNetworkArgs', 'VirtGlobalGetNetworkResult', -] - - -class VirtGlobalEntry(BaseModel): - id: int - pool: str | None = None - "Default storage pool when creating new instances and volumes" - dataset: str | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - bridge: str | None = None - v4_network: str | None = None - v6_network: str | None = None - state: Literal['INITIALIZING', 'INITIALIZED', 'NO_POOL', 'ERROR', 'LOCKED'] | None = None - - -class VirtGlobalUpdateResult(BaseModel): - result: VirtGlobalEntry - - -@single_argument_args('virt_global_update') -class VirtGlobalUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass): - pool: NonEmptyString | None = None - "Default storage pool when creating new instances and volumes" - bridge: NonEmptyString | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - v4_network: str | None = None - v6_network: str | None = None - - -class VirtGlobalBridgeChoicesArgs(BaseModel): - pass - - -class VirtGlobalBridgeChoicesResult(BaseModel): - result: dict - - -class VirtGlobalPoolChoicesArgs(BaseModel): - pass - - -class VirtGlobalPoolChoicesResult(BaseModel): - result: dict - - -class VirtGlobalGetNetworkArgs(BaseModel): - name: NonEmptyString - - -@single_argument_result -class VirtGlobalGetNetworkResult(BaseModel): - type: Literal['BRIDGE'] - managed: bool - ipv4_address: NonEmptyString - ipv4_nat: bool - ipv6_address: NonEmptyString - ipv6_nat: bool diff --git a/src/middlewared/middlewared/api/v25_04_1/virt_instance.py b/src/middlewared/middlewared/api/v25_04_1/virt_instance.py deleted file mode 100644 index 612dece7121d7..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_1/virt_instance.py +++ /dev/null @@ -1,313 +0,0 @@ -import os -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, model_validator, Secret, StringConstraints - -from middlewared.api.base import BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args - -from .virt_device import DeviceType, InstanceType - - -__all__ = [ - 'VirtInstanceEntry', 'VirtInstanceCreateArgs', 'VirtInstanceCreateResult', 'VirtInstanceUpdateArgs', - 'VirtInstanceUpdateResult', 'VirtInstanceDeleteArgs', 'VirtInstanceDeleteResult', - 'VirtInstanceStartArgs', 'VirtInstanceStartResult', 'VirtInstanceStopArgs', 'VirtInstanceStopResult', - 'VirtInstanceRestartArgs', 'VirtInstanceRestartResult', 'VirtInstanceImageChoicesArgs', - 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceDeviceListArgs', 'VirtInstanceDeviceDeviceListResult', - 'VirtInstanceDeviceDeviceAddArgs', 'VirtInstanceDeviceDeviceAddResult', 'VirtInstanceDeviceDeviceUpdateArgs', - 'VirtInstanceDeviceDeviceUpdateResult', 'VirtInstanceDeviceDeviceDeleteArgs', 'VirtInstanceDeviceDeviceDeleteResult', -] - - -REMOTE_CHOICES: TypeAlias = Literal['LINUX_CONTAINERS'] -ENV_KEY: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^\w[\w/]*$'), - 'ENV_KEY must not be empty, should start with alphanumeric characters' - ', should not contain whitespaces, and can have _ and /' - ) - ) -] -ENV_VALUE: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^(?!\s*$).+'), - 'ENV_VALUE must have at least one non-whitespace character to be considered valid' - ) - ) -] - - -class VirtInstanceAlias(BaseModel): - type: Literal['INET', 'INET6'] - address: NonEmptyString - netmask: int | None - - -class Image(BaseModel): - architecture: str | None - description: str | None - os: str | None - release: str | None - serial: str | None - type: str | None - variant: str | None - secureboot: bool | None - - -class IdmapUserNsEntry(BaseModel): - hostid: int - maprange: int - nsid: int - - -class UserNsIdmap(BaseModel): - uid: IdmapUserNsEntry - gid: IdmapUserNsEntry - - -class VirtInstanceEntry(BaseModel): - id: str - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - type: InstanceType = 'CONTAINER' - status: Literal['RUNNING', 'STOPPED', 'UNKNOWN', 'ERROR', 'FROZEN', 'STARTING', 'STOPPING', 'FREEZING', 'THAWED', 'ABORTING'] - cpu: str | None - memory: int | None - autostart: bool - environment: dict[str, str] - aliases: list[VirtInstanceAlias] - image: Image - userns_idmap: UserNsIdmap | None - raw: Secret[dict | None] - vnc_enabled: bool - vnc_port: int | None - vnc_password: Secret[NonEmptyString | None] - secure_boot: bool | None - root_disk_size: int | None - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] - storage_pool: NonEmptyString - "Storage pool in which the root of the instance is located." - - -def validate_memory(value: int) -> int: - if value < 33554432: - raise ValueError('Value must be 32MiB or larger') - return value - - -# Lets require at least 32MiB of reserved memory -# This value is somewhat arbitrary but hard to think lower value would have to be used -# (would most likely be a typo). -# Running container with very low memory will probably cause it to be killed by the cgroup OOM -MemoryType: TypeAlias = Annotated[int, AfterValidator(validate_memory)] - - -@single_argument_args('virt_instance_create') -class VirtInstanceCreateArgs(BaseModel): - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - iso_volume: NonEmptyString | None = None - source_type: Literal[None, 'IMAGE', 'ZVOL', 'ISO', 'VOLUME'] = 'IMAGE' - storage_pool: NonEmptyString | None = None - ''' - Storage pool under which to allocate root filesystem. Must be one of the pools - listed in virt.global.config output under "storage_pools". If None (default) then the pool - specified in the global configuration will be used. - ''' - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] | None = None - root_disk_size: int = Field(ge=5, default=10) # In GBs - ''' - This can be specified when creating VMs so the root device's size can be configured. Root device for VMs - is a sparse zvol and the field specifies space in GBs and defaults to 10 GBs. - ''' - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI'] = 'NVME' - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - instance_type: InstanceType = 'CONTAINER' - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = True - cpu: str | None = None - devices: list[DeviceType] | None = None - memory: MemoryType | None = None - secure_boot: bool = False - enable_vnc: bool = False - vnc_port: int | None = Field(ge=5900, le=65535, default=None) - zvol_path: NonEmptyString | None = None - ''' - This is useful when a VM wants to be booted where a ZVOL already has a VM bootstrapped in it and needs - to be ported over to virt plugin. Virt will consume this zvol and add it as a DISK device to the instance - with boot priority set to 1 so the VM can be booted from it. - ''' - volume: NonEmptyString | None = None - ''' - This should be set when source type is "VOLUME" and should be the name of the virt volume which should - be used to boot the VM instance. - ''' - vnc_password: Secret[NonEmptyString | 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') - if self.zvol_path: - raise ValueError('Zvol path is only supported for VMs') - else: - if self.enable_vnc and self.vnc_port is None: - raise ValueError('VNC port must be set when VNC is enabled') - - if self.vnc_password is not None and not self.enable_vnc: - raise ValueError('VNC password can only be set when VNC is enabled') - - if self.source_type == 'ISO' and self.iso_volume is None: - raise ValueError('ISO volume must be set when source type is "ISO"') - - if self.source_type == 'VOLUME' and self.volume is None: - raise ValueError('volume must be set when source type is "VOLUME"') - - if self.source_type == 'ZVOL': - if self.zvol_path is None: - raise ValueError('Zvol path must be set when source type is "ZVOL"') - if self.zvol_path.startswith('/dev/zvol/') is False: - raise ValueError('Zvol path must be a valid zvol path') - elif not os.path.exists(self.zvol_path): - raise ValueError(f'Zvol path {self.zvol_path} does not exist') - - if self.source_type == 'IMAGE' and self.image is None: - raise ValueError('Image must be set when source type is "IMAGE"') - elif self.source_type != 'IMAGE' and self.image: - raise ValueError('Image must not be set when source type is not "IMAGE"') - - return self - - -class VirtInstanceCreateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = None - cpu: str | None = None - memory: MemoryType | None = None - vnc_port: int | None = Field(ge=5900, le=65535) - enable_vnc: bool - vnc_password: Secret[NonEmptyString | None] - '''Setting vnc_password to null will unset VNC password''' - secure_boot: bool = False - root_disk_size: int | None = Field(ge=5, default=None) - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - - -class VirtInstanceUpdateArgs(BaseModel): - id: str - virt_instance_update: VirtInstanceUpdate - - -class VirtInstanceUpdateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceDeleteArgs(BaseModel): - id: str - - -class VirtInstanceDeleteResult(BaseModel): - result: Literal[True] - - -class VirtInstanceStartArgs(BaseModel): - id: str - - -class VirtInstanceStartResult(BaseModel): - result: bool - - -class StopArgs(BaseModel): - timeout: int = -1 - force: bool = False - - -class VirtInstanceStopArgs(BaseModel): - id: str - stop_args: StopArgs = StopArgs() - - @model_validator(mode='after') - def validate_attrs(self): - if self.stop_args.force is False and self.stop_args.timeout == -1: - raise ValueError('Timeout should be set if force is disabled') - return self - - -class VirtInstanceStopResult(BaseModel): - result: bool - - -class VirtInstanceRestartArgs(VirtInstanceStopArgs): - pass - - -class VirtInstanceRestartResult(BaseModel): - result: bool - - -class VirtInstanceImageChoices(BaseModel): - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - - -class VirtInstanceImageChoicesArgs(BaseModel): - virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() - - -class ImageChoiceItem(BaseModel): - label: str - os: str - release: str - archs: list[str] - variant: str - instance_types: list[InstanceType] - secureboot: bool | None - - -class VirtInstanceImageChoicesResult(BaseModel): - result: dict[str, ImageChoiceItem] - - -class VirtInstanceDeviceDeviceListArgs(BaseModel): - id: str - - -class VirtInstanceDeviceDeviceListResult(BaseModel): - result: list[DeviceType] - - -class VirtInstanceDeviceDeviceAddArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceAddResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceUpdateArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceUpdateResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceDeleteArgs(BaseModel): - id: str - name: str - - -class VirtInstanceDeviceDeviceDeleteResult(BaseModel): - result: Literal[True] diff --git a/src/middlewared/middlewared/api/v25_04_1/virt_volume.py b/src/middlewared/middlewared/api/v25_04_1/virt_volume.py deleted file mode 100644 index 17b62eb78561a..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_1/virt_volume.py +++ /dev/null @@ -1,124 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator, StringConstraints - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args, -) - -__all__ = [ - 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', - 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', - 'VirtVolumeDeleteResult', 'VirtVolumeImportIsoArgs', 'VirtVolumeImportIsoResult', - 'VirtVolumeImportZvolArgs', 'VirtVolumeImportZvolResult', -] - - -RE_VOLUME_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9-._]*[A-Za-z0-9]$', re.IGNORECASE) -VOLUME_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - RE_VOLUME_NAME, - 'Name can contain only letters, numbers, dashes, underscores and dots. ' - 'Name must start with a letter, and must not end with a dash.' - ), - ), - StringConstraints(max_length=63), -] - - -class VirtVolumeEntry(BaseModel): - id: NonEmptyString - name: NonEmptyString - storage_pool: 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: VOLUME_NAME - 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''' - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -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: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - iso_location: NonEmptyString | None = None - upload_iso: bool = False - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -class VirtVolumeImportIsoResult(BaseModel): - result: VirtVolumeEntry - - -class ZvolImportEntry(BaseModel): - virt_volume_name: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - zvol_path: NonEmptyString - '''Specify path of zvol in /dev/zvol''' - - @field_validator('zvol_path') - @classmethod - def validate_source(cls, zvol_path): - if not zvol_path.startswith('/dev/zvol/'): - raise ValueError('Not a valid /dev/zvol path') - - return zvol_path - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportZvolArgs(BaseModel): - to_import: list[ZvolImportEntry] - '''List of zvols to import as volumes''' - clone: bool = False - '''Optionally clone and promote zvol''' - - -class VirtVolumeImportZvolResult(BaseModel): - result: VirtVolumeEntry diff --git a/src/middlewared/middlewared/api/v25_04_2/__init__.py b/src/middlewared/middlewared/api/v25_04_2/__init__.py index 52b37f25912f2..e9b00390e9c70 100644 --- a/src/middlewared/middlewared/api/v25_04_2/__init__.py +++ b/src/middlewared/middlewared/api/v25_04_2/__init__.py @@ -66,10 +66,6 @@ from .tn_connect import * # noqa from .truenas import * # noqa from .user import * # noqa -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 from .webui_enclosure import * # noqa diff --git a/src/middlewared/middlewared/api/v25_04_2/pool_dataset_details.py b/src/middlewared/middlewared/api/v25_04_2/pool_dataset_details.py index ec91d9cb89e82..cb08de0649164 100644 --- a/src/middlewared/middlewared/api/v25_04_2/pool_dataset_details.py +++ b/src/middlewared/middlewared/api/v25_04_2/pool_dataset_details.py @@ -71,7 +71,6 @@ class PoolDatasetDetailsModel(BaseModel): iscsi_shares: list[PDD_ISCSIEntry] vms: list[PDD_VMEntry] apps: list[PDD_AppEntry] - virt_instances: list[PDD_VirtEntry] replication_tasks_count: int snapshot_tasks_count: int cloudsync_tasks_count: int diff --git a/src/middlewared/middlewared/api/v25_04_2/virt_device.py b/src/middlewared/middlewared/api/v25_04_2/virt_device.py deleted file mode 100644 index 14c92ea843c87..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_2/virt_device.py +++ /dev/null @@ -1,198 +0,0 @@ -from typing import Annotated, Literal, TypeAlias - -from pydantic import Field, field_validator - -from middlewared.api.base import BaseModel, LocalGID, LocalUID, NonEmptyString, single_argument_args - - -__all__ = [ - 'DeviceType', 'InstanceType', 'VirtDeviceUsbChoicesArgs', 'VirtDeviceUsbChoicesResult', - 'VirtDeviceGpuChoicesArgs', 'VirtDeviceGpuChoicesResult', 'VirtDeviceDiskChoicesArgs', - 'VirtDeviceDiskChoicesResult', 'VirtDeviceNicChoicesArgs', 'VirtDeviceNicChoicesResult', - 'VirtDeviceExportDiskImageArgs', 'VirtDeviceExportDiskImageResult', 'VirtDeviceImportDiskImageArgs', - 'VirtDeviceImportDiskImageResult', 'VirtDevicePciChoicesArgs', 'VirtDevicePciChoicesResult', -] - - -InstanceType: TypeAlias = Literal['CONTAINER', 'VM'] - - -class Device(BaseModel): - name: NonEmptyString | None = None - description: NonEmptyString | None = None - readonly: bool = False - - -class Disk(Device): - dev_type: Literal['DISK'] - 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) - io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which the device is located. This must be one - of the storage pools listed in virt.global.config output. - If this is set to None during device creation, then the default storage - pool defined in virt.global.config will be used. - ''' - - @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'] - - -class NIC(Device): - dev_type: Literal['NIC'] - network: NonEmptyString | None = None - nic_type: NicType | None = None - parent: NonEmptyString | None = None - - -class USB(Device): - dev_type: Literal['USB'] - bus: int | None = None - dev: int | None = None - product_id: str | None = None - vendor_id: str | None = None - - -Proto: TypeAlias = Literal['UDP', 'TCP'] - - -class Proxy(Device): - dev_type: Literal['PROXY'] - source_proto: Proto - source_port: int = Field(ge=1, le=65535) - dest_proto: Proto - dest_port: int = Field(ge=1, le=65535) - - -class TPM(Device): - dev_type: Literal['TPM'] - path: str | None = None - pathrm: str | None = None - - -GPUType: TypeAlias = Literal['PHYSICAL', 'MDEV', 'MIG', 'SRIOV'] - - -class GPU(Device): - dev_type: Literal['GPU'] - gpu_type: GPUType - id: str | None = None - gid: LocalGID | None = None - uid: LocalUID | None = None - mode: str | None = None - mdev: NonEmptyString | None = None - mig_uuid: NonEmptyString | None = None - pci: NonEmptyString | None = None - productid: NonEmptyString | None = None - vendorid: NonEmptyString | None = None - - -class PCI(Device): - dev_type: Literal['PCI'] - address: NonEmptyString - - -DeviceType: TypeAlias = Annotated[ - Disk | GPU | Proxy | TPM | USB | NIC | PCI, - Field(discriminator='dev_type') -] - - -class VirtDeviceUsbChoicesArgs(BaseModel): - pass - - -class USBChoice(BaseModel): - vendor_id: str - product_id: str - bus: int - dev: int - product: str | None - manufacturer: str | None - - -class VirtDeviceUsbChoicesResult(BaseModel): - result: dict[str, USBChoice] - - -class VirtDeviceGpuChoicesArgs(BaseModel): - gpu_type: GPUType - - -class GPUChoice(BaseModel): - bus: str - slot: str - description: str - vendor: str | None = None - pci: str - - -class VirtDeviceGpuChoicesResult(BaseModel): - result: dict[str, GPUChoice] - - -class VirtDeviceDiskChoicesArgs(BaseModel): - pass - - -class VirtDeviceDiskChoicesResult(BaseModel): - result: dict[str, str] - - -class VirtDeviceNicChoicesArgs(BaseModel): - nic_type: NicType - - -class VirtDeviceNicChoicesResult(BaseModel): - result: dict[str, str] - - -@single_argument_args('virt_device_import_disk_image') -class VirtDeviceImportDiskImageArgs(BaseModel): - diskimg: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceImportDiskImageResult(BaseModel): - result: bool - - -@single_argument_args('virt_device_export_disk_image') -class VirtDeviceExportDiskImageArgs(BaseModel): - format: NonEmptyString - directory: NonEmptyString - zvol: NonEmptyString - - -class VirtDeviceExportDiskImageResult(BaseModel): - result: bool - - -class VirtDevicePciChoicesArgs(BaseModel): - pass - - -class VirtDevicePciChoicesResult(BaseModel): - result: dict diff --git a/src/middlewared/middlewared/api/v25_04_2/virt_global.py b/src/middlewared/middlewared/api/v25_04_2/virt_global.py deleted file mode 100644 index 935b06745e807..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_2/virt_global.py +++ /dev/null @@ -1,70 +0,0 @@ -from typing import Literal - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, single_argument_result, -) - - -__all__ = [ - 'VirtGlobalEntry', 'VirtGlobalUpdateResult', 'VirtGlobalUpdateArgs', 'VirtGlobalBridgeChoicesArgs', - 'VirtGlobalBridgeChoicesResult', 'VirtGlobalPoolChoicesArgs', 'VirtGlobalPoolChoicesResult', - 'VirtGlobalGetNetworkArgs', 'VirtGlobalGetNetworkResult', -] - - -class VirtGlobalEntry(BaseModel): - id: int - pool: str | None = None - "Default storage pool when creating new instances and volumes" - dataset: str | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - bridge: str | None = None - v4_network: str | None = None - v6_network: str | None = None - state: Literal['INITIALIZING', 'INITIALIZED', 'NO_POOL', 'ERROR', 'LOCKED'] | None = None - - -class VirtGlobalUpdateResult(BaseModel): - result: VirtGlobalEntry - - -@single_argument_args('virt_global_update') -class VirtGlobalUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass): - pool: NonEmptyString | None = None - "Default storage pool when creating new instances and volumes" - bridge: NonEmptyString | None = None - storage_pools: list[str] | None = None - "ZFS pools to use as storage pools" - v4_network: str | None = None - v6_network: str | None = None - - -class VirtGlobalBridgeChoicesArgs(BaseModel): - pass - - -class VirtGlobalBridgeChoicesResult(BaseModel): - result: dict - - -class VirtGlobalPoolChoicesArgs(BaseModel): - pass - - -class VirtGlobalPoolChoicesResult(BaseModel): - result: dict - - -class VirtGlobalGetNetworkArgs(BaseModel): - name: NonEmptyString - - -@single_argument_result -class VirtGlobalGetNetworkResult(BaseModel): - type: Literal['BRIDGE'] - managed: bool - ipv4_address: NonEmptyString - ipv4_nat: bool - ipv6_address: NonEmptyString - ipv6_nat: bool diff --git a/src/middlewared/middlewared/api/v25_04_2/virt_instance.py b/src/middlewared/middlewared/api/v25_04_2/virt_instance.py deleted file mode 100644 index c5a243b995585..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_2/virt_instance.py +++ /dev/null @@ -1,261 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, model_validator, Secret, StringConstraints - -from middlewared.api.base import BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args - -from .virt_device import DeviceType, InstanceType - - -__all__ = [ - 'VirtInstanceEntry', 'VirtInstanceCreateArgs', 'VirtInstanceCreateResult', 'VirtInstanceUpdateArgs', - 'VirtInstanceUpdateResult', 'VirtInstanceDeleteArgs', 'VirtInstanceDeleteResult', - 'VirtInstanceStartArgs', 'VirtInstanceStartResult', 'VirtInstanceStopArgs', 'VirtInstanceStopResult', - 'VirtInstanceRestartArgs', 'VirtInstanceRestartResult', 'VirtInstanceImageChoicesArgs', - 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceDeviceListArgs', 'VirtInstanceDeviceDeviceListResult', - 'VirtInstanceDeviceDeviceAddArgs', 'VirtInstanceDeviceDeviceAddResult', 'VirtInstanceDeviceDeviceUpdateArgs', - 'VirtInstanceDeviceDeviceUpdateResult', 'VirtInstanceDeviceDeviceDeleteArgs', 'VirtInstanceDeviceDeviceDeleteResult', -] - - -REMOTE_CHOICES: TypeAlias = Literal['LINUX_CONTAINERS'] -ENV_KEY: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^\w[\w/]*$'), - 'ENV_KEY must not be empty, should start with alphanumeric characters' - ', should not contain whitespaces, and can have _ and /' - ) - ) -] -ENV_VALUE: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^(?!\s*$).+'), - 'ENV_VALUE must have at least one non-whitespace character to be considered valid' - ) - ) -] - - -class VirtInstanceAlias(BaseModel): - type: Literal['INET', 'INET6'] - address: NonEmptyString - netmask: int | None - - -class Image(BaseModel): - architecture: str | None - description: str | None - os: str | None - release: str | None - serial: str | None - type: str | None - variant: str | None - secureboot: bool | None - - -class IdmapUserNsEntry(BaseModel): - hostid: int - maprange: int - nsid: int - - -class UserNsIdmap(BaseModel): - uid: IdmapUserNsEntry - gid: IdmapUserNsEntry - - -class VirtInstanceEntry(BaseModel): - id: str - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - type: InstanceType = 'CONTAINER' - status: Literal['RUNNING', 'STOPPED', 'UNKNOWN', 'ERROR', 'FROZEN', 'STARTING', 'STOPPING', 'FREEZING', 'THAWED', 'ABORTING'] - cpu: str | None - memory: int | None - autostart: bool - environment: dict[str, str] - aliases: list[VirtInstanceAlias] - image: Image - userns_idmap: UserNsIdmap | None - raw: Secret[dict | None] - vnc_enabled: bool - vnc_port: int | None - vnc_password: Secret[NonEmptyString | None] - secure_boot: bool | None - root_disk_size: int | None - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] - storage_pool: NonEmptyString - "Storage pool in which the root of the instance is located." - - -def validate_memory(value: int) -> int: - if value < 33554432: - raise ValueError('Value must be 32MiB or larger') - return value - - -# Lets require at least 32MiB of reserved memory -# This value is somewhat arbitrary but hard to think lower value would have to be used -# (would most likely be a typo). -# Running container with very low memory will probably cause it to be killed by the cgroup OOM -MemoryType: TypeAlias = Annotated[int, AfterValidator(validate_memory)] - - -@single_argument_args('virt_instance_create') -class VirtInstanceCreateArgs(BaseModel): - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - iso_volume: NonEmptyString | None = None - source_type: Literal['IMAGE'] = 'IMAGE' - storage_pool: NonEmptyString | None = None - ''' - Storage pool under which to allocate root filesystem. Must be one of the pools - listed in virt.global.config output under "storage_pools". If None (default) then the pool - specified in the global configuration will be used. - ''' - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - instance_type: Literal['CONTAINER'] = 'CONTAINER' - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = True - cpu: str | None = None - devices: list[DeviceType] | None = None - memory: MemoryType | None = None - - @model_validator(mode='after') - def validate_attrs(self): - if self.image is None: - raise ValueError('Image must be set when source type is "IMAGE"') - - return self - - -class VirtInstanceCreateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): - environment: dict[ENV_KEY, ENV_VALUE] | None = None - autostart: bool | None = None - cpu: str | None = None - memory: MemoryType | None = None - vnc_port: int | None = Field(ge=5900, le=65535) - enable_vnc: bool - vnc_password: Secret[NonEmptyString | None] - '''Setting vnc_password to null will unset VNC password''' - secure_boot: bool = False - root_disk_size: int | None = Field(ge=5, default=None) - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - - -class VirtInstanceUpdateArgs(BaseModel): - id: str - virt_instance_update: VirtInstanceUpdate - - -class VirtInstanceUpdateResult(BaseModel): - result: VirtInstanceEntry - - -class VirtInstanceDeleteArgs(BaseModel): - id: str - - -class VirtInstanceDeleteResult(BaseModel): - result: Literal[True] - - -class VirtInstanceStartArgs(BaseModel): - id: str - - -class VirtInstanceStartResult(BaseModel): - result: bool - - -class StopArgs(BaseModel): - timeout: int = -1 - force: bool = False - - -class VirtInstanceStopArgs(BaseModel): - id: str - stop_args: StopArgs = StopArgs() - - @model_validator(mode='after') - def validate_attrs(self): - if self.stop_args.force is False and self.stop_args.timeout == -1: - raise ValueError('Timeout should be set if force is disabled') - return self - - -class VirtInstanceStopResult(BaseModel): - result: bool - - -class VirtInstanceRestartArgs(VirtInstanceStopArgs): - pass - - -class VirtInstanceRestartResult(BaseModel): - result: bool - - -class VirtInstanceImageChoices(BaseModel): - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - - -class VirtInstanceImageChoicesArgs(BaseModel): - virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() - - -class ImageChoiceItem(BaseModel): - label: str - os: str - release: str - archs: list[str] - variant: str - instance_types: list[InstanceType] - secureboot: bool | None - - -class VirtInstanceImageChoicesResult(BaseModel): - result: dict[str, ImageChoiceItem] - - -class VirtInstanceDeviceDeviceListArgs(BaseModel): - id: str - - -class VirtInstanceDeviceDeviceListResult(BaseModel): - result: list[DeviceType] - - -class VirtInstanceDeviceDeviceAddArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceAddResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceUpdateArgs(BaseModel): - id: str - device: DeviceType - - -class VirtInstanceDeviceDeviceUpdateResult(BaseModel): - result: Literal[True] - - -class VirtInstanceDeviceDeviceDeleteArgs(BaseModel): - id: str - name: str - - -class VirtInstanceDeviceDeviceDeleteResult(BaseModel): - result: Literal[True] diff --git a/src/middlewared/middlewared/api/v25_04_2/virt_volume.py b/src/middlewared/middlewared/api/v25_04_2/virt_volume.py deleted file mode 100644 index 17b62eb78561a..0000000000000 --- a/src/middlewared/middlewared/api/v25_04_2/virt_volume.py +++ /dev/null @@ -1,124 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator, StringConstraints - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args, -) - -__all__ = [ - 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', - 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', - 'VirtVolumeDeleteResult', 'VirtVolumeImportIsoArgs', 'VirtVolumeImportIsoResult', - 'VirtVolumeImportZvolArgs', 'VirtVolumeImportZvolResult', -] - - -RE_VOLUME_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9-._]*[A-Za-z0-9]$', re.IGNORECASE) -VOLUME_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - RE_VOLUME_NAME, - 'Name can contain only letters, numbers, dashes, underscores and dots. ' - 'Name must start with a letter, and must not end with a dash.' - ), - ), - StringConstraints(max_length=63), -] - - -class VirtVolumeEntry(BaseModel): - id: NonEmptyString - name: NonEmptyString - storage_pool: 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: VOLUME_NAME - 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''' - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -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: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - iso_location: NonEmptyString | None = None - upload_iso: bool = False - storage_pool: NonEmptyString | None = None - ''' - Storage pool in which to create the volume. This must be one of pools listed - in virt.global.config output under `storage_pools`. If the value is None, then - the pool defined as `pool` in virt.global.config will be used. - ''' - - -class VirtVolumeImportIsoResult(BaseModel): - result: VirtVolumeEntry - - -class ZvolImportEntry(BaseModel): - virt_volume_name: VOLUME_NAME - '''Specify name of the newly created volume from the ISO specified''' - zvol_path: NonEmptyString - '''Specify path of zvol in /dev/zvol''' - - @field_validator('zvol_path') - @classmethod - def validate_source(cls, zvol_path): - if not zvol_path.startswith('/dev/zvol/'): - raise ValueError('Not a valid /dev/zvol path') - - return zvol_path - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportZvolArgs(BaseModel): - to_import: list[ZvolImportEntry] - '''List of zvols to import as volumes''' - clone: bool = False - '''Optionally clone and promote zvol''' - - -class VirtVolumeImportZvolResult(BaseModel): - result: VirtVolumeEntry diff --git a/src/middlewared/middlewared/api/v25_10_0/__init__.py b/src/middlewared/middlewared/api/v25_10_0/__init__.py index e56c765be4a06..9cec950509d9e 100644 --- a/src/middlewared/middlewared/api/v25_10_0/__init__.py +++ b/src/middlewared/middlewared/api/v25_10_0/__init__.py @@ -112,10 +112,6 @@ from .update import * from .ups import * from .user import * -from .virt_device import * -from .virt_global import * -from .virt_instance import * -from .virt_volume import * from .vm import * from .vm_device import * from .vmware import * diff --git a/src/middlewared/middlewared/api/v25_10_0/virt_device.py b/src/middlewared/middlewared/api/v25_10_0/virt_device.py deleted file mode 100644 index 81df244532c0f..0000000000000 --- a/src/middlewared/middlewared/api/v25_10_0/virt_device.py +++ /dev/null @@ -1,294 +0,0 @@ -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator - -from middlewared.api.base import BaseModel, LocalGID, LocalUID, match_validator, NonEmptyString, single_argument_args -from middlewared.validators import RE_MAC_ADDRESS - - -__all__ = [ - 'DeviceType', 'InstanceType', 'VirtDeviceUsbChoicesArgs', 'VirtDeviceUsbChoicesResult', - 'VirtDeviceGpuChoicesArgs', 'VirtDeviceGpuChoicesResult', 'VirtDeviceDiskChoicesArgs', - 'VirtDeviceDiskChoicesResult', 'VirtDeviceNicChoicesArgs', 'VirtDeviceNicChoicesResult', - 'VirtDeviceExportDiskImageArgs', 'VirtDeviceExportDiskImageResult', 'VirtDeviceImportDiskImageArgs', - 'VirtDeviceImportDiskImageResult', 'VirtDevicePciChoicesArgs', 'VirtDevicePciChoicesResult', - 'VirtInstanceDeviceSetBootableDiskArgs', 'VirtInstanceDeviceSetBootableDiskResult', -] - - -InstanceType: TypeAlias = Literal['CONTAINER', 'VM'] -MAC: TypeAlias = Annotated[ - str | None, - AfterValidator( - match_validator( - RE_MAC_ADDRESS, - 'MAC address is not valid.' - ) - ) -] - - -class Device(BaseModel): - name: NonEmptyString | None = None - """Optional human-readable name for the virtualization device.""" - description: NonEmptyString | None = None - """Optional description explaining the purpose or configuration of this device.""" - readonly: bool = False - """Whether the device should be mounted in read-only mode.""" - - -class Disk(Device): - dev_type: Literal['DISK'] - """Device type identifier for virtual disk devices.""" - 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 - """Target path where the disk appears inside the virtualized instance.""" - boot_priority: int | None = Field(default=None, ge=0) - """Boot priority for this disk device. Lower numbers boot first. `null` means non-bootable.""" - io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - """Storage bus type for optimal performance and compatibility.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which the device is located. This must be one \ - of the storage pools listed in virt.global.config output. - If this is set to None during device creation, then the default storage \ - pool defined in virt.global.config will be used. - """ - - @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'] - - -class NIC(Device): - dev_type: Literal['NIC'] - """Device type identifier for network interface cards.""" - network: NonEmptyString | None = None - """Name of the network to connect this NIC to.""" - nic_type: NicType | None = None - """Type of network interface (bridged or macvlan).""" - parent: NonEmptyString | None = None - """Parent network interface on the host system.""" - mac: MAC = None - """MAC address for the virtual network interface. `null` for auto-generated.""" - - -class USB(Device): - dev_type: Literal['USB'] - """Device type identifier for USB devices.""" - bus: int | None = None - """USB bus number on the host system.""" - dev: int | None = None - """USB device number on the specified bus.""" - product_id: str | None = None - """USB product identifier for device matching.""" - vendor_id: str | None = None - """USB vendor identifier for device matching.""" - - -Proto: TypeAlias = Literal['UDP', 'TCP'] - - -class Proxy(Device): - dev_type: Literal['PROXY'] - """Device type identifier for network port forwarding.""" - source_proto: Proto - """Network protocol (TCP or UDP) for the source connection.""" - source_port: int = Field(ge=1, le=65535) - """Source port number on the host system to forward from.""" - dest_proto: Proto - """Network protocol (TCP or UDP) for the destination connection.""" - dest_port: int = Field(ge=1, le=65535) - """Destination port number inside the virtualized instance.""" - - -class TPM(Device): - dev_type: Literal['TPM'] - """Device type identifier for Trusted Platform Module devices.""" - path: str | None = None - """Path to the TPM device on the host system.""" - pathrm: str | None = None - """Resource manager path for TPM device access.""" - - -GPUType: TypeAlias = Literal['PHYSICAL', 'MDEV', 'MIG', 'SRIOV'] - - -class GPU(Device): - dev_type: Literal['GPU'] - """Device type identifier for graphics processing units.""" - gpu_type: GPUType - """Type of GPU virtualization (physical passthrough, mediated device, etc.).""" - id: str | None = None - """Unique identifier for the GPU device.""" - gid: LocalGID | None = None - """Group ID for device permissions inside the container.""" - uid: LocalUID | None = None - """User ID for device permissions inside the container.""" - mode: str | None = None - """Permission mode for device access (e.g., '660').""" - mdev: NonEmptyString | None = None - """Mediated device identifier for GPU virtualization.""" - mig_uuid: NonEmptyString | None = None - """Multi-Instance GPU UUID for NVIDIA GPU partitioning.""" - pci: NonEmptyString | None = None - """PCI address of the GPU device on the host system.""" - productid: NonEmptyString | None = None - """Product identifier for GPU device matching.""" - vendorid: NonEmptyString | None = None - """Vendor identifier for GPU device matching.""" - - -class PCI(Device): - dev_type: Literal['PCI'] - """Device type identifier for PCI device passthrough.""" - address: NonEmptyString - """PCI bus address of the device to pass through to the virtualized instance.""" - - -class CDROM(Device): - dev_type: Literal['CDROM'] - """Device type identifier for CD-ROM/DVD optical drives.""" - source: NonEmptyString - """Path to the ISO image file or physical optical drive to mount.""" - boot_priority: int | None = Field(default=None, ge=0) - """Boot priority for this optical device. Lower numbers boot first. `null` means non-bootable.""" - - -DeviceType: TypeAlias = Annotated[ - Disk | GPU | Proxy | TPM | USB | NIC | PCI | CDROM, - Field(discriminator='dev_type') -] - - -class VirtDeviceUsbChoicesArgs(BaseModel): - pass - - -class USBChoice(BaseModel): - vendor_id: str - """USB vendor identifier for this device.""" - product_id: str - """USB product identifier for this device.""" - bus: int - """USB bus number where this device is connected.""" - dev: int - """USB device number on the bus.""" - product: str | None - """Product name of the USB device. `null` if not available.""" - manufacturer: str | None - """Manufacturer name of the USB device. `null` if not available.""" - - -class VirtDeviceUsbChoicesResult(BaseModel): - result: dict[str, USBChoice] - """Object of available USB devices with their hardware information.""" - - -class VirtDeviceGpuChoicesArgs(BaseModel): - gpu_type: GPUType - """Type of GPU virtualization to filter available choices.""" - - -class GPUChoice(BaseModel): - bus: str - """PCI bus identifier for the GPU device.""" - slot: str - """PCI slot identifier for the GPU device.""" - description: str - """Human-readable description of the GPU device.""" - vendor: str | None = None - """GPU vendor name. `null` if not available.""" - pci: str - """Complete PCI address of the GPU device.""" - - -class VirtDeviceGpuChoicesResult(BaseModel): - result: dict[str, GPUChoice] - """Object of available GPU devices with their hardware information.""" - - -class VirtDeviceDiskChoicesArgs(BaseModel): - pass - - -class VirtDeviceDiskChoicesResult(BaseModel): - result: dict[str, str] - """Object of available disk devices and storage volumes for virtualization.""" - - -class VirtDeviceNicChoicesArgs(BaseModel): - nic_type: NicType - """Type of network interface to filter available choices.""" - - -class VirtDeviceNicChoicesResult(BaseModel): - result: dict[str, str] - """Object of available network interfaces for the specified NIC type.""" - - -@single_argument_args('virt_device_import_disk_image') -class VirtDeviceImportDiskImageArgs(BaseModel): - diskimg: NonEmptyString - """Path to the disk image file to import.""" - zvol: NonEmptyString - """Target ZFS volume path where the disk image will be imported.""" - - -class VirtDeviceImportDiskImageResult(BaseModel): - result: bool - """Whether the disk image import operation was successful.""" - - -@single_argument_args('virt_device_export_disk_image') -class VirtDeviceExportDiskImageArgs(BaseModel): - format: NonEmptyString - """Output format for the exported disk image (e.g., 'qcow2', 'raw').""" - directory: NonEmptyString - """Directory path where the exported disk image will be saved.""" - zvol: NonEmptyString - """Source ZFS volume to export as a disk image.""" - - -class VirtDeviceExportDiskImageResult(BaseModel): - result: bool - """Whether the disk image export operation was successful.""" - - -class VirtDevicePciChoicesArgs(BaseModel): - pass - - -class VirtDevicePciChoicesResult(BaseModel): - result: dict - """Object of available PCI devices that can be passed through to virtual instances.""" - - -class VirtInstanceDeviceSetBootableDiskArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtual instance to configure.""" - disk: NonEmptyString - """Name or identifier of the disk device to set as bootable.""" - - -class VirtInstanceDeviceSetBootableDiskResult(BaseModel): - result: bool - """Whether the bootable disk configuration was successfully applied.""" diff --git a/src/middlewared/middlewared/api/v25_10_0/virt_global.py b/src/middlewared/middlewared/api/v25_10_0/virt_global.py deleted file mode 100644 index a52edd8fb6a7e..0000000000000 --- a/src/middlewared/middlewared/api/v25_10_0/virt_global.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Literal - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, single_argument_result, -) - - -__all__ = [ - 'VirtGlobalEntry', 'VirtGlobalUpdateResult', 'VirtGlobalUpdateArgs', 'VirtGlobalBridgeChoicesArgs', - 'VirtGlobalBridgeChoicesResult', 'VirtGlobalPoolChoicesArgs', 'VirtGlobalPoolChoicesResult', - 'VirtGlobalGetNetworkArgs', 'VirtGlobalGetNetworkResult', -] - - -class VirtGlobalEntry(BaseModel): - id: int - """Unique identifier for the virtualization global configuration.""" - pool: str | None = None - """Default storage pool when creating new instances and volumes.""" - dataset: str | None = None - """ZFS dataset path used for virtualization data storage. `null` if not configured.""" - storage_pools: list[str] | None = None - """ZFS pools to use as storage pools.""" - bridge: str | None = None - """Network bridge interface for virtualized instance networking. `null` if not configured.""" - v4_network: str | None = None - """IPv4 network CIDR for the virtualization bridge network. `null` if not configured.""" - v6_network: str | None = None - """IPv6 network CIDR for the virtualization bridge network. `null` if not configured.""" - state: Literal['INITIALIZING', 'INITIALIZED', 'NO_POOL', 'ERROR', 'LOCKED'] | None = None - """Current operational state of the virtualization subsystem. `null` during initial setup.""" - - -class VirtGlobalUpdateResult(BaseModel): - result: VirtGlobalEntry - """The updated virtualization global configuration.""" - - -@single_argument_args('virt_global_update') -class VirtGlobalUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass): - pool: NonEmptyString | None = None - """Default storage pool when creating new instances and volumes.""" - bridge: NonEmptyString | None = None - """Network bridge interface for virtualized instance networking. `null` to disable.""" - storage_pools: list[str] | None = None - """ZFS pools to use as storage pools.""" - v4_network: str | None = None - """IPv4 network CIDR for the virtualization bridge network. `null` to use default.""" - v6_network: str | None = None - """IPv6 network CIDR for the virtualization bridge network. `null` to use default.""" - - -class VirtGlobalBridgeChoicesArgs(BaseModel): - pass - - -class VirtGlobalBridgeChoicesResult(BaseModel): - result: dict - """Object of available network bridge interfaces and their configurations.""" - - -class VirtGlobalPoolChoicesArgs(BaseModel): - pass - - -class VirtGlobalPoolChoicesResult(BaseModel): - result: dict - """Object of available ZFS pools that can be used for virtualization storage.""" - - -class VirtGlobalGetNetworkArgs(BaseModel): - name: NonEmptyString - """Name of the network configuration to retrieve.""" - - -@single_argument_result -class VirtGlobalGetNetworkResult(BaseModel): - type: Literal['BRIDGE'] - """Type of network configuration (currently only bridge networks are supported).""" - managed: bool - """Whether this network is managed by the virtualization system.""" - ipv4_address: NonEmptyString - """IPv4 address and CIDR of the bridge network.""" - ipv4_nat: bool - """Whether IPv4 Network Address Translation is enabled for this bridge.""" - ipv6_address: NonEmptyString - """IPv6 address and CIDR of the bridge network.""" - ipv6_nat: bool - """Whether IPv6 Network Address Translation is enabled for this bridge.""" diff --git a/src/middlewared/middlewared/api/v25_10_0/virt_instance.py b/src/middlewared/middlewared/api/v25_10_0/virt_instance.py deleted file mode 100644 index 5a97786a1522d..0000000000000 --- a/src/middlewared/middlewared/api/v25_10_0/virt_instance.py +++ /dev/null @@ -1,378 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, model_validator, Secret, StringConstraints - -from middlewared.api.base import BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args - -from .virt_device import DeviceType, InstanceType - - -__all__ = [ - 'VirtInstanceEntry', 'VirtInstanceCreateArgs', 'VirtInstanceCreateResult', 'VirtInstanceUpdateArgs', - 'VirtInstanceUpdateResult', 'VirtInstanceDeleteArgs', 'VirtInstanceDeleteResult', - 'VirtInstanceStartArgs', 'VirtInstanceStartResult', 'VirtInstanceStopArgs', 'VirtInstanceStopResult', - 'VirtInstanceRestartArgs', 'VirtInstanceRestartResult', 'VirtInstanceImageChoicesArgs', - 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceDeviceListArgs', 'VirtInstanceDeviceDeviceListResult', - 'VirtInstanceDeviceDeviceAddArgs', 'VirtInstanceDeviceDeviceAddResult', 'VirtInstanceDeviceDeviceUpdateArgs', - 'VirtInstanceDeviceDeviceUpdateResult', 'VirtInstanceDeviceDeviceDeleteArgs', - 'VirtInstanceDeviceDeviceDeleteResult', 'VirtInstancesMetricsEventSourceArgs', - 'VirtInstancesMetricsEventSourceEvent', -] - - -# Some popular OS choices -OS_ENUM = Literal['LINUX', 'FREEBSD', 'WINDOWS', 'ARCHLINUX', None] -REMOTE_CHOICES: TypeAlias = Literal['LINUX_CONTAINERS'] -ENV_KEY: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^\w[\w/]*$'), - 'ENV_KEY must not be empty, should start with alphanumeric characters' - ', should not contain whitespaces, and can have _ and /' - ) - ) -] -ENV_VALUE: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^(?!\s*$).+'), - 'ENV_VALUE must have at least one non-whitespace character to be considered valid' - ) - ) -] - - -class VirtInstanceAlias(BaseModel): - type: Literal['INET', 'INET6'] - """Type of IP address (INET for IPv4, INET6 for IPv6).""" - address: NonEmptyString - """IP address for the virtual instance.""" - netmask: int | None - """Network mask in CIDR notation.""" - - -class Image(BaseModel): - architecture: str | None - """Hardware architecture of the image (e.g., amd64, arm64).""" - description: str | None - """Human-readable description of the image.""" - os: str | None - """Operating system family of the image.""" - release: str | None - """Version or release name of the operating system.""" - serial: str | None - """Unique serial identifier for the image.""" - type: str | None - """Type of image (container, virtual-machine, etc.).""" - variant: str | None - """Image variant (default, cloud, minimal, etc.).""" - secureboot: bool | None - """Whether the image supports UEFI Secure Boot.""" - - -class IdmapUserNsEntry(BaseModel): - hostid: int - """Starting host ID for the mapping range.""" - maprange: int - """Number of IDs to map in this range.""" - nsid: int - """Starting namespace ID for the mapping range.""" - - -class UserNsIdmap(BaseModel): - uid: IdmapUserNsEntry | None - """User ID mapping configuration for user namespace isolation.""" - gid: IdmapUserNsEntry | None - """Group ID mapping configuration for user namespace isolation.""" - - -class VirtInstanceEntry(BaseModel): - id: str - """Unique identifier for the virtual instance.""" - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Human-readable name for the virtual instance.""" - type: InstanceType = 'CONTAINER' - """Type of virtual instance.""" - status: Literal[ - 'RUNNING', 'STOPPED', 'UNKNOWN', 'ERROR', 'FROZEN', 'STARTING', 'STOPPING', 'FREEZING', 'THAWED', 'ABORTING' - ] - """Current operational status of the virtual instance.""" - cpu: str | None - """CPU configuration string or `null` for default allocation.""" - memory: int | None - """Memory allocation in bytes or `null` for default allocation.""" - autostart: bool - """Whether the instance automatically starts when the host boots.""" - environment: dict[str, str] - """Environment variables to set inside the instance.""" - aliases: list[VirtInstanceAlias] - """Array of IP aliases configured for the instance.""" - image: Image - """Image information used to create this instance.""" - userns_idmap: UserNsIdmap | None - """User namespace ID mapping configuration for privilege isolation.""" - raw: Secret[dict | None] - """Raw low-level configuration options (advanced use only).""" - vnc_enabled: bool - """Whether VNC remote access is enabled for the instance.""" - vnc_port: int | None - """TCP port number for VNC connections or `null` if VNC is disabled.""" - vnc_password: Secret[NonEmptyString | None] - """Password for VNC access or `null` if no password is set.""" - secure_boot: bool | None - """Whether UEFI Secure Boot is enabled (VMs only) or `null` for containers.""" - privileged_mode: bool | None - """Whether the container runs in privileged mode or `null` for VMs.""" - root_disk_size: int | None - """Size of the root disk in GB or `null` for default size.""" - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] - """I/O bus type for the root disk or `null` for default.""" - storage_pool: NonEmptyString - """Storage pool in which the root of the instance is located.""" - - -def validate_memory(value: int) -> int: - if value < 33554432: - raise ValueError('Value must be 32MiB or larger') - return value - - -# Lets require at least 32MiB of reserved memory -# This value is somewhat arbitrary but hard to think lower value would have to be used -# (would most likely be a typo). -# Running container with very low memory will probably cause it to be killed by the cgroup OOM -MemoryType: TypeAlias = Annotated[int, AfterValidator(validate_memory)] - - -@single_argument_args('virt_instance_create') -class VirtInstanceCreateArgs(BaseModel): - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Name for the new virtual instance.""" - source_type: Literal['IMAGE'] = 'IMAGE' - """Source type for instance creation.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool under which to allocate root filesystem. Must be one of the pools \ - listed in virt.global.config output under "storage_pools". If None (default) then the pool \ - specified in the global configuration will be used. - """ - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Image identifier to use for creating the instance.""" - root_disk_size: int = Field(ge=5, default=10) # In GBs - """ - This can be specified when creating VMs so the root device's size can be configured. Root device for VMs \ - is a sparse zvol and the field specifies space in GBs and defaults to 10 GBs. - """ - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI'] = 'NVME' - """I/O bus type for the root disk (defaults to NVME for best performance).""" - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - """Remote image source to use.""" - instance_type: Literal['CONTAINER'] = 'CONTAINER' - """Type of instance to create.""" - environment: dict[ENV_KEY, ENV_VALUE] | None = None - """Environment variables to set inside the instance.""" - autostart: bool | None = True - """Whether the instance should automatically start when the host boots.""" - cpu: str | None = None - """CPU allocation specification or `null` for automatic allocation.""" - devices: list[DeviceType] | None = None - """Array of devices to attach to the instance.""" - memory: MemoryType | None = None - """Memory allocation in bytes or `null` for automatic allocation.""" - privileged_mode: bool = False - """ - This is only valid for containers and should only be set when container instance which is to be deployed is to \ - run in a privileged mode. - """ - - -class VirtInstanceCreateResult(BaseModel): - result: VirtInstanceEntry - """The created virtual instance configuration.""" - - -class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): - environment: dict[ENV_KEY, ENV_VALUE] | None = None - """Environment variables to set inside the instance.""" - autostart: bool | None = None - """Whether the instance should automatically start when the host boots.""" - cpu: str | None = None - """CPU allocation specification or `null` for automatic allocation.""" - memory: MemoryType | None = None - """Memory allocation in bytes or `null` for automatic allocation.""" - vnc_port: int | None = Field(ge=5900, le=65535) - """TCP port number for VNC access (5900-65535) or `null` to disable VNC.""" - enable_vnc: bool - """Whether to enable VNC remote access for the instance.""" - vnc_password: Secret[NonEmptyString | None] - """Setting vnc_password to null will unset VNC password.""" - secure_boot: bool - """Whether to enable UEFI Secure Boot (VMs only).""" - root_disk_size: int | None = Field(ge=5, default=None) - """Size of the root disk in GB (minimum 5GB) or `null` to keep current size.""" - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - """I/O bus type for the root disk or `null` to keep current setting.""" - image_os: str | OS_ENUM = None - """Operating system type for the instance or `null` for auto-detection.""" - privileged_mode: bool - """ - This is only valid for containers and should only be set when container instance which is to be deployed is to \ - run in a privileged mode. - """ - - -class VirtInstanceUpdateArgs(BaseModel): - id: str - """ID of the virtual instance to update.""" - virt_instance_update: VirtInstanceUpdate - """Updated configuration data for the virtual instance.""" - - -class VirtInstanceUpdateResult(BaseModel): - result: VirtInstanceEntry - """The updated virtual instance configuration.""" - - -class VirtInstanceDeleteArgs(BaseModel): - id: str - """ID of the virtual instance to delete.""" - - -class VirtInstanceDeleteResult(BaseModel): - result: Literal[True] - """Returns `true` when the virtual instance is successfully deleted.""" - - -class VirtInstanceStartArgs(BaseModel): - id: str - """ID of the virtual instance to start.""" - - -class VirtInstanceStartResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully started.""" - - -class StopArgs(BaseModel): - timeout: int = -1 - """Timeout in seconds to wait for graceful shutdown (-1 for no timeout when `force = true`).""" - force: bool = False - """Whether to force stop the instance immediately without graceful shutdown.""" - - -class VirtInstanceStopArgs(BaseModel): - id: str - """ID of the virtual instance to stop.""" - stop_args: StopArgs = StopArgs() - """Arguments controlling how the instance is stopped.""" - - @model_validator(mode='after') - def validate_attrs(self): - if self.stop_args.force is False and self.stop_args.timeout == -1: - raise ValueError('Timeout should be set if force is disabled') - return self - - -class VirtInstanceStopResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully stopped.""" - - -class VirtInstanceRestartArgs(VirtInstanceStopArgs): - pass - - -class VirtInstanceRestartResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully restarted.""" - - -class VirtInstanceImageChoices(BaseModel): - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - """Remote image source to query for available images.""" - - -class VirtInstanceImageChoicesArgs(BaseModel): - virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() - """Options for filtering available images.""" - - -class ImageChoiceItem(BaseModel): - label: str - """Human-readable label for the image.""" - os: str - """Operating system family of the image.""" - release: str - """Version or release name of the operating system.""" - archs: list[str] - """Array of supported hardware architectures.""" - variant: str - """Image variant (default, cloud, minimal, etc.).""" - instance_types: list[InstanceType] - """Array of instance types this image supports.""" - secureboot: bool | None - """Whether the image supports UEFI Secure Boot or `null` if not applicable.""" - - -class VirtInstanceImageChoicesResult(BaseModel): - result: dict[str, ImageChoiceItem] - """Available images indexed by image identifier.""" - - -class VirtInstanceDeviceDeviceListArgs(BaseModel): - id: str - """ID of the virtual instance to list devices for.""" - - -class VirtInstanceDeviceDeviceListResult(BaseModel): - result: list[DeviceType] - """Array of devices attached to the virtual instance.""" - - -class VirtInstanceDeviceDeviceAddArgs(BaseModel): - id: str - """ID of the virtual instance to add device to.""" - device: DeviceType - """Device configuration to add to the instance.""" - - -class VirtInstanceDeviceDeviceAddResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully added.""" - - -class VirtInstanceDeviceDeviceUpdateArgs(BaseModel): - id: str - """ID of the virtual instance to update device for.""" - device: DeviceType - """Updated device configuration.""" - - -class VirtInstanceDeviceDeviceUpdateResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully updated.""" - - -class VirtInstanceDeviceDeviceDeleteArgs(BaseModel): - id: str - """ID of the virtual instance to remove device from.""" - name: str - """Name of the device to remove.""" - - -class VirtInstanceDeviceDeviceDeleteResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully removed.""" - - -class VirtInstancesMetricsEventSourceArgs(BaseModel): - interval: int = Field(default=2, ge=2) - """Interval in seconds between metrics updates (minimum 2 seconds).""" - - -class VirtInstancesMetricsEventSourceEvent(BaseModel): - result: dict - """Real-time metrics data for all virtual instances.""" diff --git a/src/middlewared/middlewared/api/v25_10_0/virt_volume.py b/src/middlewared/middlewared/api/v25_10_0/virt_volume.py deleted file mode 100644 index 8a7a5096d6c2a..0000000000000 --- a/src/middlewared/middlewared/api/v25_10_0/virt_volume.py +++ /dev/null @@ -1,144 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator, StringConstraints - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args, -) - -__all__ = [ - 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', - 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', - 'VirtVolumeDeleteResult', 'VirtVolumeImportIsoArgs', 'VirtVolumeImportIsoResult', - 'VirtVolumeImportZvolArgs', 'VirtVolumeImportZvolResult' -] - - -RE_VOLUME_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9-._]*[A-Za-z0-9]$', re.IGNORECASE) -VOLUME_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - RE_VOLUME_NAME, - 'Name can contain only letters, numbers, dashes, underscores and dots. ' - 'Name must start with a letter, and must not end with a dash.' - ), - ), - StringConstraints(max_length=63), -] - - -class VirtVolumeEntry(BaseModel): - id: NonEmptyString - """Unique identifier for the virtualization volume.""" - name: NonEmptyString - """Human-readable name of the virtualization volume.""" - storage_pool: NonEmptyString - """Name of the storage pool containing this volume.""" - content_type: NonEmptyString - """Type of content stored in this volume (e.g., 'BLOCK', 'ISO').""" - created_at: str - """Timestamp when this volume was created.""" - type: NonEmptyString - """Volume type indicating its storage backend and characteristics.""" - config: dict - """Object containing detailed configuration parameters for this volume.""" - used_by: list[NonEmptyString] - """Array of virtual instance names currently using this volume.""" - - -@single_argument_args('virt_volume_create') -class VirtVolumeCreateArgs(BaseModel): - name: VOLUME_NAME - """Name for the new virtualization volume (alphanumeric, dashes, dots, underscores).""" - 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.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which to create the volume. This must be one of pools listed \ - in virt.global.config output under `storage_pools`. If the value is None, then \ - the pool defined as `pool` in virt.global.config will be used. - """ - - -class VirtVolumeCreateResult(BaseModel): - result: VirtVolumeEntry - """The newly created virtualization volume configuration.""" - - -class VirtVolumeUpdate(BaseModel, metaclass=ForUpdateMetaclass): - size: int = Field(ge=512) - """New size for the volume in MB (minimum 512MB).""" - - -class VirtVolumeUpdateArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtualization volume to update.""" - virt_volume_update: VirtVolumeUpdate - """Updated configuration for the virtualization volume.""" - - -class VirtVolumeUpdateResult(BaseModel): - result: VirtVolumeEntry - """The updated virtualization volume configuration.""" - - -class VirtVolumeDeleteArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtualization volume to delete.""" - - -class VirtVolumeDeleteResult(BaseModel): - result: Literal[True] - """Always returns true on successful volume deletion.""" - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportIsoArgs(BaseModel): - name: VOLUME_NAME - """Specify name of the newly created volume from the ISO specified.""" - iso_location: NonEmptyString | None = None - """Path to the ISO file to import. `null` if uploading via `upload_iso`.""" - upload_iso: bool = False - """Whether to upload an ISO file instead of using a local file path.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which to create the volume. This must be one of pools listed \ - in virt.global.config output under `storage_pools`. If the value is None, then \ - the pool defined as `pool` in virt.global.config will be used. - """ - - -class VirtVolumeImportIsoResult(BaseModel): - result: VirtVolumeEntry - """The newly created volume from the imported ISO file.""" - - -class ZvolImportEntry(BaseModel): - virt_volume_name: VOLUME_NAME - """Name for the new virtualization volume created from the imported ZFS volume.""" - zvol_path: NonEmptyString - """Full path to the ZFS volume device in /dev/zvol/.""" - - @field_validator('zvol_path') - @classmethod - def validate_source(cls, zvol_path): - if not zvol_path.startswith('/dev/zvol/'): - raise ValueError('Not a valid /dev/zvol path') - - return zvol_path - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportZvolArgs(BaseModel): - to_import: list[ZvolImportEntry] - """Array of ZFS volumes to import as virtualization volumes.""" - clone: bool = False - """Whether to clone and promote the ZFS volume instead of importing directly.""" - - -class VirtVolumeImportZvolResult(BaseModel): - result: VirtVolumeEntry - """The newly created volume from the imported ZFS volume.""" diff --git a/src/middlewared/middlewared/api/v26_04_0/__init__.py b/src/middlewared/middlewared/api/v26_04_0/__init__.py index e56c765be4a06..9cec950509d9e 100644 --- a/src/middlewared/middlewared/api/v26_04_0/__init__.py +++ b/src/middlewared/middlewared/api/v26_04_0/__init__.py @@ -112,10 +112,6 @@ from .update import * from .ups import * from .user import * -from .virt_device import * -from .virt_global import * -from .virt_instance import * -from .virt_volume import * from .vm import * from .vm_device import * from .vmware import * diff --git a/src/middlewared/middlewared/api/v26_04_0/pool.py b/src/middlewared/middlewared/api/v26_04_0/pool.py index 5cdc38c82786d..cf06d3fe20563 100644 --- a/src/middlewared/middlewared/api/v26_04_0/pool.py +++ b/src/middlewared/middlewared/api/v26_04_0/pool.py @@ -1,10 +1,9 @@ -import re -from typing import Annotated, Literal, TypeAlias +from typing import Annotated, Literal -from pydantic import AfterValidator, Field, PositiveInt, Secret, StringConstraints +from pydantic import Field, PositiveInt, Secret from middlewared.api.base import ( - BaseModel, Excluded, excluded_field, match_validator, NonEmptyString, single_argument_args, + BaseModel, Excluded, excluded_field, NonEmptyString, single_argument_args, LongString, ForUpdateMetaclass, ) @@ -23,20 +22,6 @@ ] -# Incus cannot consume a pool which has whitespaces in its name. -# FIXME: Once this is fixed on incus side, we can remove this and keep on relying libzfs to do the validation only -POOL_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - re.compile(r"^\S+$"), - "Pool name must not contain whitespace" - ) - ), - StringConstraints(max_length=50) -] - - class PoolTopology(BaseModel): data: list """Array of data vdev configurations in the pool.""" @@ -243,7 +228,7 @@ class PoolCreateTopology(BaseModel): class PoolCreate(BaseModel): - name: POOL_NAME + name: NonEmptyString """Name for the new storage pool.""" encryption: bool = False """If set, create a ZFS encrypted root dataset for this pool.""" @@ -481,7 +466,7 @@ class PoolImportFindResult(BaseModel): class PoolImportPoolArgs(BaseModel): guid: str """GUID of the pool to import.""" - name: POOL_NAME | None = None + name: NonEmptyString | None = None """If specified, import the pool using this name.""" @@ -592,7 +577,7 @@ class PoolUpgradeResult(BaseModel): class PoolValidateNameArgs(BaseModel): - pool_name: POOL_NAME + pool_name: NonEmptyString """Pool name to validate for compliance with naming rules.""" diff --git a/src/middlewared/middlewared/api/v26_04_0/virt_device.py b/src/middlewared/middlewared/api/v26_04_0/virt_device.py deleted file mode 100644 index dabb2666a6d31..0000000000000 --- a/src/middlewared/middlewared/api/v26_04_0/virt_device.py +++ /dev/null @@ -1,265 +0,0 @@ -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator - -from middlewared.api.base import BaseModel, LocalGID, LocalUID, match_validator, NonEmptyString -from middlewared.validators import RE_MAC_ADDRESS - - -__all__ = [ - 'DeviceType', 'InstanceType', 'VirtDeviceUsbChoicesArgs', 'VirtDeviceUsbChoicesResult', - 'VirtDeviceGpuChoicesArgs', 'VirtDeviceGpuChoicesResult', 'VirtDeviceDiskChoicesArgs', - 'VirtDeviceDiskChoicesResult', 'VirtDeviceNicChoicesArgs', 'VirtDeviceNicChoicesResult', - 'VirtDevicePciChoicesArgs', 'VirtDevicePciChoicesResult', - 'VirtInstanceDeviceSetBootableDiskArgs', 'VirtInstanceDeviceSetBootableDiskResult', -] - - -InstanceType: TypeAlias = Literal['CONTAINER', 'VM'] -MAC: TypeAlias = Annotated[ - str | None, - AfterValidator( - match_validator( - RE_MAC_ADDRESS, - 'MAC address is not valid.' - ) - ) -] - - -class Device(BaseModel): - name: NonEmptyString | None = None - """Optional human-readable name for the virtualization device.""" - description: NonEmptyString | None = None - """Optional description explaining the purpose or configuration of this device.""" - readonly: bool = False - """Whether the device should be mounted in read-only mode.""" - - -class Disk(Device): - dev_type: Literal['DISK'] - """Device type identifier for virtual disk devices.""" - 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 - """Target path where the disk appears inside the virtualized instance.""" - boot_priority: int | None = Field(default=None, ge=0) - """Boot priority for this disk device. Lower numbers boot first. `null` means non-bootable.""" - io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - """Storage bus type for optimal performance and compatibility.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which the device is located. This must be one \ - of the storage pools listed in virt.global.config output. - If this is set to None during device creation, then the default storage \ - pool defined in virt.global.config will be used. - """ - - @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'] - - -class NIC(Device): - dev_type: Literal['NIC'] - """Device type identifier for network interface cards.""" - network: NonEmptyString | None = None - """Name of the network to connect this NIC to.""" - nic_type: NicType | None = None - """Type of network interface (bridged or macvlan).""" - parent: NonEmptyString | None = None - """Parent network interface on the host system.""" - mac: MAC = None - """MAC address for the virtual network interface. `null` for auto-generated.""" - - -class USB(Device): - dev_type: Literal['USB'] - """Device type identifier for USB devices.""" - bus: int | None = None - """USB bus number on the host system.""" - dev: int | None = None - """USB device number on the specified bus.""" - product_id: str | None = None - """USB product identifier for device matching.""" - vendor_id: str | None = None - """USB vendor identifier for device matching.""" - - -Proto: TypeAlias = Literal['UDP', 'TCP'] - - -class Proxy(Device): - dev_type: Literal['PROXY'] - """Device type identifier for network port forwarding.""" - source_proto: Proto - """Network protocol (TCP or UDP) for the source connection.""" - source_port: int = Field(ge=1, le=65535) - """Source port number on the host system to forward from.""" - dest_proto: Proto - """Network protocol (TCP or UDP) for the destination connection.""" - dest_port: int = Field(ge=1, le=65535) - """Destination port number inside the virtualized instance.""" - - -class TPM(Device): - dev_type: Literal['TPM'] - """Device type identifier for Trusted Platform Module devices.""" - path: str | None = None - """Path to the TPM device on the host system.""" - pathrm: str | None = None - """Resource manager path for TPM device access.""" - - -GPUType: TypeAlias = Literal['PHYSICAL', 'MDEV', 'MIG', 'SRIOV'] - - -class GPU(Device): - dev_type: Literal['GPU'] - """Device type identifier for graphics processing units.""" - gpu_type: GPUType - """Type of GPU virtualization (physical passthrough, mediated device, etc.).""" - id: str | None = None - """Unique identifier for the GPU device.""" - gid: LocalGID | None = None - """Group ID for device permissions inside the container.""" - uid: LocalUID | None = None - """User ID for device permissions inside the container.""" - mode: str | None = None - """Permission mode for device access (e.g., '660').""" - mdev: NonEmptyString | None = None - """Mediated device identifier for GPU virtualization.""" - mig_uuid: NonEmptyString | None = None - """Multi-Instance GPU UUID for NVIDIA GPU partitioning.""" - pci: NonEmptyString | None = None - """PCI address of the GPU device on the host system.""" - productid: NonEmptyString | None = None - """Product identifier for GPU device matching.""" - vendorid: NonEmptyString | None = None - """Vendor identifier for GPU device matching.""" - - -class PCI(Device): - dev_type: Literal['PCI'] - """Device type identifier for PCI device passthrough.""" - address: NonEmptyString - """PCI bus address of the device to pass through to the virtualized instance.""" - - -class CDROM(Device): - dev_type: Literal['CDROM'] - """Device type identifier for CD-ROM/DVD optical drives.""" - source: NonEmptyString - """Path to the ISO image file or physical optical drive to mount.""" - boot_priority: int | None = Field(default=None, ge=0) - """Boot priority for this optical device. Lower numbers boot first. `null` means non-bootable.""" - - -DeviceType: TypeAlias = Annotated[ - Disk | GPU | Proxy | TPM | USB | NIC | PCI | CDROM, - Field(discriminator='dev_type') -] - - -class VirtDeviceUsbChoicesArgs(BaseModel): - pass - - -class USBChoice(BaseModel): - vendor_id: str - """USB vendor identifier for this device.""" - product_id: str - """USB product identifier for this device.""" - bus: int - """USB bus number where this device is connected.""" - dev: int - """USB device number on the bus.""" - product: str | None - """Product name of the USB device. `null` if not available.""" - manufacturer: str | None - """Manufacturer name of the USB device. `null` if not available.""" - - -class VirtDeviceUsbChoicesResult(BaseModel): - result: dict[str, USBChoice] - """Object of available USB devices with their hardware information.""" - - -class VirtDeviceGpuChoicesArgs(BaseModel): - gpu_type: GPUType - """Type of GPU virtualization to filter available choices.""" - - -class GPUChoice(BaseModel): - bus: str - """PCI bus identifier for the GPU device.""" - slot: str - """PCI slot identifier for the GPU device.""" - description: str - """Human-readable description of the GPU device.""" - vendor: str | None = None - """GPU vendor name. `null` if not available.""" - pci: str - """Complete PCI address of the GPU device.""" - - -class VirtDeviceGpuChoicesResult(BaseModel): - result: dict[str, GPUChoice] - """Object of available GPU devices with their hardware information.""" - - -class VirtDeviceDiskChoicesArgs(BaseModel): - pass - - -class VirtDeviceDiskChoicesResult(BaseModel): - result: dict[str, str] - """Object of available disk devices and storage volumes for virtualization.""" - - -class VirtDeviceNicChoicesArgs(BaseModel): - nic_type: NicType - """Type of network interface to filter available choices.""" - - -class VirtDeviceNicChoicesResult(BaseModel): - result: dict[str, str] - """Object of available network interfaces for the specified NIC type.""" - - -class VirtDevicePciChoicesArgs(BaseModel): - pass - - -class VirtDevicePciChoicesResult(BaseModel): - result: dict - """Object of available PCI devices that can be passed through to virtual instances.""" - - -class VirtInstanceDeviceSetBootableDiskArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtual instance to configure.""" - disk: NonEmptyString - """Name or identifier of the disk device to set as bootable.""" - - -class VirtInstanceDeviceSetBootableDiskResult(BaseModel): - result: bool - """Whether the bootable disk configuration was successfully applied.""" diff --git a/src/middlewared/middlewared/api/v26_04_0/virt_global.py b/src/middlewared/middlewared/api/v26_04_0/virt_global.py deleted file mode 100644 index a52edd8fb6a7e..0000000000000 --- a/src/middlewared/middlewared/api/v26_04_0/virt_global.py +++ /dev/null @@ -1,89 +0,0 @@ -from typing import Literal - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, NonEmptyString, single_argument_args, single_argument_result, -) - - -__all__ = [ - 'VirtGlobalEntry', 'VirtGlobalUpdateResult', 'VirtGlobalUpdateArgs', 'VirtGlobalBridgeChoicesArgs', - 'VirtGlobalBridgeChoicesResult', 'VirtGlobalPoolChoicesArgs', 'VirtGlobalPoolChoicesResult', - 'VirtGlobalGetNetworkArgs', 'VirtGlobalGetNetworkResult', -] - - -class VirtGlobalEntry(BaseModel): - id: int - """Unique identifier for the virtualization global configuration.""" - pool: str | None = None - """Default storage pool when creating new instances and volumes.""" - dataset: str | None = None - """ZFS dataset path used for virtualization data storage. `null` if not configured.""" - storage_pools: list[str] | None = None - """ZFS pools to use as storage pools.""" - bridge: str | None = None - """Network bridge interface for virtualized instance networking. `null` if not configured.""" - v4_network: str | None = None - """IPv4 network CIDR for the virtualization bridge network. `null` if not configured.""" - v6_network: str | None = None - """IPv6 network CIDR for the virtualization bridge network. `null` if not configured.""" - state: Literal['INITIALIZING', 'INITIALIZED', 'NO_POOL', 'ERROR', 'LOCKED'] | None = None - """Current operational state of the virtualization subsystem. `null` during initial setup.""" - - -class VirtGlobalUpdateResult(BaseModel): - result: VirtGlobalEntry - """The updated virtualization global configuration.""" - - -@single_argument_args('virt_global_update') -class VirtGlobalUpdateArgs(BaseModel, metaclass=ForUpdateMetaclass): - pool: NonEmptyString | None = None - """Default storage pool when creating new instances and volumes.""" - bridge: NonEmptyString | None = None - """Network bridge interface for virtualized instance networking. `null` to disable.""" - storage_pools: list[str] | None = None - """ZFS pools to use as storage pools.""" - v4_network: str | None = None - """IPv4 network CIDR for the virtualization bridge network. `null` to use default.""" - v6_network: str | None = None - """IPv6 network CIDR for the virtualization bridge network. `null` to use default.""" - - -class VirtGlobalBridgeChoicesArgs(BaseModel): - pass - - -class VirtGlobalBridgeChoicesResult(BaseModel): - result: dict - """Object of available network bridge interfaces and their configurations.""" - - -class VirtGlobalPoolChoicesArgs(BaseModel): - pass - - -class VirtGlobalPoolChoicesResult(BaseModel): - result: dict - """Object of available ZFS pools that can be used for virtualization storage.""" - - -class VirtGlobalGetNetworkArgs(BaseModel): - name: NonEmptyString - """Name of the network configuration to retrieve.""" - - -@single_argument_result -class VirtGlobalGetNetworkResult(BaseModel): - type: Literal['BRIDGE'] - """Type of network configuration (currently only bridge networks are supported).""" - managed: bool - """Whether this network is managed by the virtualization system.""" - ipv4_address: NonEmptyString - """IPv4 address and CIDR of the bridge network.""" - ipv4_nat: bool - """Whether IPv4 Network Address Translation is enabled for this bridge.""" - ipv6_address: NonEmptyString - """IPv6 address and CIDR of the bridge network.""" - ipv6_nat: bool - """Whether IPv6 Network Address Translation is enabled for this bridge.""" diff --git a/src/middlewared/middlewared/api/v26_04_0/virt_instance.py b/src/middlewared/middlewared/api/v26_04_0/virt_instance.py deleted file mode 100644 index 5a97786a1522d..0000000000000 --- a/src/middlewared/middlewared/api/v26_04_0/virt_instance.py +++ /dev/null @@ -1,378 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, model_validator, Secret, StringConstraints - -from middlewared.api.base import BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args - -from .virt_device import DeviceType, InstanceType - - -__all__ = [ - 'VirtInstanceEntry', 'VirtInstanceCreateArgs', 'VirtInstanceCreateResult', 'VirtInstanceUpdateArgs', - 'VirtInstanceUpdateResult', 'VirtInstanceDeleteArgs', 'VirtInstanceDeleteResult', - 'VirtInstanceStartArgs', 'VirtInstanceStartResult', 'VirtInstanceStopArgs', 'VirtInstanceStopResult', - 'VirtInstanceRestartArgs', 'VirtInstanceRestartResult', 'VirtInstanceImageChoicesArgs', - 'VirtInstanceImageChoicesResult', 'VirtInstanceDeviceDeviceListArgs', 'VirtInstanceDeviceDeviceListResult', - 'VirtInstanceDeviceDeviceAddArgs', 'VirtInstanceDeviceDeviceAddResult', 'VirtInstanceDeviceDeviceUpdateArgs', - 'VirtInstanceDeviceDeviceUpdateResult', 'VirtInstanceDeviceDeviceDeleteArgs', - 'VirtInstanceDeviceDeviceDeleteResult', 'VirtInstancesMetricsEventSourceArgs', - 'VirtInstancesMetricsEventSourceEvent', -] - - -# Some popular OS choices -OS_ENUM = Literal['LINUX', 'FREEBSD', 'WINDOWS', 'ARCHLINUX', None] -REMOTE_CHOICES: TypeAlias = Literal['LINUX_CONTAINERS'] -ENV_KEY: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^\w[\w/]*$'), - 'ENV_KEY must not be empty, should start with alphanumeric characters' - ', should not contain whitespaces, and can have _ and /' - ) - ) -] -ENV_VALUE: TypeAlias = Annotated[ - str, - AfterValidator( - match_validator( - re.compile(r'^(?!\s*$).+'), - 'ENV_VALUE must have at least one non-whitespace character to be considered valid' - ) - ) -] - - -class VirtInstanceAlias(BaseModel): - type: Literal['INET', 'INET6'] - """Type of IP address (INET for IPv4, INET6 for IPv6).""" - address: NonEmptyString - """IP address for the virtual instance.""" - netmask: int | None - """Network mask in CIDR notation.""" - - -class Image(BaseModel): - architecture: str | None - """Hardware architecture of the image (e.g., amd64, arm64).""" - description: str | None - """Human-readable description of the image.""" - os: str | None - """Operating system family of the image.""" - release: str | None - """Version or release name of the operating system.""" - serial: str | None - """Unique serial identifier for the image.""" - type: str | None - """Type of image (container, virtual-machine, etc.).""" - variant: str | None - """Image variant (default, cloud, minimal, etc.).""" - secureboot: bool | None - """Whether the image supports UEFI Secure Boot.""" - - -class IdmapUserNsEntry(BaseModel): - hostid: int - """Starting host ID for the mapping range.""" - maprange: int - """Number of IDs to map in this range.""" - nsid: int - """Starting namespace ID for the mapping range.""" - - -class UserNsIdmap(BaseModel): - uid: IdmapUserNsEntry | None - """User ID mapping configuration for user namespace isolation.""" - gid: IdmapUserNsEntry | None - """Group ID mapping configuration for user namespace isolation.""" - - -class VirtInstanceEntry(BaseModel): - id: str - """Unique identifier for the virtual instance.""" - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Human-readable name for the virtual instance.""" - type: InstanceType = 'CONTAINER' - """Type of virtual instance.""" - status: Literal[ - 'RUNNING', 'STOPPED', 'UNKNOWN', 'ERROR', 'FROZEN', 'STARTING', 'STOPPING', 'FREEZING', 'THAWED', 'ABORTING' - ] - """Current operational status of the virtual instance.""" - cpu: str | None - """CPU configuration string or `null` for default allocation.""" - memory: int | None - """Memory allocation in bytes or `null` for default allocation.""" - autostart: bool - """Whether the instance automatically starts when the host boots.""" - environment: dict[str, str] - """Environment variables to set inside the instance.""" - aliases: list[VirtInstanceAlias] - """Array of IP aliases configured for the instance.""" - image: Image - """Image information used to create this instance.""" - userns_idmap: UserNsIdmap | None - """User namespace ID mapping configuration for privilege isolation.""" - raw: Secret[dict | None] - """Raw low-level configuration options (advanced use only).""" - vnc_enabled: bool - """Whether VNC remote access is enabled for the instance.""" - vnc_port: int | None - """TCP port number for VNC connections or `null` if VNC is disabled.""" - vnc_password: Secret[NonEmptyString | None] - """Password for VNC access or `null` if no password is set.""" - secure_boot: bool | None - """Whether UEFI Secure Boot is enabled (VMs only) or `null` for containers.""" - privileged_mode: bool | None - """Whether the container runs in privileged mode or `null` for VMs.""" - root_disk_size: int | None - """Size of the root disk in GB or `null` for default size.""" - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] - """I/O bus type for the root disk or `null` for default.""" - storage_pool: NonEmptyString - """Storage pool in which the root of the instance is located.""" - - -def validate_memory(value: int) -> int: - if value < 33554432: - raise ValueError('Value must be 32MiB or larger') - return value - - -# Lets require at least 32MiB of reserved memory -# This value is somewhat arbitrary but hard to think lower value would have to be used -# (would most likely be a typo). -# Running container with very low memory will probably cause it to be killed by the cgroup OOM -MemoryType: TypeAlias = Annotated[int, AfterValidator(validate_memory)] - - -@single_argument_args('virt_instance_create') -class VirtInstanceCreateArgs(BaseModel): - name: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Name for the new virtual instance.""" - source_type: Literal['IMAGE'] = 'IMAGE' - """Source type for instance creation.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool under which to allocate root filesystem. Must be one of the pools \ - listed in virt.global.config output under "storage_pools". If None (default) then the pool \ - specified in the global configuration will be used. - """ - image: Annotated[NonEmptyString, StringConstraints(max_length=200)] - """Image identifier to use for creating the instance.""" - root_disk_size: int = Field(ge=5, default=10) # In GBs - """ - This can be specified when creating VMs so the root device's size can be configured. Root device for VMs \ - is a sparse zvol and the field specifies space in GBs and defaults to 10 GBs. - """ - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI'] = 'NVME' - """I/O bus type for the root disk (defaults to NVME for best performance).""" - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - """Remote image source to use.""" - instance_type: Literal['CONTAINER'] = 'CONTAINER' - """Type of instance to create.""" - environment: dict[ENV_KEY, ENV_VALUE] | None = None - """Environment variables to set inside the instance.""" - autostart: bool | None = True - """Whether the instance should automatically start when the host boots.""" - cpu: str | None = None - """CPU allocation specification or `null` for automatic allocation.""" - devices: list[DeviceType] | None = None - """Array of devices to attach to the instance.""" - memory: MemoryType | None = None - """Memory allocation in bytes or `null` for automatic allocation.""" - privileged_mode: bool = False - """ - This is only valid for containers and should only be set when container instance which is to be deployed is to \ - run in a privileged mode. - """ - - -class VirtInstanceCreateResult(BaseModel): - result: VirtInstanceEntry - """The created virtual instance configuration.""" - - -class VirtInstanceUpdate(BaseModel, metaclass=ForUpdateMetaclass): - environment: dict[ENV_KEY, ENV_VALUE] | None = None - """Environment variables to set inside the instance.""" - autostart: bool | None = None - """Whether the instance should automatically start when the host boots.""" - cpu: str | None = None - """CPU allocation specification or `null` for automatic allocation.""" - memory: MemoryType | None = None - """Memory allocation in bytes or `null` for automatic allocation.""" - vnc_port: int | None = Field(ge=5900, le=65535) - """TCP port number for VNC access (5900-65535) or `null` to disable VNC.""" - enable_vnc: bool - """Whether to enable VNC remote access for the instance.""" - vnc_password: Secret[NonEmptyString | None] - """Setting vnc_password to null will unset VNC password.""" - secure_boot: bool - """Whether to enable UEFI Secure Boot (VMs only).""" - root_disk_size: int | None = Field(ge=5, default=None) - """Size of the root disk in GB (minimum 5GB) or `null` to keep current size.""" - root_disk_io_bus: Literal['NVME', 'VIRTIO-BLK', 'VIRTIO-SCSI', None] = None - """I/O bus type for the root disk or `null` to keep current setting.""" - image_os: str | OS_ENUM = None - """Operating system type for the instance or `null` for auto-detection.""" - privileged_mode: bool - """ - This is only valid for containers and should only be set when container instance which is to be deployed is to \ - run in a privileged mode. - """ - - -class VirtInstanceUpdateArgs(BaseModel): - id: str - """ID of the virtual instance to update.""" - virt_instance_update: VirtInstanceUpdate - """Updated configuration data for the virtual instance.""" - - -class VirtInstanceUpdateResult(BaseModel): - result: VirtInstanceEntry - """The updated virtual instance configuration.""" - - -class VirtInstanceDeleteArgs(BaseModel): - id: str - """ID of the virtual instance to delete.""" - - -class VirtInstanceDeleteResult(BaseModel): - result: Literal[True] - """Returns `true` when the virtual instance is successfully deleted.""" - - -class VirtInstanceStartArgs(BaseModel): - id: str - """ID of the virtual instance to start.""" - - -class VirtInstanceStartResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully started.""" - - -class StopArgs(BaseModel): - timeout: int = -1 - """Timeout in seconds to wait for graceful shutdown (-1 for no timeout when `force = true`).""" - force: bool = False - """Whether to force stop the instance immediately without graceful shutdown.""" - - -class VirtInstanceStopArgs(BaseModel): - id: str - """ID of the virtual instance to stop.""" - stop_args: StopArgs = StopArgs() - """Arguments controlling how the instance is stopped.""" - - @model_validator(mode='after') - def validate_attrs(self): - if self.stop_args.force is False and self.stop_args.timeout == -1: - raise ValueError('Timeout should be set if force is disabled') - return self - - -class VirtInstanceStopResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully stopped.""" - - -class VirtInstanceRestartArgs(VirtInstanceStopArgs): - pass - - -class VirtInstanceRestartResult(BaseModel): - result: bool - """Returns `true` if the instance was successfully restarted.""" - - -class VirtInstanceImageChoices(BaseModel): - remote: REMOTE_CHOICES = 'LINUX_CONTAINERS' - """Remote image source to query for available images.""" - - -class VirtInstanceImageChoicesArgs(BaseModel): - virt_instances_image_choices: VirtInstanceImageChoices = VirtInstanceImageChoices() - """Options for filtering available images.""" - - -class ImageChoiceItem(BaseModel): - label: str - """Human-readable label for the image.""" - os: str - """Operating system family of the image.""" - release: str - """Version or release name of the operating system.""" - archs: list[str] - """Array of supported hardware architectures.""" - variant: str - """Image variant (default, cloud, minimal, etc.).""" - instance_types: list[InstanceType] - """Array of instance types this image supports.""" - secureboot: bool | None - """Whether the image supports UEFI Secure Boot or `null` if not applicable.""" - - -class VirtInstanceImageChoicesResult(BaseModel): - result: dict[str, ImageChoiceItem] - """Available images indexed by image identifier.""" - - -class VirtInstanceDeviceDeviceListArgs(BaseModel): - id: str - """ID of the virtual instance to list devices for.""" - - -class VirtInstanceDeviceDeviceListResult(BaseModel): - result: list[DeviceType] - """Array of devices attached to the virtual instance.""" - - -class VirtInstanceDeviceDeviceAddArgs(BaseModel): - id: str - """ID of the virtual instance to add device to.""" - device: DeviceType - """Device configuration to add to the instance.""" - - -class VirtInstanceDeviceDeviceAddResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully added.""" - - -class VirtInstanceDeviceDeviceUpdateArgs(BaseModel): - id: str - """ID of the virtual instance to update device for.""" - device: DeviceType - """Updated device configuration.""" - - -class VirtInstanceDeviceDeviceUpdateResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully updated.""" - - -class VirtInstanceDeviceDeviceDeleteArgs(BaseModel): - id: str - """ID of the virtual instance to remove device from.""" - name: str - """Name of the device to remove.""" - - -class VirtInstanceDeviceDeviceDeleteResult(BaseModel): - result: Literal[True] - """Returns `true` when the device is successfully removed.""" - - -class VirtInstancesMetricsEventSourceArgs(BaseModel): - interval: int = Field(default=2, ge=2) - """Interval in seconds between metrics updates (minimum 2 seconds).""" - - -class VirtInstancesMetricsEventSourceEvent(BaseModel): - result: dict - """Real-time metrics data for all virtual instances.""" diff --git a/src/middlewared/middlewared/api/v26_04_0/virt_volume.py b/src/middlewared/middlewared/api/v26_04_0/virt_volume.py deleted file mode 100644 index 8a7a5096d6c2a..0000000000000 --- a/src/middlewared/middlewared/api/v26_04_0/virt_volume.py +++ /dev/null @@ -1,144 +0,0 @@ -import re -from typing import Annotated, Literal, TypeAlias - -from pydantic import AfterValidator, Field, field_validator, StringConstraints - -from middlewared.api.base import ( - BaseModel, ForUpdateMetaclass, match_validator, NonEmptyString, single_argument_args, -) - -__all__ = [ - 'VirtVolumeEntry', 'VirtVolumeCreateArgs', 'VirtVolumeCreateResult', - 'VirtVolumeUpdateArgs', 'VirtVolumeUpdateResult', 'VirtVolumeDeleteArgs', - 'VirtVolumeDeleteResult', 'VirtVolumeImportIsoArgs', 'VirtVolumeImportIsoResult', - 'VirtVolumeImportZvolArgs', 'VirtVolumeImportZvolResult' -] - - -RE_VOLUME_NAME = re.compile(r'^[A-Za-z][A-Za-z0-9-._]*[A-Za-z0-9]$', re.IGNORECASE) -VOLUME_NAME: TypeAlias = Annotated[ - NonEmptyString, - AfterValidator( - match_validator( - RE_VOLUME_NAME, - 'Name can contain only letters, numbers, dashes, underscores and dots. ' - 'Name must start with a letter, and must not end with a dash.' - ), - ), - StringConstraints(max_length=63), -] - - -class VirtVolumeEntry(BaseModel): - id: NonEmptyString - """Unique identifier for the virtualization volume.""" - name: NonEmptyString - """Human-readable name of the virtualization volume.""" - storage_pool: NonEmptyString - """Name of the storage pool containing this volume.""" - content_type: NonEmptyString - """Type of content stored in this volume (e.g., 'BLOCK', 'ISO').""" - created_at: str - """Timestamp when this volume was created.""" - type: NonEmptyString - """Volume type indicating its storage backend and characteristics.""" - config: dict - """Object containing detailed configuration parameters for this volume.""" - used_by: list[NonEmptyString] - """Array of virtual instance names currently using this volume.""" - - -@single_argument_args('virt_volume_create') -class VirtVolumeCreateArgs(BaseModel): - name: VOLUME_NAME - """Name for the new virtualization volume (alphanumeric, dashes, dots, underscores).""" - 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.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which to create the volume. This must be one of pools listed \ - in virt.global.config output under `storage_pools`. If the value is None, then \ - the pool defined as `pool` in virt.global.config will be used. - """ - - -class VirtVolumeCreateResult(BaseModel): - result: VirtVolumeEntry - """The newly created virtualization volume configuration.""" - - -class VirtVolumeUpdate(BaseModel, metaclass=ForUpdateMetaclass): - size: int = Field(ge=512) - """New size for the volume in MB (minimum 512MB).""" - - -class VirtVolumeUpdateArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtualization volume to update.""" - virt_volume_update: VirtVolumeUpdate - """Updated configuration for the virtualization volume.""" - - -class VirtVolumeUpdateResult(BaseModel): - result: VirtVolumeEntry - """The updated virtualization volume configuration.""" - - -class VirtVolumeDeleteArgs(BaseModel): - id: NonEmptyString - """Identifier of the virtualization volume to delete.""" - - -class VirtVolumeDeleteResult(BaseModel): - result: Literal[True] - """Always returns true on successful volume deletion.""" - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportIsoArgs(BaseModel): - name: VOLUME_NAME - """Specify name of the newly created volume from the ISO specified.""" - iso_location: NonEmptyString | None = None - """Path to the ISO file to import. `null` if uploading via `upload_iso`.""" - upload_iso: bool = False - """Whether to upload an ISO file instead of using a local file path.""" - storage_pool: NonEmptyString | None = None - """ - Storage pool in which to create the volume. This must be one of pools listed \ - in virt.global.config output under `storage_pools`. If the value is None, then \ - the pool defined as `pool` in virt.global.config will be used. - """ - - -class VirtVolumeImportIsoResult(BaseModel): - result: VirtVolumeEntry - """The newly created volume from the imported ISO file.""" - - -class ZvolImportEntry(BaseModel): - virt_volume_name: VOLUME_NAME - """Name for the new virtualization volume created from the imported ZFS volume.""" - zvol_path: NonEmptyString - """Full path to the ZFS volume device in /dev/zvol/.""" - - @field_validator('zvol_path') - @classmethod - def validate_source(cls, zvol_path): - if not zvol_path.startswith('/dev/zvol/'): - raise ValueError('Not a valid /dev/zvol path') - - return zvol_path - - -@single_argument_args('virt_volume_import_iso') -class VirtVolumeImportZvolArgs(BaseModel): - to_import: list[ZvolImportEntry] - """Array of ZFS volumes to import as virtualization volumes.""" - clone: bool = False - """Whether to clone and promote the ZFS volume instead of importing directly.""" - - -class VirtVolumeImportZvolResult(BaseModel): - result: VirtVolumeEntry - """The newly created volume from the imported ZFS volume.""" diff --git a/src/middlewared/middlewared/apps/webshell_app.py b/src/middlewared/middlewared/apps/webshell_app.py index 3b6525c0f25d4..c104c9321efbf 100644 --- a/src/middlewared/middlewared/apps/webshell_app.py +++ b/src/middlewared/middlewared/apps/webshell_app.py @@ -42,7 +42,7 @@ def __init__(self, middleware, ws, input_queue, loop, username, as_root, options super(ShellWorkerThread, self).__init__(daemon=True) def get_command(self, username, as_root, options): - allowed_options = ("vm_id", "app_name", "virt_instance_id") + allowed_options = ("vm_id", "app_name") if all(options.get(k) for k in allowed_options): raise CallError( f'Only one option is supported from {", ".join(allowed_options)}' @@ -70,17 +70,6 @@ def get_command(self, username, as_root, options): if not as_root: command = ["/usr/bin/sudo", "-H", "-u", username] + command return command, not as_root - elif options.get("virt_instance_id"): - command = [ - "/usr/bin/incus", - "console" if options.get("use_console") else "exec", - options["virt_instance_id"] - ] - if options.get("command"): - command.append(options["command"]) - if not as_root: - command = ["/usr/bin/sudo", "-H", "-u", username] + command - return command, not as_root else: return ["/usr/bin/login", "-p", "-f", username], False @@ -276,27 +265,6 @@ async def run(self, ws, origin, conndata): options["vm_data"] = await self.middleware.call( "vm.get_instance", options["vm_id"] ) - if options.get("virt_instance_id"): - try: - virt_instance = await self.middleware.call( - "virt.instance.get_instance", options["virt_instance_id"] - ) - options["instance_type"] = virt_instance["type"] - if virt_instance["type"] == "VM": - if virt_instance["status"] != "RUNNING": - raise CallError("Virt instance must be running.") - options.setdefault("use_console", True) - if options["use_console"]: - options["command"] = None - else: - options["command"] = options.get("command") or "/bin/sh" - elif not options.get("command"): - command = await self.middleware.call("virt.instance.get_shell", options["virt_instance_id"]) - if not command: - command = "/bin/sh" - options["command"] = command - except InstanceNotFound: - raise CallError("Provided instance id is not valid") if options.get("app_name"): if not options.get("container_id"): raise CallError("Container id must be specified") diff --git a/src/middlewared/middlewared/etc_files/subgid.mako b/src/middlewared/middlewared/etc_files/subgid.mako index ff796f097ac87..56405526fa216 100644 --- a/src/middlewared/middlewared/etc_files/subgid.mako +++ b/src/middlewared/middlewared/etc_files/subgid.mako @@ -1,5 +1,4 @@ <% -from middlewared.api.base.types.user import INCUS_IDMAP_MIN, INCUS_IDMAP_COUNT from middlewared.utils import filter_list mapped = filter_list(render_ctx['group.query'], [ @@ -8,7 +7,6 @@ mapped = filter_list(render_ctx['group.query'], [ ['roles', '=', []] ]) %>\ -0:${INCUS_IDMAP_MIN}:${INCUS_IDMAP_COUNT} % for group in mapped: 0:${group['gid']}:${group['gid'] if group['userns_idmap'] == 'DIRECT' else group['userns_idmap']} % endfor diff --git a/src/middlewared/middlewared/etc_files/subuid.mako b/src/middlewared/middlewared/etc_files/subuid.mako index 1dc06003a11aa..5c7a48e44da65 100644 --- a/src/middlewared/middlewared/etc_files/subuid.mako +++ b/src/middlewared/middlewared/etc_files/subuid.mako @@ -1,5 +1,4 @@ <% -from middlewared.api.base.types.user import INCUS_IDMAP_MIN, INCUS_IDMAP_COUNT from middlewared.utils import filter_list mapped = filter_list(render_ctx['user.query'], [ @@ -8,7 +7,6 @@ mapped = filter_list(render_ctx['user.query'], [ ['roles', '=', []] ]) %>\ -0:${INCUS_IDMAP_MIN}:${INCUS_IDMAP_COUNT} % for user in mapped: 0:${user['uid']}:${user['uid'] if user['userns_idmap'] == 'DIRECT' else user['userns_idmap']} % endfor diff --git a/src/middlewared/middlewared/plugins/account_/constants.py b/src/middlewared/middlewared/plugins/account_/constants.py index ebf70d665cc26..312c0ae6b4c15 100644 --- a/src/middlewared/middlewared/plugins/account_/constants.py +++ b/src/middlewared/middlewared/plugins/account_/constants.py @@ -30,7 +30,6 @@ 952, # truenas_sharing_administrators } -# TRUENAS_IDMAP_MAX + 1, this is also first ID in range allocated for Incus idmaps CONTAINER_ROOT_UID = 2147000001 SYNTHETIC_CONTAINER_ROOT = { diff --git a/src/middlewared/middlewared/plugins/failover_/event.py b/src/middlewared/middlewared/plugins/failover_/event.py index 77925deb773b6..110c5d6f47d4b 100644 --- a/src/middlewared/middlewared/plugins/failover_/event.py +++ b/src/middlewared/middlewared/plugins/failover_/event.py @@ -19,7 +19,6 @@ # from middlewared.plugins.failover_.zpool_cachefile import ZPOOL_CACHE_FILE from middlewared.plugins.failover_.event_exceptions import AllZpoolsFailedToImport, IgnoreFailoverEvent, FencedError from middlewared.plugins.failover_.scheduled_reboot_alert import WATCHDOG_ALERT_FILE -from middlewared.plugins.virt.utils import VirtGlobalStatus as VirtStatus from middlewared.plugins.pwenc import PWENC_FILE_SECRET logger = logging.getLogger('failover') @@ -776,7 +775,6 @@ def vrrp_master(self, job, fobj, ifname, event): self.start_vms() self.start_apps() - self.start_virt() logger.info('Migrating interface information (if required)') self.run_call('interface.persist_link_addresses') @@ -847,14 +845,6 @@ def vrrp_backup(self, job, fobj, ifname, event): stop_vm_thread = threading.Thread(target=self.stop_vms, name='failover_stop_vms') stop_vm_thread.start() - # We will try to give some time to containers to gracefully stop before zpools will be forcefully - # exported. This is to avoid any potential data corruption. - stop_virt_thread = threading.Thread( - target=self.stop_virt, - name='failover_stop_virt', - ) - stop_virt_thread.start() - # We stop netdata before exporting pools because otherwise we might have erroneous stuff # getting logged and causing spam logger.info('Stopping reporting metrics') @@ -1067,29 +1057,6 @@ def stop_apps(self): else: logger.info('Docker service stopped gracefully') - def start_virt(self): - logger.info('Going to initialize virt plugin') - job = self.run_call('virt.global.setup') - job.wait_sync(timeout=10) - if job.error: - logger.info('Failed to setup virtualization: %r', job.error) - else: - config = self.run_call('virt.global.config') - if config['state'] == VirtStatus.INITIALIZED.value: - logger.info('Virtualization initalized.') - elif config['state'] != VirtStatus.NO_POOL.value: - logger.warning('Virtualization failed to initialize with state %r.', config['state']) - - def stop_virt(self): - logger.info('Going to stop virt plugin') - job = self.run_call('virt.global.reset') - # virt instances have a timeout of 10 seconds to stop - job.wait_sync(timeout=15) - if job.error: - logger.warning('Failed to reset virtualization state.') - else: - logger.info('Virtualization has been successfully resetted.') - async def vrrp_fifo_hook(middleware, data): ifname = data['ifname'] diff --git a/src/middlewared/middlewared/plugins/interface/internal_ifaces.py b/src/middlewared/middlewared/plugins/interface/internal_ifaces.py index 7b29e32516461..6721214e116d7 100644 --- a/src/middlewared/middlewared/plugins/interface/internal_ifaces.py +++ b/src/middlewared/middlewared/plugins/interface/internal_ifaces.py @@ -14,7 +14,6 @@ async def internal_interfaces(self): result = list(netif.INTERNAL_INTERFACES) result.extend(await self.middleware.call('failover.internal_interface.detect')) result.extend(await self.middleware.call('rdma.interface.internal_interfaces')) - result.extend(await self.middleware.call('virt.global.internal_interfaces')) if (await self.middleware.call('truenas.get_chassis_hardware')).startswith('TRUENAS-F'): # The eno1 interface needs to be masked on the f-series platform because # this interface is shared with the BMC. Details for why this is done diff --git a/src/middlewared/middlewared/plugins/network.py b/src/middlewared/middlewared/plugins/network.py index ccb6b5df670d5..17a636736490d 100644 --- a/src/middlewared/middlewared/plugins/network.py +++ b/src/middlewared/middlewared/plugins/network.py @@ -1345,10 +1345,6 @@ async def do_delete(self, oid): filters = [('type', '=', 'VLAN'), ('vlan_parent_interface', '=', iface['id'])] if vlans := ', '.join([i['name'] for i in await self.middleware.call('interface.query', filters)]): verrors.add(schema, f'The following VLANs depend on this interface: {vlans}') - elif iface['type'] == 'BRIDGE' and ( - iface['name'] == (await self.middleware.call('virt.global.config'))['bridge'] - ): - verrors.add(schema, 'Virt is using this interface as its bridge interface.') verrors.check() diff --git a/src/middlewared/middlewared/plugins/pool_/dataset.py b/src/middlewared/middlewared/plugins/pool_/dataset.py index 15176d2564c29..fb6168b9302a6 100644 --- a/src/middlewared/middlewared/plugins/pool_/dataset.py +++ b/src/middlewared/middlewared/plugins/pool_/dataset.py @@ -115,7 +115,6 @@ async def internal_datasets_filters(self): ['id', 'rnin', '/.system'], ['id', 'rnin', '/ix-applications/'], ['id', 'rnin', '/ix-apps'], - ['id', 'rnin', '/.ix-virt'], ] @private diff --git a/src/middlewared/middlewared/plugins/pool_/dataset_details.py b/src/middlewared/middlewared/plugins/pool_/dataset_details.py index b27669438f945..291ee9b9361ba 100644 --- a/src/middlewared/middlewared/plugins/pool_/dataset_details.py +++ b/src/middlewared/middlewared/plugins/pool_/dataset_details.py @@ -81,7 +81,6 @@ def normalize_dataset(self, dataset, info, mnt_info): dataset['iscsi_shares'] = self.get_iscsi_shares(dataset, info['iscsi']) dataset['vms'] = self.get_vms(dataset, info['vm']) dataset['apps'] = self.get_apps(dataset, info['app']) - dataset['virt_instances'] = self.get_virt_instances(dataset, info['virt_instance']) dataset['replication_tasks_count'] = self.get_repl_tasks_count(dataset, info['repl']) dataset['snapshot_tasks_count'] = self.get_snapshot_tasks_count(dataset, info['snap']) dataset['cloudsync_tasks_count'] = self.get_cloudsync_tasks_count(dataset, info['cloud']) @@ -116,7 +115,6 @@ def build_details(self, mntinfo): 'iscsi': [], 'nfs': [], 'smb': [], 'repl': [], 'snap': [], 'cloud': [], 'rsync': [], 'vm': [], 'app': [], - 'virt_instance': [], } # iscsi @@ -184,22 +182,6 @@ def build_details(self, mntinfo): 'mount_info': self.get_mount_info(path_config['source'], mntinfo), }) - # virt instance - for instance in self.middleware.call_sync('virt.instance.query'): - for device in self.middleware.call_sync('virt.instance.device_list', instance['id']): - if device['dev_type'] != 'DISK': - continue - if not device['source']: - continue - device['instance'] = instance['id'] - if device['source'].startswith('/dev/zvol/'): - # disk type is always a zvol - device['zvol'] = zvol_path_to_name(device['source']) - else: - # raw type is always a file - device['mount_info'] = self.get_mount_info(device['source'], mntinfo) - results['virt_instance'].append(device) - return results @private @@ -295,19 +277,6 @@ def get_vms(self, ds, _vms): return vms - @private - def get_virt_instances(self, ds, _instances): - instances = [] - for i in _instances: - if ( - 'zvol' in i and i['zvol'] == ds['id'] or - i['source'] == ds['mountpoint'] or - i.get('mount_info', {}).get('mount_source') == ds['id'] - ): - instances.append({'name': i['instance'], 'path': i['source']}) - - return instances - @private def get_apps(self, ds, _apps): apps = [] diff --git a/src/middlewared/middlewared/plugins/pool_/dataset_query_utils.py b/src/middlewared/middlewared/plugins/pool_/dataset_query_utils.py index 530ae50b2ea09..c68a092b5ddd3 100644 --- a/src/middlewared/middlewared/plugins/pool_/dataset_query_utils.py +++ b/src/middlewared/middlewared/plugins/pool_/dataset_query_utils.py @@ -19,7 +19,6 @@ ".system", "ix-applications", "ix-apps", - ".ix-virt", ) """ Tuple of internal dataset name patterns that should be filtered out by default. diff --git a/src/middlewared/middlewared/plugins/security/update.py b/src/middlewared/middlewared/plugins/security/update.py index 183023f9d191f..0abc80e88eb0d 100644 --- a/src/middlewared/middlewared/plugins/security/update.py +++ b/src/middlewared/middlewared/plugins/security/update.py @@ -134,12 +134,6 @@ async def validate_stig(self, current_cred): 'Please disable Apps as Apps are not supported under General Purpose OS STIG compatibility mode.' ) - if (await self.middleware.call('virt.global.config'))['pool']: - raise ValidationError( - 'system_security_update.enable_gpos_stig', - 'Please disable VMs as VMs are not supported under General Purpose OS STIG compatibility mode.' - ) - if (await self.middleware.call('tn_connect.config'))['enabled']: raise ValidationError( 'system_security_update.enable_gpos_stig', diff --git a/src/middlewared/middlewared/plugins/service_/services/all.py b/src/middlewared/middlewared/plugins/service_/services/all.py index 46a91f945607f..59549e50c70b9 100644 --- a/src/middlewared/middlewared/plugins/service_/services/all.py +++ b/src/middlewared/middlewared/plugins/service_/services/all.py @@ -1,7 +1,6 @@ from .cifs import CIFSService from .docker import DockerService from .ftp import FTPService -from .incus import IncusService from .iscsitarget import ISCSITargetService from .mdns import MDNSService from .netbios import NetBIOSService @@ -64,7 +63,6 @@ LibvirtGuestService, CronService, KmipService, - IncusService, LoaderService, HostnameService, HttpService, diff --git a/src/middlewared/middlewared/plugins/service_/services/incus.py b/src/middlewared/middlewared/plugins/service_/services/incus.py deleted file mode 100644 index 834c83c5e7a88..0000000000000 --- a/src/middlewared/middlewared/plugins/service_/services/incus.py +++ /dev/null @@ -1,46 +0,0 @@ -import os -import re -import signal - -from pystemd.systemd1 import Unit - -from middlewared.plugins.service_.services.base import SimpleService -from middlewared.plugins.virt.websocket import IncusWS - - -RE_DNSMASQ_PID = re.compile(r'^pid: (\d+)', flags=re.M) - - -class IncusService(SimpleService): - name = "incus" - - systemd_unit = "incus" - - async def start(self): - await super().start() - await IncusWS().start() - - async def stop(self): - await IncusWS().stop() - await self._unit_action("Stop") - # incus.socket needs to be stopped in addition to the service - unit = Unit("incus.socket") - try: - unit.load() - await self._unit_action("Stop", unit=unit) - finally: - del unit - await self.middleware.run_in_thread(self._stop_dnsmasq) - - def _stop_dnsmasq(self): - # Incus will run dnsmasq for its managed network and not stop it - # when the service is stopped. - dnsmasq_pid = '/var/lib/incus/networks/incusbr0/dnsmasq.pid' - if os.path.exists(dnsmasq_pid): - try: - with open(dnsmasq_pid) as f: - data = f.read() - if reg := RE_DNSMASQ_PID.search(data): - os.kill(int(reg.group(1)), signal.SIGTERM) - except FileNotFoundError: - pass diff --git a/src/middlewared/middlewared/plugins/usage.py b/src/middlewared/middlewared/plugins/usage.py index fca44917dbc33..2b0020293c534 100644 --- a/src/middlewared/middlewared/plugins/usage.py +++ b/src/middlewared/middlewared/plugins/usage.py @@ -421,30 +421,6 @@ async def gather_vms(self, context): return {'vms': vms} - async def gather_virt(self, context): - virt = [] - for v in await self.middleware.call('virt.instance.query'): - nics = disks = 0 - for device in await self.middleware.call('virt.instance.device_list', v['id']): - dtype = device['dev_type'] - if dtype == 'NIC': - nics += 1 - elif dtype == 'DISK': - disks += 1 - - virt.append({ - 'type': v['type'], - 'autostart': v['autostart'], - 'cpu': v['cpu'], - 'nics': nics, - 'disks': disks, - 'vnc_enabled': v['vnc_enabled'], - 'secure_boot': v['secure_boot'], - 'memory': v['memory'], - }) - - return {'virt': virt} - def gather_nspawn_containers(self, context): nspawn_containers = list() try: diff --git a/src/middlewared/middlewared/plugins/virt/__init__.py b/src/middlewared/middlewared/plugins/virt/__init__.py deleted file mode 100644 index e69de29bb2d1d..0000000000000 diff --git a/src/middlewared/middlewared/plugins/virt/attachments.py b/src/middlewared/middlewared/plugins/virt/attachments.py deleted file mode 100644 index 9c4a018a86d29..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/attachments.py +++ /dev/null @@ -1,254 +0,0 @@ -import contextlib -import ipaddress -from itertools import product -from typing import TYPE_CHECKING - -from middlewared.common.attachment import FSAttachmentDelegate -from middlewared.common.ports import PortDelegate, ServicePortDelegate - -from .utils import VirtGlobalStatus, INCUS_BRIDGE - -if TYPE_CHECKING: - from middlewared.main import Middleware - - -class VirtFSAttachmentDelegate(FSAttachmentDelegate): - - name = 'virt' - title = 'Virtualization' - service = 'incus' - - async def query(self, path, enabled, options=None): - virt_config = await self.middleware.call('virt.global.config') - - instances = [] - pool = path.split('/')[2] if path.count('/') == 2 else None # only set if path is pool mp - dataset = path.removeprefix('/mnt/') - incus_pool_change = dataset in virt_config['storage_pools'] or dataset == virt_config['pool'] - for i in await self.middleware.call('virt.instance.query'): - append = False - if pool and i['storage_pool'] == pool: - instances.append({ - 'id': i['id'], - 'name': i['name'], - 'disk_devices': [], - 'dataset': dataset, - }) - continue - - disks = [] - for device in await self.middleware.call('virt.instance.device_list', i['id']): - if device['dev_type'] != 'DISK': - continue - - if pool and device['storage_pool'] == pool: - append = True - disks.append(device['name']) - continue - - if device['source'] is None: - continue - - source_path = device['source'].removeprefix('/dev/zvol/').removeprefix('/mnt/') - if await self.middleware.call('filesystem.is_child', source_path, dataset): - append = True - disks.append(device['name']) - continue - - if append: - instances.append({ - 'id': i['id'], - 'name': i['name'], - 'disk_devices': disks, - 'dataset': dataset, - }) - - return [{ - 'id': dataset, - 'name': self.name, - 'instances': instances, - 'incus_pool_change': incus_pool_change, - }] if incus_pool_change or instances else [] - - async def delete(self, attachments): - if not attachments: - return - - # This is called in 3 cases: - # 1) A dataset is being deleted which is being consumed by virt somehow - # 2) A pool is being exported which is a storage pool in virt but not the main pool - # 3) A pool is being exported which is the main pool of incus - # - # In (1), what we want to do is to remove the disks from the instances as incus does not like if a path - # does not exist anymore and just flat out errors out, we intend to improve that but that will be a different - # change/recovery mechanism. - # In (2), we want to remove the disks from the instances which are using the pool and then unset the pool - # For unsetting, we first unset the main pool as well because of a design decision taken when implementing this - # to see if the storage pool being removed is being used anywhere and erroring out if that is the case. - # After discussing with Andrew, we want to keep that as is. Which means we do something like - # virt.global.update main_pool=None, storage_pools=[pool1, pool2] where exported pool is removed - # Then we do virt.global.update main_pool=pool1, storage_pools=[pool1, pool2] - # Finally for (3), there is nothing much to be done here in this regard and we can just unset the pool - - attachment = attachments[0] - virt_config = await self.middleware.call('virt.global.config') - storage_pools = [p for p in virt_config['storage_pools'] if p != attachment['id']] - if attachment['incus_pool_change'] and attachment['id'] == virt_config['pool']: - # We are exporting main virt pool and at this point we should just unset - # the pool - await (await self.middleware.call('virt.global.update', { - 'pool': None, - 'storage_pools': storage_pools, - })).wait(raise_error=True) - return - - disks_to_remove = [i for i in filter(lambda i: i.get('disk_devices'), attachment['instances'])] - for instance_data in disks_to_remove: - for to_remove_disk in instance_data['disk_devices']: - await self.middleware.call('virt.instance.device_delete', instance_data['name'], to_remove_disk) - - if attachment['incus_pool_change']: - # This means one of the storage pool is being exported - new_config = { - 'pool': None, - 'storage_pools': storage_pools, - } - await (await self.middleware.call('virt.global.update', new_config)).wait(raise_error=True) - await (await self.middleware.call( - 'virt.global.update', {'pool': virt_config['pool']} - )).wait(raise_error=True) - - async def toggle(self, attachments, enabled): - await getattr(self, 'start' if enabled else 'stop')(attachments) - - async def start(self, attachments): - if not attachments: - return - - attachment = attachments[0] - if attachment['incus_pool_change']: - try: - await (await self.middleware.call('virt.global.setup')).wait(raise_error=True) - except Exception: - self.middleware.logger.error('Failed to start incus', exc_info=True) - # No need to attempt to toggle instances, it won't happen either ways because none could be - # queried to be started as incus wasn't even running but better safe than sorry - return - - await self.start_instances(attachment['instances']) - - async def stop(self, attachments): - if not attachments: - return - - attachment = attachments[0] - if attachment['incus_pool_change']: - # Stopping incus service does not stop the instances - # So let's make sure to stop them separately - await self.stop_running_instances() - try: - await (await self.middleware.call('service.control', 'STOP', self.service)).wait(raise_error=True) - except Exception: - self.middleware.logger.error('Failed to stop incus', exc_info=True) - finally: - await self.middleware.call('virt.global.set_status', VirtGlobalStatus.LOCKED) - else: - await self.stop_instances(attachment['instances']) - - async def toggle_instances(self, attachments, enabled): - for attachment in attachments: - action = 'start' if enabled else 'stop' - params = [{'force': True}] if action == 'stop' else [] - try: - await ( - await self.middleware.call(f'virt.instance.{action}', attachment['id'], *params) - ).wait(raise_error=True) - except Exception as e: - self.middleware.logger.warning('Unable to %s %r: %s', action, attachment['id'], e) - - async def stop_instances(self, attachments): - await self.toggle_instances(attachments, False) - - async def start_instances(self, attachments): - await self.toggle_instances(attachments, True) - - async def disable(self, attachments): - # This has been added explicitly because we do not want to call stop when we export a pool while still - # wanting to maintain attachments as in incus case, this just breaks incus as it won't be able to boot - # anymore. There are 2 cases here: - # 1) Incus main pool being exported - # 2) Incus storage pool being exported - # - # In both cases we remove any reference from virt as virt is practically left in a broken state - # Please refer above in delete impl to see what happens in both cases - await self.delete(attachments) - - async def stop_running_instances(self): - # We need to stop all running instances before we can stop the service - params = [ - [i['id'], {'force': True, 'timeout': 10}] - for i in await self.middleware.call( - 'virt.instance.query', [('status', '=', 'RUNNING')], - {'extra': {'skip_state': True}}, - ) - ] - job = await self.middleware.call('core.bulk', 'virt.instance.stop', params, 'Stopping instances') - await job.wait(raise_error=True) - - -class VirtPortDelegate(PortDelegate): - - name = 'virt instances' - namespace = 'virt' - title = 'Virtualization Device' - - async def get_ports(self): - 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', - 'ports': instance_ports, - 'instance': instance_id, - }) - return ports - - -class IncusServicePortDelegate(ServicePortDelegate): - - name = 'virt' - namespace = 'virt.global' - title = 'Virt Service' - - async def get_ports_internal(self): - ports = [] - config = await self.middleware.call('virt.global.config') - if config['state'] != VirtGlobalStatus.INITIALIZED.value: - # No need to report ports if incus is not initialized - return ports - - with contextlib.suppress(Exception): - # Get incusbr0 network details from incus API - bridge = config['bridge'] or INCUS_BRIDGE - network_info = await self.middleware.call('virt.global.get_network', bridge) - for family in ['ipv4_address', 'ipv6_address']: - if network_info.get(family): - try: - # Extract IP address from CIDR notation - ip = ipaddress.ip_interface(network_info[family]).ip - ports.append((str(ip), 53)) - except ValueError: - continue - - return ports - - -async def setup(middleware: 'Middleware'): - middleware.create_task( - middleware.call( - 'pool.dataset.register_attachment_delegate', - VirtFSAttachmentDelegate(middleware), - ) - ) - await middleware.call('port.register_attachment_delegate', VirtPortDelegate(middleware)) - await middleware.call('port.register_attachment_delegate', IncusServicePortDelegate(middleware)) diff --git a/src/middlewared/middlewared/plugins/virt/device.py b/src/middlewared/middlewared/plugins/virt/device.py deleted file mode 100644 index 82612123c6f96..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/device.py +++ /dev/null @@ -1,132 +0,0 @@ -from dataclasses import asdict - -from middlewared.api import api_method -from middlewared.api.current import ( - VirtDeviceUsbChoicesArgs, VirtDeviceUsbChoicesResult, - VirtDeviceGpuChoicesArgs, VirtDeviceGpuChoicesResult, - VirtDeviceDiskChoicesArgs, VirtDeviceDiskChoicesResult, - VirtDeviceNicChoicesArgs, VirtDeviceNicChoicesResult, - VirtDevicePciChoicesArgs, VirtDevicePciChoicesResult, -) -from middlewared.service import CallError, private, Service -from middlewared.utils.functools_ import cache -from middlewared.utils.pci import get_all_pci_devices_details -from middlewared.utils.usb import list_usb_devices - -from .utils import PciEntry - - -class VirtDeviceService(Service): - - class Config: - namespace = 'virt.device' - cli_namespace = 'virt.device' - - @api_method(VirtDeviceUsbChoicesArgs, VirtDeviceUsbChoicesResult, roles=['VIRT_INSTANCE_READ']) - def usb_choices(self): - """ - Provide choices for USB devices. - """ - return list_usb_devices() - - @api_method(VirtDeviceGpuChoicesArgs, VirtDeviceGpuChoicesResult, roles=['VIRT_INSTANCE_READ']) - async def gpu_choices(self, gpu_type): - """ - Provide choices for GPU devices. - """ - choices = {} - - if gpu_type != 'PHYSICAL': - raise CallError('Only PHYSICAL type is 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 - choices[i['addr']['pci_slot']] = { - 'bus': i['addr']['bus'], - 'slot': i['addr']['slot'], - 'description': i['description'], - 'vendor': i['vendor'], - 'pci': i['addr']['pci_slot'], - } - return choices - - @private - async def disk_choices_internal(self, include_in_use=False): - """ - This allows optionally including in-use choices because update payloads with - /dev/zvol paths are validated against it in instance_device.py. If our validation - changes at some time in the future we can consolidate this method with the public - disk_choices method. - """ - if include_in_use: - incus_vol_filter = [] - zvol_filter = ['OR', [ - ['attachment', '=', None], - ['attachment.method', '=', 'virt.instance.query'] - ]] - else: - incus_vol_filter = [["used_by", "=", []]] - zvol_filter = ['attachment', '=', None] - - out = {} - for incus_vol in await self.middleware.call('virt.volume.query', incus_vol_filter): - out[incus_vol['id']] = incus_vol['id'] - - for zvol in await self.middleware.call( - 'zfs.dataset.unlocked_zvols_fast', [ - zvol_filter, ['ro', '=', False], - ], - {}, ['ATTACHMENT', 'RO'] - ): - out[zvol['path']] = zvol['name'] - - return out - - @api_method(VirtDeviceDiskChoicesArgs, VirtDeviceDiskChoicesResult, roles=['VIRT_INSTANCE_READ']) - async def disk_choices(self): - """ - Returns disk choices available for device type "DISK" for virtual machines. This includes - both available virt volumes and zvol choices. Disk choices for containers depend on the - mounted file tree (paths). - """ - return await self.disk_choices_internal() - - @api_method(VirtDeviceNicChoicesArgs, VirtDeviceNicChoicesResult, roles=['VIRT_INSTANCE_READ']) - async def nic_choices(self, nic_type): - """ - Returns choices for NIC device. - """ - choices = {} - match nic_type: - case 'BRIDGED': - choices = {i['id']: i['name'] for i in await self.middleware.call( - 'interface.query', [['type', '=', 'BRIDGE']] - )} - case 'MACVLAN': - choices = {i['id']: i['name'] for i in await self.middleware.call( - 'interface.query', - )} - return choices - - @api_method(VirtDevicePciChoicesArgs, VirtDevicePciChoicesResult, roles=['VIRT_INSTANCE_READ']) - def pci_choices(self): - """ - Returns choices for PCI devices valid for VM virt instances. - """ - pci_choices = {} - for i in self.get_pci_devices_choices_cache(): - pci_details = asdict(i) - if pci_details['critical'] is False and not pci_details['error']: - pci_choices[pci_details['pci_addr']] = pci_details - - return pci_choices - - @private - @cache - def get_pci_devices_choices_cache(self) -> tuple[PciEntry]: - result = list() - for pci_addr, pci_details in get_all_pci_devices_details().items(): - result.append(PciEntry(pci_addr=pci_addr, **pci_details)) - - return tuple(result) diff --git a/src/middlewared/middlewared/plugins/virt/global.py b/src/middlewared/middlewared/plugins/virt/global.py deleted file mode 100644 index 23c8ea4ac38d6..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/global.py +++ /dev/null @@ -1,812 +0,0 @@ -import asyncio -import errno -import re -import shutil -import subprocess -import uuid -from collections import defaultdict -from typing import TYPE_CHECKING - -import middlewared.sqlalchemy as sa -from middlewared.api import api_method -from middlewared.api.current import ( - VirtGlobalEntry, - VirtGlobalUpdateArgs, VirtGlobalUpdateResult, - VirtGlobalBridgeChoicesArgs, VirtGlobalBridgeChoicesResult, - VirtGlobalPoolChoicesArgs, VirtGlobalPoolChoicesResult, - VirtGlobalGetNetworkArgs, VirtGlobalGetNetworkResult, -) - -from middlewared.service import job, private -from middlewared.service import ConfigService, ValidationErrors -from middlewared.service_exception import CallError -from middlewared.utils import run, BOOT_POOL_NAME_VALID -from middlewared.utils.zfs import query_imported_fast_impl -from .utils import ( - VirtGlobalStatus, incus_call, VNC_PASSWORD_DIR, TRUENAS_STORAGE_PROP_STR, INCUS_BRIDGE, INCUS_STORAGE -) - -if TYPE_CHECKING: - from middlewared.main import Middleware - - -BRIDGE_AUTO = '[AUTO]' -POOL_DISABLED = '[DISABLED]' -RE_FAILED_INSTANCE = re.compile(r'Failed creating instance "(.*)" record') - - -class NoPoolConfigured(Exception): - pass - - -class LockedDataset(Exception): - pass - - -class VirtGlobalModel(sa.Model): - __tablename__ = 'virt_global' - - id = sa.Column(sa.Integer(), primary_key=True) - pool = sa.Column(sa.String(120), nullable=True) - storage_pools = sa.Column(sa.Text(), nullable=True) - bridge = sa.Column(sa.String(120), nullable=True) - v4_network = sa.Column(sa.String(120), nullable=True) - v6_network = sa.Column(sa.String(120), nullable=True) - - -class VirtGlobalService(ConfigService): - - class Config: - datastore = 'virt_global' - datastore_extend = 'virt.global.extend' - namespace = 'virt.global' - cli_namespace = 'virt.global' - role_prefix = 'VIRT_GLOBAL' - entry = VirtGlobalEntry - - @private - async def extend(self, data): - if data['pool']: - data['dataset'] = f'{data["pool"]}/.ix-virt' - else: - data['dataset'] = None - - if data['storage_pools']: - data['storage_pools'] = data['storage_pools'].split() - else: - data['storage_pools'] = [] - - if data['pool'] and data['pool'] not in data['storage_pools']: - data['storage_pools'].append(data['pool']) - - data['state'] = INCUS_STORAGE.state.value - return data - - @private - async def validate(self, new: dict, schema_name: str, verrors: ValidationErrors): - bridge = new['bridge'] - if not bridge: - bridge = BRIDGE_AUTO - if bridge not in await self.bridge_choices(): - verrors.add(f'{schema_name}.bridge', 'Invalid bridge') - if bridge == BRIDGE_AUTO: - new['bridge'] = None - - pool = new['pool'] - if not pool: - pool = POOL_DISABLED - if pool not in await self.pool_choices(): - verrors.add(f'{schema_name}.pool', 'Invalid pool') - if pool == POOL_DISABLED: - new['pool'] = None - - if pool and not await self.middleware.call('virt.global.license_active'): - verrors.add(f'{schema_name}.pool', 'System is not licensed to run virtualization') - - if new['pool'] and (mapping := (await self.middleware.call('port.ports_mapping')).get(53)): - port_usages = set() - for usages in map(lambda i: mapping.get(i, {}).get('port_details', []), ('0.0.0.0', '::')): - for u in filter(lambda j: 53 in [i[1] for i in j['ports']], usages): - port_usages.add(u['description']) - - if port_usages: - verrors.add( - f'{schema_name}.pool', - ( - f'Port 53 is required for virtualization but is currently in use by the following services ' - f'on wildcard IPs (0.0.0.0/::): {", ".join(port_usages)}. ' - 'Please reconfigure these services to bind to specific IP addresses instead of wildcard IPs.' - ) - ) - - @api_method( - VirtGlobalUpdateArgs, - VirtGlobalUpdateResult, - audit='Virt: Update configuration' - ) - @job(lock='virt_global_configuration') - async def do_update(self, job, data): - """ - Update global virtualization settings. - - `pool` which pool to use to store instances. - None will disable the service. - - `bridge` which bridge interface to use by default. - None means it will automatically create one. - """ - old = await self.config() - - new = old.copy() - new.update(data) - removed_storage_pools = set(old['storage_pools']) - set(new['storage_pools']) - - verrors = ValidationErrors() - await self.validate(new, 'virt_global_update', verrors) - - pool_choices = await self.pool_choices() - for idx, pool in enumerate(new['storage_pools']): - if pool in pool_choices: - continue - - verrors.add( - f'virt_global_update.storage_pools.{idx}', - f'{pool}: pool is not available for incus storage' - ) - - if new['pool'] and old['pool']: - # If we're stopping or starting the virt plugin then we don't need to worry - # about how storage changes will impact the overall running configuration. - error_message = [] - for pool in removed_storage_pools: - if usage := (await self.storage_pool_usage(pool)): - grouped_usage = defaultdict(list) - for item in usage: - grouped_usage[item["type"].capitalize()].append(item["name"]) - - usage_list = '\n'.join( - f'- Virt-{key}: {", ".join(value)}' - for key, value in grouped_usage.items() - ) - - error_message.append( - f'The pool {pool!r} cannot be removed because it is currently used by the following asset(s):\n' - f'{usage_list}' - ) - if error_message: - verrors.add('virt_global_update.storage_pools', '\n\n'.join(error_message)) - - if new['pool'] in removed_storage_pools: - verrors.add( - 'virt_global_update.storage_pools', - 'Default incus pool may not be removed from list of storage pools.' - ) - - verrors.check() - - if new['pool'] and old['pool']: - # If we're stopping or starting the virt plugin then we don't need to worry - # about how storage changes will impact the overall running configuration. - for pool in removed_storage_pools: - await self.remove_storage_pool(pool) - - # Not part of the database - new.pop('state') - new.pop('dataset') - new['storage_pools'] = ' '.join(new['storage_pools']) - - await self.middleware.call( - 'datastore.update', self._config.datastore, - new['id'], new, - ) - - job = await self.middleware.call('virt.global.setup') - await job.wait(raise_error=True) - - return await self.config() - - @api_method(VirtGlobalBridgeChoicesArgs, VirtGlobalBridgeChoicesResult, roles=['VIRT_GLOBAL_READ']) - async def bridge_choices(self): - """ - Bridge choices for virtualization purposes. - - Empty means it will be managed/created automatically. - """ - choices = {BRIDGE_AUTO: 'Automatic'} - # We do not allow custom bridge on HA because it might have bridge STP issues - # causing failover problems. - if not await self.middleware.call('failover.licensed'): - choices.update({ - i['name']: i['name'] - for i in await self.middleware.call('interface.query', [['type', '=', 'BRIDGE']]) - }) - return choices - - @api_method(VirtGlobalPoolChoicesArgs, VirtGlobalPoolChoicesResult, roles=['VIRT_GLOBAL_READ']) - async def pool_choices(self): - """ - Pool choices for virtualization purposes. - """ - pools = {POOL_DISABLED: '[Disabled]'} - imported_pools = await self.middleware.run_in_thread(query_imported_fast_impl) - for p in imported_pools.values(): - # Do not show boot pools or pools with spaces in their name - # Incus is not gracefully able to handle pools which have spaces in their names - # https://ixsystems.atlassian.net/browse/NAS-134244 - if p['name'] in BOOT_POOL_NAME_VALID or ' ' in p['name']: - continue - - ds = await self.middleware.call( - 'pool.dataset.get_instance_quick', p['name'], {'encryption': True}, - ) - if not ds['locked']: - pools[p['name']] = p['name'] - return pools - - @private - async def internal_interfaces(self): - return [INCUS_BRIDGE] - - @private - async def check_initialized(self, config=None): - if config is None: - config = await self.config() - if config['state'] != VirtGlobalStatus.INITIALIZED.value: - raise CallError('Virtualization not initialized.') - - @private - async def check_started(self): - if not await self.middleware.call('service.started', 'incus'): - raise CallError('Virtualization service not started.') - - @private - async def get_profile(self, profile_name): - result = await incus_call(f'1.0/profiles/{profile_name}', 'get') - if result.get('status_code') != 200: - raise CallError(result.get('error')) - return result['metadata'] - - @api_method(VirtGlobalGetNetworkArgs, VirtGlobalGetNetworkResult, roles=['VIRT_GLOBAL_READ']) - async def get_network(self, name): - """ - Details for the given network. - """ - await self.check_initialized() - result = await incus_call(f'1.0/networks/{name}', 'get') - if result.get('status_code') != 200: - raise CallError(result.get('error')) - data = result['metadata'] - return { - 'type': data['type'].upper(), - 'managed': data['managed'], - 'ipv4_address': data['config']['ipv4.address'], - 'ipv4_nat': data['config']['ipv4.nat'] == 'true', - 'ipv6_address': data['config']['ipv6.address'], - 'ipv6_nat': data['config']['ipv6.nat'] == 'true', - } - - @private - @job(lock='virt_global_setup') - async def setup(self, job): - """ - Sets up incus through their API. - Will create necessary storage datasets if required. - """ - try: - INCUS_STORAGE.state = VirtGlobalStatus.INITIALIZING - await self._setup_impl() - except NoPoolConfigured: - INCUS_STORAGE.state = VirtGlobalStatus.NO_POOL - except LockedDataset: - INCUS_STORAGE.state = VirtGlobalStatus.LOCKED - except Exception: - INCUS_STORAGE.state = VirtGlobalStatus.ERROR - raise - else: - INCUS_STORAGE.state = VirtGlobalStatus.INITIALIZED - finally: - self.middleware.send_event('virt.global.config', 'CHANGED', fields=await self.config()) - if INCUS_STORAGE.state == VirtGlobalStatus.INITIALIZED: - # We only want to auto start instances if incus is initialized - await self.auto_start_instances() - - @private - async def setup_storage_pool(self, pool_name): - ds_name = f'{pool_name}/.ix-virt' - try: - ds = await self.middleware.call( - 'zfs.dataset.get_instance', ds_name, { - 'extra': { - 'retrieve_children': False, - 'user_properties': True, - 'properties': ['encryption', 'keystatus'], - } - }, - ) - except Exception: - ds = None - if not ds: - await self.middleware.call('zfs.dataset.create', { - 'name': ds_name, - 'properties': { - 'aclmode': 'discard', - 'acltype': 'posix', - 'exec': 'on', - 'casesensitivity': 'sensitive', - 'atime': 'off', - TRUENAS_STORAGE_PROP_STR: pool_name, - }, - }) - else: - if ds['encrypted'] and not ds['key_loaded']: - self.logger.info('Dataset %r not unlocked, skipping virt setup.', ds['name']) - raise LockedDataset() - if TRUENAS_STORAGE_PROP_STR not in ds['properties']: - if INCUS_STORAGE.default_storage_pool is not None: - if INCUS_STORAGE.default_storage_pool != pool_name: - raise CallError( - f'ZFS pools {pool_name} and {INCUS_STORAGE.default_storage_pool} are both ' - 'configured as the default incus storage pool and may therefore not be ' - 'used simultaneously for virt storage pools.' - ) - else: - INCUS_STORAGE.default_storage_pool = pool_name - - pool_name = 'default' - - else: - expected_pool_name = ds['properties'][TRUENAS_STORAGE_PROP_STR]['value'] - if pool_name != expected_pool_name: - raise CallError( - f'The configured incus storage pool for the ZFS pool {pool_name} ' - f'is {expected_pool_name}, which should match the ZFS pool name. ' - 'This mismatch may indicate that the TrueNAS ix-virt dataset was ' - 'not initially created on this ZFS pool.' - ) - - storage = await incus_call(f'1.0/storage-pools/{pool_name}', 'get') - if storage['type'] != 'error': - if storage['metadata']['config']['source'] == ds_name: - self.logger.debug('Virt storage pool for %s already configured.', ds_name) - pool_name = None # skip recovery - else: - job = await self.middleware.call('virt.global.reset', True, None) - await job.wait(raise_error=True) - - return pool_name - - @private - async def storage_pool_usage(self, pool_name): - """ - Create a list of various user-managed incus assets that are - dependent on the specified pool. This can be used for validation prior - to deletion of an incus storage pool. - """ - resp = await incus_call(f'1.0/storage-pools/{pool_name}', 'get') - if resp['type'] == 'error': - if resp['error_code'] == 404: - # storage doesn't exist. Nothing to do. - return [] - - raise CallError(resp['error']) - - out = [] - - for dependent in resp['metadata']['used_by']: - if dependent.startswith(('/1.0/images/')): - continue - - path = dependent.split('/') - if 'storage-pools' in path: - # sample: - # /1.0/storage-pools/dozer/volumes/custom/foo - incus_type = path[4] - else: - # sample: - # /1.0/instances/myinstance - incus_type = path[2] - - out.append({'type': incus_type, 'name': path[-1]}) - - return out - - @private - @job(lock='virt_global_recover') - async def recover(self, job, to_import): - """ - Call into incus's private API to initiate a recovery action. - This is roughly equivalent to running the command "incus admin recover", and is performed - to make it so that incus on TrueNAS does not rely on the contents of /var/lib/incus. - - https://linuxcontainers.org/incus/docs/main/reference/manpages/incus/admin/recover/#incus-admin-recover-md - - The current design is to do this in the following scenarios: - 1. Setting up incus for this first time on the server - 2. After change to the storage pool path - 3. After an HA failover event - 4. After TrueNAS upgrades - 5. After we see user trying to add a volume whose dataset already exists - - NOTE: this will potentially cause user-initiated changes from incus commands to be lost. - """ - payload = { - 'pools': to_import, - 'project': 'default', - } - - result = await incus_call('internal/recover/validate', 'post', {'json': payload}) - if result['type'] == 'error': - raise CallError(f'Internal storage validation failed: {result["error"]}') - - elif result.get('status') == 'Success': - if result['metadata']['DependencyErrors']: - raise CallError('Missing dependencies: ' + ', '.join(result['metadata']['DependencyErrors'])) - - await self.recover_import_impl(payload, result) - else: - raise CallError('Internal storage validation failed') - - @private - async def recover_import_impl(self, payload, result): - identified_instances = { - i['name']: i for i in (result['metadata'].get('UnknownVolumes', []) or []) - if all(k in i for k in ('name', 'pool', 'type')) - } - attempted_recovered_instances = set() - moved_instances = set() - misconfigured_parent_datasets = set() - while True: - result = await incus_call('internal/recover/import', 'post', {'json': payload}) - if result.get('status') == 'Success': - # If we succeeded, great - let's break the loop - break - - # How this will work is the following: - # Basically we want to identify those instances which have attachments which no longer exists - # Incus refuses to start in this case so we want to attempt to recover and then move such - # instances in their respective storage pools under a new dataset - # i.e tank/.ix-virt/misconfigured_containers - # This way incus won't attempt to recover them as it will fail hard when it tries - # - # There is another problem here that for example you have 2 or more such instances which are - # problematic, incus wont' give you their names at once - rather it will raise an error - # as soon as it goes over the first one so we need to do this in a loop - # - # To conclude what will happen here is that - # failed_instance -> attempt recovery -> try recover/import again -> still fails then move - instance_name = RE_FAILED_INSTANCE.findall(result.get('error', '')) - if not instance_name: - # This is the case where in the error we were not able to identify any instance - raise CallError(result.get('error')) - - instance_name = instance_name[0] - # If we have already attempted to move such instances and we see an error again about them - # by incus, let's fail hard as this will be a vicious loop otherwise and something else - # is wrong. This should not happen as incus won't even recognize such instances at all now - # because they don't exist where it looks for them, but better safe then sorry - # However if earlier when the validation call ran, it identified instances which it was to - # recover, if we don't find the instance there - we still fail hard - if instance_name in moved_instances or instance_name not in identified_instances: - # We do not want a vicious infinite loop here - raise CallError(f'Failed to recover {instance_name!r} instance: {result["error"]}') - - instance = identified_instances[instance_name] - if instance_name not in attempted_recovered_instances: - # We will attempt recovery here before trying to move it - self.logger.debug('Attempting recovery of %r instance', instance_name) - try: - await self.middleware.call('virt.recover.instance', instance) - except CallError as e: - self.logger.error(e, exc_info=True) - finally: - attempted_recovered_instances.add(instance_name) - - continue - - moved_instances.add(instance_name) - # instance type ds_name - instance_type_ds = 'containers' if instance['type'] == 'container' else 'virtual-machines' - # This is the incus instance dataset which we want to move - instance_ds = f'{instance["pool"]}/.ix-virt/{instance_type_ds}/{instance_name}' - # This is the parent dataset under which we are going to move this misconfigured instance - new_ds_parent = f'{instance["pool"]}/.ix-virt/misconfigured_{instance_type_ds}' - # This is the new dataset name/path of the misconfigured incus instance dataset - new_ds = f'{new_ds_parent}/{instance_name}' - - # Before we move forward now, we would like to make sure new_ds_parent actually exists - # Also to avoid repeated calls for example for the case where a lot of instances are in - # a singe storage pool, we keep a local cache and check if it already exists or if it - # needs to be created - if new_ds_parent not in misconfigured_parent_datasets and not await self.middleware.call( - 'zfs.resource.query_impl', - {'paths': [new_ds_parent], 'properties': None}, - ): - await self.middleware.call( - 'zfs.dataset.create', {'name': new_ds_parent, 'type': 'FILESYSTEM'} - ) - - # Caching this now to avoid querying zfs again if this parent ds exists or not - misconfigured_parent_datasets.add(new_ds_parent) - - # Okay now we might have to rename new_ds because an instance with the same name might - # exist there - for i in range(1, 5): - if await self.middleware.call( - 'zfs.resource.query_impl', - {'paths': [new_ds], 'properties': None} - ): - new_ds = f'{new_ds}_{i}' - else: - # We found a name which hasn't been used - break - else: - # We will get here if we were not able to find a dataset even after above iterations - # Let's just add a uuid suffix then - new_ds = f'{new_ds}_{str(uuid.uuid4()).split("-")[-1]}' - - self.logger.error( - 'Could not recover %r instance because of %r, renaming %r to %r', - instance_name, result['error'], instance_ds, new_ds - ) - await self.middleware.call('zfs.dataset.rename', instance_ds, {'new_name': new_ds}) - # For VMs we want move the .block dataset as well - if instance['type'] != 'container': - await self.middleware.call( - 'zfs.dataset.rename', f'{instance_ds}.block', {'new_name': f'{new_ds}.block'} - ) - - @private - async def remove_storage_pool(self, pool_name): - resp = await incus_call(f'1.0/storage-pools/{pool_name}', 'get') - if resp['type'] == 'error': - if resp['error_code'] == 404: - # storage doesn't exist. Nothing to do. - return - - raise CallError(resp['error']) - - to_delete = [] - - for dependent in resp['metadata']['used_by']: - # Middleware internally manages the images and profiles for - # storage pools - if dependent.startswith('/1.0/images/'): - to_delete.append(dependent) - - if remainder := (set(resp['metadata']['used_by']) - set(to_delete)): - raise CallError( - f'Storage volume currently used by the following incus resource {", ".join(remainder)}', errno.EBUSY - ) - - for entry in to_delete: - path = entry[1:] # remove leading slash - resp = await incus_call(path, 'delete') - if resp['type'] == 'error' and resp['error_code'] != 404: - raise CallError(f"{resp['error_code']}: {resp['error']}") - - # Finally remove the pool itself - # We get intermittent errors here from incus API (appears to be replaying last command) - # unless we have a sleep - await asyncio.sleep(1) - - resp = await incus_call(f'1.0/storage-pools/{pool_name}', 'delete') - if resp['type'] == 'error': - raise CallError(resp['error']) - - async def _setup_impl(self): - config = await self.config() - to_import = [] - - if not config['pool']: - if await self.middleware.call('service.started', 'incus'): - job = await self.middleware.call('virt.global.reset', False, config) - await job.wait(raise_error=True) - - self.logger.debug('No pool set for virtualization, skipping.') - raise NoPoolConfigured() - else: - await ( - await self.middleware.call( - 'service.control', 'START', 'incus', {'ha_propagate': False} - ) - ).wait(raise_error=True) - - # Set up the default storage pool - for pool in config['storage_pools']: - if (pool_name := (await self.setup_storage_pool(pool))) is not None: - to_import.append({ - 'config': {'source': f'{pool}/.ix-virt'}, - 'description': '', - 'name': pool_name, - 'driver': 'zfs', - }) - - # If no bridge interface has been set, use incus managed - if not config['bridge']: - - result = await incus_call(f'1.0/networks/{INCUS_BRIDGE}', 'get') - # Create INCUS_BRIDGE if it doesn't exist - if result.get('status') != 'Success': - # Reuse v4/v6 network from database if there is one - result = await incus_call('1.0/networks', 'post', {'json': { - 'config': { - 'ipv4.address': config['v4_network'] or 'auto', - 'ipv4.nat': 'true', - 'ipv6.address': config['v6_network'] or 'auto', - 'ipv6.nat': 'true', - }, - 'description': '', - 'name': INCUS_BRIDGE, - 'type': 'bridge', - }}) - if result.get('status_code') != 200: - raise CallError(result.get('error')) - - result = await incus_call(f'1.0/networks/{INCUS_BRIDGE}', 'get') - if result.get('status_code') != 200: - raise CallError(result.get('error')) - - update_network = True - else: - - # In case user sets empty v4/v6 network we need to generate another - # range automatically. - update_network = False - netconfig = {'ipv4.nat': 'true', 'ipv6.nat': 'true'} - if not config['v4_network']: - update_network = True - netconfig['ipv4.address'] = 'auto' - else: - netconfig['ipv4.address'] = config['v4_network'] - if not config['v6_network']: - update_network = True - netconfig['ipv6.address'] = 'auto' - else: - netconfig['ipv6.address'] = config['v6_network'] - - update_network |= any( - config[f'v{i}_network'] != result['metadata']['config'][f'ipv{i}.address'] - for i in (4, 6) - ) - - if update_network: - result = await incus_call(f'1.0/networks/{INCUS_BRIDGE}', 'put', {'json': { - 'config': netconfig, - }}) - if result.get('status_code') != 200: - raise CallError(result.get('error')) - - result = await incus_call(f'1.0/networks/{INCUS_BRIDGE}', 'get') - if result.get('status_code') != 200: - raise CallError(result.get('error')) - - if update_network: - # Update automatically selected networks into our database - # so it can persist upgrades. - await self.middleware.call('datastore.update', 'virt_global', config['id'], { - 'v4_network': result['metadata']['config']['ipv4.address'], - 'v6_network': result['metadata']['config']['ipv6.address'], - }) - - nic = { - 'name': 'eth0', - 'network': INCUS_BRIDGE, - 'type': 'nic', - } - else: - nic = { - 'name': 'eth0', - 'type': 'nic', - 'nictype': 'bridged', - 'parent': config['bridge'], - } - - result = await incus_call('1.0/profiles/default', 'put', {'json': { - 'config': {}, - 'description': 'Default TrueNAS profile', - 'devices': { - 'eth0': nic, - }, - }}) - if result.get('status') != 'Success': - raise CallError(result.get('error')) - - if to_import: - await (await self.middleware.call('virt.global.recover', to_import)).wait(raise_error=True) - await (await self.middleware.call( - 'service.control', 'RESTART', 'incus', {'ha_propagate': False} - )).wait(raise_error=True) - - @private - @job(lock='virt_global_reset') - async def reset(self, job, start: bool = False, config: dict | None = None): - if config is None: - config = await self.config() - - if await self.middleware.call('service.started', 'incus'): - # Stop running instances - params = [ - [i['id'], {'force': True, 'timeout': 10}] - for i in await self.middleware.call( - 'virt.instance.query', [('status', '=', 'RUNNING')], - {'extra': {'skip_state': True}}, - ) - ] - job = await self.middleware.call('core.bulk', 'virt.instance.stop', params, 'Stopping instances') - await job.wait() - - if await self.middleware.call('virt.instance.query', [('status', '=', 'RUNNING')]): - raise CallError('Failed to stop instances') - - await ( - await self.middleware.call( - 'service.control', 'STOP', 'incus', {'ha_propagate': False} - ) - ).wait(raise_error=True) - if await self.middleware.call('service.started', 'incus'): - raise CallError('Failed to stop virtualization service') - - if not config['bridge']: - # Make sure we delete in case it exists - try: - await run(['ip', 'link', 'show', INCUS_BRIDGE], check=True) - except subprocess.CalledProcessError: - pass - else: - await run(['ip', 'link', 'delete', INCUS_BRIDGE], check=True) - - # Have incus start fresh - # Use subprocess because shutil.rmtree will traverse filesystems - # and we do have instances datasets that might be mounted beneath - await run('rm -rf --one-file-system /var/lib/incus/*', shell=True, check=True) - - if start and not await ( - await self.middleware.call( - 'service.control', 'START', 'incus', {'ha_propagate': False} - ) - ).wait(raise_error=True): - raise CallError('Failed to start virtualization service') - - if not start: - await self.middleware.run_in_thread(shutil.rmtree, VNC_PASSWORD_DIR, True) - - @private - async def auto_start_instances(self): - await self.middleware.call( - 'core.bulk', 'virt.instance.start', [ - [instance['name']] for instance in await self.middleware.call( - 'virt.instance.query', [ - ['autostart', '=', True], - ['status', '=', 'STOPPED'], - ['type', '=', 'CONTAINER'] # Only autostart CONTAINER type instances - ] - ) - # We have an explicit filter for STOPPED because old virt instances would still have - # incus autostart enabled and we don't want to attempt to start them again. - # We can remove this in FT release perhaps. - ] - ) - - @private - async def set_status(self, new_status): - INCUS_STORAGE.state = new_status - self.middleware.send_event('virt.global.config', 'CHANGED', fields=await self.config()) - - -async def _event_system_ready(middleware: 'Middleware', event_type, args): - if not await middleware.call('failover.licensed'): - middleware.create_task(middleware.call('virt.global.setup')) - - -async def setup(middleware: 'Middleware'): - middleware.event_register( - 'virt.global.config', - 'Sent on virtualziation configuration changes.', - roles=['VIRT_GLOBAL_READ'] - ) - middleware.event_subscribe('system.ready', _event_system_ready) - # Should only happen if middlewared crashes or during development - failover_licensed = await middleware.call('failover.licensed') - ready = await middleware.call('system.ready') - if ready and not failover_licensed: - await middleware.call('virt.global.setup') diff --git a/src/middlewared/middlewared/plugins/virt/instance.py b/src/middlewared/middlewared/plugins/virt/instance.py deleted file mode 100644 index 8f3f21996b4d1..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/instance.py +++ /dev/null @@ -1,758 +0,0 @@ -import collections -import json -import os -import platform - -import aiohttp - -from middlewared.service import ( - CallError, CRUDService, ValidationError, ValidationErrors, job, private -) -from middlewared.utils import filter_list - -from middlewared.api import api_method -from middlewared.api.current import ( - VirtInstanceEntry, - VirtInstanceCreateArgs, VirtInstanceCreateResult, - VirtInstanceUpdateArgs, VirtInstanceUpdateResult, - VirtInstanceDeleteArgs, VirtInstanceDeleteResult, - VirtInstanceStartArgs, VirtInstanceStartResult, - VirtInstanceStopArgs, VirtInstanceStopResult, - VirtInstanceRestartArgs, VirtInstanceRestartResult, - VirtInstanceImageChoicesArgs, VirtInstanceImageChoicesResult, -) -from middlewared.utils.size import normalize_size - -from .utils import ( - create_vnc_password_file, get_root_device_dict, get_vnc_info_from_config, - VirtGlobalStatus, incus_call, incus_call_and_wait, incus_pool_to_storage_pool, root_device_pool_from_raw, - storage_pool_to_incus_pool, validate_device_name, generate_qemu_cmd, generate_qemu_cdrom_metadata, - INCUS_METADATA_CDROM_KEY, -) - - -LC_IMAGES_SERVER = 'https://images.linuxcontainers.org' -LC_IMAGES_JSON = f'{LC_IMAGES_SERVER}/streams/v1/images.json' - - -class VirtInstanceService(CRUDService): - - class Config: - namespace = 'virt.instance' - cli_namespace = 'virt.instance' - entry = VirtInstanceEntry - role_prefix = 'VIRT_INSTANCE' - event_register = True - - async def query(self, filters, options): - """ - Query all instances with `query-filters` and `query-options`. - """ - if not options['extra'].get('skip_state'): - config = await self.middleware.call('virt.global.config') - if config['state'] != VirtGlobalStatus.INITIALIZED.value: - return [] - results = (await incus_call('1.0/instances?filter=&recursion=2', 'get'))['metadata'] - entries = [] - for i in results: - # config may be empty due to a race condition during stop - # if thats the case grab instance details without recursion - # which means aliases and state will be unknown - if not i.get('config'): - i = (await incus_call(f'1.0/instances/{i["name"]}', 'get'))['metadata'] - if not i.get('state'): - status = 'UNKNOWN' - else: - status = i['state']['status'].upper() - - autostart = i['config'].get('boot.autostart') - secureboot = None - if i['config'].get('image.requirements.secureboot') == 'true': - secureboot = True - elif i['config'].get('image.requirements.secureboot') == 'false': - secureboot = False - entry = { - 'id': i['name'], - 'name': i['name'], - 'type': 'CONTAINER' if i['type'] == 'container' else 'VM', - 'status': status, - 'cpu': i['config'].get('limits.cpu'), - 'autostart': True if i['config'].get('user.autostart', autostart) == 'true' else False, - 'environment': {}, - 'aliases': [], - 'image': { - 'architecture': i['config'].get('image.architecture'), - 'description': i['config'].get('image.description'), - 'os': i['config'].get('image.os'), - 'release': i['config'].get('image.release'), - 'serial': i['config'].get('image.serial'), - 'type': i['config'].get('image.type'), - 'variant': i['config'].get('image.variant'), - 'secureboot': secureboot, - }, - **get_vnc_info_from_config(i['config']), - 'raw': None, # Default required by pydantic - 'secure_boot': None, - 'privileged_mode': None, - 'root_disk_size': None, - 'root_disk_io_bus': None, - 'storage_pool': incus_pool_to_storage_pool(root_device_pool_from_raw(i)), - } - if entry['type'] == 'VM': - entry['secure_boot'] = True if i['config'].get('security.secureboot') == 'true' else False - root_device = i['devices'].get('root', {}) - entry['root_disk_size'] = normalize_size(root_device.get('size'), False) - # If one isn't set, it defaults to virtio-scsi - entry['root_disk_io_bus'] = (root_device.get('io.bus') or 'virtio-scsi').upper() - else: - entry['privileged_mode'] = i['config'].get('security.privileged') == 'true' - - idmap = None - if idmap_current := i['config'].get('volatile.idmap.current'): - idmap_current = json.loads(idmap_current) - uid = list(filter(lambda x: x.get('Isuid'), idmap_current)) or None - if uid: - uid = { - 'hostid': uid[0]['Hostid'], - 'maprange': uid[0]['Maprange'], - 'nsid': uid[0]['Nsid'], - } - gid = list(filter(lambda x: x.get('Isgid'), idmap_current)) or None - if gid: - gid = { - 'hostid': gid[0]['Hostid'], - 'maprange': gid[0]['Maprange'], - 'nsid': gid[0]['Nsid'], - } - idmap = { - 'uid': uid, - 'gid': gid, - } - entry['userns_idmap'] = idmap - - if options['extra'].get('raw'): - entry['raw'] = i - - entry['memory'] = normalize_size(i['config'].get('limits.memory'), False) - - for k, v in i['config'].items(): - if not k.startswith('environment.'): - continue - entry['environment'][k[12:]] = v - entries.append(entry) - - for v in ((i.get('state') or {}).get('network') or {}).values(): - for address in v['addresses']: - if address['scope'] != 'global': - continue - entry['aliases'].append({ - 'type': address['family'].upper(), - 'address': address['address'], - 'netmask': int(address['netmask']) if address['netmask'] else None, - }) - - return filter_list(entries, filters, options) - - @private - async def validate(self, new, schema_name, verrors, old=None): - # Do not validate image_choices because its an expansive operation, just fail on creation - instance_type = new.get('instance_type') or (old or {}).get('type') - if instance_type and not await self.middleware.call('virt.global.license_active', instance_type): - verrors.add( - f'{schema_name}.instance_type', f'System is not licensed to manage {instance_type!r} instances' - ) - - # Prevent VMs from having autostart enabled for security reasons - if new.get('autostart') is True and instance_type == 'VM': - verrors.add( - f'{schema_name}.autostart', - 'Autostart cannot be enabled for VM instances' - ) - - if instance_type == 'CONTAINER' and new.get('image_os'): - verrors.add(f'{schema_name}.image_os', 'This attribute is only valid for VMs') - - if new.get('storage_pool'): - valid_pools = await self.middleware.call('virt.global.pool_choices') - if new['storage_pool'] not in valid_pools: - verrors.add(f'{schema_name}.storage_pool', 'Not a valid ZFS pool') - - if not old and await self.query([('name', '=', new['name'])]): - verrors.add(f'{schema_name}.name', f'Name {new["name"]!r} already exists') - - if instance_type != 'VM' and new.get('secure_boot'): - verrors.add(f'{schema_name}.secure_boot', 'Secure boot is only supported for VMs') - - if new.get('memory'): - meminfo = await self.middleware.call('system.mem_info') - if new['memory'] > meminfo['physmem_size']: - verrors.add(f'{schema_name}.memory', 'Cannot reserve more than physical memory') - - if new.get('cpu') and new['cpu'].isdigit(): - cpuinfo = await self.middleware.call('system.cpu_info') - if int(new['cpu']) > cpuinfo['core_count']: - verrors.add(f'{schema_name}.cpu', 'Cannot reserve more than system cores') - - if old: - for k in filter(lambda x: x not in new, ('secure_boot', 'privileged_mode')): - new[k] = old[k] - - enable_vnc = new.get('enable_vnc') - if enable_vnc is False: - # User explicitly disabled VNC support, let's remove vnc port - new.update({ - 'vnc_port': None, - 'vnc_password': None, - }) - elif enable_vnc is True: - if not old['vnc_port'] and not new.get('vnc_port'): - verrors.add(f'{schema_name}.vnc_port', 'VNC port is required when VNC is enabled') - elif not new.get('vnc_port'): - new['vnc_port'] = old['vnc_port'] - - if 'vnc_password' not in new: - new['vnc_password'] = old['vnc_password'] - elif enable_vnc is None: - for k in ('vnc_port', 'vnc_password'): - if new.get(k): - verrors.add(f'{schema_name}.enable_vnc', f'Should be set when {k!r} is specified') - - if 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'], - 'vnc_password': old['vnc_password'], - }) - else: - new.update({ - 'enable_vnc': False, - 'vnc_port': None, - 'vnc_password': None, - }) - - if instance_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, instance_name: str, data: dict, devices: dict, raw: dict = None, instance_type=None - ): - config = {} - if 'environment' in data: - # If we are updating environment we need to remove current values - if raw: - for i in list(filter(lambda x: x.startswith('environment.'), raw.keys())): - raw.pop(i) - if data['environment']: - for k, v in data['environment'].items(): - config[f'environment.{k}'] = v - - if 'cpu' in data: - config['limits.cpu'] = data['cpu'] - - if 'memory' in data: - if data['memory']: - config['limits.memory'] = str(int(data['memory'] / 1024 / 1024)) + 'MiB' - else: - config['limits.memory'] = None - - config['boot.autostart'] = 'false' - if data.get('autostart') is not None: - # We do not want VMs to have autostart enabled by default - if instance_type == 'VM' and data['autostart']: - config['user.autostart'] = 'false' - else: - config['user.autostart'] = str(data['autostart']).lower() - - if instance_type == 'VM': - if data.get('image_os'): - config['image.os'] = data['image_os'].capitalize() - - config.update({ - 'security.secureboot': 'true' if data['secure_boot'] else 'false', - 'user.ix_old_raw_qemu_config': raw.get('raw.qemu', '') if raw else '', - 'user.ix_vnc_config': json.dumps({ - 'vnc_enabled': data['enable_vnc'], - 'vnc_port': data['vnc_port'], - 'vnc_password': data['vnc_password'], - }), - INCUS_METADATA_CDROM_KEY: generate_qemu_cdrom_metadata(devices), - }) - - config['raw.qemu'] = generate_qemu_cmd(config, instance_name) - else: - config.update({ - 'security.privileged': 'true' if data.get('privileged_mode') else 'false', - }) - - return config - - @api_method(VirtInstanceImageChoicesArgs, VirtInstanceImageChoicesResult, roles=['VIRT_INSTANCE_READ']) - async def image_choices(self, data): - """ - Provide choices for instance image from a remote repository. - """ - choices = {} - if data['remote'] == 'LINUX_CONTAINERS': - url = LC_IMAGES_JSON - - current_arch = platform.machine() - if current_arch == 'x86_64': - current_arch = 'amd64' - - async with aiohttp.ClientSession() as session: - async with session.get(url) as resp: - for v in (await resp.json())['products'].values(): - if v['variant'] == 'cloud': - # cloud-init based images are unsupported for now - continue - - cdrom_agent_required = v['requirements'].get('cdrom_agent', False) - alias = v['aliases'].split(',', 1)[0] - if alias not in choices: - instance_types = set() - for i in v['versions'].values(): - if 'root.tar.xz' in i['items'] and 'desktop' not in v['aliases']: - instance_types.add('CONTAINER') - if 'disk.qcow2' in i['items'] and not cdrom_agent_required: - # VM images that have a cdrom_agent requirement are not - # supported at the moment - instance_types.add('VM') - if not instance_types: - continue - secureboot = None - if v['requirements'].get('secureboot') == 'false': - secureboot = False - elif v['requirements'].get('secureboot') == 'true': - secureboot = True - choices[alias] = { - 'label': f'{v["os"]} {v["release"]} ({v["arch"]}, {v["variant"]})', - 'os': v['os'], - 'release': v['release'], - 'archs': [v['arch']], - 'variant': v['variant'], - 'instance_types': list(instance_types), - 'secureboot': secureboot, - } - else: - choices[alias]['archs'].append(v['arch']) - return choices - - @api_method( - VirtInstanceCreateArgs, - VirtInstanceCreateResult, - audit='Virt: Creating', - audit_extended=lambda data: f'{data["name"]!r} instance' - ) - @job(lock=lambda args: f'instance_action_{args[0].get("name")}') - async def do_create(self, job, data): - """ - Create a new virtualized instance. - """ - await self.middleware.call('virt.global.check_initialized') - verrors = ValidationErrors() - await self.validate(data, 'virt_instance_create', verrors) - - if data.get('storage_pool'): - pool = storage_pool_to_incus_pool(data['storage_pool']) - else: - defpool = (await self.middleware.call('virt.global.config'))['pool'] - pool = storage_pool_to_incus_pool(defpool) - - data_devices = data['devices'] or [] - - # Since instance_type is now hardcoded to CONTAINER and source_type to IMAGE - # in the API model, we only need the container root device configuration - devices = { - 'root': { - 'path': '/', - 'pool': pool, - 'type': 'disk' - } - } - virt_volumes = {v['id']: v for v in await self.middleware.call('virt.volume.query')} - for i in data_devices: - validate_device_name(i, verrors) - await self.middleware.call( - 'virt.instance.validate_device', i, 'virt_instance_create', verrors, data['name'], 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, virt_volumes, - ) - - if not verrors and data['devices']: - await self.middleware.call( - 'virt.instance.validate_devices', data['devices'], 'virt_instance_create', verrors - ) - - verrors.check() - - async def running_cb(data): - if 'metadata' in data['metadata'] and (metadata := data['metadata']['metadata']): - if 'download_progress' in metadata: - job.set_progress(None, metadata['download_progress']) - if 'create_instance_from_image_unpack_progress' in metadata: - job.set_progress(None, metadata['create_instance_from_image_unpack_progress']) - - if data['remote'] == 'LINUX_CONTAINERS': - url = LC_IMAGES_SERVER - - # Since source_type is hardcoded to IMAGE in the API model - source = { - 'type': 'image', - } - - result = await incus_call(f'1.0/images/{data["image"]}', 'get') - if result['status_code'] == 200: - source['fingerprint'] = result['metadata']['fingerprint'] - else: - source.update({ - 'server': url, - 'protocol': 'simplestreams', - 'mode': 'pull', - 'alias': data['image'], - }) - - try: - await incus_call_and_wait('1.0/instances', 'post', {'json': { - 'name': data['name'], - 'ephemeral': False, - 'config': self.__data_to_config(data['name'], data, devices, instance_type=data['instance_type']), - 'devices': devices, - 'source': source, - 'profiles': ['default'], - 'type': 'container', # Only containers are supported now - 'start': False, - }}, running_cb, timeout=15 * 60) - # We will give 15 minutes to incus to download relevant image and then timeout - except CallError as e: - if await self.middleware.call('virt.instance.query', [['name', '=', data['name']]]): - await (await self.middleware.call('virt.instance.delete', data['name'])).wait() - raise e - - if data['autostart']: - await self.start_impl(job, data['name']) - - return await self.middleware.call('virt.instance.get_instance', data['name']) - - @api_method( - VirtInstanceUpdateArgs, - VirtInstanceUpdateResult, - audit='Virt: Updating', - audit_extended=lambda i, data=None: f'{i!r} instance' - ) - @job(lock=lambda args: f'instance_action_{args[0]}') - async def do_update(self, job, oid, data): - """ - Update instance. - """ - await self.middleware.call('virt.global.check_initialized') - instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}}) - - verrors = ValidationErrors() - await self.validate(data, 'virt_instance_update', verrors, old=instance) - if instance['type'] == 'CONTAINER': - for k in ('root_disk_size', 'root_disk_io_bus', 'enable_vnc'): - if data.get(k): - verrors.add( - f'virt_instance_update.{k}', 'This attribute is not supported for containers' - ) - - if instance['type'] == 'VM': - if data.get('root_disk_size'): - if ((instance['root_disk_size'] or 0) / (1024 ** 3)) >= data['root_disk_size']: - verrors.add( - 'virt_instance_update.root_disk_size', - 'Specified size if set should be greater than the current root disk size.' - ) - - root_key = next((k for k in ('root_disk_size', 'root_disk_io_bus') if data.get(k)), None) - if root_key and instance['status'] != 'STOPPED': - verrors.add( - f'virt_instance_update.{root_key}', - 'VM should be stopped before updating the root disk config' - ) - - verrors.check() - - instance['raw']['config'].update( - self.__data_to_config(oid, data, instance['raw']['devices'], instance['raw']['config'], instance['type']) - ) - if data.get('root_disk_size') or data.get('root_disk_io_bus'): - if (pool := root_device_pool_from_raw(instance['raw'])) is None: - raise CallError(f'{oid}: instance does not have a configured pool') - - root_disk_size = data.get('root_disk_size') or int(instance['root_disk_size'] / (1024 ** 3)) - io_bus = data.get('root_disk_io_bus') or instance['root_disk_io_bus'] - instance['raw']['devices']['root'] = get_root_device_dict(root_disk_size, io_bus, pool) - - await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': instance['raw']}) - - return await self.middleware.call('virt.instance.get_instance', oid) - - @api_method( - VirtInstanceDeleteArgs, - VirtInstanceDeleteResult, - audit='Virt: Deleting', - audit_extended=lambda i: f'{i!r} instance' - ) - @job(lock=lambda args: f'instance_action_{args[0]}') - async def do_delete(self, job, oid): - """ - Delete an instance. - """ - await self.middleware.call('virt.global.check_initialized') - instance = await self.middleware.call('virt.instance.get_instance', oid) - if instance['status'] != 'STOPPED': - try: - await incus_call_and_wait(f'1.0/instances/{oid}/state', 'put', {'json': { - 'action': 'stop', - 'timeout': -1, - 'force': True, - }}) - except CallError: - self.logger.error( - 'Failed to stop %r instance having %r status before deletion', oid, instance['status'], - exc_info=True - ) - - await incus_call_and_wait(f'1.0/instances/{oid}', 'delete') - - return True - - @api_method( - VirtInstanceStartArgs, - VirtInstanceStartResult, - audit='Virt: Starting', - audit_extended=lambda i: f'{i!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - @job(lock=lambda args: f'instance_action_{args[0]}', logs=True) - async def start(self, job, oid): - """ - Start an instance. - """ - await self.middleware.call('virt.global.check_initialized') - return await self.start_impl(job, oid) - - @private - async def start_impl(self, job, oid): - instance = await self.middleware.call('virt.instance.get_instance', oid) - if instance['status'] not in ('RUNNING', 'STOPPED'): - raise ValidationError( - 'virt.instance.start.id', - f'{oid}: instance may not be started because current status is: {instance["status"]}' - ) - - if instance['type'] == 'VM' and not await self.middleware.call('hardware.virtualization.guest_vms_supported'): - raise ValidationError( - 'virt.instance.start.id', - f'Cannot start {oid!r} as virtualization is not supported on this system' - ) - - # Apply any idmap changes - if instance['type'] == 'CONTAINER' and instance['status'] == 'STOPPED' and not instance['privileged_mode']: - await self.set_account_idmaps(oid) - - if instance['vnc_password']: - await self.middleware.run_in_thread(create_vnc_password_file, oid, instance['vnc_password']) - - try: - await incus_call_and_wait(f'1.0/instances/{oid}/state', 'put', {'json': { - 'action': 'start', - }}) - except CallError as e: - log = 'lxc.log' if instance['type'] == 'CONTAINER' else 'qemu.log' - content = await incus_call(f'1.0/instances/{oid}/logs/{log}', 'get', json=False) - output = collections.deque(maxlen=10) # only keep last 10 lines - while line := await content.readline(): - output.append(line) - output = b''.join(output).strip() - errmsg = f'Failed to start instance: {e.errmsg}.' - try: - # If we get a json means there is no log file - json.loads(output.decode()) - except json.decoder.JSONDecodeError: - await job.logs_fd_write(output) - errmsg += ' Please check job logs.' - raise CallError(errmsg) - - return True - - @api_method( - VirtInstanceStopArgs, - VirtInstanceStopResult, - audit='Virt: Stopping', - audit_extended=lambda i, data=None: f'{i!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - @job(lock=lambda args: f'instance_action_{args[0]}') - async def stop(self, job, oid, data): - """ - Stop an instance. - - Timeout is how long it should wait for the instance to shutdown cleanly. - """ - # Only check started because its used when tearing the service down - await self.middleware.call('virt.global.check_started') - await incus_call_and_wait(f'1.0/instances/{oid}/state', 'put', {'json': { - 'action': 'stop', - 'timeout': data['timeout'], - 'force': data['force'], - }}) - - return True - - @api_method( - VirtInstanceRestartArgs, - VirtInstanceRestartResult, - audit='Virt: Restarting', - audit_extended=lambda i, data=None: f'{i!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - @job(lock=lambda args: f'instance_action_{args[0]}') - async def restart(self, job, oid, data): - """ - Restart an instance. - - Timeout is how long it should wait for the instance to shutdown cleanly. - """ - await self.middleware.call('virt.global.check_initialized') - instance = await self.middleware.call('virt.instance.get_instance', oid) - if instance['status'] not in ('RUNNING', 'STOPPED'): - raise ValidationError( - f'virt.instance.restart.{oid}', - f'{oid}: instance may not be restarted because current status is: {instance["status"]}' - ) - - if instance['status'] == 'RUNNING': - await incus_call_and_wait(f'1.0/instances/{oid}/state', 'put', {'json': { - 'action': 'stop', - 'timeout': data['timeout'], - 'force': data['force'], - }}) - - # Apply any idmap changes - if instance['type'] == 'CONTAINER' and not instance['privileged_mode']: - await self.set_account_idmaps(oid) - - if instance['vnc_password']: - await self.middleware.run_in_thread(create_vnc_password_file, oid, instance['vnc_password']) - - await incus_call_and_wait(f'1.0/instances/{oid}/state', 'put', {'json': { - 'action': 'start', - }}) - - return True - - @private - def get_shell(self, oid): - """ - Method to get a valid shell to be used by default. - """ - - self.middleware.call_sync('virt.global.check_initialized') - instance = self.middleware.call_sync('virt.instance.get_instance', oid) - if instance['type'] != 'CONTAINER': - raise CallError('Only available for containers.') - if instance['status'] != 'RUNNING': - raise CallError(f'{oid}: container must be running. Current status is: {instance["status"]}') - config = self.middleware.call_sync('virt.global.config') - mount_info = self.middleware.call_sync( - 'filesystem.mount_info', [['mount_source', '=', f'{config["dataset"]}/containers/{oid}']] - ) - 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 - - @private - async def set_account_idmaps(self, instance_id): - idmaps = await self.get_account_idmaps() - - raw_idmaps_value = '\n'.join([f'{i["type"]} {i["from"]} {i["to"]}' for i in idmaps]) - instance = await self.middleware.call('virt.instance.get_instance', instance_id, {'extra': {'raw': True}}) - if raw_idmaps_value: - instance['raw']['config']['raw.idmap'] = raw_idmaps_value - else: - # Remove any stale raw idmaps. This is required because entries that don't correlate - # to subuid / subgid entries will cause validation failure in incus - instance['raw']['config'].pop('raw.idmap', None) - - await incus_call_and_wait(f'1.0/instances/{instance_id}', 'put', {'json': instance['raw']}) - - @private - async def get_account_idmaps(self, filters=None, options=None): - """ - Return the list of idmaps that are configured in our user / group plugins - """ - - out = [] - - idmap_filters = [ - ['local', '=', True], - ['userns_idmap', 'nin', [0, None]], # Prevent UID / GID 0 from ever being used - ['roles', '=', []] # prevent using users / groups with roles - ] - - user_idmaps = await self.middleware.call('user.query', idmap_filters) - group_idmaps = await self.middleware.call('group.query', idmap_filters) - for user in user_idmaps: - out.append({ - 'type': 'uid', - 'from': user['uid'], - 'to': user['uid'] if user['userns_idmap'] == 'DIRECT' else user['userns_idmap'] - }) - - for group in group_idmaps: - out.append({ - 'type': 'gid', - 'from': group['gid'], - 'to': group['gid'] if group['userns_idmap'] == 'DIRECT' else group['userns_idmap'] - }) - - return filter_list(out, filters or [], options or {}) - - @private - async def get_instance_names(self): - """ - Return list of instance names, this is an endpoint to get just list of names as quickly as possible - """ - try: - instances = (await incus_call('1.0/instances', 'get'))['metadata'] - except Exception: - return [] - else: - return [name.split('/')[-1] for name in instances] diff --git a/src/middlewared/middlewared/plugins/virt/instance_device.py b/src/middlewared/middlewared/plugins/virt/instance_device.py deleted file mode 100644 index 3dca6a0cfe02c..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/instance_device.py +++ /dev/null @@ -1,661 +0,0 @@ -import errno -import os -from typing import Any - -from middlewared.service import ( - CallError, Service, ValidationErrors, private -) - -from middlewared.api import api_method -from middlewared.api.current import ( - VirtInstanceDeviceDeviceListArgs, VirtInstanceDeviceDeviceListResult, - VirtInstanceDeviceDeviceAddArgs, VirtInstanceDeviceDeviceAddResult, - VirtInstanceDeviceDeviceDeleteArgs, VirtInstanceDeviceDeviceDeleteResult, - VirtInstanceDeviceDeviceUpdateArgs, VirtInstanceDeviceDeviceUpdateResult, - VirtInstanceDeviceSetBootableDiskArgs, VirtInstanceDeviceSetBootableDiskResult, -) -from middlewared.async_validators import check_path_resides_within_volume -from .utils import ( - get_max_boot_priority_device, incus_call_and_wait, incus_pool_to_storage_pool, storage_pool_to_incus_pool, - validate_device_name, CDROM_PREFIX, update_instance_metadata_and_qemu_cmd_on_device_change, -) - - -class VirtInstanceDeviceService(Service): - - class Config: - namespace = 'virt.instance' - cli_namespace = 'virt.instance' - - @api_method(VirtInstanceDeviceDeviceListArgs, VirtInstanceDeviceDeviceListResult, roles=['VIRT_INSTANCE_READ']) - async def device_list(self, id_): - """ - List all devices associated to an instance. - """ - instance = await self.middleware.call('virt.instance.get_instance', id_, {'extra': {'raw': True}}) - instance_profiles = instance['raw']['profiles'] - raw_devices = {} - - # An incus instance may have more than one profile applied to it. - for profile in instance_profiles: - profile_devs = (await self.middleware.call('virt.global.get_profile', profile))['devices'] - # Flag devices from the profile as readonly, cannot be modified only overridden - for v in profile_devs.values(): - v['readonly'] = True - - raw_devices |= profile_devs - - raw_devices.update(instance['raw']['devices']) - - devices = [] - context = {} - for k, v in raw_devices.items(): - if (device := await self.incus_to_device(k, v, context)) is not None: - devices.append(device) - - return devices - - @private - async def incus_to_device(self, name: str, incus: dict[str, Any], context: dict): - device = { - 'name': name, - 'description': None, - 'readonly': incus.get('readonly') or False, - } - - match incus['type']: - case 'disk': - if name.startswith(CDROM_PREFIX): - device.update({ - 'dev_type': 'CDROM', - 'source': incus.get('source'), - 'description': f'{incus.get("source")!r} CDROM device source', - 'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None, - }) - else: - source = incus.get('source') - pool = incus_pool_to_storage_pool(incus.get('pool')) - if source and '/' not in source: - # Normalize how we report source here as we know it is a volume at this point - source = f'{pool}_{source}' - - device.update({ - 'dev_type': 'DISK', - 'source': source, - 'storage_pool': pool, - 'destination': incus.get('path'), - 'description': f'{incus.get("source")} -> {incus.get("path")}', - 'boot_priority': int(incus['boot.priority']) if incus.get('boot.priority') else None, - 'io_bus': incus['io.bus'].upper() if incus.get('io.bus') else None, - }) - case 'nic': - device.update({ - 'dev_type': 'NIC', - 'network': incus.get('network'), - 'mac': incus.get('hwaddr'), - }) - if device['network']: - device.update({ - 'parent': None, - 'nic_type': None, - }) - elif incus.get('nictype'): - device.update({ - 'nic_type': incus.get('nictype').upper(), - 'parent': incus.get('parent'), - }) - device['description'] = device['network'] - case 'proxy': - device['dev_type'] = 'PROXY' - # For now follow docker lead for simplification - # only allowing to bind on host (host -> container) - if incus.get('bind') == 'instance': - # bind on instance is not supported in proxy device - return None - - proto, addr, ports = incus['listen'].split(':') - if proto == 'unix' or '-' in ports or ',' in ports: - return None - - device['source_proto'] = proto.upper() - device['source_port'] = int(ports) - - proto, addr, ports = incus['connect'].split(':') - if proto == 'unix' or '-' in ports or ',' in ports: - return None - - device['dest_proto'] = proto.upper() - device['dest_port'] = int(ports) - - a = f'{device["source_proto"]}/{device["source_port"]}' - b = f'{device["dest_proto"]}/{device["dest_port"]}' - device['description'] = f'{a} -> {b}' - case 'tpm': - device['dev_type'] = 'TPM' - device['path'] = incus.get('path') - device['pathrm'] = incus.get('pathrm') - device['description'] = 'TPM' - case 'usb': - device['dev_type'] = 'USB' - if incus.get('busnum') is not None: - device['bus'] = int(incus['busnum']) - if incus.get('devnum') is not None: - device['dev'] = int(incus['devnum']) - if incus.get('productid') is not None: - device['product_id'] = incus['productid'] - if incus.get('vendorid') is not None: - device['vendor_id'] = incus['vendorid'] - - if 'usb_choices' not in context: - context['usb_choices'] = await self.middleware.call('virt.device.usb_choices') - - for choice in context['usb_choices'].values(): - if device.get('bus') and choice['bus'] != device['bus']: - continue - if device.get('dev') and choice['dev'] != device['dev']: - continue - if device.get('product_id') and choice['product_id'] != device['product_id']: - continue - if device.get('vendor_id') and choice['vendor_id'] != device['product_id']: - continue - device['description'] = f'{choice["product"]} ({choice["vendor_id"]}:{choice["product_id"]})' - break - else: - device['description'] = 'Unknown' - case 'gpu': - 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', 'PHYSICAL') - for key, choice in context['gpu_choices'].items(): - if key == incus['pci']: - device['description'] = choice['description'] - break - else: - device['description'] = 'Unknown' - case 'mdev' | 'mig' | 'srviov': - # We do not support these GPU types - return None - case 'pci': - if 'pci_choices' not in context: - context['pci_choices'] = await self.middleware.call('virt.device.pci_choices') - - device.update({ - 'dev_type': 'PCI', - 'address': incus['address'], - 'description': context['pci_choices'].get(incus['address'], {}).get('description', 'Unknown') - }) - case _: - # Unsupported incus device type - return None - - return device - - @private - async def device_to_incus( - self, instance_type: str, device: dict[str, Any], volumes: dict[str, dict] | None = None, - ) -> dict[str, Any]: - new = {} - - match device['dev_type']: - case 'DISK': - if volumes is None: - volumes = { - v['id']: v - for v in await self.middleware.call('virt.volume.query', [['id', '=', device['source']]]) - } - - if volume := volumes.get(device['source']): - device.update({ - 'source': volume['name'], - 'storage_pool': volume['storage_pool'], - }) - new |= { - 'type': 'disk', - 'source': device['source'], - 'path': '/' if device['name'] == 'root' else device['destination'], - } | ({'pool': storage_pool_to_incus_pool(device['storage_pool'])} if device['name'] == 'root' else {}) - if device['boot_priority'] is not None: - new['boot.priority'] = str(device['boot_priority']) - if new['source'] and '/' not in new['source']: - if zpool := device.get('storage_pool'): - new['pool'] = storage_pool_to_incus_pool(zpool) - else: - new['pool'] = None - if device.get('io_bus'): - new['io.bus'] = device['io_bus'].lower() - case 'CDROM': - new |= { - 'type': 'disk', - 'source': device['source'], - 'path': None, - } - if device['boot_priority'] is not None: - new['boot.priority'] = str(device['boot_priority']) - case 'NIC': - new.update({ - 'type': 'nic', - 'network': device['network'], - 'nictype': device['nic_type'].lower(), - 'parent': device['parent'], - 'hwaddr': device['mac'], - }) - case 'PROXY': - new['type'] = 'proxy' - # For now follow docker lead for simplification - # only allowing to bind on host (host -> container) - new['bind'] = 'host' - new['listen'] = f'{device["source_proto"].lower()}:0.0.0.0:{device["source_port"]}' - new['connect'] = f'{device["dest_proto"].lower()}:0.0.0.0:{device["dest_port"]}' - case 'USB': - new['type'] = 'usb' - if device.get('bus') is not None: - new['busnum'] = str(device['bus']) - if device.get('dev') is not None: - new['devnum'] = str(device['dev']) - if device.get('product_id') is not None: - new['productid'] = device['product_id'] - if device.get('vendor_id') is not None: - new['vendorid'] = device['vendor_id'] - case 'TPM': - new['type'] = 'tpm' - if device.get('path'): - if instance_type == 'VM': - raise CallError('Path is not valid for VM') - new['path'] = device['path'] - elif instance_type == 'CONTAINER': - new['path'] = '/dev/tpm0' - - if device.get('pathrm'): - if instance_type == 'VM': - raise CallError('Pathrm is not valid for VM') - new['pathrm'] = device['pathrm'] - elif instance_type == 'CONTAINER': - new['pathrm'] = '/dev/tpmrm0' - case 'GPU': - new['type'] = 'gpu' - # new['id'] = device['id'] - match device['gpu_type']: - case 'PHYSICAL': - new['gputype'] = 'physical' - new['pci'] = device['pci'] - case 'MDEV': - new['gputype'] = 'mdev' - case 'MIG': - new['gputype'] = 'mig' - if not device.get('mig_uuid'): - raise CallError('UUID is required for MIG') - new['mig.uuid'] = device['mig_uuid'] - case 'SRIOV': - new['gputype'] = 'sriov' - case 'PCI': - new.update({ - 'type': 'pci', - 'address': device['address'], - }) - case _: - raise Exception('Invalid device type') - return new - - @private - async def generate_device_name(self, device_names: list[str], device_type: str) -> str: - name = device_type.lower() - if name == 'nic': - name = 'eth' - elif name == 'cdrom': - name = CDROM_PREFIX - - i = 0 - while True: - new_name = f'{name}{i}' - if new_name not in device_names: - name = new_name - break - i += 1 - return name - - @private - async def validate_devices(self, devices, schema, verrors: ValidationErrors): - unique_src_proxies = [] - unique_dst_proxies = [] - disk_sources = set() - - for device in devices: - match device['dev_type']: - case 'PROXY': - source = (device['source_proto'], device['source_port']) - if source in unique_src_proxies: - verrors.add(f'{schema}.source_port', 'Source proto/port already in use.') - else: - unique_src_proxies.append(source) - dst = (device['dest_proto'], device['dest_port']) - if dst in unique_dst_proxies: - verrors.add(f'{schema}.dest_port', 'Destination proto/port already in use.') - else: - unique_dst_proxies.append(dst) - case 'DISK': - source = device['source'] - if source in disk_sources: - verrors.add(f'{schema}.source', 'Source already in use by another device.') - else: - disk_sources.add(source) - - @private - async def validate_device( - self, device, schema, verrors: ValidationErrors, instance_name: str, 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 - if old and old['source_port'] == device['source_port']: - return - # We want to make sure there are no other instances using that port - 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_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 'CDROM': - source = device['source'] - if os.path.isabs(source) is False: - verrors.add(schema, 'Source must be an absolute path') - if await self.middleware.run_in_thread(os.path.exists, source) is False: - verrors.add(schema, 'Specified source path does not exist') - elif await self.middleware.run_in_thread(os.path.isfile, source) is False: - verrors.add(schema, 'Specified source path is not a file') - if instance_type == 'CONTAINER': - verrors.add(schema, 'Container instance type is not supported') - case 'DISK': - source = device['source'] or '' - if source == '' and device['name'] != 'root': - verrors.add(schema, 'Source is required.') - elif source.startswith('/'): - if source.startswith('/dev/zvol/') and source not in await self.middleware.call( - 'virt.device.disk_choices_internal', True - ): - verrors.add(schema, 'Invalid ZVOL choice.') - - if instance_type == 'CONTAINER': - if device['boot_priority'] is not None: - verrors.add(schema, 'Boot priority is not valid for filesystem paths.') - if source.startswith('/dev/zvol/'): - verrors.add(schema, 'ZVOL are not allowed for containers') - - if 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.') - - if not source.startswith('/dev/zvol'): - # Verify that path resolves to an expected data pool - await check_path_resides_within_volume( - verrors, self.middleware, schema, source, True - ) - - # Limit paths to mountpoints because they're much harder for arbitrary - # processes to maliciously replace - st = await self.middleware.call('filesystem.stat', source) - if not st['is_mountpoint']: - verrors.add(schema, 'Source must be a dataset mountpoint.') - - 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 device['name'] == 'root': - if source != '': - verrors.add(schema, 'Root disk source must be unset.') - - device['storage_pool'] = old['storage_pool'] - 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') - else: - # We need to specify the storage pool for device adding to VM - # copy in what is known for the virt volume - device['storage_pool'] = available_volumes[source]['storage_pool'] - - 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') - if device.get('io_bus'): - if instance_type != 'VM': - verrors.add(f'{schema}.io_bus', 'IO bus is only available for VMs') - elif instance_config and instance_config['status'] != 'STOPPED': - verrors.add(f'{schema}.io_bus', 'VM should be stopped before updating IO bus') - if source and instance_type == 'VM': - # Containers only can consume host paths as sources and volumes or zvols are not supported - # For host paths, we have no concern regarding same host path being mounted inside different - # containers. - await self.validate_disk_device_source(instance_name, schema, source, verrors, device['name']) - case 'NIC': - if await self.middleware.call('interface.has_pending_changes'): - raise CallError('There are pending network changes, please resolve before proceeding.') - if device['nic_type'] == 'BRIDGED': - if await self.middleware.call('failover.licensed'): - verrors.add(schema, 'Bridge interface not allowed for HA') - 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') - case 'PCI': - if device['address'] not in await self.middleware.call('virt.device.pci_choices'): - verrors.add(f'{schema}.address', f'Invalid PCI {device["address"]!r} address.') - if instance_type != 'VM': - verrors.add(schema, 'PCI passthrough is only supported for vms') - - @private - async def validate_disk_device_source(self, instance_name, schema, source, verrors, device_name): - available_volumes = set(v['name'] for v in await self.middleware.call('virt.volume.query')) - sources_in_use = await self.get_all_disk_sources(instance_name, available_volumes) - if source in sources_in_use: - verrors.add( - f'{schema}.source', - f'Source {source} is currently in use by {sources_in_use[source]!r} instance' - ) - # No point in continuing further - return - - curr_instance_device = ( - await self.get_all_disk_sources_of_instance(instance_name, available_volumes) - ).get(source) - if curr_instance_device and curr_instance_device != device_name: - verrors.add( - f'{schema}.source', - f'{source} source is already in use by {curr_instance_device!r} device of {instance_name!r} instance' - ) - - @private - async def get_all_disk_sources(self, ignore_instance: str | None = None, available_volumes: set | None = None): - instances = await self.middleware.call( - 'virt.instance.query', [['name', '!=', ignore_instance]], {'extra': {'raw': True}} - ) - sources_in_use = {} - available_volumes = available_volumes or set(v['name'] for v in await self.middleware.call('virt.volume.query')) - for instance in instances: - for disk in filter(lambda d: d['type'] == 'disk' and d.get('source'), instance['raw']['devices'].values()): - if disk['source'] in available_volumes: - disk['source'] = f'{disk.get("pool")}_{disk["source"]}' - sources_in_use[disk['source']] = {'instance': instance['name']} - - return sources_in_use - - @private - async def get_all_disk_sources_of_instance(self, instance_name, available_volumes): - instance = await self.middleware.call( - 'virt.instance.query', [['name', '=', instance_name]], {'extra': {'raw': True}} - ) - if not instance: - return {} - - return { - f'{disk.get("pool")}_{disk["source"]}' if disk['source'] in available_volumes else disk['source']: disk_name - for disk_name, disk in filter( - lambda d: d[1]['type'] == 'disk' and d[1].get('source'), - instance[0]['raw']['devices'].items() - ) - } - - @api_method( - VirtInstanceDeviceDeviceAddArgs, - VirtInstanceDeviceDeviceAddResult, - audit='Virt: Adding device', - audit_extended=lambda i, device: f'{device["dev_type"]!r} to {i!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - async def device_add(self, oid, device): - """ - Add a device to an instance. - """ - instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}}) - data = instance['raw'] - verrors = ValidationErrors() - validate_device_name(device, verrors) - if device['name'] is None: - device['name'] = await self.generate_device_name(data['devices'].keys(), device['dev_type']) - - await self.validate_device(device, 'virt_device_add', verrors, oid, instance['type'], instance_config=instance) - verrors.check() - - data['devices'][device['name']] = await self.device_to_incus(instance['type'], device) - if device['dev_type'] == 'CDROM': - # We want to update qemu config here and make sure we keep track of which - # devices we have added as cdroms here - data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change( - oid, data['config'], data['devices'] - )) - - await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data}) - return True - - @api_method( - VirtInstanceDeviceDeviceUpdateArgs, - VirtInstanceDeviceDeviceUpdateResult, - audit='Virt: Updating device', - audit_extended=lambda i, device: f'{device["name"]!r} of {i!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - async def device_update(self, oid, device): - """ - Update a device in an instance. - """ - instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}}) - data = instance['raw'] - - for old in await self.device_list(oid): - if old['name'] == device['name']: - break - else: - raise CallError('Device does not exist.', errno.ENOENT) - - verrors = ValidationErrors() - await self.validate_device(device, 'virt_device_update', verrors, oid, instance['type'], old, instance) - verrors.check() - - data['devices'][device['name']] = await self.device_to_incus(instance['type'], device) - if device['dev_type'] == 'CDROM': - # We want to update qemu config here and make sure we keep track of which - # devices we have added as cdroms here - data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change( - oid, data['config'], data['devices'] - )) - - await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data}) - return True - - @api_method( - VirtInstanceDeviceDeviceDeleteArgs, - VirtInstanceDeviceDeviceDeleteResult, - audit='Virt: Deleting device', - audit_extended=lambda i, device: f'{device!r} from {i!r} instance', - roles=['VIRT_INSTANCE_DELETE'] - ) - async def device_delete(self, oid, device): - """ - Delete a device from an instance. - """ - instance = await self.middleware.call('virt.instance.get_instance', oid, {'extra': {'raw': True}}) - data = instance['raw'] - if device not in data['devices']: - raise CallError('Device not found.', errno.ENOENT) - data['devices'].pop(device) - if device.startswith(CDROM_PREFIX): - # We want to update qemu config here and make sure we keep track of which - # devices we have added as cdroms here - data['config'].update(update_instance_metadata_and_qemu_cmd_on_device_change( - oid, data['config'], data['devices'] - )) - - await incus_call_and_wait(f'1.0/instances/{oid}', 'put', {'json': data}) - return True - - @api_method( - VirtInstanceDeviceSetBootableDiskArgs, - VirtInstanceDeviceSetBootableDiskResult, - audit='Virt: Choosing', - audit_extended=lambda id_, disk: f'{disk!r} as bootable disk for {id_!r} instance', - roles=['VIRT_INSTANCE_WRITE'] - ) - async def set_bootable_disk(self, id_, disk): - """ - Specify `disk` to boot `id_` virt instance OS from. - """ - instance = await self.middleware.call('virt.instance.get_instance', id_, {'extra': {'raw': True}}) - if instance['type'] != 'VM': - raise CallError('Setting disk to boot from is only valid for VM instances.') - if disk == 'root' and instance['status'] != 'STOPPED': - raise CallError('Instance must be stopped before updating it\'s root disk configuration.') - - device_list = await self.device_list(id_) - desired_disk = None - - max_boot_priority_device = get_max_boot_priority_device(device_list) - for device in device_list: - if device['name'] == disk: - desired_disk = device - - if desired_disk is None: - raise CallError(f'{disk!r} device does not exist.', errno.ENOENT) - - if desired_disk['dev_type'] not in ('CDROM', 'DISK'): - raise CallError(f'{disk!r} device type is not DISK.') - - if max_boot_priority_device and max_boot_priority_device['name'] == disk: - return True - - data = { - 'name': disk, - 'source': desired_disk.get('source'), - 'boot_priority': max_boot_priority_device['boot_priority'] + 1 if max_boot_priority_device else 1, - } - if desired_disk['dev_type'] == 'CDROM': - data |= {'dev_type': 'CDROM'} - else: - data |= { - 'dev_type': 'DISK', - 'io_bus': desired_disk.get('io_bus'), - } | ({'destination': desired_disk['destination']} if disk != 'root' else {}) - - return await self.device_update(id_, data) diff --git a/src/middlewared/middlewared/plugins/virt/license.py b/src/middlewared/middlewared/plugins/virt/license.py deleted file mode 100644 index 30c8bcb8306b7..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/license.py +++ /dev/null @@ -1,34 +0,0 @@ -from ixhardware.chassis import TRUENAS_UNKNOWN - -from middlewared.service import private, Service - - -class VirtLicenseGlobalService(Service): - - class Config: - namespace = 'virt.global' - - @private - async def license_active(self, instance_type=None): - """ - If this is iX enterprise hardware and has NOT been licensed to run virt plugin - then this will return False, otherwise this will return true. - """ - system_chassis = await self.middleware.call('truenas.get_chassis_hardware') - if system_chassis == TRUENAS_UNKNOWN or 'MINI' in system_chassis: - # 1. if it's not iX branded hardware - # 2. OR if it's a MINI, then allow containers/vms - return True - - license_ = await self.middleware.call('system.license') - if license_ is None: - # it's iX branded hardware but has no license - return False - - if instance_type is None: - # licensed JAILS (containers) and/or VMs - return any(k in license_['features'] for k in ('JAILS', 'VM')) - else: - # license could only have JAILS (containers) licensed or VM - feature = 'JAILS' if instance_type == 'CONTAINER' else 'VM' - return feature in license_['features'] diff --git a/src/middlewared/middlewared/plugins/virt/recover.py b/src/middlewared/middlewared/plugins/virt/recover.py deleted file mode 100644 index f4a5f828d7612..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/recover.py +++ /dev/null @@ -1,36 +0,0 @@ -from middlewared.service import CallError, Service - -from .recover_utils import ( - clean_incus_devices, get_instance_ds, get_instance_configuration, mount_instance_ds, update_instance_configuration, -) - - -class VirtRecoverService(Service): - - class Config: - namespace = 'virt.recover' - private = True - - def instance(self, instance): - # Here instance must have these 3 attributes - # name/pool/type - try: - return self.recover_instance_impl(instance) - except Exception as e: - raise CallError(f'Failed to recover instance {instance["name"]}: {e}') - - def recover_instance_impl(self, instance): - instance_ds = get_instance_ds(instance) - - with mount_instance_ds(instance_ds) as mounted: - config = get_instance_configuration(mounted) - devices = config.get('container', {}).get('devices', {}) - expanded_config_devices = config.get('container', {}).get('expanded_devices', {}) - searched_paths = set() - found_paths = set() - # So it seems incus relies on devices but still keeps the expanded devices config - # However in my testing if we removed problematic device from devices only it worked - # I think we should still try to update this on both these keys regardless - clean_incus_devices(devices, searched_paths, found_paths) - clean_incus_devices(expanded_config_devices, searched_paths, found_paths) - update_instance_configuration(mounted, config) diff --git a/src/middlewared/middlewared/plugins/virt/recover_utils.py b/src/middlewared/middlewared/plugins/virt/recover_utils.py deleted file mode 100644 index 38725eacacfb2..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/recover_utils.py +++ /dev/null @@ -1,95 +0,0 @@ -import contextlib -import os -import shutil -import subprocess -import tempfile -import typing -import yaml - -from middlewared.service import CallError - - -INSTANCE_CONFIG_FILE = 'backup.yaml' - - -class NoDatesSafeLoader(yaml.SafeLoader): - pass - - -# We do not want the incus files we manipulate to change format of date time strings -# as incus breaks afterwards -NoDatesSafeLoader.add_constructor( - 'tag:yaml.org,2002:timestamp', - lambda loader, node: node.value -) - - -def get_instance_config_file_path(instance_path: str) -> str: - return os.path.join(instance_path, INSTANCE_CONFIG_FILE) - - -def get_instance_ds_type(instance: dict) -> str: - return 'containers' if instance['type'] == 'container' else 'virtual-machines' - - -def get_instance_ds(instance: dict) -> str: - return os.path.join(instance['pool'], '.ix-virt', get_instance_ds_type(instance), instance['name']) - - -@contextlib.contextmanager -def mount_instance_ds(instance_ds: str) -> typing.Iterator[str]: - with tempfile.TemporaryDirectory() as mounted: - try: - subprocess.run(['mount', '-t', 'zfs', instance_ds, mounted]) - except subprocess.CalledProcessError as e: - raise CallError(f'Invalid instance dataset: {e.stdout}') - try: - yield mounted - finally: - subprocess.run(['umount', mounted]) - - -def get_instance_configuration(instance_path: str) -> dict: - path = get_instance_config_file_path(instance_path) - try: - with open(path, 'r') as f: - return yaml.load(f.read(), NoDatesSafeLoader) - except FileNotFoundError: - raise CallError(f'Instance configuration file not found at {path!r}') - except yaml.YAMLError as e: - raise CallError(f'Failed to parse instance configuration file: {e}') - - -def update_instance_configuration(instance_path: str, config: dict) -> None: - path = get_instance_config_file_path(instance_path) - # We will always keep a backup of the current configuration before applying the modified version - shutil.copy(path, f'{path}.backup') - with open(path, 'w') as f: - yaml.safe_dump(config, f) - - -def clean_incus_devices(devices: dict, searched_paths: set, found_paths: set) -> None: - for device_name in list(devices): - device_config = devices[device_name] - if device_config.get('type') != 'disk' or 'source' not in device_config or not os.path.isabs( - device_config['source'] - ): - continue - - source = device_config['source'] - if source in searched_paths: - if source not in found_paths: - # This source does not exist, remove the device - devices.pop(device_name) - continue - else: - # Nothing to do, this path exists - continue - - searched_paths.add(source) - if os.path.exists(source): - # This source exists, add it to found_paths - found_paths.add(source) - continue - else: - devices.pop(device_name) diff --git a/src/middlewared/middlewared/plugins/virt/stats.py b/src/middlewared/middlewared/plugins/virt/stats.py deleted file mode 100644 index 096b1727bb5a9..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/stats.py +++ /dev/null @@ -1,45 +0,0 @@ -import time - -from middlewared.api.current import VirtInstancesMetricsEventSourceArgs, VirtInstancesMetricsEventSourceEvent -from middlewared.event import EventSource -from middlewared.plugins.reporting.realtime_reporting.cgroup import get_cgroup_stats -from middlewared.service import Service - - -class VirtInstancesMetricsEventSource(EventSource): - args = VirtInstancesMetricsEventSourceArgs - event = VirtInstancesMetricsEventSourceEvent - roles = ['VIRT_INSTANCE_READ'] - - def run_sync(self): - interval = self.arg['interval'] - while not self._cancel_sync.is_set(): - netdata_metrics = None - retries = 2 - while retries > 0: - try: - netdata_metrics = self.middleware.call_sync('netdata.get_all_metrics') - except Exception: - retries -= 1 - if retries <= 0: - raise - - time.sleep(0.5) - else: - break - - if netdata_metrics: - self.send_event('ADDED', fields=get_cgroup_stats( - netdata_metrics, self.middleware.call_sync('virt.instance.get_instance_names') - )) - - time.sleep(interval) - - -class VirtInstanceService(Service): - - class Config: - cli_namespace = 'virt.instance' - event_sources = { - 'virt.instance.metrics': VirtInstancesMetricsEventSource, - } diff --git a/src/middlewared/middlewared/plugins/virt/utils.py b/src/middlewared/middlewared/plugins/virt/utils.py deleted file mode 100644 index bbcd0e634e960..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/utils.py +++ /dev/null @@ -1,271 +0,0 @@ -import asyncio -import os -from dataclasses import dataclass - -import aiohttp -import enum -import httpx -import json -from collections.abc import Callable - -from middlewared.plugins.zfs_.utils import TNUserProp -from middlewared.service import CallError, ValidationErrors -from middlewared.utils import MIDDLEWARE_RUN_DIR - -from .websocket import IncusWS - -INCUS_BRIDGE = 'incusbr0' -CDROM_PREFIX = 'ix_cdrom' -HTTP_URI = 'http://unix.socket' -INCUS_METADATA_CDROM_KEY = 'user.ix_cdrom_devices' -SOCKET = '/var/lib/incus/unix.socket' -VNC_BASE_PORT = 5900 -VNC_PASSWORD_DIR = os.path.join(MIDDLEWARE_RUN_DIR, 'incus/passwords') -TRUENAS_STORAGE_PROP_STR = TNUserProp.INCUS_POOL.value - - -class VirtGlobalStatus(enum.StrEnum): - INITIALIZING = 'INITIALIZING' - INITIALIZED = 'INITIALIZED' - NO_POOL = 'NO_POOL' - LOCKED = 'LOCKED' - ERROR = 'ERROR' - - -class IncusStorage: - """ - This class contains state information for incus storage backend - - Currently we store: - state: The current status of the storage backend. - - default_storage_pool: hopefully will be None in almost all - circumstances. In BETA / RC of 25.04 we wrote on-disk configuration - for incus hard-coding a pool name of "default". - - The INCUS_STORAGE instance below is set during virt.global.setup. - """ - __status = VirtGlobalStatus.INITIALIZING - default_storage_pool = None # Compatibility with 25.04 BETA / RC - - def zfs_pool_to_storage_pool(self, zfs_pool: str) -> str: - if not isinstance(zfs_pool, str): - raise TypeError(f'{zfs_pool}: not a string') - - if zfs_pool == self.default_storage_pool: - return 'default' - - return zfs_pool - - @property - def state(self) -> VirtGlobalStatus: - return self.__status - - @state.setter - def state(self, status_in) -> None: - if not isinstance(status_in, VirtGlobalStatus): - raise TypeError(f'{status_in}: not valid Incus status') - - self.__status = status_in - - -INCUS_STORAGE = IncusStorage() - - -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: - methodobj = getattr(session, method) - r = await methodobj(f'{HTTP_URI}/{path}', **(request_kwargs or {})) - if json: - return await r.json() - else: - return r.content - - -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'] - if data['metadata']['status'] == 'Success': - return 'SUCCESS', data['metadata']['metadata'] - if data['metadata']['status'] == 'Running': - if running_cb: - await running_cb(data) - return 'RUNNING', None - - task = asyncio.ensure_future(IncusWS().wait(result['metadata']['id'], callback)) - try: - await asyncio.wait_for(task, timeout) - 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, - 'vnc_password': None, - } - if not (vnc_raw_config := config.get('user.ix_vnc_config')): - return vnc_config - - return json.loads(vnc_raw_config) - - -def root_device_pool_from_raw(raw: dict) -> str: - # First check if we have a root device defined - if 'expanded_devices' in raw: - dev = raw['expanded_devices'] - if 'root' in dev: - return dev['root']['pool'] - - # No profile default? Let caller handle the error - # maybe they want to use virt.global.config -> pool - return None - - -def get_vnc_password_file_path(instance_id: str) -> str: - return os.path.join(VNC_PASSWORD_DIR, instance_id) - - -def create_vnc_password_file(instance_id: str, password: str) -> str: - os.makedirs(VNC_PASSWORD_DIR, exist_ok=True) - pass_file_path = get_vnc_password_file_path(instance_id) - with open(pass_file_path, 'w') as w: - os.fchmod(w.fileno(), 0o600) - w.write(password) - - return pass_file_path - - -def get_root_device_dict(size: int, io_bus: str, pool_name: str) -> dict: - return { - 'path': '/', - 'pool': pool_name, - 'type': 'disk', - 'size': f'{size * (1024**3)}', - 'io.bus': io_bus.lower(), - } - - -def storage_pool_to_incus_pool(storage_pool_name: str) -> str: - """ convert to string "default" if required """ - return INCUS_STORAGE.zfs_pool_to_storage_pool(storage_pool_name) - - -def incus_pool_to_storage_pool(incus_pool_name: str) -> str: - if incus_pool_name == 'default': - # Look up the ZFS pool name from info we populated - # on virt.global.setup - return INCUS_STORAGE.default_storage_pool - - return incus_pool_name - - -def get_max_boot_priority_device(device_list: list[dict]) -> dict | None: - max_boot_priority_device = None - - for device_entry in device_list: - if (max_boot_priority_device is None and device_entry.get('boot_priority') is not None) or ( - (device_entry.get('boot_priority') or 0) > ((max_boot_priority_device or {}).get('boot_priority') or 0) - ): - max_boot_priority_device = device_entry - - return max_boot_priority_device - - -@dataclass(slots=True, frozen=True, kw_only=True) -class PciEntry: - pci_addr: str - capability: dict - controller_type: str | None - critical: bool - iommu_group: dict | None - drivers: list - device_path: str | None - reset_mechanism_defined: bool - description: str - error: str | None - - -def generate_qemu_cmd(instance_config: dict, instance_name: str) -> str: - vnc_config = json.loads(instance_config.get('user.ix_vnc_config', '{}')) - cdrom_config = json.loads(instance_config.get(INCUS_METADATA_CDROM_KEY, '[]')) - cmd = '' - if vnc_config['vnc_enabled'] and vnc_config['vnc_port']: - cmd = f'-vnc :{vnc_config["vnc_port"] - VNC_BASE_PORT}' - if vnc_config.get('vnc_password'): - cmd = (f'-object secret,id=vnc0,file={get_vnc_password_file_path(instance_name)} ' - f'{cmd},password-secret=vnc0') - - for cdrom_file in cdrom_config: - cmd += f'{" " if cmd else ""}-drive media=cdrom,if=ide,file={cdrom_file},file.locking=off' - - return cmd - - -def generate_qemu_cdrom_metadata(devices: dict) -> str: - return json.dumps([ - d['source'] for name, d in devices.items() if name.startswith(CDROM_PREFIX) - ]) - - -def validate_device_name(device: dict, verrors: ValidationErrors): - if device['dev_type'] == 'CDROM': - if device['name'] and device['name'].startswith(CDROM_PREFIX) is False: - verrors.add('virt_device_add.name', f'CDROM device name must start with {CDROM_PREFIX!r} prefix') - elif device['name'] and device['name'].startswith(CDROM_PREFIX): - verrors.add('virt_device_add.name', f'Device name must not start with {CDROM_PREFIX!r} prefix') - - -def update_instance_metadata_and_qemu_cmd_on_device_change( - instance_name: str, instance_config: dict, devices: dict -) -> dict: - data = { - INCUS_METADATA_CDROM_KEY: generate_qemu_cdrom_metadata(devices) - } - data['raw.qemu'] = generate_qemu_cmd(instance_config | data, instance_name) - data['user.ix_old_raw_qemu_config'] = instance_config.get('raw.qemu', '') - return data diff --git a/src/middlewared/middlewared/plugins/virt/volume.py b/src/middlewared/middlewared/plugins/virt/volume.py deleted file mode 100644 index 508679da6474e..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/volume.py +++ /dev/null @@ -1,357 +0,0 @@ -import datetime -import errno -import os.path -from time import time - -from middlewared.api import api_method -from middlewared.api.current import ( - VirtVolumeEntry, VirtVolumeCreateArgs, VirtVolumeCreateResult, VirtVolumeUpdateArgs, - VirtVolumeUpdateResult, VirtVolumeDeleteArgs, VirtVolumeDeleteResult, VirtVolumeImportIsoArgs, - VirtVolumeImportIsoResult, VirtVolumeImportZvolArgs, VirtVolumeImportZvolResult -) -from middlewared.plugins.zfs_.utils import zvol_path_to_name -from middlewared.service import CallError, CRUDService, job, ValidationErrors -from middlewared.utils import filter_list -from middlewared.utils.size import normalize_size - -from .utils import incus_call, incus_call_sync, VirtGlobalStatus, incus_wait, storage_pool_to_incus_pool - - -class VirtVolumeService(CRUDService): - - class Config: - namespace = 'virt.volume' - cli_namespace = 'virt.volume' - entry = VirtVolumeEntry - role_prefix = 'VIRT_IMAGE' - - async def query(self, filters, options): - config = await self.middleware.call('virt.global.config') - if config['state'] != VirtGlobalStatus.INITIALIZED.value: - return [] - - entries = [] - for storage_pool in config['storage_pools']: - pool = storage_pool_to_incus_pool(storage_pool) - storage_devices = await incus_call(f'1.0/storage-pools/{pool}/volumes/custom?recursion=2', 'get') - if storage_devices.get('status_code') != 200: - # This particular pool may not be available - continue - - for storage_device in storage_devices['metadata']: - entries.append({ - 'id': f'{storage_pool}_{storage_device["name"]}', - 'name': storage_device['name'], - 'content_type': storage_device['content_type'].upper(), - 'created_at': storage_device['created_at'], - 'type': storage_device['type'], - 'storage_pool': storage_pool, - 'config': storage_device['config'], - 'used_by': [instance.replace('/1.0/instances/', '') for instance in storage_device['used_by']] - }) - if storage_device['config'].get('size'): - normalized_size = normalize_size(storage_device['config']['size'], False) - entries[-1]['config']['size'] = normalized_size // (1024 * 1024) if normalized_size else 0 - - return filter_list(entries, filters, options) - - @api_method( - VirtVolumeCreateArgs, - VirtVolumeCreateResult, - audit='Virt: Creating', - audit_extended=lambda data: f'{data["name"]!r} volume' - ) - async def do_create(self, data): - await self.middleware.call('virt.global.check_initialized') - global_config = await self.middleware.call('virt.global.config') - target_pool = global_config['pool'] if not data['storage_pool'] else data['storage_pool'] - - verrors = ValidationErrors() - ds_name = os.path.join(target_pool, f'.ix-virt/custom/default_{data["name"]}') - if await self.middleware.call('virt.volume.query', [['id', '=', data['name']]]): - verrors.add('virt_volume_create.name', 'Volume with this name already exists') - elif await self.middleware.call( - 'zfs.resource.query_impl', {'paths': [ds_name], 'properties': None} - ): - # We will kick off recover here so that incus recognizes - # this dataset as a volume already - await (await self.middleware.call('virt.global.recover', [ - { - 'config': {'source': f'{target_pool}/.ix-virt'}, - 'description': '', - 'name': storage_pool_to_incus_pool(target_pool), - 'driver': 'zfs', - } - ])).wait(raise_error=True) - verrors.add('virt_volume_create.name', 'ZFS dataset against this volume name already exists') - - if target_pool not in global_config['storage_pools']: - verrors.add( - 'virt_volume_create.storage_pool', - f'Not a valid storage pool. Choices are: {", ".join(global_config["storage_pools"])}' - ) - - verrors.check() - - incus_pool = storage_pool_to_incus_pool(target_pool) - result = await incus_call(f'1.0/storage-pools/{incus_pool}/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(f'{target_pool}_{data["name"]}') - - @api_method( - VirtVolumeUpdateArgs, - VirtVolumeUpdateResult, - audit='Virt: Updating', - audit_extended=lambda name, data=None: f'{name!r} volume' - ) - async def do_update(self, name, data): - volume = await self.get_instance(name) - if data.get('size') is None: - return volume - - pool = storage_pool_to_incus_pool(volume['storage_pool']) - result = await incus_call(f'1.0/storage-pools/{pool}/volumes/custom/{volume["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, - audit='Virt: Deleting', - audit_extended=lambda name: f'{name!r} volume' - ) - 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"])}') - - pool = storage_pool_to_incus_pool(volume['storage_pool']) - result = await incus_call(f'1.0/storage-pools/{pool}/volumes/custom/{volume["name"]}', 'delete') - if result.get('status_code') != 200: - raise CallError(f'Failed to delete volume: {result["error"]}') - - return True - - @api_method( - VirtVolumeImportIsoArgs, - VirtVolumeImportIsoResult, - audit='Virt: Importing', - audit_extended=lambda data: f'{data["name"]!r} ISO', - roles=['VIRT_IMAGE_WRITE'] - ) - @job(lock='virt_volume_import', pipes=['input'], check_pipes=False) - async def import_iso(self, job, data): - await self.middleware.call('virt.global.check_initialized') - global_config = await self.middleware.call('virt.global.config') - target_pool_ = global_config['pool'] if not data['storage_pool'] else data['storage_pool'] - if target_pool_ not in global_config['storage_pools']: - raise CallError('Not a valid storage pool') - - target_pool = storage_pool_to_incus_pool(target_pool_) - - if data['upload_iso']: - job.check_pipe('input') - elif data['iso_location'] is None: - raise CallError('Either upload iso or provide iso_location') - - if await self.middleware.call('virt.volume.query', [['id', '=', data['name']]]): - raise CallError('Volume with this name already exists', errno=errno.EEXIST) - - request_kwargs = { - 'headers': { - 'X-Incus-type': 'iso', - 'X-Incus-name': data['name'], - 'Content-Type': 'application/octet-stream', - } - } - - def read_input_stream(): - while True: - chunk = job.pipes.input.r.read(1048576) - if not chunk: - break - - yield chunk - - def upload_file(): - job.set_progress(25, 'Importing ISO as incus volume') - if data['upload_iso']: - return incus_call_sync( - f'1.0/storage-pools/{target_pool}/volumes/custom', - 'post', - request_kwargs=request_kwargs | {'data': read_input_stream()}, - ) - else: - try: - with open(data['iso_location'], 'rb') as f: - return incus_call_sync( - f'1.0/storage-pools/{target_pool}/volumes/custom', - 'post', - request_kwargs=request_kwargs | {'data': f}, - ) - except Exception as e: - raise CallError(f'Failed opening ISO: {e}') - - 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(f'{target_pool_}_{data["name"]}') - - @api_method( - VirtVolumeImportZvolArgs, - VirtVolumeImportZvolResult, - audit='Virt: Importing', - audit_extended=lambda data: f'{data["name"]!r} zvol', - roles=['VIRT_IMAGE_WRITE'] - ) - @job(lock='virt_volume_import') - async def import_zvol(self, job, data): - await self.middleware.call('virt.global.check_initialized') - global_config = await self.middleware.call('virt.global.config') - zvol_choices = set([ - x for x in (await self.middleware.call('virt.device.disk_choices')).keys() if x.startswith('/dev') - ]) - pools = set() - - verrors = ValidationErrors() - if len(data['to_import']) == 0: - verrors.add('virt_volume_import_zvol.import', 'At least one entry is required.') - - for idx, entry in enumerate(data['to_import']): - entry['zvol_name'] = zvol_path_to_name(entry['zvol_path']) - entry['zpool'] = entry['zvol_name'].split('/')[0] - entry['new_name'] = f'{entry["zpool"]}/.ix-virt/custom/default_{entry["virt_volume_name"]}' - if entry['zpool'] not in global_config['storage_pools']: - verrors.add( - f'virt_volume_import_zvol.import.{idx}.entry.zvol_path', - f'{entry["zpool"]}: zvol is not located in pool configured ' - 'as a virt storage pool.' - ) - elif entry['zvol_path'] not in zvol_choices: - verrors.add( - f'virt_volume_import_zvol.import.{idx}.entry.zvol_path', - f'{entry["zvol_path"]}: not an available zvol choice.' - ) - - else: - pools.add(entry['zpool']) - - # The ZFS rename will break snapshot task attachments - # and so user will need to remove any snapshot tasks - attachments = await self.middleware.call('pool.dataset.attachments', entry['zvol_name']) - if attachments: - attachment_types = [x['type'] for x in attachments] - verrors.add( - f'virt_volume_import_zvol.import.{idx}.entry.zvol_name', - f'{entry["zvol_name"]}: specified zvol is currently in use: {", ".join(attachment_types)}' - ) - - verrors.check() - - job.set_progress(5, 'Preparing to rename zvols') - - # Revert dataset operations on failure - # each entry will be tuple of method name and args for API call to revert the previous action - revert = [] - for entry in data['to_import']: - orig_name = entry['zvol_name'] - new_name = entry['new_name'] - now = int(time()) # use unix timestamp to reduce character count - snap_name = f'incus_{now}' - full_snap = f'{orig_name}@{snap_name}' - - try: - if data['clone']: - job.set_progress(description=f'Cloning {orig_name} to {new_name}') - await self.middleware.call('zfs.snapshot.create', {'dataset': orig_name, 'name': snap_name}) - revert.append(('zfs.snapshot.delete', [full_snap])) - - await self.middleware.call('zfs.snapshot.clone', {'snapshot': full_snap, 'dataset_dst': new_name}) - revert.append(('zfs.dataset.delete', [new_name])) - - await self.middleware.call('zfs.dataset.promote', new_name) - revert.append(('zfs.dataset.promote', [orig_name])) - else: - job.set_progress(description=f'Renaming {orig_name} to {new_name}') - await self.middleware.call('zfs.dataset.rename', orig_name, {'new_name': new_name}) - revert.append(('zfs.dataset.rename', [new_name, {'new_name': orig_name}])) - - await self.middleware.call('zfs.dataset.update', new_name, {"properties": { - 'incus:content_type': {'value': 'block'}, - }}) - ds = await self.middleware.call( - 'zfs.resource.query_impl', - {'paths': [new_name], 'properties': ['volsize', 'creation']} - )[0]['properties'] - entry['volsize'] = ds['volsize']['value'] - entry['creation'] = ds['creation']['value'] - except Exception: - self.logger.error('%s: failed to import zvol', orig_name, exc_info=True) - - job.set_progress(description='Reverting changes') - for action in reversed(revert): - method, args = action - await self.middleware.call(method, *args) - - raise - - recover_payload = [] - - # We need to trigger a recovery action from incus to get the volumes - # inserted into the incus database and recovery files - for pool in pools: - incus_pool = storage_pool_to_incus_pool(pool) - recover_payload.append({ - 'config': {'source': f'{pool}/.ix-virt'}, - 'description': '', - 'name': incus_pool, - 'driver': 'zfs', - }) - - # If this fails, our state cannot be cleanly rolled back. - # admin will need to toggle virt.global enabled state - job.set_progress(50, 'Updating backend database') - await (await self.middleware.call('virt.global.recover', recover_payload)).wait(raise_error=True) - - # At this point the zvols have been renamed and incus DB updated - # but we still need to fix some volume-related metadata. The size - # of the volume and the create time of the volume are not properly - # set by the incus ZFS driver - job.set_progress(50, 'Updating volume metadata') - - for entry in data['to_import']: - pool = storage_pool_to_incus_pool(entry['zpool']) - name = entry['virt_volume_name'] - - result = await incus_call(f'1.0/storage-pools/{pool}/volumes/custom/{name}', 'patch', { - 'json': { - 'config': { - 'size': str(entry['volsize']), - }, - 'created_at': datetime.datetime.fromtimestamp( - entry['creation'], datetime.UTC - ).isoformat() - }, - }) - if result.get('error') != '': - raise CallError(f'Failed to update volume: {result["error"]}') diff --git a/src/middlewared/middlewared/plugins/virt/websocket.py b/src/middlewared/middlewared/plugins/virt/websocket.py deleted file mode 100644 index 569d6ac62072e..0000000000000 --- a/src/middlewared/middlewared/plugins/virt/websocket.py +++ /dev/null @@ -1,135 +0,0 @@ -import asyncio -from collections.abc import Callable -from collections import defaultdict -from typing import TYPE_CHECKING - -import aiohttp -import logging - -from middlewared.service import CallError - -if TYPE_CHECKING: - from middlewared.main import Middleware - - -logger = logging.getLogger(__name__) -SOCKET = '/var/lib/incus/unix.socket' - - -class Singleton(type): - - instance = None - - def __call__(cls, *args, **kwargs): - if cls.instance is None: - cls.instance = super(Singleton, cls).__call__(*args, **kwargs) - return cls.instance - - -class IncusWS(object, metaclass=Singleton): - - def __init__(self, middleware): - self.middleware = middleware - self._incoming = defaultdict(list) - self._waiters = defaultdict(list) - self._task = None - - async def run(self): - while True: - try: - await self._run_impl() - except aiohttp.client_exceptions.UnixClientConnectorError as e: - logger.warning('Failed to connect to incus socket: %r', e) - except Exception: - logger.warning('Incus websocket failure', exc_info=True) - await asyncio.sleep(1) - - async def _run_impl(self): - async with aiohttp.UnixConnector(path=SOCKET) as conn: - async with aiohttp.ClientSession(connector=conn) as session: - async with session.ws_connect('ws://unix.socket/1.0/events') as ws: - async for msg in ws: - if msg.type != aiohttp.WSMsgType.TEXT: - continue - data = msg.json() - match data['type']: - case 'operation': - if 'metadata' in data and 'id' in data['metadata']: - self._incoming[data['metadata']['id']].append(data) - for i in self._waiters[data['metadata']['id']]: - i.set() - if data['metadata'].get('class') == 'task': - if data['metadata'].get('description') in ( - 'Starting instance', - 'Stopping instance', - ) and data['metadata']['status_code'] == 200: - for instance in data['metadata']['resources']['instances']: - instance_id = instance.replace('/1.0/instances/', '') - self.middleware.send_event( - 'virt.instance.query', - 'CHANGED', - id=instance_id, - fields={ - 'status': ( - 'RUNNING' - if data['metadata']['description'] == 'Starting instance' - else - 'STOPPED' - ), - }, - ) - case 'logging': - if data['metadata']['message'] == 'Instance agent started': - self.middleware.send_event( - 'virt.instance.agent_running', - 'CHANGED', - id=data['metadata']['context']['instance'], - ) - - async def wait(self, id_: str, callback: Callable[[str], None]): - event = asyncio.Event() - self._waiters[id_].append(event) - - try: - while True: - if not self._incoming[id_]: - await event.wait() - event.clear() - - for i in list(self._incoming[id_]): - self._incoming[id_].remove(i) - if (result := await callback(i)) is None: - continue - status, data = result - match status: - case 'SUCCESS': - return data - case 'ERROR': - raise CallError(data) - case 'RUNNING': - pass - case _: - raise CallError(f'Unknown status: {status}') - finally: - self._waiters[id_].remove(event) - - async def start(self): - if not self._task: - self._task = asyncio.ensure_future(self.run()) - - async def stop(self): - if self._task: - self._task.cancel() - self._task = None - - -async def __event_system_shutdown(middleware, event_type, args): - await IncusWS().stop() - - -async def setup(middleware: 'Middleware'): - middleware.event_register( - 'virt.instance.agent_running', 'Agent is running on guest.', roles=['VIRT_INSTANCE_READ'], - ) - IncusWS(middleware) - middleware.event_subscribe('system.shutdown', __event_system_shutdown) diff --git a/src/middlewared/middlewared/plugins/zfs/utils.py b/src/middlewared/middlewared/plugins/zfs/utils.py index d61c779c86c3e..0ca4f3222354f 100644 --- a/src/middlewared/middlewared/plugins/zfs/utils.py +++ b/src/middlewared/middlewared/plugins/zfs/utils.py @@ -11,7 +11,6 @@ INTERNAL_PATHS = ( "ix-apps", - ".ix-virt", "ix-applications", ".system" ) diff --git a/src/middlewared/middlewared/plugins/zfs_/dataset_encryption.py b/src/middlewared/middlewared/plugins/zfs_/dataset_encryption.py index b0d51b2743e76..19ed93a99e655 100644 --- a/src/middlewared/middlewared/plugins/zfs_/dataset_encryption.py +++ b/src/middlewared/middlewared/plugins/zfs_/dataset_encryption.py @@ -44,16 +44,6 @@ def get_attachments(): zvol_path_to_name(i['attributes']['path']): i for i in vm_devices } - instance_zvols = {} - for instance in self.middleware.call_sync('virt.instance.query'): - for device in self.middleware.call_sync('virt.instance.device_list', instance['id']): - if device['dev_type'] != 'DISK': - continue - if not device['source'] or not device['source'].startswith('/dev/zvol/'): - continue - # Remove /dev/zvol/ from source - instance_zvols[device['source'][10:]] = instance - namespaces = self.middleware.call_sync( 'nvmet.namespace.query', [['device_type', '=', 'ZVOL']], {'select': ['device_path']} ) @@ -63,7 +53,6 @@ def get_attachments(): return { 'iscsi.extent.query': iscsi_zvols, 'vm.devices.query': vm_zvols, - 'virt.instance.query': instance_zvols, 'nvmet.namespace.query': nvmet_zvols, } diff --git a/src/middlewared/middlewared/plugins/zfs_/utils.py b/src/middlewared/middlewared/plugins/zfs_/utils.py index b554ee7651b87..854b7225c5b06 100644 --- a/src/middlewared/middlewared/plugins/zfs_/utils.py +++ b/src/middlewared/middlewared/plugins/zfs_/utils.py @@ -44,7 +44,6 @@ class TNUserProp(enum.Enum): REFQUOTA_WARN = f'{LEGACY_USERPROP_PREFIX}:refquota_warning' REFQUOTA_CRIT = f'{LEGACY_USERPROP_PREFIX}:refquota_critical' MANAGED_BY = f'{USERPROP_PREFIX}:managedby' - INCUS_POOL = f'{USERPROP_PREFIX}:incus_storage_pool' # used only in virt/global.py def default(self): match self: @@ -140,13 +139,6 @@ def get_zvols(info_level, data): for file in files: path = root + '/' + file - # zvols located within ix-virt are managed by incus and should - # never be presented as choices in middleware. Removing this - # check may introduce data corruption bugs by allowing zvols - # to be simultaneously used by multiple VMs. - if '.ix-virt' in path: - continue - zvol_name = zvol_path_to_name(path) try: diff --git a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_attachment_delegate.py b/src/middlewared/middlewared/pytest/unit/plugins/virt/test_attachment_delegate.py deleted file mode 100644 index c8fe575eacee2..0000000000000 --- a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_attachment_delegate.py +++ /dev/null @@ -1,227 +0,0 @@ -import pytest - -from middlewared.pytest.unit.middleware import Middleware -from middlewared.plugins.virt.attachments import VirtFSAttachmentDelegate -from middlewared.utils.path import is_child_realpath - - -INSTANCE_QUERY = [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'storage_pool': 'test2' - }, -] - -GLOBAL_CONFIG = { - 'pool': 'test', - 'storage_pools': [ - 'test', - 'test1', - 'test2', - 'test3', - 'test4', - ], -} - - -@pytest.mark.parametrize('path,devices,expected', [ - ( - '/mnt/test4', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/dev/zvol/test4/test_zvol', - 'storage_pool': 'test2' - }, - { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': '/dev/zvol/test/test_zvol', - 'storage_pool': 'test2' - }, - { - 'name': 'eth0', - 'dev_type': 'NIC', - }, - ], - [ - { - 'id': 'test4', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': ['disk0'], - 'dataset': 'test4' - } - ], - 'incus_pool_change': True - } - ], - ), - ( - '/mnt/test/test_zvol', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/dev/zvol/test2/test_zvol', - 'storage_pool': 'test2' - }, - { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': '/dev/zvol/test/test_zvol', - 'storage_pool': 'test2' - }, - ], - [ - { - 'id': 'test/test_zvol', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': ['disk1'], - 'dataset': 'test/test_zvol' - } - ], - 'incus_pool_change': False - } - ], - ), - ( - '/mnt/test5', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/mnt/test5/test_zvol', - 'storage_pool': 'test2' - }, - { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': '/mnt/test5/test_zvol', - 'storage_pool': 'test2' - }, - ], - [ - { - 'id': 'test5', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': ['disk0', 'disk1'], - 'dataset': 'test5' - } - ], - 'incus_pool_change': False - } - ], - ), - ( - '/mnt/test45', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/mnt/test45', - 'storage_pool': 'test2' - }, - ], - [ - { - 'id': 'test45', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': ['disk0'], - 'dataset': 'test45' - } - ], - 'incus_pool_change': False - } - ] - ), - ( - '/mnt/test2', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/mnt/test4/test_zvol', - 'storage_pool': 'test3' - }, - { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': '/mnt/test/test_zvol', - 'storage_pool': 'test3' - } - ], - [ - { - 'id': 'test2', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': [], - 'dataset': 'test2' - } - ], - 'incus_pool_change': True - } - ], - ), - ( - '/mnt/test', - [ - { - 'name': 'disk0', - 'dev_type': 'DISK', - 'source': '/mnt/test4/test_zvol', - 'storage_pool': 'test3' - }, - { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': '/mnt/test/test_zvol', - 'storage_pool': 'test3' - } - ], - [ - { - 'id': 'test', - 'name': 'virt', - 'instances': [ - { - 'id': 'test-instance', - 'name': 'test-instance', - 'disk_devices': ['disk1'], - 'dataset': 'test' - } - ], - 'incus_pool_change': True - } - ], - ), -]) -@pytest.mark.asyncio -async def test_virt_instance_attachment_delegate(path, devices, expected): - m = Middleware() - m['virt.global.config'] = lambda *arg: GLOBAL_CONFIG - m['virt.instance.query'] = lambda *arg: INSTANCE_QUERY - m['virt.instance.device_list'] = lambda *arg: devices - m['filesystem.is_child'] = is_child_realpath - assert await VirtFSAttachmentDelegate(m).query(path, False) == expected diff --git a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_instance_env.py b/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_instance_env.py deleted file mode 100644 index 94ac877f2cf1a..0000000000000 --- a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_instance_env.py +++ /dev/null @@ -1,120 +0,0 @@ -import unittest.mock - -import pytest - -from middlewared.plugins.virt.instance import VirtInstanceService -from middlewared.pytest.unit.middleware import Middleware -from middlewared.service_exception import ValidationErrors - - -@pytest.mark.parametrize('environment, should_work', [ - ( - {'': ''}, - False - ), - ( - {'FOO': ''}, - False - ), - ( - {'FOO': ' '}, - False - ), - ( - {'': 'BAR'}, - False - ), - ( - {' ': ' '}, - False - ), - ( - {'FOO': 'BAR'}, - True - ), - ( - {'FOO': 'bar'}, - True - ), - ( - {'local_host': '127.0.0.1'}, - True - ), - ( - {'123_ABC': 'XYZ'}, - True - ), - ( - {'WORKING_DIR': '/home/user'}, - True - ), - ( - {'API_BASE_URL': 'https://api.example.com/v1/'}, - True - ), - ( - {'@username': 'xyz'}, - False - ), - ( - {'USER': 'truenas admin'}, - True - ) -]) -@unittest.mock.patch('middlewared.plugins.virt.global.VirtGlobalService.config') -@unittest.mock.patch('middlewared.plugins.virt.instance.VirtInstanceService.validate') -@unittest.mock.patch('middlewared.plugins.virt.instance.incus_call_and_wait') -@unittest.mock.patch('middlewared.plugins.virt.instance.VirtInstanceService.get_account_idmaps') -@unittest.mock.patch('middlewared.plugins.virt.instance.VirtInstanceService.set_account_idmaps') -@unittest.mock.patch('middlewared.plugins.virt.instance.VirtInstanceService.start_impl') -@unittest.mock.patch('middlewared.plugins.virt.instance.incus_call') -@pytest.mark.asyncio -async def test_virt_environment_validation( - mock_incus_call, mock_start_impl, mock_set_idmaps, - mock_get_idmaps, mock_incus_call_and_wait, mock_validate, - mock_config, environment, should_work -): - middleware = Middleware() - mock_config.return_value = { - 'pool': 'dozer', - 'storage_pools': ['dozer'], - 'state': 'INITIALIZED' - } - mock_validate.return_value = None - mock_incus_call_and_wait.return_value = None - mock_get_idmaps.return_value = [] - mock_start_impl.return_value = True - mock_incus_call.return_value = {'status_code': 400} - virt_obj = VirtInstanceService(middleware) - - if should_work: - instance = { - 'name': 'test-vm', - 'image': 'alpine/3.18/default', - 'environment': environment, - 'raw': {'config': {}} - } - global_config = { - 'pool': 'dozer', - 'storage_pools': ['dozer'], - 'state': 'INITIALIZED' - } - mock_set_idmaps.return_value = instance - middleware['virt.global.config'] = lambda *args : global_config - middleware['virt.global.check_initialized'] = lambda *args: True - middleware['virt.instance.get_instance'] = lambda *args: instance - middleware['virt.volume.query'] = lambda *args: [] - - result = await virt_obj.do_create(12, { - 'name': 'test-vm', - 'image': 'alpine/3.18/default', - 'environment': environment - }) - assert result is not None - else: - with pytest.raises(ValidationErrors): - await virt_obj.do_create(12, { - 'name': 'test-vm', - 'image': 'alpine/3.18/default', - 'environment': environment - }) diff --git a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_license.py b/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_license.py deleted file mode 100644 index 02d24d61a9e1e..0000000000000 --- a/src/middlewared/middlewared/pytest/unit/plugins/virt/test_virt_license.py +++ /dev/null @@ -1,68 +0,0 @@ -import pytest - -from middlewared.plugins.virt.license import VirtLicenseGlobalService -from middlewared.pytest.unit.middleware import Middleware - - -@pytest.mark.parametrize('instance_type,chassis_hardware,license_active,expected_result', [ - ( - None, - 'TRUENAS-UNKNOWN', - None, - True - ), - ( - None, - 'TRUENAS-MINI-3.0-XL+', - None, - True - ), - ( - None, - 'TRUENAS-M60-HA', - {'features': ['JAILS', 'VM']}, - True - ), - ( - 'CONTAINER', - 'TRUENAS-M60-HA', - {'features': ['JAILS', 'VM']}, - True - ), - ( - 'VM', - 'TRUENAS-M60-HA', - {'features': ['JAILS', 'VM']}, - True - ), - ( - 'CONTAINER', - 'TRUENAS-M60-HA', - {'features': ['VM']}, - False - ), - ( - 'VM', - 'TRUENAS-M60-HA', - {'features': ['JAILS']}, - False - ), - ( - None, - 'TRUENAS-M60-HA', - None, - False - ), - ( - 'VM', - 'TRUENAS-M60-HA', - None, - False - ), -]) -@pytest.mark.asyncio -async def test_virt_license_validation(instance_type, chassis_hardware, license_active, expected_result): - m = Middleware() - m['truenas.get_chassis_hardware'] = lambda *arg: chassis_hardware - m['system.license'] = lambda *arg: license_active - assert await VirtLicenseGlobalService(m).license_active(instance_type) == expected_result diff --git a/src/middlewared/middlewared/role.py b/src/middlewared/middlewared/role.py index 32f2fbcfcdee3..f3cab1f42d266 100644 --- a/src/middlewared/middlewared/role.py +++ b/src/middlewared/middlewared/role.py @@ -245,15 +245,6 @@ class Role: 'SYSTEM_UPDATE_READ': Role(), 'SYSTEM_UPDATE_WRITE': Role(includes=['SYSTEM_UPDATE_READ']), - # Virtualization - 'VIRT_GLOBAL_READ': Role(), - 'VIRT_GLOBAL_WRITE': Role(includes=['VIRT_GLOBAL_READ'], stig=None), - 'VIRT_INSTANCE_READ': Role(), - 'VIRT_INSTANCE_WRITE': Role(includes=['VIRT_INSTANCE_READ'], stig=None), - 'VIRT_INSTANCE_DELETE': Role(stig=None), - 'VIRT_IMAGE_READ': Role(), - 'VIRT_IMAGE_WRITE': Role(includes=['VIRT_IMAGE_READ'], stig=None), - # ZFS Resources (query, create/update/delete) 'ZFS_RESOURCE_READ': Role(), } diff --git a/src/middlewared/middlewared/test/integration/assets/virt.py b/src/middlewared/middlewared/test/integration/assets/virt.py deleted file mode 100644 index cd16eb6360f06..0000000000000 --- a/src/middlewared/middlewared/test/integration/assets/virt.py +++ /dev/null @@ -1,105 +0,0 @@ -import contextlib -import os.path -import uuid - -from middlewared.test.integration.assets.account import user, group -from middlewared.test.integration.utils import call, ssh, pool -from time import sleep - - -@contextlib.contextmanager -def virt(pool_data: dict | None = None): - pool_name = pool_data['name'] if pool_data else pool - - virt_config = call('virt.global.update', {'pool': pool_name}, job=True) - assert virt_config['pool'] == pool_name, virt_config - try: - yield virt_config - finally: - with contextlib.suppress(ValueError): - virt_config['storage_pools'].remove(pool_name) - - virt_config = call( - 'virt.global.update', - { - 'pool': None, - 'storage_pools': virt_config['storage_pools'] - }, - 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', f'{pool_name}_{volume_name}') - - -@contextlib.contextmanager -def volume(volume_name: str, size: int, storage_pool: str | None = None): - vol = call('virt.volume.create', { - 'name': volume_name, - 'size': size, - 'content_type': 'BLOCK', - 'storage_pool': storage_pool - }) - try: - yield vol - finally: - call('virt.volume.delete', vol['id']) - - -@contextlib.contextmanager -def virt_device(instance_name: str, device_name: str, payload: dict): - resp = call('virt.instance.device_add', instance_name, {'name': device_name, **payload}) - try: - yield resp - finally: - call('virt.instance.device_delete', instance_name, device_name) - - -@contextlib.contextmanager -def virt_instance( - instance_name: str = 'tmp-instance', - image: str | None = 'debian/trixie', # Can be null when source is null - **kwargs -) -> dict: - # Create a virt instance and return dict containing full config and raw info - call('virt.instance.create', { - 'name': instance_name, - 'image': image, - **kwargs - }, job=True) - - instance = call('virt.instance.get_instance', instance_name, {'extra': {'raw': True}}) - try: - yield instance - finally: - call('virt.instance.delete', instance_name, job=True) - - -@contextlib.contextmanager -def userns_user(username, userns_idmap='DIRECT'): - with user({ - 'username': username, - 'full_name': username, - 'group_create': True, - 'random_password': True, - 'userns_idmap': userns_idmap - }) as u: - yield u - - -@contextlib.contextmanager -def userns_group(groupname, userns_idmap='DIRECT'): - with group({ - 'name': groupname, - 'userns_idmap': userns_idmap - }) as g: - yield g diff --git a/tests/api2/test_account_idmap.py b/tests/api2/test_account_idmap.py index 044918dd87264..4085a49939433 100644 --- a/tests/api2/test_account_idmap.py +++ b/tests/api2/test_account_idmap.py @@ -67,11 +67,9 @@ def test_mutable_idmaps(): 'password': 'test1234', 'group': g['id'], }) as u: - # Adding user and group to Incus mapping call('user.update', u['id'], {'userns_idmap': 'DIRECT'}) call('group.update', g['id'], {'userns_idmap': 'DIRECT'}) - # Removing user from Incus mapping user_response = call('user.update', u['id'], {'userns_idmap': None}) group_response = call('group.update', g['id'], {'userns_idmap': None}) diff --git a/tests/api2/test_account_privilege_role.py b/tests/api2/test_account_privilege_role.py index 75502b1ac3ba2..36af22bd0ea94 100644 --- a/tests/api2/test_account_privilege_role.py +++ b/tests/api2/test_account_privilege_role.py @@ -189,5 +189,3 @@ def test_can_not_subscribe_to_event(): def test_can_subscribe_to_event(): with unprivileged_user_client(["READONLY_ADMIN"]) as unprivileged: unprivileged.subscribe("alert.list", lambda *args, **kwargs: None) - # Verify that can also subscribe using unprivileged user. - unprivileged.subscribe('virt.instance.metrics:{"id": "test"}', lambda *args, **kwargs: None) diff --git a/tests/api2/test_usage_reporting.py b/tests/api2/test_usage_reporting.py index 4ca71777ac89c..7111ad8041093 100644 --- a/tests/api2/test_usage_reporting.py +++ b/tests/api2/test_usage_reporting.py @@ -33,7 +33,6 @@ class GatherTypes: 'nspawn_containers': ['nspawn_containers'], 'vendor_info': ['is_vendored', 'vendor_name'], 'hypervisor': ['hypervisor', 'is_virtualized'], - 'virt': ['virt'], 'method_stats': ['method_stats'] # Add new gather type here } diff --git a/tests/api2/test_virt_001_global.py b/tests/api2/test_virt_001_global.py deleted file mode 100644 index 91f7c94b3340f..0000000000000 --- a/tests/api2/test_virt_001_global.py +++ /dev/null @@ -1,22 +0,0 @@ -import pytest - -from auto_config import pool_name -from middlewared.test.integration.utils.call import call -from middlewared.test.integration.utils.ssh import ssh - -pytestmark = pytest.mark.skip('Disable VIRT tests for the moment') - - -def test_virt_pool(): - call('virt.global.update', {'pool': pool_name}, job=True) - ssh(f'zfs list {pool_name}/.ix-virt') - - -def test_virt_no_pool(): - call('virt.global.update', {'pool': None}, job=True) - ssh('incus storage show default 2>&1 | grep "incus daemon doesn\'t appear to be started"') - - -def test_virt_pool_auto_bridge(): - call('virt.global.update', {'pool': pool_name, 'bridge': None}, job=True) - ssh('ifconfig incusbr0') diff --git a/tests/api2/test_virt_002_instance.py b/tests/api2/test_virt_002_instance.py deleted file mode 100644 index f9ef884d301e1..0000000000000 --- a/tests/api2/test_virt_002_instance.py +++ /dev/null @@ -1,252 +0,0 @@ -import pytest - -from threading import Event - -from middlewared.service_exception import ValidationErrors -from middlewared.test.integration.assets.filesystem import mkfile -from middlewared.test.integration.assets.pool import dataset -from middlewared.test.integration.assets.virt import ( - userns_user, - userns_group, - virt, - virt_device, - virt_instance, -) -from middlewared.test.integration.utils import call, client, ssh, pool - -INS2_NAME = 'void' -INS2_OS = 'Void Linux' -INS2_IMAGE = 'voidlinux/musl' - -INS3_NAME = 'ubuntu' -INS3_OS = 'Ubuntu' -INS3_IMAGE = 'ubuntu/oracular/default' - - -@pytest.fixture(scope='module') -def virt_setup(): - # ensure that any stale config from other tests is nuked - call('virt.global.update', {'pool': None}, job=True) - ssh(f'zfs destroy -r {pool}/.ix-virt || true') - - with virt(): - yield - - -def check_idmap_entry(instance_name, entry): - raw = call('virt.instance.get_instance', instance_name, {'extra': {'raw': True}})['raw'] - - assert 'raw.idmap' in raw['config'] - return entry in raw['config']['raw.idmap'] - - -@pytest.fixture(scope='module') -def virt_instances(virt_setup): - # Create first so there is time for the agent to start - with virt_instance(INS2_NAME, INS2_IMAGE) as v2: - nics = list(call('virt.device.nic_choices', 'MACVLAN').keys()) - assert len(nics) > 0 - with virt_instance(INS3_NAME, INS3_IMAGE, devices=[ - { - 'dev_type': 'TPM', - 'path': '/dev/tpm0', - 'pathrm': '/dev/tmprm0' - }, - { - 'dev_type': 'PROXY', - 'source_proto': 'TCP', - 'source_port': 60123, - 'dest_proto': 'TCP', - 'dest_port': 2000 - }, - { - 'dev_type': 'NIC', - 'name': 'eth1', - 'nic_type': 'MACVLAN', - 'parent': nics[0] - }, - ]) as v3: - yield v2, v3 - - -def test_virt_instance_create(virt_instances): - for name, os_rel in ( - (INS2_NAME, INS2_OS), - (INS3_NAME, INS3_OS), - ): - ssh(f'incus exec {name} grep "{os_rel}" /etc/os-release') - - devices = call('virt.instance.device_list', INS3_NAME) - assert any(i for i in devices if i['name'] == 'tpm0'), devices - assert any(i for i in devices if i['name'] == 'proxy0'), devices - assert any(i for i in devices if i['name'] == 'eth1'), devices - - -def test_virt_instance_update(virt_instances): - call('virt.instance.update', INS2_NAME, {'cpu': '1', 'memory': 500 * 1024 * 1024, 'environment': {'FOO': 'BAR'}}, job=True) - ssh(f'incus exec {INS2_NAME} grep MemTotal: /proc/meminfo|grep 512000') - # Checking CPUs seems to cause a racing condition (perhaps CPU currently in use in the container?) - # rv = ssh('incus exec void cat /proc/cpuinfo |grep processor|wc -l') - # assert rv.strip() == '1' - rv = ssh(f'incus exec {INS2_NAME} env | grep ^FOO=') - assert rv.strip() == 'FOO=BAR' - - call('virt.instance.update', INS2_NAME, {'cpu': None, 'memory': None, 'environment': {}}, job=True) - - rv = ssh(f'incus exec {INS2_NAME} env | grep ^FOO= || true') - assert rv.strip() == '' - - -def test_virt_instance_stop(virt_instances): - wait_status_event = Event() - - def wait_status(event_type, **kwargs): - if kwargs['collection'] == 'virt.instance.query' and kwargs['id'] == INS2_NAME: - fields = kwargs.get('fields') - if fields and fields.get('status') == 'STOPPED': - wait_status_event.set() - - with client() as c: - c.subscribe('virt.instance.query', wait_status, sync=True) - - # Stop only one of them so the others are stopped during delete - assert ssh(f'incus list {INS2_NAME} -f json| jq ".[].status"').strip() == '"Running"' - instance = c.call('virt.instance.query', [['id', '=', INS2_NAME]], {'get': True}) - assert instance['status'] == 'RUNNING' - call('virt.instance.stop', INS2_NAME, {'force': True}, job=True) - instance = c.call('virt.instance.query', [['id', '=', INS2_NAME]], {'get': True}) - assert instance['status'] == 'STOPPED' - assert wait_status_event.wait(timeout=1) - assert ssh(f'incus list {INS2_NAME} -f json| jq ".[].status"').strip() == '"Stopped"' - - -def test_virt_instance_restart(virt_instances): - # Stop only one of them so the others are stopped during delete - assert ssh(f'incus list {INS3_NAME} -f json| jq ".[].status"').strip() == '"Running"' - instance = call('virt.instance.query', [['id', '=', INS3_NAME]], {'get': True}) - assert instance['status'] == 'RUNNING' - call('virt.instance.restart', INS3_NAME, {'force': True}, job=True) - instance = call('virt.instance.query', [['id', '=', INS3_NAME]], {'get': True}) - assert instance['status'] == 'RUNNING' - assert ssh(f'incus list {INS3_NAME} -f json| jq ".[].status"').strip() == '"Running"' - - -def test_virt_instance_device_add(virt_instances): - assert call('virt.instance.device_add', INS3_NAME, { - 'name': 'proxy', - 'dev_type': 'PROXY', - 'source_proto': 'TCP', - 'source_port': 8005, - 'dest_proto': 'TCP', - 'dest_port': 80, - }) is True - - devices = call('virt.instance.device_list', INS3_NAME) - assert any(i for i in devices if i['name'] == 'proxy'), devices - - with dataset('virtshare') as ds: - call('virt.instance.device_add', INS3_NAME, { - 'name': 'disk1', - 'dev_type': 'DISK', - 'source': f'/mnt/{ds}', - 'destination': '/host', - }) - devices = call('virt.instance.device_list', INS3_NAME) - assert any(i for i in devices if i['name'] == 'disk1'), devices - with mkfile(f'/mnt/{ds}/testfile'): - ssh(f'incus exec {INS3_NAME} ls /host/testfile') - assert call('virt.instance.device_delete', INS3_NAME, 'disk1') is True - - -def test_virt_instance_device_update(virt_instances): - assert call('virt.instance.device_update', INS3_NAME, { - 'name': 'proxy', - 'dev_type': 'PROXY', - 'source_proto': 'TCP', - 'source_port': 8005, - 'dest_proto': 'TCP', - 'dest_port': 81, - }) is True - - -def test_virt_instance_proxy(virt_instances): - ssh(f'incus exec -T {INS3_NAME} -- bash -c "nohup nc -l 0.0.0.0 81 > /tmp/nc 2>&1 &"') - ssh('echo "foo" | nc -w 1 localhost 8005 || true') - rv = ssh(f'incus exec {INS3_NAME} -- cat /tmp/nc') - - assert rv.strip() == 'foo' - - -def test_virt_instance_shell(virt_instances): - assert call('virt.instance.get_shell', INS3_NAME) == '/bin/bash' - - -def test_virt_instance_idmap(virt_instances): - with virt_instance('tmpinstance') as instance: - uid = None - gid = None - assert 'raw.idmap' in instance['raw']['config'] - # check that apps user / group are present - assert check_idmap_entry(instance['name'], f'uid 568 568') - assert check_idmap_entry(instance['name'], f'gid 568 568') - - with userns_user('bob') as u: - # check user DIRECT map - uid = u['uid'] - assert u['userns_idmap'] == 'DIRECT' - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - assert check_idmap_entry(instance['name'], f'uid {uid} {uid}') - - # check custom user map - call('user.update', u['id'], {'userns_idmap': 8675309}) - - # restart to update idmap - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - assert check_idmap_entry(instance['name'], f'uid {uid} 8675309') - assert not check_idmap_entry(instance['name'], f'uid {uid} {uid}') - - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - assert not check_idmap_entry(instance['name'], f'uid {uid} 8675309') - - with userns_group('bob_group') as g: - gid = g['gid'] - assert g['userns_idmap'] == 'DIRECT' - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - - assert check_idmap_entry(instance['name'], f'gid {gid} {gid}') - # check custom user map - call('group.update', g['id'], {'userns_idmap': 8675309}) - - # restart to update idmap - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - assert not check_idmap_entry(instance['name'], f'gid {gid} {gid}') - assert check_idmap_entry(instance['name'], f'gid {gid} 8675309') - - call('virt.instance.restart', instance['name'], {'force': True}, job=True) - assert not check_idmap_entry(instance['name'], f'gid {gid} 8675309') - - -def test_virt_instance_device_validation(virt_setup): - with dataset('tmpdataset') as ds: - with virt_instance('tmpinstance') as i: - ssh(f'mkdir /mnt/{ds}/testdir') - - # check path is dataset mountpoint - with pytest.raises(ValidationErrors, match='Source must be a dataset mountpoint.'): - with virt_device(i['name'], 'testdisk', { - 'dev_type': 'DISK', - 'source': f'/mnt/{ds}/testdir', - 'destination': '/nfs4acl', - }): - pass - - ssh(f'mkdir /mnt/testdir') - - # check path outside known pools - with pytest.raises(ValidationErrors, match='The path must reside within a pool mount point'): - with virt_device(i['name'], 'testdisk', { - 'dev_type': 'DISK', - 'source': f'/mnt/testdir', - 'destination': '/nfs4acl', - }): - pass diff --git a/tests/api2/test_virt_attachment_delegate.py b/tests/api2/test_virt_attachment_delegate.py deleted file mode 100644 index 618fe1b57b778..0000000000000 --- a/tests/api2/test_virt_attachment_delegate.py +++ /dev/null @@ -1,152 +0,0 @@ -import os.path - -import pytest - -from middlewared.test.integration.assets.pool import another_pool, dataset -from middlewared.test.integration.assets.virt import virt -from middlewared.test.integration.utils import call - - -CONTAINER_NAME = 'virt-container' -ENCRYPTED_POOL_NAME = 'enc_pool_virt' -POOL_PASSPHRASE = '12345678' - -pytestmark = pytest.mark.skip('Disable VIRT tests for the moment') - - -@pytest.fixture(scope='module') -def check_unused_disks(): - if len(call('disk.get_unused')) < 3: - pytest.skip('Insufficient number of disks to perform these tests') - - -@pytest.fixture(scope='module') -def encrypted_pool(): - with another_pool({ - 'name': ENCRYPTED_POOL_NAME, - 'encryption': True, - 'encryption_options': { - 'algorithm': 'AES-128-CCM', - 'passphrase': POOL_PASSPHRASE, - }, - }) as pool: - yield pool - - -@pytest.fixture(scope='module') -def non_encrypted_pool(): - with another_pool() as pool: - yield pool - - -@pytest.mark.usefixtures('check_unused_disks') -def test_instance_attachment(non_encrypted_pool, encrypted_pool): - with virt(non_encrypted_pool): - instance = call('virt.instance.create', { - 'name': CONTAINER_NAME, - 'source_type': 'IMAGE', - 'image': 'ubuntu/oracular/default', - 'instance_type': 'CONTAINER', - }, job=True) - assert instance['status'] != 'STOPPED', instance - - with dataset('incus', pool=ENCRYPTED_POOL_NAME) as ds_name: - src_path = os.path.join('/mnt', ds_name) - call('virt.instance.device_add', CONTAINER_NAME, { - 'dev_type': 'DISK', - 'source': src_path, - 'destination': '/abcd', - }) - - assert any( - d['source'] == src_path for d in call('virt.instance.device_list', CONTAINER_NAME) - if d['dev_type'] == 'DISK' - ) - # Let's ensure that attachments are reported accurately - assert any( - a['service'] == 'incus' - for a in call('pool.dataset.attachments', ds_name) - ) - - # We will test lock/unlock to see if instance is in a started/stopped state etc - assert call('pool.dataset.lock', ENCRYPTED_POOL_NAME, job=True) - - # the container should have stopped now - assert call('virt.instance.get_instance', CONTAINER_NAME)['status'] == 'STOPPED' - - unlock_resp = call('pool.dataset.unlock', ENCRYPTED_POOL_NAME, { - 'datasets': [{ - 'name': ENCRYPTED_POOL_NAME, - 'passphrase': POOL_PASSPHRASE, - }] - }, job=True) - assert unlock_resp['unlocked'] == [ENCRYPTED_POOL_NAME], unlock_resp - - # the container should have started now - we do not directly assert RUNNING to avoid - # any random failure in the CI pipeline if it is in an intermediate state or something - # similar - assert call('virt.instance.get_instance', CONTAINER_NAME)['status'] != 'STOPPED' - - # Now that the dataset no longer exists, we should not have that disk listed anymore - assert not any( - d['source'] == src_path for d in call('virt.instance.device_list', CONTAINER_NAME) - if d['dev_type'] == 'DISK' - ) - - -@pytest.mark.usefixtures('check_unused_disks') -def test_exporting_storage_pool(non_encrypted_pool): - pool1 = non_encrypted_pool['name'] - call('virt.global.update', {'pool': pool1}, job=True) - try: - pool2 = 'incuspool2' - with another_pool({'name': pool2}): - virt_config = call('virt.global.update', {'pool': pool1, 'storage_pools': [pool1, pool2]}, job=True) - assert set(virt_config['storage_pools']) == {pool1, pool2}, virt_config - - # Now that the pool no longer exists, we should not have it listed here as storage pool anymore - assert call('virt.global.config')['storage_pools'] == [pool1] - finally: - # Finally unset virt pool - call('virt.global.update', {'pool': None, 'storage_pools': []}, job=True) - - -@pytest.mark.usefixtures('check_unused_disks') -def test_exporting_main_pool(): - pool = 'incusmainpool' - with another_pool({'name': pool}): - virt_config = call('virt.global.update', {'pool': pool, 'storage_pools': [pool]}, job=True) - assert virt_config['pool'] == pool, virt_config - - # Now that the pool no longer exists, we should not have it listed here as storage pool anymore or set - config = call('virt.global.config') - assert config['pool'] is None, config - assert config['storage_pools'] == [] - - -@pytest.mark.usefixtures('check_unused_disks') -def test_virt_on_enc_pool(encrypted_pool): - config = call('virt.global.update', {'pool': ENCRYPTED_POOL_NAME, 'storage_pools': [ENCRYPTED_POOL_NAME]}, job=True) - try: - assert config['pool'] == ENCRYPTED_POOL_NAME, config - # We will test lock/unlock to see if virt is reported as locked and this is handled gracefully - assert call('pool.dataset.lock', ENCRYPTED_POOL_NAME, job=True) - # Now virt should come up as locked - assert call('virt.global.config')['state'] == 'LOCKED' - # Just doing a sanity check to ensure virt.instance.query is not failing - assert call('virt.instance.query') == [] - - # Now let's unlock it - unlock_resp = call('pool.dataset.unlock', ENCRYPTED_POOL_NAME, { - 'datasets': [{ - 'name': ENCRYPTED_POOL_NAME, - 'passphrase': POOL_PASSPHRASE, - }] - }, job=True) - assert unlock_resp['unlocked'] == [ENCRYPTED_POOL_NAME], unlock_resp - - # Incus should show up as initialized now - assert call('virt.global.config')['state'] == 'INITIALIZED' - finally: - # Finally unset virt pool - call('virt.global.update', {'pool': None, 'storage_pools': []}, job=True) diff --git a/tests/api2/test_virt_instance_acl.py b/tests/api2/test_virt_instance_acl.py deleted file mode 100644 index 3aa21d15cb4d1..0000000000000 --- a/tests/api2/test_virt_instance_acl.py +++ /dev/null @@ -1,240 +0,0 @@ -import pytest - -from copy import deepcopy - -from middlewared.test.integration.assets.pool import dataset -from middlewared.test.integration.assets.virt import ( - userns_user, - userns_group, - virt, - virt_device, - virt_instance, -) -from middlewared.test.integration.utils import call, ssh -from time import sleep - -pytestmark = pytest.mark.skip('Disable VIRT tests for the moment') - - -@pytest.fixture(scope='module') -def instance(): - with virt(): - with virt_instance('virtacltest') as i: - # install dependencies - - # libjansson is required for our nfsv4 acl tools (once they work) - ssh(f'incus exec {i["name"]} -- apt install -y libjansson4') - - yield i - - -@pytest.fixture(scope='function') -def nfs4acl_dataset(instance): - with userns_group('testgrp') as g: - with userns_user('testusr') as u: - # restart to get idmap changes - call('virt.instance.restart', instance['name']) - sleep(5) - with dataset('virtnfsshare', {'share_type': 'SMB'}) as ds: - with virt_device(instance['name'], 'disknfs', { - 'dev_type': 'DISK', - 'source': f'/mnt/{ds}', - 'destination': '/nfs4acl', - }): - yield { - 'user': u, - 'group': g, - 'dataset': ds, - 'dev': '/nfs4acl' - } - - -def create_virt_users(instance_name, uid, gid): - """ - Create three test users. - * One with the specified UID. - * One with the specified GID. - * One who has auxiliary group of specified GID - - These all get evaluated differently based on ACL - """ - prefix = f'incus exec {instance_name} --' - ssh(' '.join([prefix, 'useradd', f'-u {uid}', 'larry'])) - ssh(' '.join([prefix, 'useradd', f'-g {gid}', 'curly'])) - ssh(' '.join([prefix, 'useradd', f'-G {gid}', 'moe'])) - - -def check_access(instance_name, path, username, expected_access): - prefix = f'incus exec {instance_name}' - account_string = f'-- sudo -i -u {username}' - - # READ and MODIFY should be able to list - match expected_access: - case 'READ': - ssh(' '.join([prefix, account_string, 'ls', path])) - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'mkdir', f'{path}/testdir'])) - - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'chown', username, path])) - - case 'MODIFY': - ssh(' '.join([prefix, account_string, 'ls', path])) - ssh(' '.join([prefix, account_string, 'mkdir', f'{path}/testdir'])) - ssh(' '.join([prefix, account_string, 'rmdir', f'{path}/testdir'])) - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'chown', username, path])) - - case 'FULL_CONTROL': - ssh(' '.join([prefix, account_string, 'chown', username, path])) - - case None: - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'ls', path])) - - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'mkdir', f'{path}/testdir'])) - - with pytest.raises(AssertionError, match='Operation not permitted'): - ssh(' '.join([prefix, account_string, 'chown', username, path])) - case _: - raise ValueError(f'{expected_access}: unexpected access string') - - -def test_virt_instance_nfs4acl_functional(instance, nfs4acl_dataset): - create_virt_users( - instance['name'], - nfs4acl_dataset['user']['uid'], - nfs4acl_dataset['group']['gid'] - ) - - path = f'/mnt/{nfs4acl_dataset["dataset"]}' - acl_info = call('filesystem.getacl', path) - assert acl_info['acltype'] == 'NFS4' - acl = deepcopy(acl_info['acl']) - acl.extend([ - { - 'tag': 'GROUP', - 'type': 'ALLOW', - 'perms': {'BASIC': 'READ'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['group']['gid'] - }, - { - 'tag': 'USER', - 'type': 'ALLOW', - 'perms': {'BASIC': 'READ'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['user']['uid'] - } - ]) - - for username in ('larry', 'curly', 'moe'): - check_access( - instance['name'], - nfs4acl_dataset['dev'], - username, - None - ) - - # set READ ACL - call('filesystem.setacl', {'path': path, 'dacl': acl}, job=True) - - ssh(f'cp /bin/nfs4xdr_getfacl {path}/nfs4xdr_getfacl') - ssh(f'cp /bin/nfs4xdr_setfacl {path}/nfs4xdr_setfacl') - - # FIXME - NAS-134466 - """ - ssh(f'cp /bin/nfs4xdr_getfacl /mnt/{ds}/nfs4xdr_getfacl') - - cmd = [ - 'incus', 'exec', '-T', instance['name'], - '-- bash -c "/host/nfs4xdr_getfacl -j /host"' - ] - instance_acl = json.loads(ssh(' '.join(cmd))) - - # Check that the ids in the ACL have been mapped - check_nfs4_acl_entry( - instance_acl['acl'], - nfs4acl_dataset['group']['gid'], - 'ALLOW', - 'GROUP', - {'BASIC': 'READ'}, - {'BASIC': 'INHERIT'} - ) - - check_nfs4_acl_entry( - instance_acl['acl'], - instance['user']['uid'], - 'ALLOW', - 'USER', - {'BASIC': 'READ'}, - {'BASIC': 'INHERIT'} - ) - """ - - for username in ('larry', 'curly', 'moe'): - check_access( - instance['name'], - nfs4acl_dataset['dev'], - username, - 'READ' - ) - - acl = deepcopy(acl_info['acl']) - acl.extend([ - { - 'tag': 'GROUP', - 'type': 'ALLOW', - 'perms': {'BASIC': 'MODIFY'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['group']['gid'] - }, - { - 'tag': 'USER', - 'type': 'ALLOW', - 'perms': {'BASIC': 'MODIFY'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['user']['uid'] - } - ]) - - # set MODIFY ACL - call('filesystem.setacl', {'path': path, 'dacl': acl}, job=True) - - for username in ('larry', 'curly', 'moe'): - check_access( - instance['name'], - nfs4acl_dataset['dev'], - username, - 'MODIFY' - ) - - acl = deepcopy(acl_info['acl']) - acl.extend([ - { - 'tag': 'GROUP', - 'type': 'ALLOW', - 'perms': {'BASIC': 'FULL_CONTROL'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['group']['gid'] - }, - { - 'tag': 'USER', - 'type': 'ALLOW', - 'perms': {'BASIC': 'FULL_CONTROL'}, - 'flags': {'BASIC': 'INHERIT'}, - 'id': nfs4acl_dataset['user']['uid'] - } - ]) - - # set FULL_CONTROL ACL - call('filesystem.setacl', {'path': path, 'dacl': acl}, job=True) - - for username in ('larry', 'curly', 'moe'): - check_access( - instance['name'], - nfs4acl_dataset['dev'], - username, - 'FULL_CONTROL' - ) diff --git a/tests/api2/test_virt_instances.py b/tests/api2/test_virt_instances.py deleted file mode 100644 index 5186d413439e6..0000000000000 --- a/tests/api2/test_virt_instances.py +++ /dev/null @@ -1,156 +0,0 @@ -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, dataset -from middlewared.test.integration.assets.virt import ( - virt, import_iso_as_volume, volume, virt_device, virt_instance -) -from middlewared.test.integration.utils import call, ssh -from middlewared.service_exception import ValidationErrors as ClientValidationErrors - -from functions import POST, wait_on_job - - -ISO_VOLUME_NAME = 'testiso' -VM_NAME = 'virt-vm' -CONTAINER_NAME = 'virt-container' -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 container(virt_pool): - call('virt.instance.create', { - 'name': CONTAINER_NAME, - 'source_type': 'IMAGE', - 'image': 'ubuntu/oracular/default', - 'instance_type': 'CONTAINER', - }, job=True) - call('virt.instance.stop', CONTAINER_NAME, {'force': True, 'timeout': 1}, job=True) - try: - yield call('virt.instance.get_instance', CONTAINER_NAME) - finally: - call('virt.instance.delete', CONTAINER_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' - - -@pytest.mark.parametrize('vol_name, should_work', [ - ( - '-invlaid-name', False - ), - ( - 'valid-name', True - ), - ( - 'volume-name-should-not-have-characters-more-than-sixty-three-characters--', - False - ), - ( - 'alpine-3.18-default.iso', True - ), -]) -def test_volume_name_validation(virt_pool, vol_name, should_work): - if should_work: - v = call('virt.volume.create', {'name': vol_name}) - call('virt.volume.delete', v['id']) - else: - with pytest.raises(ClientValidationErrors): - call('virt.volume.create', {'name': vol_name}) - - -def test_volume_name_dataset_existing_validation_error(virt_pool): - pool_name = virt_pool['pool'] - vol_name = 'test_ds_volume_exist' - ds_name = f'{pool_name}/.ix-virt/custom/default_{vol_name}' - ssh(f'zfs create -V 500MB -s {ds_name}') - try: - with pytest.raises(ClientValidationErrors): - call('virt.volume.create', {'name': vol_name, 'storage_pool': pool_name}) - - ds = call('zfs.resource.query', {'paths': [ds_name], 'properties': None}) - assert len(ds) == 1 - finally: - ssh(f'zfs destroy {ds_name}') - - -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, - 'storage_pool': virt_pool['pool'] - } - ] - } - 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', f'{virt_pool["pool"]}_{vol_name}') - assert vol['name'] == vol_name - assert vol['config']['size'] == 50 - assert vol['content_type'] == 'ISO' - - call('virt.volume.delete', vol['id']) - - -def test_disk_device_attachment_validation_on_containers(container): - with dataset('virt-vol', {'type': 'VOLUME', 'volsize': 200 * 1024 * 1024, 'sparse': True}) as ds: - with pytest.raises(ClientValidationErrors) as ve: - call('virt.instance.device_add', CONTAINER_NAME, { - 'dev_type': 'DISK', - 'source': f'/dev/zvol/{ds}', - 'destination': '/zvol', - }) - - assert ve.value.errors[0].errmsg == 'ZVOL are not allowed for containers' diff --git a/tests/api2/test_virt_storage_pool.py b/tests/api2/test_virt_storage_pool.py deleted file mode 100644 index 2f2f7eaa35b56..0000000000000 --- a/tests/api2/test_virt_storage_pool.py +++ /dev/null @@ -1,226 +0,0 @@ -import pytest - -from middlewared.service_exception import InstanceNotFound -from middlewared.test.integration.assets.pool import another_pool, dataset -from middlewared.test.integration.assets.virt import ( - virt, - virt_device, - virt_instance, - volume, -) -from middlewared.test.integration.utils import call -from truenas_api_client.exc import ValidationErrors as ClientValidationErrors - -pytestmark = pytest.mark.skip('Disable VIRT tests for the moment') - - -@pytest.fixture(scope='module') -def virt_init(): - # NOTE this yields only initial config - with virt() as v: - yield v - - -@pytest.fixture(scope='module') -def virt_two_pools(virt_init): - with another_pool() as pool: - assert call('pool.dataset.attachments', pool['name']) == [] - call('virt.global.update', {'storage_pools': [virt_init['pool'], pool['name']]}, job=True) - config = call('virt.global.config') - # Make sure that VMs also generate attachments properly - dsa = call('pool.dataset.attachments', pool['name']) - - assert dsa == [ - {'attachments': ['virt'], 'service': 'incus', 'type': 'Virtualization'} - ] - assert dsa[0]['type'] == 'Virtualization' - assert dsa[0]['attachments'] == ['virt'] - - assert len(config['storage_pools']) == 2 - - try: - yield (pool, config) - finally: - call('virt.global.update', {'storage_pools': virt_init['storage_pools']}, job=True) - - -def test_add_second_pool(virt_init): - with another_pool() as pool: - pool_choices = call('virt.global.pool_choices') - assert pool['name'] in pool_choices - - call('virt.global.update', {'storage_pools': [virt_init['pool'], pool['name']]}, job=True) - - try: - config = call('virt.global.config') - assert config['state'] == 'INITIALIZED' - assert pool['name'] in config['storage_pools'] - finally: - call('virt.global.update', {'storage_pools': [virt_init['pool']]}, job=True) - - -def test_add_instance_second_pool(virt_two_pools): - pool, config = virt_two_pools - - with virt_instance('inst-second-pool', storage_pool=pool['name']) as instance: - assert instance['storage_pool'] == pool['name'] - - dsa = call('pool.dataset.attachments', pool['name']) - assert len(dsa) == 1 - - assert dsa[0]['type'] == 'Virtualization' - assert dsa[0]['attachments'] == ['virt'] - - with pytest.raises(ClientValidationErrors, match='Virt-Instances: inst-second-pool'): - - # Trying to remove pool holding instances should fail - call('virt.global.update', {'storage_pools': [config['pool']]}, job=True) - - -def test_add_volume_second_pool(virt_two_pools): - pool, config = virt_two_pools - VOLNAME = 'test-vol-pool2' - - with volume(VOLNAME, 1024, pool['name']): - vol = call('virt.volume.get_instance', f'{pool["name"]}_{VOLNAME}') - assert vol['storage_pool'] == pool['name'] - - -def test_virt_device_second_pool(virt_two_pools): - pool, config = virt_two_pools - - with virt_instance( - 'inst-second-pool', - storage_pool=pool['name'], - instance_type='VM' - ) as instance: - instance_name = instance['name'] - - assert instance['storage_pool'] == pool['name'] - - call('virt.instance.stop', instance_name, {'force': True, 'timeout': 1}, job=True) - - with volume('vmtestzvol', 1024, pool['name']) as v: - assert v['id'] in call('virt.device.disk_choices') - - with virt_device(instance_name, 'test_disk', {'dev_type': 'DISK', 'source': v['id']}): - devices = call('virt.instance.device_list', instance_name) - root_pool = None - test_disk_pool = None - - for device in devices: - if device['name'] == 'root': - root_pool = device['storage_pool'] - - elif device.get('source') == v['id']: - test_disk_pool = device['storage_pool'] - - assert root_pool == pool['name'] - assert test_disk_pool == pool['name'] - - -def test_virt_span_two_pools(virt_two_pools): - pool, config = virt_two_pools - - # Sanity check that we're properly testing both pools - assert pool['name'] != config['pool'] - - with virt_instance( - 'inst-second-pool', - storage_pool=pool['name'], - instance_type='VM' - ) as instance: - instance_name = instance['name'] - - assert instance['storage_pool'] == pool['name'] - - call('virt.instance.stop', instance_name, {'force': True, 'timeout': 1}, job=True) - - # create volume on other pool and attach to VM as disk - with volume('vmtestzvol', 1024, config['pool']) as v: - assert v['id'] in call('virt.device.disk_choices') - - with virt_device(instance_name, 'test_disk', {'dev_type': 'DISK', 'source': v['id']}): - devices = call('virt.instance.device_list', instance_name) - root_pool = None - test_disk_pool = None - - for device in devices: - if device['name'] == 'root': - root_pool = device['storage_pool'] - - elif device.get('source') == v['id']: - test_disk_pool = device['storage_pool'] - - assert root_pool == pool['name'] - assert test_disk_pool == config['pool'] - - -def check_volumes(volumes): - for spec in volumes: - vol = call('virt.volume.get_instance', f'{spec["pool"]}_{spec["name"]}') - assert vol['storage_pool'] == spec['pool'], str(vol) - assert vol['type'] == 'custom', str(vol) - assert vol['content_type'] == 'BLOCK', str(vol) - assert vol['config']['size'] == int(spec['volsize'] / 1024 / 1024), str(vol) - - -def test_virt_import_zvol_two_pools_rename(virt_two_pools): - pool, config = virt_two_pools - with dataset("teszv1", {"type": "VOLUME", "volsize": 1048576}, pool=config['pool']) as zv1: - with dataset("teszv2", {"type": "VOLUME", "volsize": 1048576}, pool=pool['name']) as zv2: - call('virt.volume.import_zvol', { - 'to_import': [ - {'virt_volume_name': 'vol1', 'zvol_path': f'/dev/zvol/{zv1}'}, - {'virt_volume_name': 'vol2', 'zvol_path': f'/dev/zvol/{zv2}'} - ] - }, job=True) - - try: - check_volumes([ - {'name': 'vol1', 'pool': config['pool'], 'volsize': 1048576}, - {'name': 'vol2', 'pool': pool['name'], 'volsize': 1048576}, - ]) - finally: - try: - call('virt.volume.delete', f'{config["pool"]}_vol1') - except InstanceNotFound: - pass - - try: - call('virt.volume.delete', f'{pool["name"]}_vol2') - except InstanceNotFound: - pass - - -def test_virt_import_zvol_two_pools_clone(virt_two_pools): - pool, config = virt_two_pools - with dataset("teszv1", {"type": "VOLUME", "volsize": 1048576}, pool=config['pool']) as zv1: - with dataset("teszv2", {"type": "VOLUME", "volsize": 1048576}, pool=pool['name']) as zv2: - call('virt.volume.import_zvol', { - 'to_import': [ - {'virt_volume_name': 'vol1', 'zvol_path': f'/dev/zvol/{zv1}'}, - {'virt_volume_name': 'vol2', 'zvol_path': f'/dev/zvol/{zv2}'} - ], - 'clone': True - }, job=True) - - # This should succeed since we did clone/promote - call('pool.dataset.delete', zv2) - call('pool.dataset.delete', zv1) - - try: - check_volumes([ - {'name': 'vol1', 'pool': config['pool'], 'volsize': 1048576}, - {'name': 'vol2', 'pool': pool['name'], 'volsize': 1048576}, - ]) - finally: - try: - call('virt.volume.delete', f'{config["pool"]}_vol1') - except InstanceNotFound: - pass - - try: - call('virt.volume.delete', f'{pool["name"]}_vol2') - except InstanceNotFound: - pass diff --git a/tests/stig/test_01_stig.py b/tests/stig/test_01_stig.py index 0fb754016c430..761c89ccdac7a 100644 --- a/tests/stig/test_01_stig.py +++ b/tests/stig/test_01_stig.py @@ -289,12 +289,6 @@ def test_docker_apps_enabled_fail(enterprise_product, two_factor_enabled): call('system.security.update', {'enable_fips': True, 'enable_gpos_stig': True}, job=True) -def test_vm_support_enabled_fail(enterprise_product, two_factor_enabled): - with mock('virt.global.config', return_value={"pool": "VirtualMachinePool"}): - with pytest.raises(ValidationErrors, match='Please disable VMs as VMs are not supported'): - call('system.security.update', {'enable_fips': True, 'enable_gpos_stig': True}, job=True) - - def test_tn_connect_enabled_fail(enterprise_product, two_factor_enabled): with mock('tn_connect.config', return_value={"enabled": True}): with pytest.raises(ValidationErrors, match='Please disable TrueNAS Connect as it is not supported'): @@ -454,7 +448,6 @@ def stig_admin(self, setup_stig): @pytest.mark.parametrize('cmd, args, is_job', [ pp('truecommand.update', {'enabled': True, 'api_key': '1234567890-ABCDE'}, True, id="Truecommand"), pp('docker.update', {'pool': 'NotApplicable'}, True, id="Docker"), - pp('virt.global.update', {'pool': 'NotApplicable'}, True, id="VM support"), pp('tn_connect.update', {'enabled': True, 'ips': ['1.2.3.4']}, False, id="TrueNAS Connect"), ]) def test_stig_prevent_operation(self, stig_admin, cmd, args, is_job): diff --git a/tests/unit/test_account_userns.py b/tests/unit/test_account_userns.py index efb1022637521..1686cb62b889b 100644 --- a/tests/unit/test_account_userns.py +++ b/tests/unit/test_account_userns.py @@ -75,109 +75,6 @@ def privileged_user(user, privileged_group): yield c.call('user.update', user['id'], {'groups': user['groups'] + [privileged_group[1]['id']]}) -def check_subid_file(filename, from_id, to_id): - entries = [] - with open(filename, 'r') as f: - for line in f: - if not line.startswith(f'0:{from_id}:'): - entries.append(line) - continue - - assert to_id is not None # There shouldn't be an entry - procid, xid_from, xid_to = line.split(':') - target = from_id if to_id == 'DIRECT' else to_id - assert int(xid_to) == target - return - - assert not to_id, f'{from_id}: entry is missing from {filename}: {entries}' - - -@pytest.mark.parametrize('param', ['DIRECT', 1000, None]) -def test__user_idmap_namespace_create(param): - with create_user('test_user', userns_idmap=param) as res: - assert res['userns_idmap'] == param - with Client() as c: - entry = c.call('virt.instance.get_account_idmaps', [ - ['type', '=', 'uid'], - ['from', '=', res['uid']], - ]) - - if param: - assert res['userns_idmap'] == param - assert entry - assert entry[0]['to'] == res['uid'] if param == 'DIRECT' else param - else: - assert res['userns_idmap'] is None - assert not entry - - check_subid_file('/etc/subuid', res['uid'], param) - - -@pytest.mark.parametrize('param', ['DIRECT', 1000, None]) -def test__user_idmap_namespace_update(user, param): - with Client() as c: - res = c.call('user.update', user['id'], {'userns_idmap': param}) - - entry = c.call('virt.instance.get_account_idmaps', [ - ['type', '=', 'uid'], - ['from', '=', res['uid']], - ]) - - if param: - assert res['userns_idmap'] == param - assert entry - assert entry[0]['to'] == res['uid'] if param == 'DIRECT' else param - else: - assert res['userns_idmap'] is None - assert not entry - - check_subid_file('/etc/subuid', res['uid'], param) - - -@pytest.mark.parametrize('param', ['DIRECT', 1000, None]) -def test__group_idmap_namespace_create(param): - with create_group('test_group', userns_idmap=param) as res: - assert res['userns_idmap'] == param - with Client() as c: - entry = c.call('virt.instance.get_account_idmaps', [ - ['type', '=', 'gid'], - ['from', '=', res['gid']], - ]) - - if param: - assert res['userns_idmap'] == param - assert entry - assert entry[0]['to'] == res['gid'] if param == 'DIRECT' else param - else: - assert res['userns_idmap'] is None - assert not entry - - check_subid_file('/etc/subgid', res['gid'], param) - - -@pytest.mark.parametrize('param', ['DIRECT', 1000, None]) -def test__group_idmap_namespace_update(param): - with create_group('test_group') as res: - with Client() as c: - pk = c.call('group.update', res['id'], {'userns_idmap': param}) - idmap = c.call('group.query', [['id', '=', pk]], {'get': True})['userns_idmap'] - - entry = c.call('virt.instance.get_account_idmaps', [ - ['type', '=', 'gid'], - ['from', '=', res['gid']], - ]) - - if param: - assert idmap == param - assert entry - assert entry[0]['to'] == res['gid'] if param == 'DIRECT' else param - else: - assert idmap is None - assert not entry - - check_subid_file('/etc/subgid', res['gid'], param) - - def test__privileged_user_idmap_namespace_deny(privileged_user): with pytest.raises(ClientException, match='privileged account'): with Client() as c: diff --git a/tests/unit/test_auditd_rules.py b/tests/unit/test_auditd_rules.py index 3335c8844e460..3fbf070a05b50 100644 --- a/tests/unit/test_auditd_rules.py +++ b/tests/unit/test_auditd_rules.py @@ -12,13 +12,12 @@ # Non-STIG test items SAMPLE_CE_RULES = ["-a always,exclude -F msgtype=USER_START", "-a always,exclude -F msgtype=SERVICE_START"] # Common test items -INCUS_RULE = "-a always,exit -F arch=b64 -S all -F path=/usr/bin/incus -F perm=x -F auid!=-1 -F key=escalation" REBOOT_RULE = "-a always,exit -F arch=b64 -S execve -F path=/usr/sbin/reboot -F key=escalation" STIG_ASSERT_IN = [MODULE_STIG_RULE, SAMPLE_STIG_RULE, REBOOT_RULE] # TODO: IMMUTABLE_STIG_RULE when enabled STIG_ASSERT_NOT_IN = SAMPLE_CE_RULES -NON_STIG_ASSERT_IN = [INCUS_RULE, REBOOT_RULE] + SAMPLE_CE_RULES +NON_STIG_ASSERT_IN = [REBOOT_RULE] + SAMPLE_CE_RULES NON_STIG_ASSERT_NOT_IN = [SAMPLE_STIG_RULE] diff --git a/tests/unit/test_builtin_gid.py b/tests/unit/test_builtin_gid.py index e97c956fe70a6..059a886b2966a 100644 --- a/tests/unit/test_builtin_gid.py +++ b/tests/unit/test_builtin_gid.py @@ -19,9 +19,9 @@ def local_user(): @pytest.fixture(scope='module') -def incus_admin_dbid(): +def libvirt_admin_dbid(): with Client() as c: - yield c.call('group.query', [['name', '=', 'incus-admin']], {'get': True})['id'] + yield c.call('group.query', [['name', '=', 'libvirt']], {'get': True})['id'] @pytest.fixture(scope='module') @@ -36,16 +36,16 @@ def builtin_admins_dbid(): ('sudo_commands', ['/usr/sbin/zpool']), ('sudo_commands_nopasswd', ['/usr/sbin/zpool']), )) -def test__builtin_group_immutable(key, value, incus_admin_dbid): +def test__builtin_group_immutable(key, value, libvirt_admin_dbid): with pytest.raises(ClientException, match='Immutable groups cannot be changed'): with Client() as c: - c.call('group.update', incus_admin_dbid, {key: value}) + c.call('group.update', libvirt_admin_dbid, {key: value}) -def test__builtin_group_deny_member_change(incus_admin_dbid, local_user): +def test__builtin_group_deny_member_change(libvirt_admin_dbid, local_user): with pytest.raises(ClientException, match='Immutable groups cannot be changed'): with Client() as c: - c.call('group.update', incus_admin_dbid, {'users': [local_user['id']]}) + c.call('group.update', libvirt_admin_dbid, {'users': [local_user['id']]}) def test__change_full_admin_member(local_user, builtin_admins_dbid): diff --git a/tests/unit/test_role_manager.py b/tests/unit/test_role_manager.py index 19efbbdc6b9cc..a3ec8d35d3fb7 100644 --- a/tests/unit/test_role_manager.py +++ b/tests/unit/test_role_manager.py @@ -115,7 +115,7 @@ def test__check_readonly_role(): """ We _really_ shouldn't be directly assigning resources to FULL_ADMIN. The reason for this is that it provides no granularity for restricting what FA can do when STIG is enabled. - Mostly we don't want methods like "virt.global.update" being populated here. + Mostly we don't want methods like "docker.update" being populated here. """ with Client() as c: method_allowlists = c.call('privilege.dump_role_manager')['method_allowlists'] diff --git a/tests/unit/test_stig.py b/tests/unit/test_stig.py index fefd1672c7841..933340685c859 100644 --- a/tests/unit/test_stig.py +++ b/tests/unit/test_stig.py @@ -10,8 +10,9 @@ def enable_stig(): finally: c.call('datastore.update', 'system.security', 1, {'enable_gpos_stig': False}) + def test__stig_restrictions_af_unix(enable_stig): # STIG RBAC should still be effective despite root session with pytest.raises(ClientException, match='Not authorized'): with Client() as c: - c.call('virt.global.update', {}, job=True) + c.call('docker.update', {}, job=True) diff --git a/tests/unit/test_virt_utils.py b/tests/unit/test_virt_utils.py deleted file mode 100644 index 611db0937a1fe..0000000000000 --- a/tests/unit/test_virt_utils.py +++ /dev/null @@ -1,40 +0,0 @@ -import pytest - -from middlewared.plugins.virt import utils - -storage = utils.INCUS_STORAGE - -DEFAULT_POOL = 'pool_default' -REGULAR_POOL = 'pool_regular' - - -@pytest.fixture(scope='function') -def default_storage_pool(): - storage.default_storage_pool = 'pool_default' - try: - yield - finally: - storage.default_storage_pool = None - - -def test__init_storage_value(): - assert storage.state is utils.VirtGlobalStatus.INITIALIZING - assert storage.default_storage_pool is None - - -@pytest.mark.parametrize('status', utils.VirtGlobalStatus) -def test__setting_storage_state(status): - storage.state = status - assert storage.state is status - - -def test__setting_invalid_storage_status(): - with pytest.raises(TypeError): - storage.state = 'Canary' - - -def test_default_storage_pool(default_storage_pool): - assert utils.storage_pool_to_incus_pool(DEFAULT_POOL) == 'default' - assert utils.storage_pool_to_incus_pool(REGULAR_POOL) == REGULAR_POOL - assert utils.incus_pool_to_storage_pool('default') == DEFAULT_POOL - assert utils.incus_pool_to_storage_pool(REGULAR_POOL) == REGULAR_POOL