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
+93 -1
View File
@@ -12,7 +12,11 @@ from unittest.mock import Mock, patch
from uvm.cli import build_parser, main
from uvm.config import Settings
from uvm.domain import PortRule, VmRecord, VmResources, VmSpec
from uvm.errors import ConfigurationError, ConflictError, NotFoundError, UvmError, ValidationError
from uvm.errors import (
ConfigurationError, ConflictError, GuestAgentUnavailableError, GuestExecutionError,
NotFoundError, UvmError, ValidationError,
)
from uvm.guest_exec import ExecResult
from uvm.lifecycle import ListedVm, OperationResult
from uvm.server import _http_status_for, create_api, run_server
@@ -141,6 +145,8 @@ class ServerErrorTests(unittest.TestCase):
(ConfigurationError("invalid settings"), 422),
(UvmError("this operation needs root"), 403),
(UvmError("host operation failed"), 500),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost connection", outcome_unknown=True), 502),
):
with self.subTest(error=error):
self.assertEqual(_http_status_for(error), expected)
@@ -159,6 +165,7 @@ class FakeLifecycle:
def __init__(self) -> None:
self.last_spec: VmSpec | None = None
self.last_resize: dict | None = None
self.last_execution: dict | None = None
self.observed_status = "running"
self.policy_status = "enforced"
self.vm = VmRecord(
@@ -256,6 +263,19 @@ class FakeLifecycle:
self.inspect(identifier)
return SimpleNamespace(content=f"last {tail} log lines", truncated=False)
def execute(self, vm_id: str, command: str, *, sudo: bool, timeout_seconds: float) -> ExecResult:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
self.last_execution = dict(vm_id=vm_id, command=command, sudo=sudo, timeout_seconds=timeout_seconds)
return ExecResult(7, "hello\n", "problem\n", False, False)
def install_guest_agent(self, vm_id: str) -> VmRecord:
if vm_id != self.vm.id:
raise NotFoundError(f"VM not found: {vm_id}")
if self.vm.status != "stopped":
raise ConflictError("stop the VM before installing the guest agent")
return self.vm
def diagnostics(self) -> list[dict]:
return [{"name": "kvm", "ok": True, "message": "available"}]
@@ -341,6 +361,8 @@ class ApiRouterTests(unittest.TestCase):
("DELETE", "/vms/test-vm", {}),
("POST", "/vms/test-vm/ports", {}),
("POST", "/vms/test-vm/ports/sync", {}),
("POST", "/vms/vm-test/exec", {"command": "id"}),
("POST", "/vms/vm-test/agent/install", {}),
("GET", "/diagnostics", {}),
("POST", "/exit", {}),
("POST", "/install", {}),
@@ -655,6 +677,74 @@ class ApiRouterTests(unittest.TestCase):
self.assertEqual(sync.json()["id"], "vm-test")
self.assertEqual(sync.json()["policy_status"], "inactive")
def test_exec_preserves_script_text_privileges_and_nonzero_exit_results(self) -> None:
command = "printf 'hello\\n'\nprintf 'problem\\n' >&2\nexit 7"
response = self.client.post("/vms/vm-test/exec", headers=self.headers,
json={"command": command, "sudo": True, "timeout_seconds": 12.5})
self.assertEqual(response.status_code, 200, response.text)
self.assertEqual(response.json(), {
"exit_code": 7, "stdout": "hello\n", "stderr": "problem\n", "timed_out": False, "truncated": False,
})
self.assertEqual(self.lifecycle.last_execution, {
"vm_id": "vm-test", "command": command, "sudo": True, "timeout_seconds": 12.5,
})
self.assertNotIn("guest-secret", response.text)
default = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(default.status_code, 200)
self.assertEqual(self.lifecycle.last_execution["sudo"], False)
self.assertEqual(self.lifecycle.last_execution["timeout_seconds"], 60)
def test_exec_rejects_malformed_requests_before_calling_the_service(self) -> None:
for payload in (
{}, {"command": ""}, {"command": " \n"}, {"command": "a\0b"}, {"command": 42},
{"command": "x" * 65537}, {"command": "\u00e9" * 40000},
{"command": "id", "sudo": "false"}, {"command": "id", "sudo": 1},
{"command": "id", "timeout_seconds": True}, {"command": "id", "timeout_seconds": "10"},
{"command": "id", "timeout_seconds": 0}, {"command": "id", "timeout_seconds": 3601},
{"command": "id", "username": "root"}, {"command": "id", "host_path": "/etc/passwd"},
):
with self.subTest(payload=str(payload)[:100]):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json=payload)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_exec_errors_distinguish_vm_state_agent_readiness_and_unknown_outcomes(self) -> None:
for error, expected in ((NotFoundError("missing"), 404), (ConflictError("stopped"), 409),
(GuestAgentUnavailableError("not ready"), 503),
(GuestExecutionError("lost response", outcome_unknown=True), 502)):
with self.subTest(error=error), patch.object(self.lifecycle, "execute", side_effect=error):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "id"})
self.assertEqual(response.status_code, expected)
if isinstance(error, GuestExecutionError):
self.assertTrue(response.json()["error"]["outcome_unknown"])
with patch.object(self.lifecycle, "execute", return_value=ExecResult(None, "partial", "", True, True)):
response = self.client.post("/vms/vm-test/exec", headers=self.headers, json={"command": "sleep 10"})
self.assertEqual(response.status_code, 200)
self.assertIsNone(response.json()["exit_code"])
self.assertTrue(response.json()["timed_out"])
self.assertTrue(response.json()["truncated"])
def test_exec_rejects_nonfinite_and_invalid_unicode_json_without_serialization_errors(self) -> None:
for body in ('{"command":"id","timeout_seconds":NaN}',
'{"command":"id","timeout_seconds":Infinity}',
'{"command":"\\ud800"}', '{"command":"id","sudo":NaN}'):
with self.subTest(body=body):
response = self.client.post("/vms/vm-test/exec", headers={**self.headers, "Content-Type": "application/json"},
content=body)
self.assertEqual(response.status_code, 422, response.text)
self.assertIsNone(self.lifecycle.last_execution)
def test_agent_install_requires_stopping_and_uses_the_existing_vm_disk(self) -> None:
blocked = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(blocked.status_code, 409)
self.client.post("/vms/vm-test/stop", headers=self.headers)
with patch.object(self.lifecycle, "install_guest_agent", wraps=self.lifecycle.install_guest_agent) as install:
response = self.client.post("/vms/vm-test/agent/install", headers=self.headers)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"id": "vm-test", "status": "installed", "restart_required": True})
install.assert_called_once_with("vm-test")
self.assertEqual(self.lifecycle.vm.status, "stopped")
def test_bulk_action_resolves_targets_before_running_fixed_ids(self) -> None:
with (
patch.object(self.lifecycle, "resolve_targets", wraps=self.lifecycle.resolve_targets) as resolve,
@@ -786,6 +876,8 @@ class ApiRouterTests(unittest.TestCase):
("/vms/{vm_id}", "delete"),
("/vms/{vm_id}/ports", "post"),
("/vms/{vm_id}/ports/sync", "post"),
("/vms/{vm_id}/exec", "post"),
("/vms/{vm_id}/agent/install", "post"),
("/diagnostics", "get"),
},
)