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:
+93
-1
@@ -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"),
|
||||
},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user