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:
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.
@@ -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
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user