Enhance documentation and validation for VM lifecycle and network management; add tests for legacy cleanup and address validation.
This commit is contained in:
+125
-5
@@ -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")):
|
||||
|
||||
Reference in New Issue
Block a user