Enhance documentation and validation for VM lifecycle and network management; add tests for legacy cleanup and address validation.

This commit is contained in:
2026-09-09 19:21:00 +05:30
parent ee26962902
commit 3d7af4adb8
9 changed files with 366 additions and 35 deletions
+125 -5
View File
@@ -1,11 +1,12 @@
from __future__ import annotations
import json
import tempfile
import unittest
from collections.abc import Callable, Iterable, Iterator
from contextlib import contextmanager
from dataclasses import replace
from ipaddress import IPv4Address
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import patch
@@ -485,6 +486,81 @@ class LifecycleTests(LifecycleTestCase):
self.provision.assert_not_called()
self.copy_disk.assert_not_called()
def test_create_rejects_retained_topology_changes_before_allocation_or_bridge_work(self) -> None:
vm = self.create_vm()
self.copy_disk.reset_mock()
self.provision.reset_mock()
changes = (
{"gateway": IPv4Address(vm.guest_ip)},
{"gateway": IPv4Address("10.42.0.10")},
{"network": IPv4Network("10.42.0.0/25")},
{"bridge": "uvm-other"},
{"gateway": IPv4Address(vm.guest_ip), "network": IPv4Network("10.42.0.0/25")},
)
for status in ("running", "stopped"):
if status == "stopped":
self.service.stop(vm.id)
before = self.settings.state_path.read_bytes()
before_directories = set(self.settings.vms_dir.iterdir())
before_taps = self.network.taps.copy()
before_active = self.network.active_taps.copy()
before_tables = self.network.firewall.tables.copy()
for changed in changes:
with self.subTest(status=status, changed=changed):
settings = replace(self.settings, **changed)
service = LifecycleService(
settings, self.store, self.images, self.network, self.process,
client_factory=self.make_client,
)
self.host.events.clear()
with (
patch.object(self.network, "_settings", settings),
patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate,
self.assertRaisesRegex(ConflictError, "topology conflicts with retained VMs"),
):
service.create(VmSpec(cpu=1, ram_mib=512))
allocate.assert_not_called()
self.assertEqual(self.settings.state_path.read_bytes(), before)
self.assertEqual(set(self.settings.vms_dir.iterdir()), before_directories)
self.assertEqual(self.network.taps, before_taps)
self.assertEqual(self.network.active_taps, before_active)
self.assertEqual(self.network.firewall.tables, before_tables)
self.assertEqual(self.host.events, [("firewall.check_available", None)])
self.copy_disk.assert_not_called()
self.provision.assert_not_called()
self.assertEqual(len(self.process.started), 1)
self.assertEqual(Path(vm.disk).read_bytes(), b"rootfs")
def test_non_rfc1918_network_fails_before_kvm_assets_or_registry_reservation(self) -> None:
for network, gateway in (
("203.0.113.0/24", "203.0.113.1"),
("100.64.0.0/24", "100.64.0.1"),
("8.8.8.0/24", "8.8.8.1"),
("10.0.0.0/7", "10.0.0.1"),
):
with self.subTest(network=network):
settings = replace(self.settings, network=IPv4Network(network), gateway=IPv4Address(gateway))
service = LifecycleService(
settings, self.store, self.images, self.network, self.process,
client_factory=self.make_client,
)
with (
patch.object(self.network, "_settings", settings),
patch.object(self.images, "installed_assets", wraps=self.images.installed_assets) as assets,
patch.object(self.network, "allocate_ip", wraps=self.network.allocate_ip) as allocate,
self.assertRaisesRegex(ValidationError, "RFC 1918"),
):
service.create(VmSpec(cpu=1, ram_mib=512))
assets.assert_not_called()
allocate.assert_not_called()
self.check_kvm.assert_not_called()
self.assertFalse(self.settings.state_path.exists())
self.assertEqual(self.store.load().vms, {})
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
self.assertEqual(self.host.events, [])
self.provision.assert_not_called()
self.copy_disk.assert_not_called()
def test_create_cleans_up_if_policy_readback_is_not_enforced_after_boot(self) -> None:
with patch.object(self.network.firewall, "status", return_value="drift"):
with self.assertRaisesRegex(UvmError, "enforcement could not be confirmed after boot"):
@@ -605,10 +681,54 @@ class LifecycleTests(LifecycleTestCase):
self.copy_disk.assert_not_called()
self.assertEqual(self.network.created_taps, [])
self.assertEqual(self.process.started, [])
retained = self.store.load().vms.get("vm-existing")
if retained is not None:
self.assertFalse(retained.runtime_owned)
self.assertFalse(retained.disk_ready)
retained = self.store.get("vm-existing")
self.assertFalse(retained.runtime_owned)
self.assertFalse(retained.disk_ready)
with self.assertRaisesRegex(UvmError, "refusing to remove unowned VM directory"):
self.service.destroy(retained.id)
self.assertFalse(self.store.get(retained.id).runtime_owned)
self.assertEqual({path.name: (path.stat().st_ino, path.read_bytes()) for path in directory.iterdir()}, before)
def test_delete_legacy_runtime_without_private_disk_preserves_shared_rootfs(self) -> None:
shared_rootfs = self.settings.rootfs_image
before = (shared_rootfs.stat().st_ino, shared_rootfs.read_bytes())
for disk_field in ({}, {"disk": ""}):
with self.subTest(disk_field=disk_field):
directory = self.settings.vm_dir("vm-legacy")
directory.mkdir(parents=True, mode=0o700)
raw = {
"id": "vm-legacy", "cpu": 1, "ram_mib": 512,
"guest_ip": "10.42.0.2", "gateway": "10.42.0.1",
"tap": self.network.tap_name("vm-legacy"), "mac": self.network.mac_for(1),
"socket": str(directory / "firecracker.sock"),
"config": str(directory / "config.json"),
"log": str(directory / "firecracker.log"),
"status": "running", "pid": 12345, "process_start_time": "42",
**disk_field,
}
Path(raw["socket"]).touch()
Path(raw["log"]).write_bytes(b"legacy boot log")
Path(raw["config"]).write_text(json.dumps({
"drives": [{"path_on_host": str(shared_rootfs), "is_read_only": False}],
}), encoding="utf-8")
self.settings.state_path.write_text(json.dumps({
"schema_version": 1, "vms": {raw["id"]: raw},
}), encoding="utf-8")
self.settings.state_path.chmod(0o600)
vm = self.store.get(raw["id"])
self.assertTrue(vm.runtime_owned)
self.assertEqual(vm.disk, "")
self.assertFalse(vm.disk_ready)
self.assertEqual(self.process.observe(vm), "gone")
self.service.destroy(vm.id)
self.assertEqual(self.store.load().vms, {})
self.assertFalse(directory.exists())
self.assertEqual((shared_rootfs.stat().st_ino, shared_rootfs.read_bytes()), before)
self.copy_disk.assert_not_called()
self.provision.assert_not_called()
self.check_kvm.assert_not_called()
def test_stale_stopping_and_deleting_records_can_finish_cleanup(self) -> None:
for action, status, operation in (("stop", "stopping", "stop"), ("destroy", "terminating", "delete")):