feat: enhance guest network configuration and recovery process for UVM
This commit is contained in:
+100
@@ -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)
|
||||
|
||||
+3
-6
@@ -14,14 +14,14 @@ from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .errors import UvmError
|
||||
from .images import GuestAssets
|
||||
from .images import GUEST_NETWORK_TEMPLATE, GuestAssets
|
||||
from .integrity import load_manifest, require_checksum, sha256_file, verify_file, write_manifest
|
||||
from .state import StateStore
|
||||
from .system import CommandRunner, check_kvm, ensure_data_directories, require_root
|
||||
|
||||
|
||||
_UBUNTU_RELEASE = "jammy"
|
||||
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v2\n"
|
||||
_ROOTFS_RECIPE = "uvm-ubuntu-jammy-v3\n"
|
||||
_GUEST_PACKAGES = (
|
||||
"ca-certificates", "openssh-server", "sudo", "systemd-sysv", "udev",
|
||||
"dbus", "libpam-systemd", "iproute2", "iputils-ping", "curl",
|
||||
@@ -383,10 +383,7 @@ class Installer:
|
||||
"etc/resolv.conf": "nameserver 1.1.1.1\nnameserver 8.8.8.8\n",
|
||||
"etc/machine-id": "",
|
||||
"etc/uvm-image": _ROOTFS_RECIPE,
|
||||
"etc/systemd/network/10-uvm.network": (
|
||||
"[Match]\nName=eth0\n\n[Network]\n"
|
||||
"KeepConfiguration=static\nDHCP=no\nLinkLocalAddressing=no\nIPv6AcceptRA=no\n"
|
||||
),
|
||||
"etc/systemd/network/10-uvm.network": GUEST_NETWORK_TEMPLATE,
|
||||
}
|
||||
for relative, contents in files.items():
|
||||
path = root / relative
|
||||
|
||||
@@ -89,6 +89,7 @@ class LifecycleService:
|
||||
self._save(vm)
|
||||
disk = self._images.create_vm_disk(assets.rootfs, Path(vm.disk), storage_mib=spec.storage_mib)
|
||||
self._images.provision_credentials(disk, vm.username, vm.password)
|
||||
self._images.configure_guest_network(disk, vm.guest_ip)
|
||||
vm.disk_ready = True
|
||||
self._save(vm)
|
||||
self._boot(vm, assets.kernel)
|
||||
@@ -188,6 +189,7 @@ class LifecycleService:
|
||||
try:
|
||||
self._cleanup_runtime(vm)
|
||||
self._apply_pending_resize(vm)
|
||||
self._images.configure_guest_network(Path(vm.disk), vm.guest_ip)
|
||||
vm.network, vm.bridge = str(self._settings.network), self._settings.bridge
|
||||
self._boot(vm, kernel)
|
||||
return vm
|
||||
|
||||
Reference in New Issue
Block a user