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
+1 -1
View File
@@ -296,7 +296,7 @@ def _sandbox(parent_fd):
print("PASS: host/peer inbound services and host/peer/routed guest TCP egress", flush=True)
def service_ids():
listing = run("conntrack", "--list", "--family", "ipv4", "--proto", "tcp",
listing = run("conntrack", "-L", "--family", "ipv4", "--proto", "tcp",
"--orig-dst", vm.guest_ip, "--dport", "8080", "-o", "id")
return set(re.findall(r"\bid=(\d+)", listing))
+70
View File
@@ -0,0 +1,70 @@
from __future__ import annotations
import os
import unittest
from types import SimpleNamespace
from unittest.mock import patch
from uvm.config import Settings
from uvm.errors import ConfigurationError
class ArchitectureSettingsTests(unittest.TestCase):
def test_resolves_native_defaults_when_settings_are_created(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("AMD64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
for factory in (Settings, Settings.from_environment):
with (
self.subTest(machine=machine, factory=factory),
patch.dict(os.environ, {}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = factory()
base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
self.assertEqual(settings.architecture, architecture)
self.assertEqual(settings.kernel_url, f"{base}/kernels/vmlinux.bin")
self.assertEqual(settings.rootfs_url, f"{base}/rootfs/bionic.rootfs.ext4")
def test_preserves_individual_environment_url_overrides_on_arm64(self) -> None:
kernel = "https://example.com/custom-arm64-kernel"
rootfs = "https://example.com/custom-arm64-rootfs.ext4"
base = "https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/aarch64"
for overrides in (
{"UVM_KERNEL_URL": kernel},
{"UVM_ROOTFS_URL": rootfs},
{"UVM_KERNEL_URL": kernel, "UVM_ROOTFS_URL": rootfs},
):
with (
self.subTest(overrides=overrides),
patch.dict(os.environ, overrides, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="aarch64")),
):
settings = Settings.from_environment()
self.assertEqual(
settings.kernel_url,
overrides.get("UVM_KERNEL_URL", f"{base}/kernels/vmlinux.bin"),
)
self.assertEqual(
settings.rootfs_url,
overrides.get("UVM_ROOTFS_URL", f"{base}/rootfs/bionic.rootfs.ext4"),
)
def test_rejects_unsupported_hosts_even_with_custom_guest_assets(self) -> None:
with (
patch.dict(os.environ, {
"UVM_KERNEL_URL": "https://example.com/kernel",
"UVM_ROOTFS_URL": "https://example.com/rootfs.ext4",
}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine="riscv64")),
self.assertRaisesRegex(ConfigurationError, "unsupported host architecture: riscv64"),
):
Settings.from_environment()
+27
View File
@@ -19,6 +19,33 @@ class RecordingFirecrackerClient(FirecrackerClient):
class FirecrackerConfigTests(unittest.TestCase):
def test_uses_arm_boot_console_arguments_only_on_arm64(self) -> None:
vm = VmRecord(
id="vm-test",
cpu=1,
ram_mib=512,
guest_ip="10.42.0.2",
gateway="10.42.0.1",
tap="uvm-test",
mac="02:fc:00:00:00:01",
socket="/tmp/firecracker.sock",
config="/tmp/config.json",
log="/tmp/firecracker.log",
)
for architecture in ("x86_64", "aarch64", "arm64"):
with self.subTest(architecture=architecture):
config = build_config(
Settings(architecture=architecture),
vm,
Path("/images/vmlinux"),
Path("/vm/rootfs.ext4"),
)
arguments = config["boot-source"]["boot_args"].split()
self.assertEqual("keep_bootcon" in arguments, architecture != "x86_64")
self.assertIn("console=ttyS0", arguments)
self.assertIn("ip=10.42.0.2::10.42.0.1:255.255.255.0::eth0:off", arguments)
def test_builds_a_writable_vm_disk_configuration(self) -> None:
vm = VmRecord(
id="vm-test",
+89 -6
View File
@@ -102,9 +102,9 @@ class FakeRunner:
self.after_batch()
elif args == ("conntrack", "--version"):
stdout = "conntrack v1.4.8 (conntrack-tools)"
elif args[:2] == ("conntrack", "--delete"):
elif args[:2] == ("conntrack", "-D"):
code = self.ct_delete_code
elif args[:2] == ("conntrack", "--list"):
elif args[:2] == ("conntrack", "-L"):
stdout = self.ct_remaining
else:
raise AssertionError(f"unexpected command: {args}")
@@ -376,6 +376,34 @@ class FirewallManagerTests(unittest.TestCase):
exprs[:-1] = reversed(exprs[:-1])
self.assertEqual(self.manager.status(self.vm), "enforced")
def test_apply_accepts_nft_eliding_ethertype_from_the_ipv4_tcp_guard(self):
self.vm.ports = (PortRule(22),)
def elide_ethertype():
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if not any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
continue
# nft 1.0.9 keeps ip protocol tcp to bind TCP to IPv4 in a
# bridge table, but omits its redundant ether type ip check.
expressions[:] = [expr for expr in expressions if expr != {
"match": {"left": {"payload": {"protocol": "ether", "field": "type"}},
"op": "==", "right": "ip"},
}]
self.runner.after_batch = elide_ethertype
self.manager.apply(self.vm)
self.assertEqual(self.manager.status(self.vm), "enforced")
# Losing BOTH IPv4 constraints is a real policy change, not formatting.
for item in self.policy():
expressions = item.get("rule", {}).get("expr", [])
if any("&" in expr.get("match", {}).get("left", {}) for expr in expressions):
expressions[:] = [expr for expr in expressions if expr.get("match", {}).get("left") != {
"payload": {"protocol": "ip", "field": "protocol"},
}]
self.assertEqual(self.manager.status(self.vm), "drift")
def test_empty_sets_can_omit_elements_and_missing_conditions_are_drift(self):
self.vm.ports = ()
self.manager.apply(self.vm)
@@ -430,7 +458,7 @@ class FirewallManagerTests(unittest.TestCase):
self.runner.ct_delete_code = 1 # conntrack uses 1 for no matching entries too.
self.manager.cleanup_connections(self.vm)
for selector in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst"):
for command in ("--delete", "--list"):
for command in ("-D", "-L"):
self.assertIn(("conntrack", command, "--family", "ipv4", selector, self.vm.guest_ip),
[args for args, _ in self.runner.calls])
self.assertFalse(any("--flush" in args or "-F" in args for args, _ in self.runner.calls))
@@ -438,7 +466,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.assertRaisesRegex(UvmError, "do not reuse"):
self.manager.cleanup_connections(self.vm)
self.runner.ct_remaining = ""
self.runner.fail_command = ("conntrack", "--list", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
self.runner.fail_command = ("conntrack", "-L", "--family", "ipv4", "--orig-src", self.vm.guest_ip)
with self.assertRaises(CommandError):
self.manager.cleanup_connections(self.vm)
@@ -455,7 +483,7 @@ class FirewallManagerTests(unittest.TestCase):
expected = [("ip", "-j", "-d", "link", "show")]
expected.extend(("conntrack", command, "--family", "ipv4", field, "203.0.113.7")
for field in ("--orig-src", "--orig-dst", "--reply-src", "--reply-dst")
for command in ("--delete", "--list"))
for command in ("-D", "-L"))
# No nft policy, publishing, or unrelated address/conntrack flush.
self.assertEqual([args for args, _ in self.runner.calls], expected)
@@ -476,7 +504,7 @@ class FirewallManagerTests(unittest.TestCase):
with self.subTest(address=address, network=network):
self.runner.calls.clear()
self.manager.cleanup_connections(vm_record(guest_ip=address, network=network, ports=None))
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "--delete")]
deletes = [args for args, _ in self.runner.calls if args[:2] == ("conntrack", "-D")]
self.assertEqual(len(deletes), 4)
self.assertTrue(all(args[-1] == address for args in deletes))
@@ -688,6 +716,32 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertEqual(self.normalize_rule(dumped), self.normalize_rule(numeric))
self.assertNotEqual(self.normalize_rule(dumped), self.normalize_rule(numeric.replace('}}, 3]', '}}, 2]')))
def test_bridge_tcp_guard_normalizes_numeric_protocol_and_implicit_ethertype(self):
# nft 1.0.9 src/payload.c payload_may_dependency_kill() removes the
# EtherType before ip protocol, but retains ip protocol before TCP.
# Protocol and flag representations follow the upstream fixtures in
# tests/py/inet/ip_tcp.t.json.output and tests/py/inet/tcp.t.json.
dumped = '''[
{"match": {"left": {"payload": {"protocol": "ip", "field": "protocol"}},
"op": "==", "right": 6}},
{"match": {"left": {"ct": {"key": "state"}}, "op": "in", "right": "new"}},
{"match": {"left": {"&": [
{"payload": {"protocol": "tcp", "field": "flags"}}, ["fin", "syn", "rst", "ack"]
]}, "op": "!=", "right": "syn"}},
{"drop": null}
]'''
policy = FirewallManager(Settings(), FakeRunner())._policy(vm_record(), quarantine=False)
guards = [item["rule"] for item in policy if "rule" in item
and any("&" in expr.get("match", {}).get("left", {}) for expr in item["rule"]["expr"])]
self.assertEqual({rule["chain"] for rule in guards}, {"from_vm", "to_vm"})
for rule in guards:
with self.subTest(chain=rule["chain"]):
expected = self.normalize_rule(json.dumps(rule["expr"]))
self.assertEqual(expected, self.normalize_rule(dumped))
self.assertNotEqual(expected, self.normalize_rule(json.dumps(json.loads(dumped)[1:])))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"right": 6', '"right": 17')))
self.assertNotEqual(expected, self.normalize_rule(dumped.replace('"op": "!="', '"op": "=="')))
def test_upstream_full_dump_ignores_only_handles_and_metainfo(self):
# Literal RULESET from tests/shell/testcases/json/0006obj_comment_0.
dumped = json.loads('''{"nftables": [
@@ -706,6 +760,35 @@ class NftDumpFixtureTests(unittest.TestCase):
self.assertNotEqual(_normalize(dumped), _normalize(expected))
@unittest.skipUnless(shutil.which("conntrack"), "conntrack unavailable for nonmutating option checks")
class ConntrackCommandParserTests(unittest.TestCase):
@staticmethod
def parse(command):
# An invalid final option aborts before any netlink operation, even
# when the tests run as root. This also checks delete command syntax.
return subprocess.run(
(*command, "--uvm-parser-sentinel"), text=True, capture_output=True,
timeout=5, env={**os.environ, "LC_ALL": "C"},
)
def test_cleanup_commands_reach_only_the_final_invalid_option(self):
runner = FakeRunner()
FirewallManager(Settings(), runner).cleanup_connections(vm_record())
commands = [args for args, _ in runner.calls if args[0] == "conntrack"]
self.assertEqual(len(commands), 8)
for command in commands:
with self.subTest(command=command):
result = self.parse(command)
self.assertEqual(result.returncode, 2, result.stderr)
self.assertRegex(result.stderr, r"(?:unknown|unrecognized) option [`']--uvm-parser-sentinel'")
def test_invalid_option_is_rejected_before_the_sentinel(self):
result = self.parse(("conntrack", "--uvm-invalid-option"))
self.assertEqual(result.returncode, 2)
self.assertIn("--uvm-invalid-option", result.stderr)
self.assertNotIn("--uvm-parser-sentinel", result.stderr)
@unittest.skipUnless(shutil.which("nft"), "nft unavailable for noncommitting JSON parser checks")
class NftJsonParserTests(unittest.TestCase):
"""Exercise the actual JSON parser, NOT semantic/kernel/packet validation.
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
import hashlib
import io
import os
import tarfile
import tempfile
import unittest
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError
from uvm.install import Installer
from uvm.integrity import load_manifest
from uvm.state import StateStore
from uvm.system import CommandRunner
class InstallerArchitectureTests(unittest.TestCase):
def test_installs_matching_native_artifacts_with_a_verified_manifest(self) -> None:
for machine, architecture in (
("x86_64", "x86_64"),
("aarch64", "aarch64"),
("arm64", "aarch64"),
):
with (
self.subTest(machine=machine),
tempfile.TemporaryDirectory() as temporary_directory,
patch.dict(os.environ, {"UVM_BASE": temporary_directory}, clear=True),
patch("uvm.config.os.uname", return_value=SimpleNamespace(machine=machine)),
):
settings = Settings.from_environment()
version = settings.firecracker_version
binaries = {
"firecracker": f"firecracker for {architecture}".encode(),
"jailer": f"jailer for {architecture}".encode(),
}
archive_buffer = io.BytesIO()
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
for name, content in binaries.items():
member = tarfile.TarInfo(
f"release-{version}-{architecture}/{name}-{version}-{architecture}"
)
member.size = len(content)
member.mode = 0o755
archive.addfile(member, io.BytesIO(content))
archive_bytes = archive_buffer.getvalue()
kernel_bytes = f"kernel for {architecture}".encode()
rootfs_bytes = f"rootfs for {architecture}".encode()
settings = replace(
settings,
firecracker_sha256=hashlib.sha256(archive_bytes).hexdigest(),
kernel_sha256=hashlib.sha256(kernel_bytes).hexdigest(),
rootfs_sha256=hashlib.sha256(rootfs_bytes).hexdigest(),
)
guest_base = (
"https://s3.amazonaws.com/spec.ccfc.min/img/quickstart_guide/"
f"{architecture}"
)
payloads = {
"https://github.com/firecracker-microvm/firecracker/releases/download/"
f"{version}/firecracker-{version}-{architecture}.tgz": archive_bytes,
f"{guest_base}/kernels/vmlinux.bin": kernel_bytes,
f"{guest_base}/rootfs/bionic.rootfs.ext4": rootfs_bytes,
}
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm"),
patch(
"uvm.install.urllib.request.urlopen",
side_effect=lambda url, timeout: io.BytesIO(payloads[url]),
) as download,
):
firecracker, assets = installer.install()
self.assertEqual(firecracker.read_bytes(), binaries["firecracker"])
self.assertEqual(settings.jailer_binary.read_bytes(), binaries["jailer"])
self.assertEqual(assets.kernel.read_bytes(), kernel_bytes)
self.assertEqual(assets.rootfs.read_bytes(), rootfs_bytes)
self.assertEqual(download.call_count, 3)
self.assertEqual({call.args[0] for call in download.call_args_list}, set(payloads))
manifest = load_manifest(settings.integrity_manifest_path)
self.assertTrue(manifest.verified)
self.assertEqual(manifest.checksums, {
name: hashlib.sha256(content).hexdigest()
for name, content in {
**binaries, "kernel": kernel_bytes, "rootfs": rootfs_bytes,
}.items()
})
def test_arm64_install_requires_checksums_before_host_commands_or_downloads(self) -> None:
with tempfile.TemporaryDirectory() as temporary_directory:
settings = Settings(base=Path(temporary_directory), architecture="aarch64")
runner = Mock(spec=CommandRunner)
installer = Installer(settings, runner, StateStore(settings), emit=None)
with (
patch("uvm.install.require_root"),
patch("uvm.install.check_kvm") as check_kvm,
patch("uvm.install.urllib.request.urlopen") as download,
self.assertRaisesRegex(UvmError, "Firecracker archive checksum is required"),
):
installer.install()
runner.run.assert_not_called()
check_kvm.assert_not_called()
download.assert_not_called()
self.assertFalse(settings.integrity_manifest_path.exists())