feat: enhance guest network configuration and recovery process for UVM

This commit is contained in:
2026-09-23 08:20:35 +05:30
parent dca3cbde2f
commit 660ba9efee
9 changed files with 474 additions and 12 deletions
+213
View File
@@ -0,0 +1,213 @@
from __future__ import annotations
import configparser
import hashlib
import shutil
import tempfile
import unittest
from dataclasses import replace
from ipaddress import IPv4Address, IPv4Network
from pathlib import Path
from unittest.mock import Mock, patch
from uvm.config import Settings
from uvm.errors import UvmError, ValidationError
from uvm.images import GUEST_NETWORK_TEMPLATE, ImageStore
from uvm.system import CommandResult, CommandRunner
PROFILE = "/etc/systemd/network/10-uvm.network"
# The shipped v1/v2 profile that retained the IP but lost the proto-boot route.
LEGACY_PROFILE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
class GuestNetworkValidationTests(unittest.TestCase):
def test_invalid_guest_addresses_fail_before_disk_access(self) -> None:
runner = Mock(spec=CommandRunner)
store = ImageStore(Settings(), runner)
for address in ("invalid", "::1", "192.0.2.2", "10.42.0.0", "10.42.0.255", "10.42.0.1"):
with self.subTest(address=address), self.assertRaises(ValidationError):
store.configure_guest_network(Path("/nonexistent/rootfs.ext4"), address)
runner.run.assert_not_called()
def test_shared_or_linked_disks_are_rejected_before_guest_commands(self) -> None:
with tempfile.TemporaryDirectory() as name:
settings = Settings(base=Path(name))
settings.images_dir.mkdir()
settings.rootfs_image.write_bytes(b"shared template")
disk = settings.base / "rootfs.ext4"
disk.write_bytes(b"private disk")
linked = settings.base / "linked.ext4"
linked.hardlink_to(disk)
symlink = settings.base / "symlink.ext4"
symlink.symlink_to(disk)
runner = Mock(spec=CommandRunner)
store = ImageStore(settings, runner)
for candidate in (settings.rootfs_image, disk, linked, symlink):
with self.subTest(candidate=candidate), self.assertRaisesRegex(UvmError, "unsafe"):
store.configure_guest_network(candidate, "10.42.0.2")
runner.run.assert_not_called()
@unittest.skipUnless(all(shutil.which(tool) for tool in ("mkfs.ext4", "debugfs", "e2fsck", "cp")),
"e2fsprogs and coreutils are required for offline guest networking checks")
class OfflineGuestNetworkTests(unittest.TestCase):
def setUp(self) -> None:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.base = Path(temporary.name)
self.settings = Settings(base=self.base / "uvm")
self.settings.images_dir.mkdir(parents=True)
self.disk = self.base / "VM with spaces" / "rootfs.ext4"
self.disk.parent.mkdir()
self.runner = CommandRunner(emit=None)
self.store = ImageStore(self.settings, self.runner)
self.retained = {
"/etc/shadow": "adm:$6$original-password-hash:1:0:99999:7:::\n",
"/etc/ssh/ssh_host_ed25519_key": "original private host key\n",
"/home/adm/data": "retained guest application data\n",
}
def make_disk(self, *, marker="uvm-ubuntu-jammy-v2\n", profile=LEGACY_PROFILE, symlink=False) -> None:
root = self.base / "tree"
if root.exists():
shutil.rmtree(root)
files = dict(self.retained)
if marker is not None:
files["/etc/uvm-image"] = marker
if profile is not None:
files["/etc/alternate.network" if symlink else PROFILE] = profile
for path, contents in files.items():
local = root / path.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.write_text(contents, encoding="utf-8")
local.chmod(0o644)
if symlink:
local = root / PROFILE.lstrip("/")
local.parent.mkdir(parents=True, exist_ok=True)
local.symlink_to("/etc/alternate.network")
with self.settings.rootfs_image.open("wb") as image:
image.truncate(32 * 1024 * 1024)
self.runner.run(("mkfs.ext4", "-q", "-F", "-b", "4096", "-d", root, self.settings.rootfs_image), capture=True)
if profile is not None:
for field in ("uid", "gid"):
self.runner.run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0",
self.settings.rootfs_image), capture=True)
self.runner.run(("cp", "--sparse=always", self.settings.rootfs_image, self.disk), capture=True)
self.disk.chmod(0o600)
def guest_file(self, path: str) -> str:
return self.runner.run(("debugfs", "-R", f"cat {path}", self.disk), capture=True).stdout
def snapshot(self) -> tuple[int, bytes]:
return self.disk.stat().st_ino, hashlib.sha256(self.disk.read_bytes()).digest()
def test_templates_gain_an_explicit_address_gateway_and_boot_route_preservation(self) -> None:
for marker, profile in (("uvm-ubuntu-jammy-v1\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE),
("uvm-ubuntu-jammy-v3\n", GUEST_NETWORK_TEMPLATE)):
with self.subTest(marker=marker):
self.make_disk(marker=marker, profile=profile)
original_template = hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest()
self.assertTrue(self.store.configure_guest_network(self.disk, "10.42.0.2"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Match"]["Name"], "eth0")
self.assertEqual(config["Network"]["Address"], "10.42.0.2/24")
self.assertEqual(config["Network"]["Gateway"], "10.42.0.1")
self.assertTrue(config["Network"].getboolean("KeepConfiguration"))
self.assertFalse(config["Network"].getboolean("DHCP"))
for path, contents in self.retained.items():
self.assertEqual(self.guest_file(path), contents)
self.assertEqual(hashlib.sha256(self.settings.rootfs_image.read_bytes()).digest(), original_template)
self.assertEqual(self.disk.stat().st_size, 32 * 1024 * 1024)
self.assertEqual(self.disk.stat().st_mode & 0o777, 0o600)
self.runner.run(("e2fsck", "-f", "-n", self.disk), capture=True)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_custom_subnets_and_gateways_are_used_in_the_guest_profile(self) -> None:
settings = replace(self.settings, network=IPv4Network("10.50.0.0/16"), gateway=IPv4Address("10.50.1.1"))
self.make_disk()
self.assertTrue(ImageStore(settings, self.runner).configure_guest_network(self.disk, "10.50.8.9"))
config = configparser.ConfigParser()
config.read_string(self.guest_file(PROFILE))
self.assertEqual(config["Network"]["Address"], "10.50.8.9/16")
self.assertEqual(config["Network"]["Gateway"], "10.50.1.1")
def test_custom_images_missing_profiles_and_guest_edits_are_left_intact(self) -> None:
for marker, profile, symlink in (
(None, LEGACY_PROFILE, False), ("custom-image\n", LEGACY_PROFILE, False),
("uvm-ubuntu-jammy-v2\n", None, False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "# guest customization\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE + "Gateway=10.42.0.9\n", False),
("uvm-ubuntu-jammy-v2\n", LEGACY_PROFILE, True),
):
with self.subTest(marker=marker, profile=profile, symlink=symlink):
self.make_disk(marker=marker, profile=profile, symlink=symlink)
before = self.snapshot()
with patch.object(self.store, "_copy_sparse") as copy:
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
copy.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_a_failed_or_interrupted_profile_write_keeps_the_original_disk(self) -> None:
for error in (UvmError("profile readback failed"), KeyboardInterrupt()):
with self.subTest(error=type(error).__name__):
self.make_disk()
before = self.snapshot()
write = self.store._write_guest_file
def fail(candidate, path, contents):
self.assertNotEqual(candidate, self.disk)
write(candidate, path, contents)
raise error
with patch.object(self.store, "_write_guest_file", side_effect=fail), self.assertRaises(type(error)):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_filesystem_check_failure_prevents_publication(self) -> None:
self.make_disk()
before = self.snapshot()
run = self.runner.run
def check_failure(command, **options):
if command[0] == "e2fsck":
return CommandResult(tuple(map(str, command)), 4, stderr="unrepaired filesystem errors")
return run(command, **options)
with patch.object(self.runner, "run", side_effect=check_failure):
with self.assertRaisesRegex(UvmError, "unrepaired filesystem errors"):
self.store.configure_guest_network(self.disk, "10.42.0.2")
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
def test_guest_edits_revealed_after_journal_replay_prevent_migration(self) -> None:
self.make_disk()
before = self.snapshot()
edited = self.base / "edited.network"
edited.write_text(LEGACY_PROFILE + "Gateway=10.42.0.9\n", encoding="utf-8")
run = self.runner.run
def replay(command, **options):
result = run(command, **options)
if command[:3] == ("e2fsck", "-f", "-p"):
run(("debugfs", "-w", "-R", f"rm {PROFILE}", command[-1]), capture=True)
run(("debugfs", "-w", "-R", f"write {edited} {PROFILE}", command[-1]), capture=True)
for field in ("uid", "gid"):
run(("debugfs", "-w", "-R", f"set_inode_field {PROFILE} {field} 0", command[-1]), capture=True)
return result
with patch.object(self.runner, "run", side_effect=replay):
self.assertFalse(self.store.configure_guest_network(self.disk, "10.42.0.2"))
self.assertEqual(self.snapshot(), before)
self.assertEqual(list(self.disk.parent.iterdir()), [self.disk])
+2 -1
View File
@@ -158,6 +158,7 @@ class InstallerArchitectureTests(unittest.TestCase):
for verified, marker, force in (
(True, _ROOTFS_RECIPE, False), (False, _ROOTFS_RECIPE, False),
(True, "", False), (True, "uvm-ubuntu-jammy-v1\n", False), (True, _ROOTFS_RECIPE, True),
(True, "uvm-ubuntu-jammy-v2\n", False),
):
with self.subTest(verified=verified, marker=marker, force=force), tempfile.TemporaryDirectory() as temporary:
settings = Settings(base=Path(temporary))
@@ -335,7 +336,7 @@ class UbuntuRootfsBuildTests(unittest.TestCase):
self.assertRegex(guest("stat /usr/bin/sudo").stdout, r"Mode:\s+0?4755")
self.assertEqual(guest("cat /etc/uvm-image").stdout, _ROOTFS_RECIPE)
self.assertIn("nameserver 1.1.1.1", guest("cat /etc/resolv.conf").stdout)
self.assertIn("KeepConfiguration=static", guest("cat /etc/systemd/network/10-uvm.network").stdout)
self.assertIn("KeepConfiguration=yes", guest("cat /etc/systemd/network/10-uvm.network").stdout)
self.assertIn("jammy-security main universe", guest("cat /etc/apt/sources.list").stdout)
for path in ("/usr/sbin/policy-rc.d", "/etc/ssh/ssh_host_rsa_key"):
self.assertIn("File not found", guest(f"stat {path}").stderr)
+52 -1
View File
@@ -307,6 +307,9 @@ class LifecycleTestCase(unittest.TestCase):
self.images, "resize_vm_disk", side_effect=self.resize_vm_disk,
))
self.provision = self.enterContext(patch.object(self.images, "provision_credentials"))
self.configure_network = self.enterContext(patch.object(
self.images, "configure_guest_network", side_effect=self.configure_guest_network,
))
self.copy_disk = self.enterContext(patch.object(
self.images, "create_vm_disk", wraps=self.images.create_vm_disk,
))
@@ -340,6 +343,14 @@ class LifecycleTestCase(unittest.TestCase):
raise AssertionError("cannot resize a running or ambiguous VM")
self.disk_sizes[disk] = storage_mib * 1024 * 1024
def configure_guest_network(self, disk: Path, guest_ip: str) -> None:
vm = self.store.get(disk.parent.name)
self.host.call("images.configure_network", vm.id)
if self.process.observe(vm) != "gone":
raise AssertionError("cannot configure a running or ambiguous VM disk")
if guest_ip != vm.guest_ip:
raise AssertionError("guest network must use the VM's retained address")
def create_vm(self, **kwargs) -> VmRecord:
return self.service.create(VmSpec(cpu=1, ram_mib=512, **kwargs))
@@ -481,7 +492,7 @@ class LifecycleTests(LifecycleTestCase):
def test_boot_persists_intent_and_identity_with_policy_before_tap_and_vmm(self) -> None:
vm = self.create_vm()
self.assert_call_order(
vm, "network.prepare_tap", "firewall.apply", "firewall.readback",
vm, "images.configure_network", "network.prepare_tap", "firewall.apply", "firewall.readback",
"network.activate_tap", "process.start", "process.spawn", "process.ready",
"client.configure", "firewall.status",
)
@@ -499,6 +510,46 @@ class LifecycleTests(LifecycleTestCase):
self.assertEqual(spawned.operation, "configuring")
self.assertEqual(self.host.records["client.configure", vm.id].pid, vm.pid)
def test_guest_network_configuration_failure_rolls_back_before_marking_the_disk_ready(self) -> None:
self.host.failures["images.configure_network", None] = UvmError("guest network verification failed")
with self.assertRaisesRegex(UvmError, "guest network verification failed"):
self.create_vm()
self.assertEqual(self.store.load().vms, {})
self.assertFalse(self.network.created_taps)
self.assertFalse(self.process.started)
self.assertEqual(list(self.settings.vms_dir.iterdir()), [])
def test_retained_guest_network_is_configured_after_exit_and_before_boot(self) -> None:
vm = self.create_vm()
self.host.events.clear()
self.service.restart(vm.id)
self.assert_call_order(vm, "images.configure_network", "process.start", "client.configure")
actions = self.host.actions(vm.id)
self.assertLess(max(index for index, action in enumerate(actions) if action == "process.gone"),
actions.index("images.configure_network"))
self.configure_network.assert_called_with(Path(vm.disk), vm.guest_ip)
self.assertEqual(self.provision.call_count, 1)
def test_live_start_does_not_modify_the_guest_disk(self) -> None:
vm = self.create_vm()
self.configure_network.reset_mock()
self.service.start(vm.id)
self.configure_network.assert_not_called()
def test_failed_retained_network_configuration_keeps_disk_for_retry(self) -> None:
vm = self.create_vm()
self.service.stop(vm.id)
disk = Path(vm.disk)
disk.write_bytes(b"retained data and credentials")
self.host.failures["images.configure_network", vm.id] = UvmError("network verification failed")
with self.assertRaisesRegex(UvmError, "network verification failed"):
self.service.start(vm.id)
self.assertEqual(disk.read_bytes(), b"retained data and credentials")
self.assertEqual(self.process.observe(self.store.get(vm.id)), "gone")
self.assertEqual(len(self.process.started), 1)
self.assertEqual(self.service.start(vm.id).status, "running")
self.assertEqual(self.provision.call_count, 1)
def test_failed_initial_policy_never_activates_tap_or_starts_vmm(self) -> None:
self.host.failures["firewall.apply", None] = UvmError("policy unavailable")
with self.assertRaisesRegex(UvmError, "policy unavailable"):