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
+100
View File
@@ -8,6 +8,7 @@ import stat
import tempfile
import time
from dataclasses import dataclass
from ipaddress import IPv4Address
from pathlib import Path
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
@@ -35,6 +36,16 @@ _HOST_KEY_TYPES = (
("ed25519", None),
)
# Keep the kernel's proto-boot default route until networkd takes over. On
# Ubuntu 22.04, KeepConfiguration=static preserves addresses but drops that route.
GUEST_NETWORK_TEMPLATE = (
"[Match]\nName=eth0\n\n[Network]\n"
"KeepConfiguration=yes\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
)
_LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
"KeepConfiguration=yes", "KeepConfiguration=static",
)
@dataclass(frozen=True, slots=True)
class GuestAssets:
@@ -150,6 +161,91 @@ class ImageStore:
finally:
temporary.unlink(missing_ok=True)
def configure_guest_network(self, disk: Path, guest_ip: str) -> bool:
"""Specialize an unedited UVM profile on a proven-offline private disk.
Lifecycle owns the operation lock and confirms process exit. Stage and
check a sparse copy so failed migrations retain the original disk. A
completed migration, custom image, or guest-edited profile is a no-op.
"""
try:
address = IPv4Address(guest_ip)
except ValueError as error:
raise ValidationError("guest network configuration requires a valid IPv4 address") from error
if address not in self._settings.network or address in {
self._settings.network.network_address,
self._settings.network.broadcast_address,
self._settings.gateway,
}:
raise ValidationError("guest network configuration requires a usable IP inside the UVM subnet")
try:
metadata = disk.lstat()
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
or disk.resolve() == self._settings.rootfs_image.resolve()):
raise UvmError(f"private VM disk is unsafe to configure: {disk}")
with tempfile.TemporaryDirectory(prefix="uvm-guest-network-") as name:
files = Path(name)
if self._unconfigured_guest_network(disk, files) is None:
return False
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-network-", suffix=".ext4", dir=disk.parent)
os.close(descriptor)
temporary = Path(candidate)
try:
self._copy_sparse(disk, temporary)
temporary.chmod(0o600)
checked = self._runner.run(("e2fsck", "-f", "-p", temporary), check=False, capture=True)
if checked.returncode not in (0, 1):
detail = checked.stderr.strip() or checked.stdout.strip()
raise UvmError(f"filesystem check failed before guest network configuration: {detail}")
# Journal replay may reveal guest edits not visible to the
# initial debugfs read. Recheck ownership on the recovered copy.
profile = self._unconfigured_guest_network(temporary, files)
if profile is None:
return False
profile.path.write_text(
GUEST_NETWORK_TEMPLATE
+ f"Address={address}/{self._settings.network.prefixlen}\n"
+ f"Gateway={self._settings.gateway}\n",
encoding="utf-8",
)
self._write_guest_file(temporary, "/etc/systemd/network/10-uvm.network", profile)
self._runner.run(("e2fsck", "-f", "-n", temporary), capture=True)
with temporary.open("rb") as image:
os.fsync(image.fileno())
os.replace(temporary, disk)
directory = os.open(disk.parent, os.O_RDONLY)
try:
os.fsync(directory)
finally:
os.close(directory)
finally:
temporary.unlink(missing_ok=True)
return True
except (OSError, UnicodeError) as error:
raise UvmError(f"could not configure guest networking: {error}") from error
def _unconfigured_guest_network(self, disk: Path, temporary: Path) -> _GuestFile | None:
marker = self._read_guest_file(
disk, "/etc/uvm-image", temporary / "marker", required=False, regular_only=True,
)
if marker is None or marker.path.read_text(encoding="utf-8") not in {
"uvm-ubuntu-jammy-v1\n", "uvm-ubuntu-jammy-v2\n", "uvm-ubuntu-jammy-v3\n",
}:
return None
profile = self._read_guest_file(
disk, "/etc/systemd/network/10-uvm.network", temporary / "network", required=False, regular_only=True,
)
if (profile is None or (profile.uid, profile.gid, profile.mode, profile.links) != (0, 0, 0o644, 1)
or profile.has_extended_attributes):
return None
if profile.path.read_text(encoding="utf-8") not in {
_LEGACY_GUEST_NETWORK_TEMPLATE, GUEST_NETWORK_TEMPLATE,
}:
return None
return profile
@staticmethod
def _validate_storage(storage_mib: int) -> None:
if type(storage_mib) is not int or not MIN_STORAGE_MIB <= storage_mib <= MAX_STORAGE_MIB:
@@ -397,12 +493,16 @@ class ImageStore:
destination: Path,
*,
required: bool = True,
regular_only: bool = False,
) -> _GuestFile | None:
stat_result = self._runner.run(
("debugfs", "-R", f"stat {guest_path}", disk),
check=False,
capture=True,
)
file_type = re.search(r"Type:\s+(\S+)", stat_result.stdout)
if regular_only and file_type is not None and file_type.group(1) != "regular":
return None
mode_match = _MODE_PATTERN.search(stat_result.stdout)
owner_match = _OWNER_PATTERN.search(stat_result.stdout)
flags_match = _FLAGS_PATTERN.search(stat_result.stdout)