Compare commits

..

No commits in common. "master" and "v0.1.0-dev3" have entirely different histories.

32 changed files with 156 additions and 363 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
dist/ dist/
docs/build/ docs/build/
packaging/*/build/ packaging/build/
instance.yaml instance.yaml
.ruff_cache/ .ruff_cache/
__pycache__/ __pycache__/

View File

@ -5,7 +5,7 @@ DOCS_BUILDDIR = docs/build
.PHONY: docs .PHONY: docs
all: build debian archlinux all: docs build-deb
requirements.txt: requirements.txt:
poetry export -f requirements.txt -o requirements.txt poetry export -f requirements.txt -o requirements.txt
@ -13,11 +13,8 @@ requirements.txt:
build: version format lint build: version format lint
poetry build poetry build
debian: build-deb: build
cd packaging/debian && $(MAKE) cd packaging && $(MAKE)
archlinux:
cd packaging/archlinux && $(MAKE)
version: version:
VERSION=$$(awk '/^version/{print $$3}' pyproject.toml); \ VERSION=$$(awk '/^version/{print $$3}' pyproject.toml); \
@ -45,12 +42,11 @@ clean:
[ -d $(DISTDIR) ] && rm -rf $(DISTDIR) || true [ -d $(DISTDIR) ] && rm -rf $(DISTDIR) || true
[ -d $(DOCS_BUILDDIR) ] && rm -rf $(DOCS_BUILDDIR) || true [ -d $(DOCS_BUILDDIR) ] && rm -rf $(DOCS_BUILDDIR) || true
find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
cd packaging/debian && $(MAKE) clean cd packaging && $(MAKE) clean
cd packaging/archlinux && $(MAKE) clean
test-build: build debian test-build: build-deb
scp packaging/debian/build/compute*.deb vm:~ scp packaging/build/compute*.deb vm:~
upload-docs: upload-docs: docs-versions
ssh root@hitomi 'rm -rf /srv/http/nixhacks.net/hstack/compute/*' ssh root@hitomi 'rm -rf /srv/http/nixhacks.net/hstack/*'
scp -r $(DOCS_BUILDDIR)/* root@hitomi:/srv/http/nixhacks.net/hstack/compute/ scp -r $(DOCS_BUILDDIR)/* root@hitomi:/srv/http/nixhacks.net/hstack/

View File

@ -4,8 +4,7 @@ Compute instances management library.
## Docs ## Docs
Documantation is available [here](https://nixhacks.net/hstack/compute/master/index.html). Run `make serve-docs`. See [Development](#development) below.
To build actual docs run `make serve-docs`. See [Development](#development) below.
## Roadmap ## Roadmap
@ -42,7 +41,6 @@ To build actual docs run `make serve-docs`. See [Development](#development) belo
- [ ] LXC - [ ] LXC
- [ ] Attaching CDROM from sources: block, (http|https|ftp|ftps|tftp):// - [ ] Attaching CDROM from sources: block, (http|https|ftp|ftps|tftp)://
- [ ] Instance clones (thin, fat) - [ ] Instance clones (thin, fat)
- [ ] MicroVM
## Development ## Development
@ -54,7 +52,7 @@ Install [poetry](https://python-poetry.org/), clone this repository and run:
poetry install --with dev --with docs poetry install --with dev --with docs
``` ```
## Build Debian package # Build Debian package
Install Docker first, then run: Install Docker first, then run:
@ -64,11 +62,11 @@ make build-deb
`compute` and `compute-doc` packages will built. See packaging/build directory. `compute` and `compute-doc` packages will built. See packaging/build directory.
## Installation # Installation
See [Installation](https://nixhacks.net/hstack/compute/master/installation.html). See [Installation](https://nixhacks.net/hstack/compute/master/installation.html).
## Basic usage # Basic usage
To get help run: To get help run:

View File

@ -15,7 +15,7 @@
"""Compute instances management library.""" """Compute instances management library."""
__version__ = '0.1.0-dev5' __version__ = '0.1.0-dev3'
from .config import Config from .config import Config
from .instance import CloudInit, Instance, InstanceConfig, InstanceSchema from .instance import CloudInit, Instance, InstanceConfig, InstanceSchema

View File

@ -55,7 +55,7 @@ def init(session: Session, args: argparse.Namespace) -> None:
capabilities = session.get_capabilities() capabilities = session.get_capabilities()
node_info = session.get_node_info() node_info = session.get_node_info()
base_instance_config = { base_instance_config = {
'name': str(uuid.uuid4()).split('-')[0], 'name': str(uuid.uuid4()),
'title': None, 'title': None,
'description': None, 'description': None,
'arch': capabilities.arch, 'arch': capabilities.arch,
@ -70,28 +70,16 @@ def init(session: Session, args: argparse.Namespace) -> None:
'topology': None, 'topology': None,
'features': None, 'features': None,
}, },
'network_interfaces': [
{
'source': 'default',
'mac': ids.random_mac(),
},
],
'boot': {'order': ['cdrom', 'hd']}, 'boot': {'order': ['cdrom', 'hd']},
'cloud_init': None, 'cloud_init': None,
} }
data = dictutil.override(base_instance_config, data) data = dictutil.override(base_instance_config, data)
net_default_interface = {
'model': 'virtio',
'source': 'default',
'mac': ids.random_mac(),
}
net_config = data.get('network', 'DEFAULT')
if net_config == 'DEFAULT' or net_config is True:
data['network'] = {'interfaces': [net_default_interface]}
elif net_config is None or net_config is False:
pass # allow creating instance without network interfaces
else:
interfaces = data['network'].get('interfaces')
if interfaces:
interfaces_configs = [
dictutil.override(net_default_interface, interface)
for interface in interfaces
]
data['network']['interfaces'] = interfaces_configs
volumes = [] volumes = []
targets = [] targets = []
for volume in data['volumes']: for volume in data['volumes']:
@ -258,8 +246,8 @@ def shutdown(session: Session, args: argparse.Namespace) -> None:
method = 'SOFT' method = 'SOFT'
elif args.hard: elif args.hard:
method = 'HARD' method = 'HARD'
elif args.destroy: elif args.unsafe:
method = 'DESTROY' method = 'UNSAFE'
else: else:
method = 'NORMAL' method = 'NORMAL'
instance.shutdown(method) instance.shutdown(method)

View File

@ -226,7 +226,7 @@ def get_parser() -> argparse.ArgumentParser:
'-s', '-s',
'--soft', '--soft',
action='store_true', action='store_true',
help='guest OS shutdown using guest agent', help='normal guest OS shutdown, guest agent is used',
) )
shutdown_opts.add_argument( shutdown_opts.add_argument(
'-n', '-n',
@ -244,12 +244,12 @@ def get_parser() -> argparse.ArgumentParser:
), ),
) )
shutdown_opts.add_argument( shutdown_opts.add_argument(
'-d', '-u',
'--destroy', '--unsafe',
action='store_true', action='store_true',
help=( help=(
'destroy instance, this is similar to a power outage ' 'destroy instance, this is similar to a power outage '
'and may result in data corruption' 'and may result in data loss or corruption'
), ),
) )
shutdown.set_defaults(func=commands.shutdown) shutdown.set_defaults(func=commands.shutdown)

View File

@ -92,7 +92,7 @@ class InvalidDeviceConfigError(ComputeError):
"""Initialise InvalidDeviceConfigError.""" """Initialise InvalidDeviceConfigError."""
self.msg = f'Invalid device XML config: {msg}' self.msg = f'Invalid device XML config: {msg}'
self.loc = f' {xml}' self.loc = f' {xml}'
super().__init__(f'{self.msg}:\n{self.loc}') super().__init__(f'{self.msg}\n:{self.loc}')
class InvalidDataUnitError(ValueError, ComputeError): class InvalidDataUnitError(ValueError, ComputeError):

View File

@ -137,7 +137,7 @@ class CloudInit:
subprocess.run( subprocess.run(
['/usr/sbin/mkfs.vfat', '-n', 'CIDATA', '-C', str(disk), '1024'], ['/usr/sbin/mkfs.vfat', '-n', 'CIDATA', '-C', str(disk), '1024'],
check=True, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
self._write_to_disk( self._write_to_disk(
disk=disk, disk=disk,
@ -206,7 +206,6 @@ class CloudInit:
Attach cloud-init disk to instance. Attach cloud-init disk to instance.
:param disk: Path to disk. :param disk: Path to disk.
:param target: Disk target name e.g. `vda`.
:param instance: Compute instance object. :param instance: Compute instance object.
""" """
instance.attach_device( instance.attach_device(

View File

@ -92,8 +92,6 @@ class DiskConfig(DeviceConfig):
encoding='unicode', encoding='unicode',
pretty_print=True, pretty_print=True,
).strip() ).strip()
source = xml.find('source')
target = xml.find('target')
driver = xml.find('driver') driver = xml.find('driver')
cachetype = driver.get('cache') cachetype = driver.get('cache')
disk_params = { disk_params = {
@ -104,14 +102,14 @@ class DiskConfig(DeviceConfig):
type=driver.get('type'), type=driver.get('type'),
**({'cache': cachetype} if cachetype else {}), **({'cache': cachetype} if cachetype else {}),
), ),
'source': source.get('file') if source is not None else None, 'source': xml.find('source').get('file'),
'target': target.get('dev') if target is not None else None, 'target': xml.find('target').get('dev'),
'bus': target.get('bus') if target is not None else None, 'bus': xml.find('target').get('bus'),
'is_readonly': False if xml.find('readonly') is None else True, 'is_readonly': False if xml.find('readonly') is None else True,
} }
for param in disk_params: for param in disk_params:
if disk_params[param] is None: if disk_params[param] is None:
msg = f"missing tag '{param}'" msg = f"missing XML tag '{param}'"
raise InvalidDeviceConfigError(msg, xml_str) raise InvalidDeviceConfigError(msg, xml_str)
if param == 'driver': if param == 'driver':
driver = disk_params[param] driver = disk_params[param]

View File

@ -20,7 +20,6 @@ __all__ = ['Instance', 'InstanceConfig', 'InstanceInfo']
import logging import logging
import time import time
from typing import NamedTuple from typing import NamedTuple
from uuid import UUID
import libvirt import libvirt
from lxml import etree from lxml import etree
@ -67,7 +66,7 @@ class InstanceConfig(EntityConfig):
self.emulator = schema.emulator self.emulator = schema.emulator
self.arch = schema.arch self.arch = schema.arch
self.boot = schema.boot self.boot = schema.boot
self.network = schema.network self.network_interfaces = schema.network_interfaces
def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element: def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
options = { options = {
@ -120,7 +119,6 @@ class InstanceConfig(EntityConfig):
return E.interface( return E.interface(
E.source(network=interface.source), E.source(network=interface.source),
E.mac(address=interface.mac), E.mac(address=interface.mac),
E.model(type=interface.model),
type='network', type='network',
) )
@ -167,9 +165,8 @@ class InstanceConfig(EntityConfig):
) )
devices = E.devices() devices = E.devices()
devices.append(E.emulator(str(self.emulator))) devices.append(E.emulator(str(self.emulator)))
if self.network: for interface in self.network_interfaces:
for interface in self.network.interfaces: devices.append(self._gen_network_interface_xml(interface))
devices.append(self._gen_network_interface_xml(interface))
devices.append(E.graphics(type='vnc', autoport='yes')) devices.append(E.graphics(type='vnc', autoport='yes'))
devices.append(E.input(type='tablet', bus='usb')) devices.append(E.input(type='tablet', bus='usb'))
devices.append( devices.append(
@ -215,14 +212,18 @@ class Instance:
def __init__(self, domain: libvirt.virDomain): def __init__(self, domain: libvirt.virDomain):
""" """
Initialise Compute Instance object. Initialise Instance.
:ivar libvirt.virDomain domain: domain object
:ivar libvirt.virConnect connection: connection object
:ivar str name: domain name
:ivar GuestAgent guest_agent: :class:`GuestAgent` object
:param domain: libvirt domain object :param domain: libvirt domain object
""" """
self._domain = domain self._domain = domain
self._connection = domain.connect() self._connection = domain.connect()
self._name = domain.name() self._name = domain.name()
self._uuid = domain.UUID()
self._guest_agent = GuestAgent(domain) self._guest_agent = GuestAgent(domain)
@property @property
@ -240,11 +241,6 @@ class Instance:
"""Instance name.""" """Instance name."""
return self._name return self._name
@property
def uuid(self) -> UUID:
"""Instance UUID."""
return UUID(bytes=self._uuid)
@property @property
def guest_agent(self) -> GuestAgent: def guest_agent(self) -> GuestAgent:
""":class:`GuestAgent` object.""" """:class:`GuestAgent` object."""
@ -291,9 +287,10 @@ class Instance:
def is_running(self) -> bool: def is_running(self) -> bool:
"""Return True if instance is running, else return False.""" """Return True if instance is running, else return False."""
if self.domain.isActive() == 1: if self.domain.isActive() != 1:
return True # 0 - is inactive, -1 - is error
return False return False
return True
def is_autostart(self) -> bool: def is_autostart(self) -> bool:
"""Return True if instance autostart is enabled, else return False.""" """Return True if instance autostart is enabled, else return False."""
@ -321,7 +318,7 @@ class Instance:
log.info("Starting instance '%s'", self.name) log.info("Starting instance '%s'", self.name)
if self.is_running(): if self.is_running():
log.warning( log.warning(
"Instance '%s' is already started, nothing to do", self.name 'Already started, nothing to do instance=%s', self.name
) )
return return
try: try:
@ -350,8 +347,8 @@ class Instance:
to unplugging machine from power. Internally send SIGTERM to to unplugging machine from power. Internally send SIGTERM to
instance process and destroy it gracefully. instance process and destroy it gracefully.
DESTROY UNSAFE
Forced shutdown. Internally send SIGKILL to instance process. Force shutdown. Internally send SIGKILL to instance process.
There is high data corruption risk! There is high data corruption risk!
If method is None NORMAL method will used. If method is None NORMAL method will used.
@ -364,7 +361,7 @@ class Instance:
'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT, 'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT, 'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL, 'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
'DESTROY': libvirt.VIR_DOMAIN_DESTROY_DEFAULT, 'UNSAFE': libvirt.VIR_DOMAIN_DESTROY_DEFAULT,
} }
if method is None: if method is None:
method = 'NORMAL' method = 'NORMAL'
@ -375,13 +372,11 @@ class Instance:
method = method.upper() method = method.upper()
if method not in methods: if method not in methods:
raise ValueError(f"Unsupported shutdown method: '{method}'") raise ValueError(f"Unsupported shutdown method: '{method}'")
if method == 'SOFT' and self.guest_agent.is_available() is False:
method = 'NORMAL'
log.info("Performing instance shutdown with method '%s'", method) log.info("Performing instance shutdown with method '%s'", method)
try: try:
if method in ['SOFT', 'NORMAL']: if method in ['SOFT', 'NORMAL']:
self.domain.shutdownFlags(flags=methods[method]) self.domain.shutdownFlags(flags=methods[method])
elif method in ['HARD', 'DESTROY']: elif method in ['HARD', 'UNSAFE']:
self.domain.destroyFlags(flags=methods[method]) self.domain.destroyFlags(flags=methods[method])
except libvirt.libvirtError as e: except libvirt.libvirtError as e:
raise InstanceError( raise InstanceError(
@ -448,7 +443,8 @@ class Instance:
self.domain.setAutostart(autostart) self.domain.setAutostart(autostart)
except libvirt.libvirtError as e: except libvirt.libvirtError as e:
raise InstanceError( raise InstanceError(
f"Cannot set {autostart=} flag for instance '{self.name}': {e}" f'Cannot set autostart flag for instance={self.name} '
f'{autostart=}: {e}'
) from e ) from e
def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None: def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None:
@ -470,7 +466,7 @@ class Instance:
raise InstanceError('vCPUs count is greather than max_vcpus') raise InstanceError('vCPUs count is greather than max_vcpus')
if nvcpus == self.get_info().nproc: if nvcpus == self.get_info().nproc:
log.warning( log.warning(
"Instance '%s' already have %s vCPUs, nothing to do", 'Instance instance=%s already have %s vCPUs, nothing to do',
self.name, self.name,
nvcpus, nvcpus,
) )
@ -496,17 +492,18 @@ class Instance:
self.domain.setVcpusFlags(nvcpus, flags=flags) self.domain.setVcpusFlags(nvcpus, flags=flags)
except GuestAgentCommandNotSupportedError: except GuestAgentCommandNotSupportedError:
log.warning( log.warning(
"'guest-set-vcpus' command is not supported, '" 'Cannot set vCPUs in guest via agent, you may '
'you may need to enable CPUs in guest manually.' 'need to apply changes in guest manually.'
) )
else: else:
log.warning( log.warning(
'Guest agent is not installed or not connected, ' 'Cannot set vCPUs in guest OS on instance=%s. '
'you may need to enable CPUs in guest manually.' 'You may need to apply CPUs in guest manually.',
self.name,
) )
except libvirt.libvirtError as e: except libvirt.libvirtError as e:
raise InstanceError( raise InstanceError(
f"Cannot set vCPUs for instance '{self.name}': {e}" f'Cannot set vCPUs for instance={self.name}: {e}'
) from e ) from e
def set_memory(self, memory: int, *, live: bool = False) -> None: def set_memory(self, memory: int, *, live: bool = False) -> None:
@ -788,12 +785,6 @@ class Instance:
disk.source, disk.source,
) )
continue continue
if volume.storagePoolLookupByVolume().name() == 'images':
log.info(
'Volume %s skipped because it is from images pool',
volume.path(),
)
continue
log.info('Delete volume: %s', volume.path()) log.info('Delete volume: %s', volume.path())
volume.delete() volume.delete()
log.info('Undefine instance') log.info('Undefine instance')

View File

@ -16,11 +16,11 @@
"""Compute instance related objects schemas.""" """Compute instance related objects schemas."""
import re import re
from collections import Counter
from enum import StrEnum from enum import StrEnum
from pathlib import Path from pathlib import Path
from pydantic import validator from pydantic import ValidationError, validator
from pydantic.error_wrappers import ErrorWrapper
from compute.abstract import EntityModel from compute.abstract import EntityModel
from compute.utils.units import DataUnit from compute.utils.units import DataUnit
@ -74,30 +74,12 @@ class VolumeCapacitySchema(EntityModel):
unit: DataUnit unit: DataUnit
class DiskCache(StrEnum):
"""Possible disk cache mechanisms enumeration."""
NONE = 'none'
WRITETHROUGH = 'writethrough'
WRITEBACK = 'writeback'
DIRECTSYNC = 'directsync'
UNSAFE = 'unsafe'
class DiskDriverSchema(EntityModel): class DiskDriverSchema(EntityModel):
"""Virtual disk driver model.""" """Virtual disk driver model."""
name: str name: str
type: str # noqa: A003 type: str # noqa: A003
cache: DiskCache = DiskCache.WRITETHROUGH cache: str = 'writethrough'
class DiskBus(StrEnum):
"""Possible disk buses enumeration."""
VIRTIO = 'virtio'
IDE = 'ide'
SATA = 'sata'
class VolumeSchema(EntityModel): class VolumeSchema(EntityModel):
@ -110,30 +92,15 @@ class VolumeSchema(EntityModel):
source: str | None = None source: str | None = None
is_readonly: bool = False is_readonly: bool = False
is_system: bool = False is_system: bool = False
bus: DiskBus = DiskBus.VIRTIO bus: str = 'virtio'
device: str = 'disk' device: str = 'disk'
class NetworkAdapterModel(StrEnum):
"""Network adapter models."""
VIRTIO = 'virtio'
E1000 = 'e1000'
RTL8139 = 'rtl8139'
class NetworkInterfaceSchema(EntityModel): class NetworkInterfaceSchema(EntityModel):
"""Network inerface model.""" """Network inerface model."""
source: str source: str
mac: str mac: str
model: NetworkAdapterModel
class NetworkSchema(EntityModel):
"""Network configuration schema."""
interfaces: list[NetworkInterfaceSchema]
class BootOptionsSchema(EntityModel): class BootOptionsSchema(EntityModel):
@ -167,7 +134,7 @@ class InstanceSchema(EntityModel):
arch: str arch: str
boot: BootOptionsSchema boot: BootOptionsSchema
volumes: list[VolumeSchema] volumes: list[VolumeSchema]
network: NetworkSchema | None | bool network_interfaces: list[NetworkInterfaceSchema]
image: str | None = None image: str | None = None
cloud_init: CloudInitSchema | None = None cloud_init: CloudInitSchema | None = None
@ -175,7 +142,7 @@ class InstanceSchema(EntityModel):
def _check_name(cls, value: str) -> str: # noqa: N805 def _check_name(cls, value: str) -> str: # noqa: N805
if not re.match(r'^[a-z0-9_-]+$', value): if not re.match(r'^[a-z0-9_-]+$', value):
msg = ( msg = (
'Name must contain only lowercase letters, numbers, ' 'Name can contain only lowercase letters, numbers, '
'minus sign and underscore.' 'minus sign and underscore.'
) )
raise ValueError(msg) raise ValueError(msg)
@ -195,33 +162,27 @@ class InstanceSchema(EntityModel):
if len([v for v in volumes if v.is_system is True]) != 1: if len([v for v in volumes if v.is_system is True]) != 1:
msg = 'volumes list must contain one system volume' msg = 'volumes list must contain one system volume'
raise ValueError(msg) raise ValueError(msg)
index = 0 for vol in volumes:
for volume in volumes: if vol.source is None and vol.capacity is None:
index += 1 raise ValidationError(
if volume.source is None and volume.capacity is None: [
msg = f"{index}: capacity is required if 'source' is unset" ErrorWrapper(
raise ValueError(msg) Exception(
if volume.is_system is True and volume.is_readonly is True: "capacity is required if 'source' is unset"
),
loc='X.capacity',
)
],
model=VolumeSchema,
)
if vol.is_system is True and vol.is_readonly is True:
msg = 'volume marked as system cannot be readonly' msg = 'volume marked as system cannot be readonly'
raise ValueError(msg) raise ValueError(msg)
sources = [v.source for v in volumes if v.source is not None]
targets = [v.target for v in volumes]
for item in [sources, targets]:
duplicates = Counter(item) - Counter(set(item))
if duplicates:
msg = f'find duplicate values: {list(duplicates)}'
raise ValueError(msg)
return volumes return volumes
@validator('network') @validator('network_interfaces')
def _check_network( def _check_network_interfaces(cls, value: list) -> list: # noqa: N805
cls, # noqa: N805 if not value:
network: NetworkSchema | None | bool, msg = 'Network interfaces list must contain at least one element'
) -> NetworkSchema | None | bool:
if network is True:
msg = (
"'network' cannot be True, set it to False "
'or provide network configuration'
)
raise ValueError(msg) raise ValueError(msg)
return network return value

View File

@ -17,7 +17,6 @@
import logging import logging
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from pathlib import Path
from types import TracebackType from types import TracebackType
from typing import Any, NamedTuple from typing import Any, NamedTuple
from uuid import uuid4 from uuid import uuid4
@ -208,12 +207,9 @@ class Session(AbstractContextManager):
:param volumes: List of storage volume configs. For more info :param volumes: List of storage volume configs. For more info
see :class:`VolumeSchema`. see :class:`VolumeSchema`.
:type volumes: list[dict] :type volumes: list[dict]
:param network: List of virtual network interfaces configs. :param network_interfaces: List of virtual network interfaces
See :class:`NetworkSchema` for more info. configs. See :class:`NetworkInterfaceSchema` for more info.
:type network_interfaces: list[dict] :type network_interfaces: list[dict]
:param cloud_init: Cloud-init configuration. See
:class:`CloudInitSchema` for info.
:type cloud_init: dict
""" """
data = InstanceSchema(**kwargs) data = InstanceSchema(**kwargs)
config = InstanceConfig(data) config = InstanceConfig(data)
@ -246,17 +242,11 @@ class Session(AbstractContextManager):
log.info('Volume %s is CDROM device', volume_name) log.info('Volume %s is CDROM device', volume_name)
elif volume.source is not None: elif volume.source is not None:
log.info('Using volume %s as source', volume_name) log.info('Using volume %s as source', volume_name)
volume_source = volume.source
if volume.capacity: if volume.capacity:
capacity = units.to_bytes( capacity = units.to_bytes(
volume.capacity.value, volume.capacity.unit volume.capacity.value, volume.capacity.unit
) )
log.info('Getting volume %s', volume.source)
vol = volumes_pool.get_volume(Path(volume_name).name)
log.info(
'Resize volume to specified size: %s',
capacity,
)
vol.resize(capacity, unit=units.DataUnit.BYTES)
else: else:
capacity = units.to_bytes( capacity = units.to_bytes(
volume.capacity.value, volume.capacity.unit volume.capacity.value, volume.capacity.unit
@ -266,7 +256,7 @@ class Session(AbstractContextManager):
path=str(volumes_pool.path.joinpath(volume_name)), path=str(volumes_pool.path.joinpath(volume_name)),
capacity=capacity, capacity=capacity,
) )
volume.source = volume_config.path volume_source = volume_config.path
log.debug('Volume config: %s', volume_config) log.debug('Volume config: %s', volume_config)
if volume.is_system is True and data.image: if volume.is_system is True and data.image:
log.info( log.info(
@ -276,20 +266,21 @@ class Session(AbstractContextManager):
image = images_pool.get_volume(data.image) image = images_pool.get_volume(data.image)
log.info('Cloning image into volumes pool...') log.info('Cloning image into volumes pool...')
vol = volumes_pool.clone_volume(image, volume_config) vol = volumes_pool.clone_volume(image, volume_config)
log.info(
'Resize cloned volume to specified size: %s',
capacity,
)
vol.resize(capacity, unit=units.DataUnit.BYTES)
else: else:
log.info('Create volume %s', volume_config.name) log.info('Create volume %s', volume_config.name)
volumes_pool.create_volume(volume_config) volumes_pool.create_volume(volume_config)
if capacity is not None:
log.info(
'Resize cloned volume to specified size: %s',
capacity,
)
vol.resize(capacity, unit=units.DataUnit.BYTES)
log.info('Attaching volume to instance...') log.info('Attaching volume to instance...')
instance.attach_device( instance.attach_device(
DiskConfig( DiskConfig(
type=volume.type, type=volume.type,
device=volume.device, device=volume.device,
source=volume.source, source=volume_source,
target=volume.target, target=volume.target,
is_readonly=volume.is_readonly, is_readonly=volume.is_readonly,
bus=volume.bus, bus=volume.bus,

View File

@ -73,9 +73,9 @@ class StoragePool:
""" """
Refresh storage pool. Refresh storage pool.
:param retry: If True retry pool refresh on 'pool have running :param retry: If True retry pool refresh on :class:`libvirtError`
asynchronous jobs' error. with running asynchronous jobs.
:param timeout: Retry timeout in seconds. Affects only if `retry` :param timeout: Retry timeout in secodns. Affets only if `retry`
is True. is True.
""" """
retry_timeout = dt.now(tz=datetime.UTC) + timedelta(seconds=timeout) retry_timeout = dt.now(tz=datetime.UTC) + timedelta(seconds=timeout)
@ -119,7 +119,7 @@ class StoragePool:
'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s', 'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s',
src.pool_name, src.pool_name,
src.name, src.name,
self.pool.name(), self.pool.name,
dst.name, dst.name,
) )
vol = self.pool.createXMLFrom( vol = self.pool.createXMLFrom(
@ -134,9 +134,7 @@ class StoragePool:
def get_volume(self, name: str) -> Volume | None: def get_volume(self, name: str) -> Volume | None:
"""Lookup and return Volume instance or None.""" """Lookup and return Volume instance or None."""
log.info( log.info(
'Lookup for storage volume vol=%s in pool=%s', 'Lookup for storage volume vol=%s in pool=%s', name, self.pool.name
name,
self.pool.name(),
) )
try: try:
vol = self.pool.storageVolLookupByName(name) vol = self.pool.storageVolLookupByName(name)

View File

@ -15,19 +15,19 @@
"""Random identificators.""" """Random identificators."""
# ruff: noqa: S311 # ruff: noqa: S311, C417
import random import random
def random_mac() -> str: def random_mac() -> str:
"""Retrun random MAC address.""" """Retrun random MAC address."""
bits = [ mac = [
0x0A, 0x00,
random.randint(0x00, 0xFF), 0x16,
random.randint(0x00, 0xFF), 0x3E,
random.randint(0x00, 0xFF), random.randint(0x00, 0x7F),
random.randint(0x00, 0xFF), random.randint(0x00, 0xFF),
random.randint(0x00, 0xFF), random.randint(0x00, 0xFF),
] ]
return ':'.join([f'{b:02x}' for b in bits]) return ':'.join(map(lambda x: '%02x' % x, mac))

View File

@ -15,7 +15,6 @@
"""Tools for data units convertion.""" """Tools for data units convertion."""
from collections.abc import Callable
from enum import StrEnum from enum import StrEnum
from compute.exceptions import InvalidDataUnitError from compute.exceptions import InvalidDataUnitError
@ -29,14 +28,6 @@ class DataUnit(StrEnum):
MIB = 'MiB' MIB = 'MiB'
GIB = 'GiB' GIB = 'GiB'
TIB = 'TiB' TIB = 'TiB'
KB = 'kb'
MB = 'Mb'
GB = 'Gb'
TB = 'Tb'
KBIT = 'kbit'
MBIT = 'Mbit'
GBIT = 'Gbit'
TBIT = 'Tbit'
@classmethod @classmethod
def _missing_(cls, name: str) -> 'DataUnit': def _missing_(cls, name: str) -> 'DataUnit':
@ -46,74 +37,17 @@ class DataUnit(StrEnum):
return None return None
def validate_input(*args: str) -> Callable: def to_bytes(value: int, unit: DataUnit = DataUnit.BYTES) -> int:
"""Validate data units in functions input.""" """Convert value to bytes. See :class:`DataUnit`."""
to_validate = args try:
_ = DataUnit(unit)
def decorator(func: Callable) -> Callable: except ValueError as e:
def wrapper(*args: float | str, **kwargs: str) -> Callable: raise InvalidDataUnitError(e, list(DataUnit)) from e
try: powers = {
if kwargs:
for arg in to_validate:
unit = kwargs[arg]
DataUnit(unit)
else:
for arg in args[1:]:
unit = arg
DataUnit(unit)
except ValueError as e:
raise InvalidDataUnitError(e, list(DataUnit)) from e
return func(*args, **kwargs)
return wrapper
return decorator
@validate_input('unit')
def to_bytes(value: float, unit: DataUnit = DataUnit.BYTES) -> float:
"""Convert value to bytes."""
unit = DataUnit(unit)
basis = 2 if unit.endswith('iB') else 10
factor = 125 if unit.endswith('bit') else 1
power = {
DataUnit.BYTES: 0, DataUnit.BYTES: 0,
DataUnit.KIB: 10, DataUnit.KIB: 1,
DataUnit.MIB: 20, DataUnit.MIB: 2,
DataUnit.GIB: 30, DataUnit.GIB: 3,
DataUnit.TIB: 40, DataUnit.TIB: 4,
DataUnit.KB: 3,
DataUnit.MB: 6,
DataUnit.GB: 9,
DataUnit.TB: 12,
DataUnit.KBIT: 0,
DataUnit.MBIT: 3,
DataUnit.GBIT: 6,
DataUnit.TBIT: 9,
} }
return value * factor * pow(basis, power[unit]) return value * pow(1024, powers[unit])
@validate_input('from_unit', 'to_unit')
def convert(value: float, from_unit: DataUnit, to_unit: DataUnit) -> float:
"""Convert units."""
value_in_bits = to_bytes(value, from_unit) * 8
to_unit = DataUnit(to_unit)
basis = 2 if to_unit.endswith('iB') else 10
divisor = 1 if to_unit.endswith('bit') else 8
power = {
DataUnit.BYTES: 0,
DataUnit.KIB: 10,
DataUnit.MIB: 20,
DataUnit.GIB: 30,
DataUnit.TIB: 40,
DataUnit.KB: 3,
DataUnit.MB: 6,
DataUnit.GB: 9,
DataUnit.TB: 12,
DataUnit.KBIT: 3,
DataUnit.MBIT: 6,
DataUnit.GBIT: 9,
DataUnit.TBIT: 12,
}
return value_in_bits / divisor / pow(basis, power[to_unit])

View File

@ -27,6 +27,9 @@ Cloud-init configs may be set inplace into :file:`instance.yaml`.
user_data: | user_data: |
## template: jinja ## template: jinja
#cloud-config #cloud-config
merge_how:
- name: list
settings: [append]
hostname: {{ ds.meta_data.hostname }} hostname: {{ ds.meta_data.hostname }}
fqdn: {{ ds.meta_data.hostname }}.instances.generic.cloud fqdn: {{ ds.meta_data.hostname }}.instances.generic.cloud
manage_etc_hosts: true manage_etc_hosts: true

View File

@ -6,7 +6,7 @@ sys.path.insert(0, os.path.abspath('../..'))
project = 'Compute' project = 'Compute'
copyright = '2023, Compute Authors' copyright = '2023, Compute Authors'
author = 'Compute Authors' author = 'Compute Authors'
release = '0.1.0-dev5' release = '0.1.0-dev3'
# Sphinx general settings # Sphinx general settings
extensions = [ extensions = [

View File

@ -127,11 +127,9 @@ boot:
- hd - hd
# Network configuration. This decision is temporary and will be changed in # Network configuration. This decision is temporary and will be changed in
# the future. We recommend not using this option. # the future. We recommend not using this option.
network: network_interfaces:
interfaces: - mac: 00:16:3e:7e:8c:4a
- mac: 00:16:3e:7e:8c:4a source: default
source: default
model: virtio
# Disk image # Disk image
image: /images/debian-12-generic-amd64.qcow2 image: /images/debian-12-generic-amd64.qcow2
# Storage volumes list # Storage volumes list

24
packaging/Makefile Normal file
View File

@ -0,0 +1,24 @@
DOCKER_CMD ?= docker
DOCKER_IMG = pybuilder:bookworm
DEBBUILDDIR = build
all: docker-build build
clean:
test -d $(DEBBUILDDIR) && rm -rf $(DEBBUILDDIR) || true
docker-build:
$(DOCKER_CMD) build -f Dockerfile -t $(DOCKER_IMG) .
build: clean
mkdir -p $(DEBBUILDDIR)
cp -v ../dist/compute-*[.tar.gz] $(DEBBUILDDIR)/
cp -r ../docs $(DEBBUILDDIR)/
if [ -f build.sh.bak ]; then mv build.sh{.bak,}; fi
cp build.sh{,.bak}
awk '/authors/{gsub(/[\[\]]/,"");print $$3" "$$4}' ../pyproject.toml \
| sed "s/['<>]//g" \
| tr ' ' '\n' \
| xargs -I {} sed "0,/%placeholder%/s//{}/" -i build.sh
$(DOCKER_CMD) run --rm -i -v $$PWD:/mnt $(DOCKER_IMG) bash < build.sh
mv build.sh{.bak,}

View File

@ -1,10 +0,0 @@
FROM archlinux:latest
WORKDIR /mnt
RUN chown 1000:1000 /mnt; \
pacman -Sy --noconfirm \
fakeroot \
binutils \
python \
python-pip; \
echo "alias ll='ls -alFh'" >> /etc/bash.bashrc
USER 1000:1000

View File

@ -1,22 +0,0 @@
DOCKER_CMD ?= docker
DOCKER_IMG = computebuilder:archlinux
BUILDDIR = build
all: docker-build build
clean:
test -d $(BUILDDIR) && rm -rf $(BUILDDIR) || true
docker-build:
$(DOCKER_CMD) build -f Dockerfile -t $(DOCKER_IMG) .
build: clean
mkdir -p $(BUILDDIR)
VERSION=$$(awk '/^version/{print $$3}' ../../pyproject.toml | sed s'/-/\./'); \
sed "s/pkgver=.*/pkgver=$$VERSION/" PKGBUILD > $(BUILDDIR)/PKGBUILD
cp -v ../../dist/compute-*[.tar.gz] $(BUILDDIR)/
cp ../../extra/completion.bash $(BUILDDIR)/
$(DOCKER_CMD) run --rm -i -v $$PWD/$(BUILDDIR):/mnt --ulimit "nofile=1024:1048576" \
$(DOCKER_IMG) makepkg --nodeps --clean
# Remove unwanted files from build dir
find $(BUILDDIR) ! -name '*.pkg.tar.zst' -type f -exec rm -f {} +

