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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user