feat: Add guest execution agent and vsock support
- Introduced a standalone guest execution agent to facilitate command execution within the guest VM over Firecracker's vsock. - Enhanced FirecrackerClient to support vsock configuration, validating guest CID and UDS path. - Updated configuration to include vsock settings by default. - Refactored FirecrackerProcessManager to manage runtime sockets more effectively. - Implemented guest agent installation and provisioning in the image store. - Added API endpoints for executing commands in the VM and installing the guest agent. - Improved error handling for guest execution failures and agent availability.
This commit is contained in:
+165
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
@@ -11,6 +13,7 @@ from dataclasses import dataclass
|
||||
from ipaddress import IPv4Address
|
||||
from pathlib import Path
|
||||
|
||||
from . import guest_agent
|
||||
from .config import MAX_STORAGE_MIB, MIN_STORAGE_MIB, Settings
|
||||
from .errors import UvmError, ValidationError
|
||||
from .integrity import load_manifest, verify_file
|
||||
@@ -46,6 +49,54 @@ _LEGACY_GUEST_NETWORK_TEMPLATE = GUEST_NETWORK_TEMPLATE.replace(
|
||||
"KeepConfiguration=yes", "KeepConfiguration=static",
|
||||
)
|
||||
|
||||
GUEST_AGENT_SERVICE = """\
|
||||
[Unit]
|
||||
Description=UVM guest command execution agent
|
||||
After=local-fs.target network.target
|
||||
ConditionPathExists=/etc/uvm-agent.json
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStartPre=/bin/sh /usr/lib/uvm/prepare-agent.sh
|
||||
ExecStart=/usr/bin/python3 -I /usr/lib/uvm/agent.py
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
TimeoutStartSec=600
|
||||
TimeoutStopSec=5
|
||||
KillMode=control-group
|
||||
Delegate=yes
|
||||
UMask=0077
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
# Runs only INSIDE the VM. Old Jammy guests can acquire the runtime without SSH
|
||||
# or executing a retained guest's binaries in a privileged host chroot.
|
||||
GUEST_AGENT_PREPARE = """\
|
||||
set -eu
|
||||
check_python() {
|
||||
/usr/bin/python3 -I -c 'import sys, json, math, pwd, selectors, socket, struct, subprocess, threading, uuid; assert sys.version_info >= (3, 10)' 2>/dev/null
|
||||
}
|
||||
if check_python; then
|
||||
exit 0
|
||||
fi
|
||||
export DEBIAN_FRONTEND=noninteractive LC_ALL=C
|
||||
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -o APT::Update::Error-Mode=any update
|
||||
/usr/bin/apt-get -o DPkg::Lock::Timeout=300 -y --no-install-recommends install python3
|
||||
check_python
|
||||
"""
|
||||
|
||||
|
||||
def guest_agent_files() -> dict[str, bytes]:
|
||||
"""The same standalone payload is used by the builder and offline upgrades."""
|
||||
return {
|
||||
"/usr/lib/uvm/agent.py": Path(guest_agent.__file__).read_bytes(),
|
||||
"/usr/lib/uvm/prepare-agent.sh": GUEST_AGENT_PREPARE.encode("utf-8"),
|
||||
"/etc/systemd/system/uvm-agent.service": GUEST_AGENT_SERVICE.encode("utf-8"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GuestAssets:
|
||||
@@ -232,6 +283,7 @@ class ImageStore:
|
||||
)
|
||||
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",
|
||||
"uvm-ubuntu-jammy-v4\n",
|
||||
}:
|
||||
return None
|
||||
profile = self._read_guest_file(
|
||||
@@ -246,6 +298,119 @@ class ImageStore:
|
||||
return None
|
||||
return profile
|
||||
|
||||
def provision_guest_agent(self, disk: Path, vm_id: str, username: str, *, install: bool = False) -> bool:
|
||||
"""Configure new templates, or explicitly install on a proven-offline disk.
|
||||
|
||||
The lifecycle lock and confirmed VMM exit are required. A checked sparse
|
||||
copy preserves the original disk on failure and makes retries atomic.
|
||||
"""
|
||||
parse_username(username)
|
||||
if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,80}", vm_id) is None:
|
||||
raise ValidationError("invalid VM ID for guest agent configuration")
|
||||
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("private VM disk is unsafe for guest agent installation")
|
||||
with tempfile.TemporaryDirectory(prefix="uvm-guest-agent-") as name:
|
||||
files = Path(name)
|
||||
if not install:
|
||||
marker = self._read_guest_file(disk, "/etc/uvm-image", files / "marker",
|
||||
required=False, regular_only=True)
|
||||
if marker is None or marker.path.read_text(encoding="utf-8") != "uvm-ubuntu-jammy-v4\n":
|
||||
return False
|
||||
descriptor, candidate = tempfile.mkstemp(prefix=".rootfs-agent-", 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):
|
||||
raise UvmError("filesystem check failed before guest agent installation")
|
||||
# All ownership/content checks happen AFTER journal replay.
|
||||
self._install_agent_files(temporary, files, vm_id, username)
|
||||
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 provision guest agent: {error}") from error
|
||||
|
||||
def _install_agent_files(self, disk: Path, files: Path, vm_id: str, username: str) -> None:
|
||||
for binary in ("/usr/lib/systemd/systemd", "/usr/bin/apt-get"):
|
||||
result = self._runner.run(("debugfs", "-R", f"stat {binary}", disk), capture=True)
|
||||
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
||||
raise UvmError("guest agent installation requires a systemd/apt image (Ubuntu 22.04 or newer)")
|
||||
for directory in ("/etc", "/usr", "/usr/lib", "/usr/lib/uvm", "/etc/systemd",
|
||||
"/etc/systemd/system", "/etc/systemd/system/multi-user.target.wants"):
|
||||
self._agent_directory(disk, directory)
|
||||
payloads = guest_agent_files()
|
||||
payloads["/etc/uvm-agent.json"] = (json.dumps({"vm_id": vm_id, "username": username}) + "\n").encode("utf-8")
|
||||
manifest_path = "/etc/uvm-agent-files.json"
|
||||
previous = self._agent_file(disk, manifest_path, files / "manifest")
|
||||
old_hashes = {}
|
||||
if previous is not None:
|
||||
try:
|
||||
old_hashes = json.loads(previous.path.read_bytes())
|
||||
except (ValueError, UnicodeError) as error:
|
||||
raise UvmError("invalid guest agent ownership manifest") from error
|
||||
if (not isinstance(old_hashes, dict) or set(old_hashes) - set(payloads)
|
||||
or any(not isinstance(value, str) or re.fullmatch(r"[0-9a-f]{64}", value) is None
|
||||
for value in old_hashes.values())):
|
||||
raise UvmError("invalid guest agent ownership manifest")
|
||||
hashes = {path: hashlib.sha256(content).hexdigest() for path, content in payloads.items()}
|
||||
for index, (path, content) in enumerate(payloads.items()):
|
||||
existing = self._agent_file(disk, path, files / f"existing-{index}")
|
||||
if existing is not None:
|
||||
digest = hashlib.sha256(existing.path.read_bytes()).hexdigest()
|
||||
if digest not in {hashes[path], old_hashes.get(path)}:
|
||||
raise UvmError(f"refusing to overwrite a customized guest agent file: {path}")
|
||||
source = files / f"payload-{index}"
|
||||
source.write_bytes(content)
|
||||
self._write_guest_file(disk, path, _GuestFile(source, 0o600 if path.endswith(".json") else 0o644, 0, 0))
|
||||
source = files / "new-manifest"
|
||||
source.write_text(json.dumps(hashes, sort_keys=True) + "\n", encoding="utf-8")
|
||||
self._write_guest_file(disk, manifest_path, _GuestFile(source, 0o600, 0, 0))
|
||||
link = "/etc/systemd/system/multi-user.target.wants/uvm-agent.service"
|
||||
target = "/etc/systemd/system/uvm-agent.service"
|
||||
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
||||
if "file not found" in (result.stdout + result.stderr).lower():
|
||||
self._runner.run(("debugfs", "-w", "-R", f"symlink {link} {target}", disk), capture=True)
|
||||
result = self._runner.run(("debugfs", "-R", f"stat {link}", disk), capture=True)
|
||||
if (re.search(r"Type:\s+symlink\b", result.stdout) is None
|
||||
or f'Fast link dest: "{target}"' not in result.stdout):
|
||||
raise UvmError("could not enable guest agent without replacing a customized service link")
|
||||
|
||||
def _agent_directory(self, disk: Path, path: str) -> None:
|
||||
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
||||
if "file not found" in (result.stdout + result.stderr).lower():
|
||||
self._create_guest_directory(disk, path, 0o755, 0, 0)
|
||||
return
|
||||
mode, owner = _MODE_PATTERN.search(result.stdout), _OWNER_PATTERN.search(result.stdout)
|
||||
if (re.search(r"Type:\s+directory\b", result.stdout) is None or mode is None or owner is None
|
||||
or int(owner.group(1)) != 0 or int(mode.group(1), 8) & 0o022):
|
||||
raise UvmError(f"unsafe guest agent directory: {path}")
|
||||
|
||||
def _agent_file(self, disk: Path, path: str, destination: Path) -> _GuestFile | None:
|
||||
result = self._runner.run(("debugfs", "-R", f"stat {path}", disk), capture=True)
|
||||
if "file not found" in (result.stdout + result.stderr).lower():
|
||||
return None
|
||||
if re.search(r"Type:\s+regular\b", result.stdout) is None:
|
||||
raise UvmError(f"unsafe guest agent file: {path}")
|
||||
source = self._read_guest_file(disk, path, destination)
|
||||
if (source.uid != 0 or source.mode & 0o022 or source.links != 1 or source.has_extended_attributes):
|
||||
raise UvmError(f"unsafe guest agent file: {path}")
|
||||
return source
|
||||
|
||||
@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:
|
||||
|
||||
Reference in New Issue
Block a user