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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+19
View File
@@ -40,6 +40,7 @@ class VmCreateRequest(BaseModel):
cpu: float = Field(default=1, gt=0)
ram: str | int | float = "512"
storage: str | int | float = Field(default="30G", description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
guest_ip: IPv4Address | None = None
username: str = "root"
password: SecretStr = SecretStr("root")
@@ -48,11 +49,27 @@ class VmCreateRequest(BaseModel):
no_default_ports: bool = Field(default=False, strict=True)
class VmResizeRequest(BaseModel):
model_config = ConfigDict(extra="forbid")
cpu: float | None = Field(default=None, gt=0, allow_inf_nan=False, strict=True)
ram: str | int | float | None = None
storage: str | int | float | None = Field(default=None, description="Disk capacity, minimum 30 GiB; bare numbers use GiB")
class VmResourcesModel(BaseModel):
cpu: float
ram_mib: int
storage_mib: int
class VmResponse(BaseModel):
id: str
name: str | None
cpu: float
ram_mib: int
storage_mib: int | None
pending_resize: VmResourcesModel | None
guest_ip: str
gateway: str
mac: str
@@ -124,6 +141,8 @@ def vm_response(
name=vm.name,
cpu=vm.cpu,
ram_mib=vm.ram_mib,
storage_mib=vm.storage_mib,
pending_resize=VmResourcesModel(**vm.pending_resize.to_dict()) if vm.pending_resize is not None else None,
guest_ip=vm.guest_ip,
gateway=vm.gateway,
mac=vm.mac,
+40 -2
View File
@@ -22,6 +22,7 @@ from .validation import (
parse_password,
parse_ports,
parse_ram,
parse_storage,
parse_timeout,
parse_username,
)
@@ -97,6 +98,10 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
default=str(application.settings.default_ram_mib),
help="RAM, e.g. 512, 1G, 512M",
)
create.add_argument(
"--storage", default=f"{application.settings.default_storage_mib}M",
help="private disk capacity, e.g. 30G, 50G, 1T; minimum 30 GiB (bare numbers use GiB)",
)
create.add_argument(
"--guest-ip",
"--host-ip",
@@ -130,6 +135,14 @@ def build_parser(application: Application) -> argparse.ArgumentParser:
)
create.add_argument("--json", action="store_true")
resize = subcommands.add_parser("resize", help="stop, resize CPU/RAM/storage, and boot the retained VM")
resize.add_argument("vm", help="VM ID, name, or guest IP")
resize.add_argument("--cpu", help="new CPU capacity; omitted values are retained")
resize.add_argument("--ram", help="new RAM, e.g. 512M or 4G")
resize.add_argument("--storage", help="new private disk capacity, e.g. 30G or 50G; minimum 30 GiB")
resize.add_argument("--timeout", help="seconds to wait after SIGTERM before SIGKILL (normally 5s)")
resize.add_argument("--json", action="store_true")
listing = subcommands.add_parser("list", help="list VMs")
listing.add_argument("--status", action="append", default=[], help="observed status (repeatable)")
output = listing.add_mutually_exclusive_group()
@@ -293,6 +306,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
VmSpec(
cpu=parse_cpu(args.cpu),
ram_mib=parse_ram(args.ram),
storage_mib=parse_storage(args.storage),
guest_ip=guest_ip,
username=parse_username(args.username),
password=parse_password(password),
@@ -321,6 +335,7 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" Name: {vm.name}")
print(f" IP: {vm.guest_ip}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" CPU: {vm.cpu}")
print(f" TAP: {vm.tap}")
print(f" Username: {vm.username}")
@@ -329,6 +344,27 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
print(f" SSH: ssh {vm.username}@{vm.guest_ip}")
return 0
if args.command == "resize":
if args.cpu is None and args.ram is None and args.storage is None:
raise ValidationError("resize requires at least one of --cpu, --ram, or --storage")
vm = application.lifecycle.resize(
args.vm,
cpu=parse_cpu(args.cpu) if args.cpu is not None else None,
ram_mib=parse_ram(args.ram) if args.ram is not None else None,
storage_mib=parse_storage(args.storage) if args.storage is not None else None,
timeout=parse_timeout(args.timeout) if args.timeout is not None else None,
)
if args.json:
_print_json(_vm_output(vm, policy_status="enforced"))
else:
print(f"VM resized and started: {vm.id}")
print(f" CPU: {vm.cpu}")
print(f" RAM: {vm.ram_mib} MiB")
print(f" Storage: {vm.storage_mib} MiB")
print(f" IP: {vm.guest_ip}")
print(f" Username: {vm.username}")
return 0
if args.command == "list":
vms = [
listed for listed in application.lifecycle.list_vms()
@@ -349,14 +385,14 @@ def _run_command(application: Application, args: argparse.Namespace) -> int:
return 0
print(
f"{'ID':<37} {'NAME':<20} {'IP':<16} {'USER':<16} {'CPU':<7} "
f"{'RAM':<8} {'STATUS':<12} {'POLICY':<12} PID"
f"{'RAM':<8} {'DISK(MiB)':<12} {'STATUS':<12} {'POLICY':<12} PID"
)
for listed in vms:
vm = listed.vm
pid = vm.pid if vm.pid is not None else ""
print(
f"{vm.id:<37} {vm.name or '-':<20} {vm.guest_ip:<16} {vm.username:<16} {vm.cpu:<7} "
f"{vm.ram_mib:<8} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
f"{vm.ram_mib:<8} {vm.storage_mib or '-':<12} {listed.observed_status:<12} {listed.policy_status:<12} {pid}"
)
return 0
@@ -543,6 +579,8 @@ def _vm_output(
"name": vm.name,
"cpu": vm.cpu,
"ram_mib": vm.ram_mib,
"storage_mib": vm.storage_mib,
"pending_resize": vm.pending_resize.to_dict() if vm.pending_resize is not None else None,
"guest_ip": vm.guest_ip,
"gateway": vm.gateway,
"tap": vm.tap,
+23 -13
View File
@@ -3,21 +3,16 @@
from __future__ import annotations
import os
from dataclasses import dataclass
from dataclasses import dataclass, field
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from .errors import ConfigurationError
DEFAULT_KERNEL_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/kernels/vmlinux.bin"
)
DEFAULT_ROOTFS_URL = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
"x86_64/rootfs/bionic.rootfs.ext4"
)
DEFAULT_GUEST_ASSETS_URL = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide"
MIN_STORAGE_MIB = 30 * 1024
MAX_STORAGE_MIB = (2**63 - 1) // (1024 * 1024)
@dataclass(frozen=True, slots=True)
@@ -29,12 +24,14 @@ class Settings:
network: IPv4Network = IPv4Network("10.42.0.0/24")
gateway: IPv4Address = IPv4Address("10.42.0.1")
bridge: str = "uvm0"
architecture: str = field(default_factory=lambda: os.uname().machine)
firecracker_version: str = "v1.16.1"
default_ram_mib: int = 512
default_storage_mib: int = MIN_STORAGE_MIB
default_vcpu: float = 1
default_ssh_user: str = "root"
kernel_url: str = DEFAULT_KERNEL_URL
rootfs_url: str = DEFAULT_ROOTFS_URL
kernel_url: str = ""
rootfs_url: str = ""
firecracker_sha256: str | None = None
firecracker_binary_sha256: str | None = None
kernel_sha256: str | None = None
@@ -48,6 +45,19 @@ class Settings:
terminate_timeout_s: float = 5.0
def __post_init__(self) -> None:
architecture = self.architecture.lower()
architecture = {"amd64": "x86_64", "arm64": "aarch64"}.get(architecture, architecture)
if architecture not in ("x86_64", "aarch64"):
raise ConfigurationError(f"unsupported host architecture: {self.architecture}")
object.__setattr__(self, "architecture", architecture)
# Resolve defaults at construction time, while preserving individual URL overrides.
asset_base = f"{DEFAULT_GUEST_ASSETS_URL}/{architecture}"
if not self.kernel_url:
object.__setattr__(self, "kernel_url", f"{asset_base}/kernels/vmlinux.bin")
if not self.rootfs_url:
object.__setattr__(self, "rootfs_url", f"{asset_base}/rootfs/bionic.rootfs.ext4")
if self.gateway not in self.network:
raise ConfigurationError(
f"gateway {self.gateway} is outside configured network {self.network}"
@@ -124,8 +134,8 @@ class Settings:
network=network,
gateway=gateway,
bridge=os.environ.get("UVM_BRIDGE", "uvm0"),
kernel_url=os.environ.get("UVM_KERNEL_URL", DEFAULT_KERNEL_URL),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", DEFAULT_ROOTFS_URL),
kernel_url=os.environ.get("UVM_KERNEL_URL", ""),
rootfs_url=os.environ.get("UVM_ROOTFS_URL", ""),
firecracker_sha256=_optional_environment_value("UVM_FIRECRACKER_SHA256"),
firecracker_binary_sha256=_optional_environment_value(
"UVM_FIRECRACKER_BINARY_SHA256"
+49 -1
View File
@@ -2,16 +2,18 @@
from __future__ import annotations
import math
import time
import uuid
from dataclasses import dataclass, field
from ipaddress import IPv4Address
from typing import Any
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import StateError, ValidationError
STATE_SCHEMA_VERSION = 2
STATE_SCHEMA_VERSION = 3
@dataclass(frozen=True, slots=True)
@@ -52,6 +54,40 @@ class VmSpec:
password: str = field(default="root", repr=False)
name: str | None = None
ports: tuple[PortRule, ...] = (PortRule(22),)
storage_mib: int = MIN_STORAGE_MIB
@dataclass(frozen=True, slots=True)
class VmResources:
"""Complete resource target retained while a stop/resize/start is pending."""
cpu: float
ram_mib: int
storage_mib: int
def __post_init__(self) -> None:
try:
valid_cpu = type(self.cpu) in (int, float) and math.isfinite(self.cpu) and self.cpu > 0
except OverflowError:
valid_cpu = False
if not valid_cpu:
raise ValidationError("CPU must be a finite value greater than 0")
if type(self.ram_mib) is not int or self.ram_mib < 128:
raise ValidationError("RAM must be an integer of at least 128 MiB")
if type(self.storage_mib) is not int or not MIN_STORAGE_MIB <= self.storage_mib <= MAX_STORAGE_MIB:
raise ValidationError(f"storage must be between {MIN_STORAGE_MIB} and {MAX_STORAGE_MIB} MiB (minimum 30 GiB)")
def to_dict(self) -> dict[str, Any]:
return {"cpu": self.cpu, "ram_mib": self.ram_mib, "storage_mib": self.storage_mib}
@classmethod
def from_dict(cls, value: object) -> "VmResources":
if not isinstance(value, dict) or set(value) != {"cpu", "ram_mib", "storage_mib"}:
raise StateError("invalid pending resize resources")
try:
return cls(**value)
except ValidationError as error:
raise StateError(f"invalid pending resize resources: {error}") from error
@dataclass(slots=True)
@@ -87,12 +123,16 @@ class VmRecord:
spawn_pending: bool = False
network: str | None = None
bridge: str | None = None
storage_mib: int | None = None
pending_resize: VmResources | None = None
def to_dict(self) -> dict[str, Any]:
data: dict[str, Any] = {
"id": self.id,
"cpu": self.cpu,
"ram_mib": self.ram_mib,
"storage_mib": self.storage_mib,
"pending_resize": self.pending_resize.to_dict() if self.pending_resize is not None else None,
"guest_ip": self.guest_ip,
"gateway": self.gateway,
"tap": self.tap,
@@ -155,6 +195,12 @@ class VmRecord:
if ports is not None and len({port.port for port in ports}) != len(ports):
raise StateError("VM has duplicate TCP port rules")
legacy_ready = bool(value.get("disk")) and value.get("status") in {"running", "stopped"}
storage_mib = value.get("storage_mib")
if storage_mib is not None and (
type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB
):
raise StateError("VM storage_mib must be a supported integer capacity of at least 30 GiB or null")
pending_resize = value.get("pending_resize")
for name in ("disk_ready", "runtime_owned", "tap_owned", "spawn_pending"):
if name in value and type(value[name]) is not bool:
raise StateError(f"VM {name} must be a boolean")
@@ -200,6 +246,8 @@ class VmRecord:
spawn_pending=value.get("spawn_pending", value.get("operation") == "spawning"),
network=str(value["network"]) if value.get("network") is not None else None,
bridge=str(value["bridge"]) if value.get("bridge") is not None else None,
storage_mib=storage_mib,
pending_resize=VmResources.from_dict(pending_resize) if pending_resize is not None else None,
)
except (TypeError, ValueError) as error:
raise StateError(f"invalid VM record: {error}") from error
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+2
View File
@@ -44,6 +44,8 @@ def build_config(
boot_ip_argument(IPv4Address(vm.guest_ip), settings.gateway, settings.network),
)
)
if settings.architecture == "aarch64":
boot_args = f"keep_bootcon {boot_args}"
return {
"boot-source": {
"kernel_image_path": str(kernel),
+31 -19
View File
@@ -161,8 +161,8 @@ class FirewallManager:
address = str(address)
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
selector = ("--family", "ipv4", field, address)
self._run(("conntrack", "--delete", *selector), check=False)
remaining = self._run(("conntrack", "--list", *selector))
self._run(("conntrack", "-D", *selector), check=False)
remaining = self._run(("conntrack", "-L", *selector))
if remaining.stdout.strip():
raise UvmError(f"conntrack entries remain for {address}; do not reuse its IP")
@@ -408,8 +408,9 @@ class FirewallManager:
def _normalize(objects: list[dict[str, Any]]) -> str:
"""Canonical nft JSON, retaining every effective object, rule and verdict.
Ignore only handles, empty set presentation and nft's redundant protocol
dependencies. Rule order is significant; pure conjunctions and sets are not.
Normalize handles, empty set presentation and implied protocol dependencies
without losing address-family constraints. Rule order is significant;
pure conjunctions and sets are not.
Unknown expressions/properties remain different rather than being ignored.
"""
def canonical(value: Any) -> Any:
@@ -442,6 +443,18 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
return result
return value
def payload_protocols(value: Any) -> set[str]:
if not isinstance(value, dict):
return set()
payload = value.get("payload")
if (set(value) == {"payload"} and isinstance(payload, dict)
and set(payload) == {"protocol", "field"}
and isinstance(payload["protocol"], str) and isinstance(payload["field"], str)):
return {payload["protocol"]}
if set(value) == {"&"} and isinstance(value["&"], list) and len(value["&"]) == 2:
return payload_protocols(value["&"][0]) | payload_protocols(value["&"][1])
return set()
definitions, rules = [], {}
for item in objects:
kind, raw = next(iter(item.items()))
@@ -490,25 +503,24 @@ def _normalize(objects: list[dict[str, Any]]) -> str:
left["&"][1] = bitmask(left["&"][1], tcp_flags)
match["right"] = bitmask(match["right"], tcp_flags)
# nft's replayable JSON omits dependencies implied by named payloads.
encoded = json.dumps(expressions, sort_keys=True)
has_tcp = '"protocol": "tcp"' in encoded
has_icmp = '"protocol": "icmp"' in encoded
expressions = [expr for expr in expressions if not (
(has_tcp and expr == _match(_payload("ip", "protocol"), "tcp"))
or (has_icmp and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
encoded = json.dumps(expressions, sort_keys=True)
expressions = [expr for expr in expressions if not (
('"protocol": "arp"' in encoded and expr == _match(_payload("ether", "type"), "arp"))
or (('"protocol": "ip"' in encoded or has_icmp)
and expr == _match(_payload("ether", "type"), "ip"))
)]
count = len(expressions)
if expressions and (not isinstance(expressions[-1], dict) or set(expressions[-1]) != {"match"}):
count -= 1
if all(isinstance(expr, dict) and set(expr) == {"match"} for expr in expressions[:count]):
expressions = sorted(expressions[:count], key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
matches = expressions[:count]
protocols = set().union(*(payload_protocols(expr["match"].get("left")) for expr in matches))
# nft elides ether type ip before ip protocol tcp. Materialize its
# IPv4 constraint BEFORE folding the transport dependency: a TCP
# payload alone can also match IPv6 in a bridge table.
for payloads, ethertype in (({"ip", "icmp"}, "ip"), ({"arp"}, "arp")):
dependency = _match(_payload("ether", "type"), ethertype)
if protocols & payloads and dependency not in matches:
matches.append(dependency)
matches = [expr for expr in matches if not (
("tcp" in protocols and expr == _match(_payload("ip", "protocol"), "tcp"))
or ("icmp" in protocols and expr == _match(_payload("ip", "protocol"), "icmp"))
)]
expressions = sorted(matches, key=lambda expr: json.dumps(expr, sort_keys=True)) + expressions[count:]
obj["expr"] = expressions
rules.setdefault(obj.get("chain", ""), []).append(obj)
definitions.sort(key=lambda item: json.dumps(item, sort_keys=True))
+77 -7
View File
@@ -2,14 +2,15 @@
from __future__ import annotations
import os
import re
import shutil
import stat
import tempfile
from dataclasses import dataclass
from pathlib import Path
from .config import Settings
from .errors import UvmError
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
from .errors import UvmError, ValidationError
from .integrity import load_manifest, verify_file
from .system import CommandRunner
@@ -88,23 +89,92 @@ class ImageStore:
allow_unverified=self._settings.allow_unverified_downloads)
return kernel
def create_vm_disk(self, source: Path, destination: Path) -> Path:
def create_vm_disk(
self, source: Path, destination: Path, *, storage_mib: int = MIN_STORAGE_MIB,
) -> Path:
"""Copy the template before Firecracker opens it read-write for a VM."""
self._validate_storage(storage_mib)
if destination.exists():
raise UvmError(f"VM disk already exists: {destination}")
destination.parent.mkdir(parents=True, exist_ok=True)
try:
shutil.copy2(source, destination)
self._copy_sparse(source, destination)
destination.chmod(0o600)
except OSError as error:
self._resize_filesystem(destination, storage_mib)
except BaseException as error:
try:
destination.unlink()
except FileNotFoundError:
pass
raise UvmError(f"could not create VM disk {destination}: {error}") from error
if isinstance(error, OSError):
raise UvmError(f"could not create VM disk {destination}: {error}") from error
raise
return destination
def resize_vm_disk(self, disk: Path, storage_mib: int) -> None:
"""Resize a proven-offline private disk on a copy, then publish atomically."""
self._validate_storage(storage_mib)
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to resize: {disk}")
target_bytes = storage_mib * 1024 * 1024
if metadata.st_size == target_bytes and self._filesystem_size(disk) == target_bytes:
return # A previous resize may have published its disk before saving metadata.
descriptor, name = tempfile.mkstemp(prefix=".rootfs-resize-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(name)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
self._resize_filesystem(temporary, storage_mib)
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
except OSError as error:
raise UvmError(f"could not resize VM disk {disk}: {error}") from error
finally:
temporary.unlink(missing_ok=True)
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
raise ValidationError("storage must be a supported whole-MiB capacity of at least 30 GiB")
def _copy_sparse(self, source: Path, destination: Path) -> None:
self._runner.run(("cp", "--reflink=auto", "--sparse=always", "--", source, destination), capture=True)
def _filesystem_size(self, disk: Path) -> int:
result = self._runner.run(("env", "LC_ALL=C", "dumpe2fs", "-h", disk), capture=True)
blocks = re.search(r"^Block count:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
block_size = re.search(r"^Block size:\s+(\d+)\s*$", result.stdout, re.MULTILINE)
if blocks is None or block_size is None:
raise UvmError(f"could not determine ext4 filesystem capacity: {disk}")
return int(blocks.group(1)) * int(block_size.group(1))
def _resize_filesystem(self, disk: Path, storage_mib: int) -> None:
"""Grow the file before ext4; shrink ext4 before truncating the file."""
checked = self._runner.run(("e2fsck", "-f", "-p", disk), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before resize ({checked.returncode}): {detail}")
target_bytes = storage_mib * 1024 * 1024
if disk.stat().st_size < target_bytes:
with disk.open("r+b") as image:
image.truncate(target_bytes)
self._runner.run(("resize2fs", disk, f"{storage_mib}M"), capture=True)
if self._filesystem_size(disk) != target_bytes:
raise UvmError("resized ext4 filesystem does not match the requested capacity")
with disk.open("r+b") as image:
image.truncate(target_bytes)
image.flush()
os.fsync(image.fileno())
def provision_credentials(self, disk: Path, username: str, password: str) -> None:
"""Set an existing guest account password in an offline ext4 disk."""
+3 -15
View File
@@ -11,7 +11,7 @@ import urllib.request
from collections.abc import Callable
from pathlib import Path
from .config import DEFAULT_KERNEL_URL, DEFAULT_ROOTFS_URL, Settings
from .config import Settings
from .errors import UvmError
from .images import GuestAssets
from .integrity import require_checksum, sha256_file, verify_file, write_manifest
@@ -42,7 +42,6 @@ class Installer:
return self._install_locked(force_assets=force_assets)
def _install_locked(self, *, force_assets: bool) -> tuple[Path, GuestAssets]:
self._validate_guest_asset_architecture()
self._validate_integrity_policy()
if self._settings.allow_unverified_downloads and self._emit is not None:
self._emit(
@@ -56,16 +55,6 @@ class Installer:
self._write_integrity_manifest(firecracker, assets)
return firecracker, assets
def _validate_guest_asset_architecture(self) -> None:
architecture = os.uname().machine
default_kernel = self._settings.kernel_url == DEFAULT_KERNEL_URL
default_rootfs = self._settings.rootfs_url == DEFAULT_ROOTFS_URL
if architecture == "aarch64" and (default_kernel or default_rootfs):
raise UvmError(
"default guest assets support x86_64 only; set both UVM_KERNEL_URL "
"and UVM_ROOTFS_URL to compatible aarch64 assets"
)
def _validate_integrity_policy(self) -> None:
require_checksum(
self._settings.firecracker_sha256,
@@ -112,6 +101,7 @@ class Installer:
"iptables",
"nftables",
"conntrack",
"coreutils",
"e2fsprogs",
"util-linux",
"ca-certificates",
@@ -122,9 +112,7 @@ class Installer:
self._runner.run(("apt-get", "install", "-y", *packages))
def install_firecracker_binary(self, *, force: bool = False) -> Path:
architecture = os.uname().machine
if architecture not in ("x86_64", "aarch64"):
raise UvmError(f"unsupported host architecture: {architecture}")
architecture = self._settings.architecture
firecracker = self._settings.firecracker_binary
jailer = self._settings.jailer_binary
+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,
+23
View File
@@ -13,16 +13,19 @@ from ..api_models import (
VmCreateRequest,
VmLogResponse,
VmResponse,
VmResizeRequest,
vm_response,
)
from ..app import Application
from ..domain import PortRule, VmRecord, VmSpec
from ..errors import ValidationError
from ..validation import (
parse_cpu,
parse_name,
parse_password,
parse_ports,
parse_ram,
parse_storage,
parse_timeout,
parse_username,
)
@@ -71,6 +74,7 @@ def create_vm(
VmSpec(
cpu=parse_cpu(str(request.cpu)),
ram_mib=parse_ram(str(request.ram)),
storage_mib=parse_storage(str(request.storage)),
guest_ip=request.guest_ip,
username=parse_username(request.username),
password=parse_password(request.password.get_secret_value()),
@@ -141,6 +145,25 @@ def stop_vm(
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.post("/{vm_id}/resize", response_model=VmResponse)
def resize_vm(
vm_id: str,
request: VmResizeRequest,
timeout: float | None = None,
application: Application = Depends(get_authorized_application),
) -> VmResponse:
if request.cpu is None and request.ram is None and request.storage is None:
raise ValidationError("resize requires at least one of CPU, RAM, or storage")
vm = application.lifecycle.resize(
vm_id,
cpu=parse_cpu(str(request.cpu)) if request.cpu is not None else None,
ram_mib=parse_ram(str(request.ram)) if request.ram is not None else None,
storage_mib=parse_storage(str(request.storage)) if request.storage is not None else None,
timeout=parse_timeout(str(timeout)) if timeout is not None else None,
)
return vm_response(vm, policy_status=_mutation_policy_status(vm))
@router.delete("/{vm_id}", response_model=DestroyResponse)
def destroy_vm(
vm_id: str,
+17 -1
View File
@@ -5,13 +5,15 @@ from __future__ import annotations
import math
import re
from collections.abc import Sequence
from decimal import Decimal, InvalidOperation
from decimal import Decimal, InvalidOperation, ROUND_CEILING
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB
from .errors import ValidationError
from .domain import PortRule
_RAM_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMG]i?B?)?\s*", re.IGNORECASE)
_STORAGE_PATTERN = re.compile(r"\s*(\d+(?:\.\d+)?)\s*([BKMGT]i?B?)?\s*", re.IGNORECASE)
_USERNAME_PATTERN = re.compile(r"[a-z_][a-z0-9_-]{0,31}\$?")
_MIB_FACTORS = {
"b": Decimal(1) / Decimal(1024 * 1024),
@@ -62,6 +64,20 @@ def parse_cpu(value: str) -> float:
return cpu
def parse_storage(value: str) -> int:
"""Parse GiB-default storage, rounding capacity up to a whole MiB."""
match = _STORAGE_PATTERN.fullmatch(value)
if match is None:
raise ValidationError(f"invalid storage value: {value}; use e.g. 30G, 50G, or 1T")
factors = {**_MIB_FACTORS, **dict.fromkeys(("t", "tb", "ti", "tib"), Decimal(1024 * 1024))}
amount = Decimal(match.group(1)) * factors[(match.group(2) or "g").lower()]
if amount < MIN_STORAGE_MIB:
raise ValidationError("storage must be at least 30 GiB")
if amount > MAX_STORAGE_MIB:
raise ValidationError(f"storage must not exceed {MAX_STORAGE_MIB} MiB")
return int(amount.to_integral_value(rounding=ROUND_CEILING))
def parse_username(value: str) -> str:
"""Validate a conventional Linux account name."""