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:
2026-09-25 17:39:06 +05:30
parent dca7f78dae
commit b0bf99a6ee
25 changed files with 1893 additions and 30 deletions
+42
View File
@@ -17,6 +17,8 @@ from .errors import ConflictError, NotFoundError, UvmError, ValidationError
from .firecracker.api import FirecrackerClient
from .firecracker.config import build_config, write_config
from .firecracker.process import FirecrackerProcessManager, ProcessInfo
from .guest_agent import DEFAULT_TIMEOUT_SECONDS
from .guest_exec import ExecResult, GuestSession, validate_exec
from .images import ImageStore
from .integrity import load_manifest, verify_file
from .known_hosts import KnownHostsManager
@@ -58,6 +60,7 @@ class LifecycleService:
network: NetworkManager, process: FirecrackerProcessManager, *,
known_hosts: KnownHostsManager,
client_factory: Callable[[Path, float], FirecrackerClient] = FirecrackerClient,
guest_connector: Callable[[Path, str, str, int], GuestSession] = GuestSession.connect,
clock: Callable[[], float] = time.time,
) -> None:
self._settings = settings
@@ -67,6 +70,7 @@ class LifecycleService:
self._process = process
self._known_hosts = known_hosts
self._client_factory = client_factory
self._guest_connector = guest_connector
self._clock = clock
def create(self, spec: VmSpec) -> VmRecord:
@@ -90,6 +94,7 @@ class LifecycleService:
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)
self._images.provision_guest_agent(disk, vm.id, vm.username)
vm.disk_ready = True
self._save(vm)
self._boot(vm, assets.kernel)
@@ -494,6 +499,41 @@ class LifecycleService:
def find_for_ssh(self, identifier: str) -> VmRecord:
return self._state_store.find_by_id_or_ip(identifier)
def execute(
self, vm_id: str, command: str, *, sudo: bool = False,
timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS,
) -> ExecResult:
"""Pin a running VM's guest connection under the lock, then wait unlocked."""
require_root()
validate_exec(command, sudo, timeout_seconds)
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "running" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "alive"):
raise ConflictError("guest execution requires a running VM with no pending lifecycle operation")
session = self._guest_connector(self._settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid)
try:
# This session stays attached to that VMM even if the VM is stopped,
# deleted, or restarted concurrently. Never reconnect/retry a command.
return session.execute(command, sudo=sudo, timeout_seconds=timeout_seconds)
finally:
session.close()
def install_guest_agent(self, vm_id: str) -> VmRecord:
"""Explicitly install/upgrade the agent on a cleanly stopped private disk."""
require_root()
with self._state_store.operation_lock():
vm = self._load_for_operation(vm_id, exact_id=True)
self._not_deleting(vm)
if (vm.status != "stopped" or vm.operation is not None or vm.pending_resize is not None
or self._process.observe(vm) != "gone"):
raise ConflictError("stop the VM and finish pending operations before installing the guest agent")
if not vm.disk_ready or not vm.disk:
raise ConflictError("guest agent installation requires a completely provisioned private disk")
self._images.provision_guest_agent(Path(vm.disk), vm.id, vm.username, install=True)
return vm
def log_path(self, identifier: str) -> Path:
vm = self._state_store.find_by_id_or_ip(identifier)
self._validate_paths(vm)
@@ -566,6 +606,8 @@ class LifecycleService:
directory = self._settings.vm_dir(vm.id).absolute()
if directory.is_symlink():
raise ConflictError("VM directory must not be a symbolic link")
if self._settings.guest_socket_path(vm.id).is_symlink():
raise ConflictError("unsafe guest vsock path")
for field, filename in (("socket", "firecracker.sock"), ("config", "config.json"),
("log", "firecracker.log"), ("disk", "rootfs.ext4")):
path = getattr(vm, field)