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
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import Mock
from tests.test_lifecycle import LifecycleTestCase
from uvm.errors import ConflictError, GuestAgentUnavailableError, GuestExecutionError, NotFoundError
from uvm.guest_exec import ExecResult
class GuestExecLifecycleTests(LifecycleTestCase):
def test_create_provisions_agent_before_the_disk_is_ready_or_the_guest_is_started(self) -> None:
def provision(disk, vm_id, username):
self.host.call("images.provision_agent", vm_id)
vm = self.store.get(vm_id)
self.assertFalse(vm.disk_ready)
self.assertEqual(self.process.observe(vm), "gone")
self.assertEqual(disk, Path(vm.disk))
self.assertEqual(username, "worker")
self.provision_agent.side_effect = provision
vm = self.create_vm(username="worker")
self.assert_call_order(vm, "images.provision_agent", "process.start")
def test_execution_pins_the_exact_vm_under_lock_and_waits_without_holding_it(self) -> None:
vm = self.create_vm(name="worker", ports=())
result = ExecResult(7, "output", "problem", False, False)
session = Mock()
def connect(path, vm_id, username, pid):
self.assertTrue(self.store.operation_active)
self.assertEqual((path, vm_id, username, pid), (self.settings.guest_socket_path(vm.id), vm.id, vm.username, vm.pid))
return session
def execute(command, **options):
self.assertFalse(self.store.operation_active)
self.assertFalse(self.store.transaction_active)
self.service.stop(vm.id)
return result
self.service._guest_connector = Mock(side_effect=connect)
session.execute.side_effect = execute
self.assertEqual(self.service.execute(vm.id, "exit 7", sudo=True, timeout_seconds=2), result)
session.execute.assert_called_once_with("exit 7", sudo=True, timeout_seconds=2)
session.close.assert_called_once()
def test_execution_rejects_aliases_stopped_unknown_and_pending_vms_before_connecting(self) -> None:
vm = self.create_vm(name="worker")
self.service._guest_connector = Mock()
for alias in (vm.name, vm.guest_ip):
with self.assertRaises(NotFoundError):
self.service.execute(alias, "id")
for changes in ({"status": "stopped"}, {"operation": "policy"}, {"operation": "delete"}):
self.persist(vm, **changes)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.persist(vm, status="running", operation=None)
self.process.unknown.add(vm.id)
with self.assertRaises(ConflictError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_not_called()
def test_unavailable_or_lost_agent_does_not_retry_or_mutate_vm_state(self) -> None:
vm = self.create_vm()
before = self.settings.state_path.read_bytes()
self.service._guest_connector = Mock(side_effect=GuestAgentUnavailableError("not ready"))
with self.assertRaises(GuestAgentUnavailableError):
self.service.execute(vm.id, "id")
self.service._guest_connector.assert_called_once()
session = Mock()
session.execute.side_effect = GuestExecutionError("connection lost", outcome_unknown=True)
self.service._guest_connector = Mock(return_value=session)
with self.assertRaises(GuestExecutionError):
self.service.execute(vm.id, "id")
session.execute.assert_called_once()
session.close.assert_called_once()
self.assertEqual(self.settings.state_path.read_bytes(), before)
def test_agent_upgrade_requires_a_stopped_proven_offline_disk_and_retains_identity(self) -> None:
vm = self.create_vm(username="worker")
self.provision_agent.reset_mock()
with self.assertRaises(ConflictError):
self.service.install_guest_agent(vm.id)
self.provision_agent.assert_not_called()
stopped = self.service.stop(vm.id)
before = stopped.to_dict()
def provision(disk, vm_id, username, *, install):
self.assertTrue(self.store.operation_active)
self.assertEqual(self.process.observe(self.store.get(vm_id)), "gone")
self.assertEqual((disk, username, install), (Path(vm.disk), "worker", True))
self.provision_agent.side_effect = provision
self.assertEqual(self.service.install_guest_agent(vm.id).to_dict(), before)
self.provision_agent.assert_called_once()
self.assertEqual(self.store.get(vm.id).to_dict(), before)
self.assertEqual(len(self.process.started), 1)