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:
@@ -0,0 +1,114 @@
|
||||
"""Host-side, one-shot guest execution over Firecracker's vsock Unix socket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import GuestAgentUnavailableError, GuestExecutionError, ValidationError
|
||||
from .guest_agent import (
|
||||
AGENT_PORT, CLEANUP_SECONDS, CONNECT_TIMEOUT_SECONDS, MAX_OUTPUT_BYTES,
|
||||
MAX_REQUEST_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, ProtocolError,
|
||||
receive_exact, receive_frame, send_frame, validate_command,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ExecResult:
|
||||
exit_code: int | None
|
||||
stdout: str
|
||||
stderr: str
|
||||
timed_out: bool
|
||||
truncated: bool
|
||||
|
||||
|
||||
def validate_exec(command: object, sudo: object, timeout_seconds: object) -> None:
|
||||
try:
|
||||
validate_command(command, sudo, timeout_seconds)
|
||||
except ProtocolError as error:
|
||||
raise ValidationError(str(error)) from error
|
||||
|
||||
|
||||
class GuestSession:
|
||||
"""A connected session pins the selected VMM; never reconnect after submission."""
|
||||
|
||||
def __init__(self, connection: socket.socket) -> None:
|
||||
self._connection = connection
|
||||
|
||||
@classmethod
|
||||
def connect(cls, path: Path, vm_id: str, username: str, pid: int) -> GuestSession:
|
||||
connection = None
|
||||
try:
|
||||
connection = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
metadata = path.lstat()
|
||||
if not stat.S_ISSOCK(metadata.st_mode):
|
||||
raise ProtocolError("guest socket is not a Unix socket")
|
||||
deadline = time.monotonic() + CONNECT_TIMEOUT_SECONDS
|
||||
connection.settimeout(CONNECT_TIMEOUT_SECONDS)
|
||||
connection.connect(str(path))
|
||||
peer_pid, _uid, _gid = struct.unpack("3i", connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, 12))
|
||||
if peer_pid != pid:
|
||||
raise ProtocolError("guest socket does not belong to the selected VM process")
|
||||
connection.sendall(f"CONNECT {AGENT_PORT}\n".encode("ascii"))
|
||||
acknowledgement = bytearray()
|
||||
while not acknowledgement.endswith(b"\n") and len(acknowledgement) < 32:
|
||||
acknowledgement.extend(receive_exact(connection, 1, deadline))
|
||||
parts = bytes(acknowledgement).split()
|
||||
if (not acknowledgement.endswith(b"\n") or len(parts) != 2
|
||||
or parts[0] != b"OK" or not parts[1].isdigit()):
|
||||
raise ProtocolError("invalid Firecracker vsock acknowledgement")
|
||||
hello = receive_frame(connection, 4096, deadline)
|
||||
if (set(hello) != {"version", "vm_id", "username"}
|
||||
or type(hello["version"]) is not int or hello["version"] != PROTOCOL_VERSION
|
||||
or hello["vm_id"] != vm_id or hello["username"] != username):
|
||||
raise ProtocolError("guest agent identity or protocol version does not match this VM")
|
||||
return cls(connection)
|
||||
except (OSError, EOFError, ProtocolError) as error:
|
||||
if connection is not None:
|
||||
connection.close()
|
||||
raise GuestAgentUnavailableError(
|
||||
"guest agent is unavailable or not ready; install it with POST /vms/{vm_id}/agent/install "
|
||||
"while stopped, then start the VM (vsock-capable kernel required)"
|
||||
) from error
|
||||
|
||||
def execute(self, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
|
||||
validate_exec(command, sudo, timeout_seconds)
|
||||
try:
|
||||
send_frame(self._connection, {
|
||||
"version": PROTOCOL_VERSION, "command": command,
|
||||
"sudo": sudo, "timeout_seconds": timeout_seconds,
|
||||
}, MAX_REQUEST_BYTES, time.monotonic() + CONNECT_TIMEOUT_SECONDS)
|
||||
result = receive_frame(
|
||||
self._connection, MAX_RESPONSE_BYTES,
|
||||
time.monotonic() + timeout_seconds + CLEANUP_SECONDS + CONNECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
if "error" in result:
|
||||
if (set(result) != {"error", "outcome_unknown"} or not isinstance(result["error"], str)
|
||||
or len(result["error"]) > 4096 or type(result["outcome_unknown"]) is not bool):
|
||||
raise ProtocolError("invalid guest failure response")
|
||||
result["error"].encode("utf-8") # Reject invalid Unicode before rendering an HTTP error.
|
||||
raise GuestExecutionError(result["error"], outcome_unknown=result["outcome_unknown"])
|
||||
if set(result) != {"exit_code", "stdout", "stderr", "timed_out", "truncated"}:
|
||||
raise ProtocolError("invalid guest result fields")
|
||||
if (type(result["timed_out"]) is not bool or type(result["truncated"]) is not bool
|
||||
or not isinstance(result["stdout"], str) or not isinstance(result["stderr"], str)
|
||||
or (result["timed_out"] and result["exit_code"] is not None)
|
||||
or (not result["timed_out"] and (
|
||||
type(result["exit_code"]) is not int or not -64 <= result["exit_code"] <= 255))):
|
||||
raise ProtocolError("invalid guest result types")
|
||||
if sum(len(result[name].encode("utf-8")) for name in ("stdout", "stderr")) > 3 * MAX_OUTPUT_BYTES:
|
||||
raise ProtocolError("guest output exceeds its limit")
|
||||
return ExecResult(**result)
|
||||
except (OSError, EOFError, ProtocolError, UnicodeError) as error:
|
||||
raise GuestExecutionError(
|
||||
"guest execution connection was lost or returned an invalid result; "
|
||||
"the command may have run and will not be retried",
|
||||
outcome_unknown=True,
|
||||
) from error
|
||||
|
||||
def close(self) -> None:
|
||||
self._connection.close()
|
||||
Reference in New Issue
Block a user