View File

@ -1,21 +0,0 @@
pkgname=compute
pkgver='%placeholder%'
pkgrel=1
pkgdesc='Compute instances management library'
arch=(any)
url=https://get.lulzette.ru/hstack/compute
license=('GPL-3-or-later')
makedepends=(python python-pip)
depends=(python libvirt libvirt-python qemu-base qemu-system-x86 qemu-img)
optdepends=(
'dnsmasq: required for default NAT/DHCP'
'iptables-nft: required for default NAT'
)
provides=(compute)
conflicts=()
package() {
pip install --no-cache-dir --no-deps --root $pkgdir ../$pkgname-*.tar.gz
install -Dm644 ../completion.bash $pkgdir/usr/share/bash-completion/completions/compute
install -Dm644 $pkgdir/usr/lib/*/site-packages/computed.toml $pkgdir/etc/compute/computed.toml
}

View File

@ -11,6 +11,5 @@ sed -e "s%\.\./\.\.%$PWD%" -i ../docs/source/conf.py
dh_make --copyright gpl3 --yes --python --file ../compute-*[.tar.gz] dh_make --copyright gpl3 --yes --python --file ../compute-*[.tar.gz]
rm debian/*.ex debian/README.{Debian,source} debian/*.docs rm debian/*.ex debian/README.{Debian,source} debian/*.docs
sed -e 's/\* Initial release.*/\* This is the development build, see commits in upstream repo for info./' -i debian/changelog sed -e 's/\* Initial release.*/\* This is the development build, see commits in upstream repo for info./' -i debian/changelog
cp -v ../../files/{control,rules,copyright,docs,install} debian/ cp -v ../../files/{control,rules,copyright,docs,compute.bash-completion,install} debian/
mv ../compute.bash-completion debian/
dpkg-buildpackage -us -uc dpkg-buildpackage -us -uc

View File

@ -1,29 +0,0 @@
DOCKER_CMD ?= docker
DOCKER_IMG = computebuilder:debian-bookworm
BUILDDIR = build
KEEP_BUILDFILES ?=
all: docker-build build
clean:
test -d $(BUILDDIR) && rm -rf $(BUILDDIR) || true
docker-build:
$(DOCKER_CMD) build -f Dockerfile -t $(DOCKER_IMG) .
build: clean
mkdir -p $(BUILDDIR)
cp -v ../../dist/compute-*[.tar.gz] $(BUILDDIR)/
cp -r ../../docs $(BUILDDIR)/
cp ../../extra/completion.bash $(BUILDDIR)/compute.bash-completion
if [ -f build.sh.bak ]; then mv build.sh.bak build.sh; fi
cp build.sh{,.bak}
awk '/authors/{gsub(/[\[\]]/,"");print $$3" "$$4}' ../pyproject.toml \
| sed "s/['<>]//g" \
| tr ' ' '\n' \
| xargs -I {} sed "0,/%placeholder%/s//{}/" -i build.sh
$(DOCKER_CMD) run --rm -i -v $$PWD:/mnt $(DOCKER_IMG) bash < build.sh
mv build.sh{.bak,}
# Remove unwanted files from build dir
find $(BUILDDIR) -mindepth 1 -type d -exec rm -rf {} +
[ -z $(KEEP_BUILDFILES) ] && find $(BUILDDIR) ! -name '*.deb' -type f -exec rm -f {} + || true

View File

@ -28,19 +28,16 @@ Depends:
${misc:Depends}, ${misc:Depends},
qemu-system, qemu-system,
qemu-utils, qemu-utils,
libvirt-daemon,
libvirt-daemon-system, libvirt-daemon-system,
libvirt-daemon-driver-qemu,
libvirt-clients, libvirt-clients,
python3-libvirt, python3-libvirt,
python3-lxml, python3-lxml,
python3-yaml, python3-yaml,
python3-pydantic, python3-pydantic,
mtools, mtools,
dosfstools, dosfstools
Recommends: Recommends:
dnsmasq, dnsmasq
dnsmasq-base
Suggests: Suggests:
compute-doc compute-doc
Description: Compute instances management library (Python 3) Description: Compute instances management library (Python 3)

View File

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = 'compute' name = 'compute'
version = '0.1.0-dev5' version = '0.1.0-dev3'
description = 'Compute instances management library' description = 'Compute instances management library'
license = 'GPL-3.0-or-later' license = 'GPL-3.0-or-later'
authors = ['ge <ge@nixhacks.net>'] authors = ['ge <ge@nixhacks.net>']