various updates v.dev3
This commit is contained in:
@ -13,6 +13,7 @@
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from .cloud_init import CloudInit
|
||||
from .guest_agent import GuestAgent
|
||||
from .instance import Instance, InstanceConfig
|
||||
from .schemas import InstanceSchema
|
||||
|
221
compute/instance/cloud_init.py
Normal file
221
compute/instance/cloud_init.py
Normal file
@ -0,0 +1,221 @@
|
||||
# This file is part of Compute
|
||||
#
|
||||
# Compute is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Compute is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Compute. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# ruff: noqa: S603
|
||||
|
||||
"""
|
||||
`Cloud-init`_ integration for bootstraping compute instances.
|
||||
|
||||
.. _Cloud-init: https://cloudinit.readthedocs.io
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from compute.exceptions import InstanceError
|
||||
|
||||
from .devices import DiskConfig, DiskDriver
|
||||
from .instance import Instance
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudInit:
|
||||
"""
|
||||
Cloud-init integration.
|
||||
|
||||
:ivar str user_data: user-data.
|
||||
:ivar str vendor_data: vendor-data.
|
||||
:ivar str network_config: network-config.
|
||||
:ivar str meta_data: meta-data.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialise :class:`CloudInit`."""
|
||||
self.user_data = None
|
||||
self.vendor_data = None
|
||||
self.network_config = None
|
||||
self.meta_data = None
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Represent :class:`CloudInit` object."""
|
||||
return (
|
||||
self.__class__.__name__
|
||||
+ '('
|
||||
+ ', '.join(
|
||||
[
|
||||
f'{self.user_data=}',
|
||||
f'{self.vendor_data=}',
|
||||
f'{self.network_config=}',
|
||||
f'{self.meta_data=}',
|
||||
]
|
||||
)
|
||||
+ ')'
|
||||
).replace('self.', '')
|
||||
|
||||
def _write_to_disk(
|
||||
self,
|
||||
disk: str,
|
||||
filename: str,
|
||||
data: str | None,
|
||||
*,
|
||||
force_file_create: bool = False,
|
||||
delete_existing_file: bool = False,
|
||||
default_data: str | None = None,
|
||||
) -> None:
|
||||
data = data or default_data
|
||||
log.debug('Input data %s: %r', filename, data)
|
||||
if isinstance(data, str):
|
||||
data = data.encode()
|
||||
if data is None and force_file_create is False:
|
||||
return
|
||||
with tempfile.NamedTemporaryFile() as data_file:
|
||||
if data is not None:
|
||||
data_file.write(data)
|
||||
data_file.flush()
|
||||
if delete_existing_file:
|
||||
log.debug('Deleting existing file')
|
||||
filelist = subprocess.run(
|
||||
['/usr/bin/mdir', '-i', disk, '-b'],
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
files = [
|
||||
f.replace('::/', '')
|
||||
for f in filelist.stdout.decode().splitlines()
|
||||
]
|
||||
log.debug('Files on disk: %s', files)
|
||||
log.debug("Removing '%s'", filename)
|
||||
if filename in files:
|
||||
subprocess.run(
|
||||
['/usr/bin/mdel', '-i', disk, f'::{filename}'],
|
||||
check=True,
|
||||
)
|
||||
log.debug("Writing file '%s'", filename)
|
||||
subprocess.run(
|
||||
[
|
||||
'/usr/bin/mcopy',
|
||||
'-i',
|
||||
disk,
|
||||
data_file.name,
|
||||
f'::{filename}',
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def create_disk(self, disk: Path, *, force: bool = False) -> None:
|
||||
"""
|
||||
Create disk with cloud-init config files.
|
||||
|
||||
:param path: Disk path.
|
||||
:param force: Replace existing disk.
|
||||
"""
|
||||
if not isinstance(disk, Path):
|
||||
disk = Path(disk)
|
||||
if disk.exists():
|
||||
if disk.is_file() is False:
|
||||
raise InstanceError('Cloud-init disk must be regular file')
|
||||
if force:
|
||||
disk.unlink()
|
||||
else:
|
||||
raise InstanceError('File already exists')
|
||||
subprocess.run(
|
||||
['/usr/sbin/mkfs.vfat', '-n', 'CIDATA', '-C', str(disk), '1024'],
|
||||
check=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='user-data',
|
||||
data=self.user_data,
|
||||
force_file_create=True,
|
||||
default_data='#cloud-config',
|
||||
)
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='vendor-data',
|
||||
data=self.vendor_data,
|
||||
)
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='network-config',
|
||||
data=self.network_config,
|
||||
)
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='meta-data',
|
||||
data=self.meta_data,
|
||||
force_file_create=True,
|
||||
)
|
||||
|
||||
def update_disk(self, disk: Path) -> None:
|
||||
"""Update files on existing disk."""
|
||||
if not isinstance(disk, Path):
|
||||
disk = Path(disk)
|
||||
if not disk.exists():
|
||||
raise InstanceError(f"File '{disk}' does not exists")
|
||||
if self.user_data:
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='user-data',
|
||||
data=self.user_data,
|
||||
force_file_create=True,
|
||||
default_data='#cloud-config',
|
||||
delete_existing_file=True,
|
||||
)
|
||||
if self.vendor_data:
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='vendor-data',
|
||||
data=self.vendor_data,
|
||||
delete_existing_file=True,
|
||||
)
|
||||
if self.network_config:
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='network-config',
|
||||
data=self.network_config,
|
||||
delete_existing_file=True,
|
||||
)
|
||||
if self.meta_data:
|
||||
self._write_to_disk(
|
||||
disk=disk,
|
||||
filename='meta-data',
|
||||
data=self.meta_data,
|
||||
force_file_create=True,
|
||||
delete_existing_file=True,
|
||||
)
|
||||
|
||||
def attach_disk(self, disk: Path, target: str, instance: Instance) -> None:
|
||||
"""
|
||||
Attach cloud-init disk to instance.
|
||||
|
||||
:param disk: Path to disk.
|
||||
:param instance: Compute instance object.
|
||||
"""
|
||||
instance.attach_device(
|
||||
DiskConfig(
|
||||
type='file',
|
||||
device='disk',
|
||||
source=disk,
|
||||
target=target,
|
||||
is_readonly=True,
|
||||
bus='virtio',
|
||||
driver=DiskDriver('qemu', 'raw'),
|
||||
)
|
||||
)
|
@ -24,7 +24,7 @@ from typing import Union
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
from compute.common import DeviceConfig
|
||||
from compute.abstract import DeviceConfig
|
||||
from compute.exceptions import InvalidDeviceConfigError
|
||||
|
||||
|
||||
@ -32,9 +32,9 @@ from compute.exceptions import InvalidDeviceConfigError
|
||||
class DiskDriver:
|
||||
"""Disk driver description for libvirt."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
cache: str
|
||||
name: str = 'qemu'
|
||||
type: str = 'qcow2'
|
||||
cache: str = 'default'
|
||||
|
||||
def __call__(self):
|
||||
"""Return self."""
|
||||
@ -56,13 +56,7 @@ class DiskConfig(DeviceConfig):
|
||||
is_readonly: bool = False
|
||||
device: str = 'disk'
|
||||
bus: str = 'virtio'
|
||||
driver: DiskDriver = field(
|
||||
default_factory=DiskDriver(
|
||||
name='qemu',
|
||||
type='qcow2',
|
||||
cache='writethrough',
|
||||
)
|
||||
)
|
||||
driver: DiskDriver = field(default_factory=DiskDriver())
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""Return XML config for libvirt."""
|
||||
@ -99,13 +93,14 @@ class DiskConfig(DeviceConfig):
|
||||
pretty_print=True,
|
||||
).strip()
|
||||
driver = xml.find('driver')
|
||||
cachetype = driver.get('cache')
|
||||
disk_params = {
|
||||
'type': xml.get('type'),
|
||||
'device': xml.get('device'),
|
||||
'driver': DiskDriver(
|
||||
name=driver.get('name'),
|
||||
type=driver.get('type'),
|
||||
cache=driver.get('cache'),
|
||||
**({'cache': cachetype} if cachetype else {}),
|
||||
),
|
||||
'source': xml.find('source').get('file'),
|
||||
'target': xml.find('target').get('dev'),
|
||||
@ -122,7 +117,7 @@ class DiskConfig(DeviceConfig):
|
||||
if driver_param is None:
|
||||
msg = (
|
||||
"'driver' tag must have "
|
||||
"'name', 'type' and 'cache' attributes"
|
||||
"'name' and 'type' attributes"
|
||||
)
|
||||
raise InvalidDeviceConfigError(msg, xml_str)
|
||||
return cls(**disk_params)
|
||||
|
@ -27,7 +27,7 @@ import libvirt_qemu
|
||||
from compute.exceptions import (
|
||||
GuestAgentCommandNotSupportedError,
|
||||
GuestAgentError,
|
||||
GuestAgentTimeoutError,
|
||||
GuestAgentTimeoutExpired,
|
||||
GuestAgentUnavailableError,
|
||||
)
|
||||
|
||||
@ -114,7 +114,7 @@ class GuestAgent:
|
||||
if command not in supported:
|
||||
raise GuestAgentCommandNotSupportedError(command)
|
||||
|
||||
def guest_exec( # noqa: PLR0913
|
||||
def guest_exec(
|
||||
self,
|
||||
path: str,
|
||||
args: list[str] | None = None,
|
||||
@ -199,7 +199,7 @@ class GuestAgent:
|
||||
sleep(poll_interval)
|
||||
now = time()
|
||||
if now - start_time > self.timeout:
|
||||
raise GuestAgentTimeoutError(self.timeout)
|
||||
raise GuestAgentTimeoutExpired(self.timeout)
|
||||
log.debug(
|
||||
'Polling command pid=%s finished, time taken: %s seconds',
|
||||
pid,
|
||||
|
@ -25,7 +25,7 @@ import libvirt
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
from compute.common import DeviceConfig, EntityConfig
|
||||
from compute.abstract import DeviceConfig, EntityConfig
|
||||
from compute.exceptions import (
|
||||
GuestAgentCommandNotSupportedError,
|
||||
InstanceError,
|
||||
@ -141,6 +141,14 @@ class InstanceConfig(EntityConfig):
|
||||
)
|
||||
)
|
||||
xml.append(self._gen_cpu_xml(self.cpu))
|
||||
xml.append(
|
||||
E.clock(
|
||||
E.timer(name='rtc', tickpolicy='catchup'),
|
||||
E.timer(name='pit', tickpolicy='delay'),
|
||||
E.timer(name='hpet', present='no'),
|
||||
offset='utc',
|
||||
)
|
||||
)
|
||||
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
|
||||
for dev in self.boot.order:
|
||||
os.append(E.boot(dev=dev))
|
||||
@ -159,7 +167,7 @@ class InstanceConfig(EntityConfig):
|
||||
devices.append(E.emulator(str(self.emulator)))
|
||||
for interface in self.network_interfaces:
|
||||
devices.append(self._gen_network_interface_xml(interface))
|
||||
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
|
||||
devices.append(E.graphics(type='vnc', autoport='yes'))
|
||||
devices.append(E.input(type='tablet', bus='usb'))
|
||||
devices.append(
|
||||
E.channel(
|
||||
@ -171,6 +179,7 @@ class InstanceConfig(EntityConfig):
|
||||
type='unix',
|
||||
)
|
||||
)
|
||||
devices.append(E.serial(E.target(port='0'), type='pty'))
|
||||
devices.append(
|
||||
E.console(E.target(type='serial', port='0'), type='pty')
|
||||
)
|
||||
@ -212,10 +221,30 @@ class Instance:
|
||||
|
||||
:param domain: libvirt domain object
|
||||
"""
|
||||
self.domain = domain
|
||||
self.connection = domain.connect()
|
||||
self.name = domain.name()
|
||||
self.guest_agent = GuestAgent(domain)
|
||||
self._domain = domain
|
||||
self._connection = domain.connect()
|
||||
self._name = domain.name()
|
||||
self._guest_agent = GuestAgent(domain)
|
||||
|
||||
@property
|
||||
def connection(self) -> libvirt.virConnect:
|
||||
"""Libvirt connection object."""
|
||||
return self._connection
|
||||
|
||||
@property
|
||||
def domain(self) -> libvirt.virDomain:
|
||||
"""Libvirt domain object."""
|
||||
return self._domain
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Instance name."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def guest_agent(self) -> GuestAgent:
|
||||
""":class:`GuestAgent` object."""
|
||||
return self._guest_agent
|
||||
|
||||
def _expand_instance_state(self, state: int) -> str:
|
||||
states = {
|
||||
@ -279,6 +308,9 @@ class Instance:
|
||||
|
||||
def get_max_vcpus(self) -> int:
|
||||
"""Maximum vCPUs number for domain."""
|
||||
if not self.is_running():
|
||||
xml = etree.fromstring(self.dump_xml(inactive=True))
|
||||
return int(xml.xpath('/domain/vcpu/text()')[0])
|
||||
return self.domain.maxVcpus()
|
||||
|
||||
def start(self) -> None:
|
||||
@ -324,7 +356,6 @@ class Instance:
|
||||
:param method: Method used to shutdown instance
|
||||
"""
|
||||
if not self.is_running():
|
||||
log.warning('Instance is not running, nothing to do')
|
||||
return
|
||||
methods = {
|
||||
'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
|
||||
@ -737,13 +768,24 @@ class Instance:
|
||||
|
||||
:param with_volumes: If True delete local volumes with instance.
|
||||
"""
|
||||
log.info("Shutdown instance '%s'", self.name)
|
||||
self.shutdown(method='HARD')
|
||||
disks = self.list_disks(persistent=True)
|
||||
log.debug('Disks list: %s', disks)
|
||||
for disk in disks:
|
||||
if with_volumes and disk.type == 'file':
|
||||
volume = self.connection.storageVolLookupByPath(disk.source)
|
||||
log.debug('Delete volume: %s', volume.path())
|
||||
try:
|
||||
volume = self.connection.storageVolLookupByPath(
|
||||
disk.source
|
||||
)
|
||||
except libvirt.libvirtError as e:
|
||||
if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL:
|
||||
log.warning(
|
||||
"Volume '%s' not found, skipped",
|
||||
disk.source,
|
||||
)
|
||||
continue
|
||||
log.info('Delete volume: %s', volume.path())
|
||||
volume.delete()
|
||||
log.debug('Undefine instance')
|
||||
log.info('Undefine instance')
|
||||
self.domain.undefine()
|
||||
|
@ -22,7 +22,7 @@ from pathlib import Path
|
||||
from pydantic import ValidationError, validator
|
||||
from pydantic.error_wrappers import ErrorWrapper
|
||||
|
||||
from compute.common import EntityModel
|
||||
from compute.abstract import EntityModel
|
||||
from compute.utils.units import DataUnit
|
||||
|
||||
|
||||
@ -109,6 +109,15 @@ class BootOptionsSchema(EntityModel):
|
||||
order: tuple
|
||||
|
||||
|
||||
class CloudInitSchema(EntityModel):
|
||||
"""Cloud-init config model."""
|
||||
|
||||
user_data: str | None = None
|
||||
meta_data: str | None = None
|
||||
vendor_data: str | None = None
|
||||
network_config: str | None = None
|
||||
|
||||
|
||||
class InstanceSchema(EntityModel):
|
||||
"""Compute instance model."""
|
||||
|
||||
@ -127,6 +136,7 @@ class InstanceSchema(EntityModel):
|
||||
volumes: list[VolumeSchema]
|
||||
network_interfaces: list[NetworkInterfaceSchema]
|
||||
image: str | None = None
|
||||
cloud_init: CloudInitSchema | None = None
|
||||
|
||||
@validator('name')
|
||||
def _check_name(cls, value: str) -> str: # noqa: N805
|
||||
|
Reference in New Issue
Block a user