feat: Add storage management for VM creation and resizing

- Introduced storage parameter in VmCreateRequest and VmResizeRequest models.
- Implemented storage validation to ensure minimum and maximum limits.
- Enhanced CLI to support storage options during VM creation and resizing.
- Updated lifecycle management to handle pending resize operations for VM resources.
- Added methods for resizing VM disks and validating storage capacity.
- Updated API endpoints to accommodate new storage functionality.
- Refactored related validation and configuration logic to support storage management.
This commit is contained in:
2026-09-21 19:16:33 +05:30
parent 781ee753ec
commit fb80ce7c8a
38 changed files with 728 additions and 98 deletions
+82 -23
View File
@@ -11,8 +11,8 @@ from dataclasses import dataclass, replace
from ipaddress import IPv4Network
from pathlib import Path
from .config import Settings
from .domain import PortRule, VmRecord, VmSpec, new_vm_id
from .config import MIN_STORAGE_MIB, Settings
from .domain import PortRule, VmRecord, VmResources, VmSpec, new_vm_id
from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config
@@ -22,7 +22,7 @@ from .integrity import load_manifest, verify_file
from .network import NetworkManager
from .state import StateStore
from .system import check_kvm, ensure_data_directories, require_root
from .validation import parse_cpu, parse_name, parse_password, parse_ports, parse_username
from .validation import parse_name, parse_password, parse_ports, parse_timeout, parse_username
@dataclass(frozen=True, slots=True)
@@ -68,9 +68,7 @@ class LifecycleService:
def create(self, spec: VmSpec) -> VmRecord:
require_root()
parse_cpu(str(spec.cpu))
if type(spec.ram_mib) is not int or spec.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
VmResources(spec.cpu, spec.ram_mib, spec.storage_mib)
parse_username(spec.username)
parse_password(spec.password)
if spec.name is not None:
@@ -86,7 +84,7 @@ class LifecycleService:
runtime.mkdir(mode=0o700, exist_ok=False)
vm.runtime_owned = True
self._save(vm)
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk))
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
self._images.provision_credentials(disk, vm.username, vm.password)
vm.disk_ready = True
self._save(vm)
@@ -164,7 +162,7 @@ class LifecycleService:
with self._state_store.operation_lock():
return self._start_locked(self._load_for_operation(identifier))
def _start_locked(self, vm: VmRecord) -> VmRecord:
def _start_locked(self, vm: VmRecord, *, kernel: Path | None = None) -> VmRecord:
self._not_deleting(vm)
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
@@ -172,22 +170,18 @@ class LifecycleService:
if observation == "unknown":
raise ConflictError(f"cannot start {vm.id}: process identity is unknown; inspect before retrying")
if observation == "alive":
if vm.pending_resize is not None:
raise ConflictError(f"resize is pending for {vm.id}; retry resize or stop it before starting")
if vm.status != "running" or vm.operation not in {None, "policy"}:
raise ConflictError(f"{vm.id} has an interrupted boot/stop; stop it before starting")
return self._sync_ports_locked(vm)
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
self._check_boot()
kernel = self._images.installed_kernel()
self._validate_resume(vm)
if kernel is None:
self._check_boot()
kernel = self._images.installed_kernel()
try:
self._cleanup_runtime(vm)
self._apply_pending_resize(vm)
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
self._boot(vm, kernel)
return vm
@@ -209,12 +203,13 @@ class LifecycleService:
def _stop_locked(self, vm: VmRecord, *, timeout: float | None = None) -> VmRecord:
deleting = vm.operation == "delete" or vm.status == "terminating"
vm.status, vm.operation = "stopping", "delete" if deleting else "stop"
pending = "delete" if deleting else "resize" if vm.pending_resize is not None else None
vm.status, vm.operation = "stopping", pending or "stop"
self._save(vm)
try:
self._cleanup_runtime(vm, timeout=timeout)
vm.status = "stopped"
vm.operation = "delete" if deleting else None
vm.operation = pending
vm.last_error = None
self._save(vm)
return vm
@@ -232,6 +227,69 @@ class LifecycleService:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm)
def resize(
self, identifier: str, *, cpu: float | None = None, ram_mib: int | None = None,
storage_mib: int | None = None, timeout: float | None = None,
) -> VmRecord:
"""Stop, apply resource changes to the retained disk, then boot under one lock."""
require_root()
if cpu is None and ram_mib is None and storage_mib is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
VmResources(cpu if cpu is not None else 1, ram_mib if ram_mib is not None else 128,
storage_mib if storage_mib is not None else MIN_STORAGE_MIB)
if timeout is not None:
timeout = parse_timeout(str(timeout))
with self._state_store.operation_lock():
vm = self._load_for_operation(identifier)
self._not_deleting(vm)
self._validate_resume(vm)
if self._process.observe(vm) == "unknown":
raise ConflictError(f"cannot resize {vm.id}: process identity is unknown")
# Verify boot prerequisites before stopping an otherwise healthy VM.
self._check_boot()
kernel = self._images.installed_kernel()
previous = vm.pending_resize
capacity = previous.storage_mib if previous is not None else vm.storage_mib
if capacity is None:
capacity = max(MIN_STORAGE_MIB, (Path(vm.disk).stat().st_size + 1024 * 1024 - 1) // (1024 * 1024))
vm.pending_resize = VmResources(
cpu=cpu if cpu is not None else previous.cpu if previous is not None else vm.cpu,
ram_mib=ram_mib if ram_mib is not None else previous.ram_mib if previous is not None else vm.ram_mib,
storage_mib=storage_mib if storage_mib is not None else capacity,
)
vm.operation = "resize"
self._save(vm)
try:
self._stop_locked(vm, timeout=timeout)
return self._start_locked(vm, kernel=kernel)
except UvmError as error:
raise UvmError(f"failed to resize {vm.id}: {error}") from error
def _validate_resume(self, vm: VmRecord) -> None:
if vm.ports is None:
raise ConflictError("legacy VM has no port policy; stop it, use ports set, then start")
if not vm.disk_ready:
raise ConflictError(f"{vm.id} has no completely provisioned private disk")
disk = Path(vm.disk)
if not vm.disk or not disk.is_file() or disk.is_symlink() or disk.stat().st_nlink != 1:
raise UvmError(f"private VM disk is missing or unsafe: {disk}")
if (vm.gateway != str(self._settings.gateway)
or vm.network not in {None, str(self._settings.network)}
or vm.bridge not in {None, self._settings.bridge}):
raise ConflictError("configured network differs from the VM's retained network")
def _apply_pending_resize(self, vm: VmRecord) -> None:
target = vm.pending_resize
if target is None:
return
if self._process.observe(vm) != "gone":
raise ConflictError("cannot resize a disk without confirmed process exit")
if target.storage_mib != vm.storage_mib:
self._images.resize_vm_disk(Path(vm.disk), target.storage_mib)
vm.cpu, vm.ram_mib, vm.storage_mib = target.cpu, target.ram_mib, target.storage_mib
vm.pending_resize = None
self._save(vm)
def destroy(self, identifier: str, *, timeout: float | None = None) -> VmRecord:
require_root()
with self._state_store.operation_lock():
@@ -472,7 +530,8 @@ class LifecycleService:
results.append({"name": name, "ok": True, "message": "available"})
except (UvmError, OSError) as error:
results.append({"name": name, "ok": False, "message": str(error)})
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "openssl", "ssh-keygen", "ssh"):
for command in ("ip", "iptables", "nft", "conntrack", "debugfs", "e2fsck", "resize2fs", "dumpe2fs",
"cp", "openssl", "ssh-keygen", "ssh"):
path = shutil.which(command)
results.append({"name": command, "ok": path is not None, "message": path or "not installed"})
return results
@@ -529,7 +588,7 @@ class LifecycleService:
state.next_mac_index += 1
directory = self._settings.vm_dir(vm_id)
vm = VmRecord(
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib,
id=vm_id, name=spec.name, cpu=spec.cpu, ram_mib=spec.ram_mib, storage_mib=spec.storage_mib,
guest_ip=str(guest_ip), gateway=str(self._settings.gateway),
network=str(self._settings.network), bridge=self._settings.bridge,
tap=self._network.tap_name(vm_id), mac=mac,