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