- 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.
1318 lines
58 KiB
Python
1318 lines
58 KiB
Python
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
import signal
|
|
import socket
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import replace
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from unittest.mock import Mock, call, patch
|
|
|
|
from uvm.config import Settings
|
|
from uvm.domain import VmRecord
|
|
from uvm.errors import FirecrackerError
|
|
from uvm.firecracker import process as process_module
|
|
from uvm.firecracker.process import FirecrackerProcessManager, ProcessInfo
|
|
|
|
|
|
BOOT_ID = "6a2e1188-3925-456b-867c-855e7542b092"
|
|
OLD_BOOT_ID = "02e208af-7560-41ee-b8f9-df3579e57c71"
|
|
PID = 12345
|
|
PIDFD = 91
|
|
|
|
|
|
def make_vm(
|
|
directory: Path,
|
|
pid: int | None = PID,
|
|
start_time: str | None = "42",
|
|
*,
|
|
boot_id: str | None = BOOT_ID,
|
|
operation: str | None = None,
|
|
spawn_pending: bool = False,
|
|
) -> VmRecord:
|
|
return VmRecord(
|
|
id="vm-test",
|
|
cpu=1,
|
|
ram_mib=512,
|
|
guest_ip="10.42.0.2",
|
|
gateway="10.42.0.1",
|
|
tap="uvm-test",
|
|
mac="02:fc:00:00:00:01",
|
|
pid=pid,
|
|
process_start_time=start_time,
|
|
boot_id=boot_id,
|
|
operation=operation,
|
|
spawn_pending=spawn_pending,
|
|
socket=str(directory / "firecracker.sock"),
|
|
config=str(directory / "config.json"),
|
|
log=str(directory / "firecracker.log"),
|
|
disk=str(directory / "rootfs.ext4"),
|
|
)
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self) -> None:
|
|
self.now = 0.0
|
|
|
|
def monotonic(self) -> float:
|
|
return self.now
|
|
|
|
def sleep(self, duration: float) -> None:
|
|
self.now += duration
|
|
|
|
|
|
class ProcessTestCase(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
temporary_directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary_directory.cleanup)
|
|
self.directory = Path(temporary_directory.name)
|
|
self.clock = FakeClock()
|
|
self.child = Mock(pid=PID)
|
|
self.child.poll.return_value = None
|
|
self.popen = Mock(return_value=self.child)
|
|
self.manager = FirecrackerProcessManager(
|
|
Settings(
|
|
base=self.directory,
|
|
api_socket_timeout_s=0.1,
|
|
terminate_timeout_s=0.1,
|
|
),
|
|
popen=self.popen,
|
|
sleep=self.clock.sleep,
|
|
monotonic=self.clock.monotonic,
|
|
)
|
|
self.vm = make_vm(self.directory)
|
|
self.boot_id = self.enterContext(patch.object(self.manager, "_boot_id", return_value=BOOT_ID))
|
|
self.stat = self.enterContext(
|
|
patch.object(self.manager, "_process_stat", return_value=("S", "42"))
|
|
)
|
|
# Every signal/handle boundary is mocked for the synthetic PID and fd.
|
|
self.kill = Mock()
|
|
self.close = Mock()
|
|
self.enterContext(
|
|
patch.object(
|
|
process_module,
|
|
"os",
|
|
SimpleNamespace(
|
|
kill=self.kill, close=self.close, getpid=os.getpid, fsencode=os.fsencode,
|
|
),
|
|
)
|
|
)
|
|
self.open_pidfd = self.enterContext(patch.object(process_module, "_open_pidfd", return_value=PIDFD))
|
|
self.signal_pidfd = self.enterContext(patch.object(process_module, "_signal_pidfd"))
|
|
self.exited = self.enterContext(patch.object(process_module, "_pidfd_exited", return_value=False))
|
|
|
|
def tearDown(self) -> None:
|
|
for invocation in self.kill.call_args_list:
|
|
self.assertEqual(invocation.args[1], 0, "never signal a bare PID")
|
|
|
|
|
|
class FirecrackerProcessSafetyTests(ProcessTestCase):
|
|
def test_vsock_socket_is_removed_only_after_confirmed_process_exit(self) -> None:
|
|
path = self.manager._settings.guest_socket_path(self.vm.id)
|
|
path.parent.mkdir(parents=True)
|
|
path.touch()
|
|
self.vm.process_start_time = None
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
self.assertTrue(path.exists())
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
self.manager.terminate(self.vm)
|
|
self.assertFalse(path.exists())
|
|
|
|
def test_unowned_vsock_socket_keeps_a_missing_pid_ambiguous(self) -> None:
|
|
path = self.manager._settings.guest_socket_path(self.vm.id)
|
|
path.parent.mkdir(parents=True)
|
|
path.touch()
|
|
self.vm.pid = self.vm.process_start_time = None
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
self.assertTrue(path.exists())
|
|
|
|
def test_observes_a_matching_identity_as_alive(self) -> None:
|
|
self.assertEqual(self.manager.observe(self.vm), "alive")
|
|
self.assertTrue(self.manager.is_alive(self.vm))
|
|
|
|
def test_refuses_to_signal_an_unidentified_legacy_pid(self) -> None:
|
|
self.vm.process_start_time = None
|
|
Path(self.vm.socket).touch()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
self.open_pidfd.assert_not_called()
|
|
|
|
def test_unreadable_legacy_proc_with_a_possible_live_pid_is_unknown(self) -> None:
|
|
self.vm.process_start_time = None
|
|
self.stat.return_value = None
|
|
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.kill.assert_called_once_with(PID, 0)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_allows_cleanup_when_an_unidentified_legacy_pid_is_already_gone(self) -> None:
|
|
self.vm.process_start_time = None
|
|
self.stat.return_value = None
|
|
self.kill.side_effect = ProcessLookupError
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_rejects_non_positive_pids_without_signaling_or_unlinking(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
for pid in (0, -1):
|
|
with self.subTest(pid=pid):
|
|
self.vm.pid = pid
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaisesRegex(FirecrackerError, "invalid persisted"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.kill.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_a_prior_boot_proves_exit_even_if_pid_and_start_time_match(self) -> None:
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.stat.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_missing_saved_or_current_boot_identity_is_unknown(self) -> None:
|
|
for saved_boot, current_boot in ((None, BOOT_ID), (BOOT_ID, None), (None, None)):
|
|
with self.subTest(saved=saved_boot, current=current_boot):
|
|
self.vm.boot_id = saved_boot
|
|
self.boot_id.return_value = current_boot
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
self.assertTrue(self.manager.is_alive(self.vm))
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_malformed_saved_tokens_do_not_prove_pid_reuse(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
for boot_id in (None, BOOT_ID):
|
|
for start_time in ("invalid", "-42", " 42", "42\0", "\u0664\u0662", 42):
|
|
with self.subTest(boot=boot_id, start_time=start_time):
|
|
self.vm.boot_id = boot_id
|
|
self.vm.process_start_time = start_time
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_unreadable_proc_is_unknown_unless_pid_is_proven_missing(self) -> None:
|
|
self.stat.return_value = None
|
|
for error in (None, PermissionError(), OSError(errno.EIO, "unavailable"), OverflowError()):
|
|
with self.subTest(error=error):
|
|
self.kill.side_effect = error
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
self.kill.side_effect = ProcessLookupError
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.assertFalse(self.manager.is_alive(self.vm))
|
|
|
|
def test_pid_reuse_is_gone_and_never_signaled(self) -> None:
|
|
self.stat.return_value = ("S", "99")
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.open_pidfd.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_zombie_and_dead_processes_are_gone_when_the_group_has_exited(self) -> None:
|
|
self.exited.return_value = True
|
|
for state in ("Z", "X", "x"):
|
|
with self.subTest(state=state):
|
|
self.stat.return_value = (state, "42")
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
|
|
self.assertEqual(self.close.call_count, 3)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_zombie_leader_with_live_threads_is_not_safe_to_delete(self) -> None:
|
|
self.stat.return_value = ("Z", "42")
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_zombie_observation_fails_closed_without_a_safe_handle(self) -> None:
|
|
self.stat.return_value = ("Z", "42")
|
|
self.open_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported")
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
|
|
def test_no_pid_during_spawning_is_unknown_even_without_a_socket(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
self.vm.operation = "spawning"
|
|
for saved_boot in (None, BOOT_ID):
|
|
with self.subTest(boot=saved_boot):
|
|
self.vm.boot_id = saved_boot
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.start(self.vm)
|
|
|
|
self.popen.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_pending_spawn_survives_persisted_stop_and_delete_intent_without_a_pid(self) -> None:
|
|
for operation in ("stop", "delete"):
|
|
for saved_boot, current_boot in ((None, BOOT_ID), (BOOT_ID, BOOT_ID), (OLD_BOOT_ID, None)):
|
|
with self.subTest(operation=operation, saved_boot=saved_boot, current_boot=current_boot):
|
|
vm = make_vm(
|
|
self.directory, pid=None, start_time=None, boot_id=saved_boot,
|
|
operation=operation, spawn_pending=True,
|
|
)
|
|
vm = VmRecord.from_dict(vm.to_dict())
|
|
self.boot_id.return_value = current_boot
|
|
self.assertEqual(self.manager.observe(vm), "unknown")
|
|
with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"):
|
|
self.manager.terminate(vm)
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.start(vm)
|
|
self.assertTrue(vm.spawn_pending)
|
|
|
|
self.popen.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_prior_boot_proves_a_pending_spawn_gone_despite_stop_or_delete_intent(self) -> None:
|
|
for operation in ("stop", "delete"):
|
|
with self.subTest(operation=operation):
|
|
vm = make_vm(
|
|
self.directory, pid=None, start_time=None, boot_id=OLD_BOOT_ID,
|
|
operation=operation, spawn_pending=True,
|
|
)
|
|
Path(vm.socket).touch()
|
|
self.assertEqual(self.manager.observe(vm), "gone")
|
|
self.manager.terminate(vm)
|
|
self.assertFalse(Path(vm.socket).exists())
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_known_pid_observation_is_independent_of_spawn_pending(self) -> None:
|
|
self.vm.spawn_pending = True
|
|
self.vm.operation = "delete"
|
|
self.assertEqual(self.manager.observe(self.vm), "alive")
|
|
self.stat.return_value = None
|
|
self.kill.side_effect = ProcessLookupError
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
|
|
def test_no_pid_spawning_from_a_proven_prior_boot_is_gone(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
self.vm.operation = "spawning"
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_no_pid_spawning_cannot_infer_a_reboot_from_an_unreadable_boot_id(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
self.vm.operation = "spawning"
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
self.boot_id.return_value = None
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
|
|
def test_a_clean_unspawned_record_is_gone(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
self.vm.boot_id = None
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_an_unowned_socket_is_not_assumed_stale(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
Path(self.vm.socket).touch()
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.start(self.vm)
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.popen.assert_not_called()
|
|
|
|
def test_an_unreadable_unowned_socket_is_unknown(self) -> None:
|
|
self.vm.pid = None
|
|
self.vm.process_start_time = None
|
|
with patch.object(Path, "lstat", side_effect=PermissionError):
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
|
|
def test_term_exit_is_confirmed_before_unlinking_and_identity_is_retained(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.exited.side_effect = [False, True]
|
|
self.signal_pidfd.side_effect = lambda *_args: self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.signal_pidfd.assert_called_once_with(PIDFD, signal.SIGTERM)
|
|
self.close.assert_called_once_with(PIDFD)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
self.kill.assert_not_called()
|
|
|
|
def test_forced_stop_waits_for_exit_even_with_zero_term_timeout(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.exited.side_effect = [False, False, False, True]
|
|
self.signal_pidfd.side_effect = lambda *_args: self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
self.manager.terminate(self.vm, timeout=0)
|
|
|
|
self.assertEqual(
|
|
self.signal_pidfd.call_args_list,
|
|
[call(PIDFD, signal.SIGTERM), call(PIDFD, signal.SIGKILL)],
|
|
)
|
|
self.assertGreater(self.clock.now, 0)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
def test_unconfirmed_kill_retains_socket_and_identity(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "could not confirm exit.*SIGKILL"):
|
|
self.manager.terminate(self.vm, timeout=0)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
self.assertEqual(self.clock.now, 1.0)
|
|
self.close.assert_called_once_with(PIDFD)
|
|
|
|
def test_signal_errors_preserve_the_socket(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
for failing_signal in (signal.SIGTERM, signal.SIGKILL):
|
|
with self.subTest(signal=failing_signal):
|
|
def fail_signal(_pidfd: int, signal_number: int) -> None:
|
|
if signal_number == failing_signal:
|
|
raise PermissionError("denied")
|
|
|
|
self.signal_pidfd.side_effect = fail_signal
|
|
with self.assertRaisesRegex(FirecrackerError, "could not safely stop"):
|
|
self.manager.terminate(self.vm, timeout=0)
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
def test_signal_lookup_error_still_requires_exit_confirmation(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.signal_pidfd.side_effect = ProcessLookupError
|
|
self.exited.side_effect = [False, True]
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.assertEqual(self.exited.call_count, 2)
|
|
|
|
def test_signal_lookup_error_does_not_hide_an_unconfirmed_exit(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.signal_pidfd.side_effect = ProcessLookupError
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "could not confirm exit"):
|
|
self.manager.terminate(self.vm, timeout=0)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
def test_pid_disappearing_before_handle_acquisition_is_safe_to_clean(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.open_pidfd.side_effect = ProcessLookupError
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
self.close.assert_not_called()
|
|
|
|
def test_unavailable_or_denied_pidfd_does_not_fall_back_to_pid_signals(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
for error in (OSError(errno.ENOSYS, "unsupported"), PermissionError("denied")):
|
|
with self.subTest(error=error):
|
|
self.open_pidfd.side_effect = error
|
|
with self.assertRaisesRegex(FirecrackerError, "safely open a pidfd"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.kill.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_unavailable_pidfd_signaling_does_not_fall_back_to_pid_signals(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.signal_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported")
|
|
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.kill.assert_not_called()
|
|
self.close.assert_called_once_with(PIDFD)
|
|
|
|
def test_identity_is_rechecked_after_opening_the_pidfd(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.stat.side_effect = [("S", "42"), ("S", "99")]
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
self.close.assert_called_once_with(PIDFD)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
def test_identity_becoming_unreadable_after_open_is_not_signaled(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.stat.side_effect = [("S", "42"), None]
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "identity became ambiguous"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
self.close.assert_called_once_with(PIDFD)
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
def test_an_already_exited_handle_needs_no_signal(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.exited.return_value = True
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
def test_poll_failure_retains_the_socket_and_closes_the_handle(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
self.exited.side_effect = OSError(errno.EIO, "poll failed")
|
|
|
|
with self.assertRaises(FirecrackerError):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.close.assert_called_once_with(PIDFD)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_socket_unlink_errors_are_not_suppressed(self) -> None:
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
with patch.object(Path, "unlink", side_effect=PermissionError("denied")):
|
|
with self.assertRaisesRegex(FirecrackerError, "could not remove Firecracker socket"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
def test_timeout_override_controls_term_grace_not_exit_confirmation(self) -> None:
|
|
with patch.object(self.manager, "_wait_for_exit", side_effect=[False, True]) as wait:
|
|
self.manager.terminate(self.vm, timeout=0.025)
|
|
|
|
self.assertEqual(wait.call_args_list, [call(PIDFD, 0.025), call(PIDFD, 1.0)])
|
|
|
|
def test_invalid_timeouts_are_rejected_before_signaling(self) -> None:
|
|
for timeout in (-1, float("nan"), float("inf")):
|
|
with self.subTest(timeout=timeout):
|
|
with self.assertRaisesRegex(FirecrackerError, "timeout must be"):
|
|
self.manager.terminate(self.vm, timeout=timeout)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_observation_reaps_only_owned_children(self) -> None:
|
|
self.manager._children[PID] = self.child
|
|
self.child.poll.return_value = 0
|
|
self.stat.return_value = None
|
|
self.kill.side_effect = ProcessLookupError
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
|
|
self.child.poll.assert_called_once_with()
|
|
self.assertNotIn(PID, self.manager._children)
|
|
|
|
|
|
class FirecrackerProcessStartTests(ProcessTestCase):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None)
|
|
self.wait = self.enterContext(patch.object(self.manager, "_wait_for_socket"))
|
|
|
|
def test_identity_is_in_memory_and_published_before_socket_readiness(self) -> None:
|
|
events = []
|
|
self.vm.operation = "boot"
|
|
persisted = [replace(self.vm, operation="spawning", boot_id=BOOT_ID, spawn_pending=True).to_dict()]
|
|
self.assertEqual(self.manager.observe(VmRecord.from_dict(persisted[0])), "unknown")
|
|
|
|
def on_spawn(info: ProcessInfo) -> None:
|
|
events.append("spawn")
|
|
self.assertEqual(info, ProcessInfo(PID, "42", BOOT_ID))
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
self.assertTrue(self.vm.spawn_pending)
|
|
self.wait.assert_not_called()
|
|
self.vm.operation = "configuring"
|
|
self.vm.spawn_pending = False
|
|
persisted.append(self.vm.to_dict())
|
|
|
|
self.wait.side_effect = lambda *_args: events.append("socket")
|
|
|
|
info = self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertEqual(info, ProcessInfo(PID, "42", BOOT_ID))
|
|
self.assertEqual(events, ["spawn", "socket"])
|
|
self.assertEqual(self.vm.operation, "configuring")
|
|
self.assertFalse(self.vm.spawn_pending)
|
|
self.assertEqual(VmRecord.from_dict(persisted[1]), self.vm)
|
|
self.assertTrue(self.popen.call_args.kwargs["start_new_session"])
|
|
self.signal_pidfd.assert_called_once_with(PIDFD, 0)
|
|
self.open_pidfd.assert_called_once_with(os.getpid())
|
|
self.assertIsNone(ProcessInfo(PID, "42").boot_id)
|
|
|
|
def test_start_clears_pending_after_identity_only_when_no_callback_is_supplied(self) -> None:
|
|
def spawn(*_args, **_kwargs):
|
|
self.assertTrue(self.vm.spawn_pending)
|
|
self.assertEqual(self.vm.operation, "spawning")
|
|
self.assertIsNone(self.vm.pid)
|
|
return self.child
|
|
|
|
self.popen.side_effect = spawn
|
|
for on_spawn in (None, Mock()):
|
|
with self.subTest(callback=on_spawn is not None):
|
|
self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None)
|
|
self.wait.side_effect = lambda *_args: self.assertEqual(
|
|
self.vm.spawn_pending, on_spawn is not None,
|
|
)
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
self.assertEqual(self.vm.spawn_pending, on_spawn is not None)
|
|
self.assertEqual(self.manager.observe(self.vm), "alive")
|
|
|
|
def test_start_refuses_live_or_ambiguous_existing_identity(self) -> None:
|
|
self.vm.pid = PID
|
|
self.vm.boot_id = BOOT_ID
|
|
Path(self.vm.socket).touch()
|
|
for start_time in ("42", None):
|
|
with self.subTest(start_time=start_time):
|
|
self.vm.process_start_time = start_time
|
|
with self.assertRaisesRegex(FirecrackerError, "refusing to start"):
|
|
self.manager.start(self.vm)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.popen.assert_not_called()
|
|
self.open_pidfd.assert_not_called()
|
|
|
|
def test_start_replaces_only_a_proven_stale_socket(self) -> None:
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
self.vm.operation = "spawning"
|
|
Path(self.vm.socket).touch()
|
|
|
|
def on_spawn(_info: ProcessInfo) -> None:
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertEqual(self.vm.boot_id, BOOT_ID)
|
|
self.assertEqual(self.vm.pid, PID)
|
|
|
|
def test_failing_persistence_callback_cleans_up_without_losing_identity(self) -> None:
|
|
failure = RuntimeError("persistence failed")
|
|
self.exited.side_effect = [False, True]
|
|
self.child.poll.side_effect = [None, None, -signal.SIGTERM]
|
|
|
|
def on_spawn(_info: ProcessInfo) -> None:
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
Path(self.vm.socket).touch()
|
|
raise failure
|
|
|
|
with self.assertRaises(RuntimeError) as raised:
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertIs(raised.exception, failure)
|
|
self.wait.assert_not_called()
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
self.assertNotIn(PID, self.manager._children)
|
|
self.assertEqual(self.signal_pidfd.call_args_list, [call(PIDFD, 0), call(PIDFD, signal.SIGTERM)])
|
|
|
|
def test_interrupt_during_startup_terminates_the_child_process(self) -> None:
|
|
self.wait.side_effect = KeyboardInterrupt
|
|
self.exited.side_effect = [False, True]
|
|
|
|
with self.assertRaises(KeyboardInterrupt):
|
|
self.manager.start(self.vm)
|
|
|
|
self.assertEqual(self.signal_pidfd.call_args_list, [call(PIDFD, 0), call(PIDFD, signal.SIGTERM)])
|
|
self.assertEqual(self.vm.pid, PID)
|
|
|
|
def test_incomplete_spawn_cleanup_surfaces_both_failures_and_preserves_resources(self) -> None:
|
|
failure = RuntimeError("persistence failed")
|
|
|
|
def on_spawn(_info: ProcessInfo) -> None:
|
|
Path(self.vm.socket).touch()
|
|
raise failure
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "persistence failed.*cleanup incomplete") as raised:
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertIs(raised.exception.__cause__, failure)
|
|
self.wait.assert_not_called()
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
|
|
def test_identity_capture_failure_retains_partial_identity_and_reports_incomplete_cleanup(self) -> None:
|
|
self.stat.return_value = None
|
|
on_spawn = Mock()
|
|
|
|
def spawn(*_args, **_kwargs):
|
|
Path(self.vm.socket).touch()
|
|
return self.child
|
|
|
|
self.popen.side_effect = spawn
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "cleanup incomplete.*identity is ambiguous"):
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
on_spawn.assert_not_called()
|
|
self.wait.assert_not_called()
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, None, BOOT_ID))
|
|
self.assertTrue(self.vm.spawn_pending)
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_called_once_with(PIDFD, 0)
|
|
|
|
def test_popen_oserror_restores_prior_operation_and_metadata(self) -> None:
|
|
self.popen.side_effect = FileNotFoundError(errno.ENOENT, "missing binary")
|
|
for pid, start_time, boot_id, operation, spawn_pending in (
|
|
(None, None, None, None, False),
|
|
(None, None, BOOT_ID, "boot", False),
|
|
(PID, "42", OLD_BOOT_ID, "boot", False),
|
|
(None, None, OLD_BOOT_ID, "spawning", True),
|
|
(None, None, OLD_BOOT_ID, "boot", True),
|
|
):
|
|
with self.subTest(pid=pid, boot=boot_id, operation=operation):
|
|
self.vm = make_vm(
|
|
self.directory, pid, start_time, boot_id=boot_id, operation=operation,
|
|
spawn_pending=spawn_pending,
|
|
)
|
|
previous = self.vm.to_dict()
|
|
on_spawn = Mock()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "could not start Firecracker"):
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertEqual(self.vm.to_dict(), previous)
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
on_spawn.assert_not_called()
|
|
self.wait.assert_not_called()
|
|
self.assertEqual(self.manager._children, {})
|
|
|
|
def test_uncertain_popen_failure_preserves_the_spawning_marker(self) -> None:
|
|
for error in (
|
|
KeyboardInterrupt(), SystemExit(), RuntimeError("uncertain spawn"),
|
|
InterruptedError(errno.EINTR, "interrupted spawn"),
|
|
):
|
|
with self.subTest(error=type(error).__name__):
|
|
self.vm = make_vm(
|
|
self.directory, pid=None, start_time=None, boot_id=None, operation="boot",
|
|
)
|
|
self.popen.side_effect = error
|
|
exception_type = FirecrackerError if isinstance(error, OSError) else type(error)
|
|
|
|
with self.assertRaises(exception_type):
|
|
self.manager.start(self.vm)
|
|
|
|
self.assertEqual(self.vm.operation, "spawning")
|
|
self.assertTrue(self.vm.spawn_pending)
|
|
self.assertEqual(self.vm.boot_id, BOOT_ID)
|
|
self.assertIsNone(self.vm.pid)
|
|
self.assertIsNone(self.vm.process_start_time)
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
|
|
def test_post_spawn_oserror_does_not_restore_the_old_identity(self) -> None:
|
|
self.vm.operation = "boot"
|
|
self.exited.side_effect = [False, True]
|
|
|
|
def on_spawn(_info: ProcessInfo) -> None:
|
|
self.vm.operation = "configuring"
|
|
raise OSError("persistence failed")
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "persistence failed"):
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertEqual(self.vm.operation, "configuring")
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (PID, "42", BOOT_ID))
|
|
self.wait.assert_not_called()
|
|
|
|
def test_unsupported_pidfd_prevents_spawn_and_preserves_the_old_record(self) -> None:
|
|
self.vm.boot_id = OLD_BOOT_ID
|
|
Path(self.vm.socket).touch()
|
|
self.open_pidfd.side_effect = OSError(errno.ENOSYS, "unsupported")
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "safe pidfd support is required"):
|
|
self.manager.start(self.vm)
|
|
|
|
self.popen.assert_not_called()
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.assertEqual(self.vm.boot_id, OLD_BOOT_ID)
|
|
self.assertIsNone(self.vm.operation)
|
|
|
|
def test_unknown_current_boot_prevents_spawn(self) -> None:
|
|
self.boot_id.return_value = None
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "current host boot identity"):
|
|
self.manager.start(self.vm)
|
|
|
|
self.popen.assert_not_called()
|
|
self.open_pidfd.assert_not_called()
|
|
|
|
def test_socket_wait_checks_exit_before_socket_presence(self) -> None:
|
|
self.child.poll.return_value = 0
|
|
with patch.object(Path, "is_socket", return_value=True) as is_socket:
|
|
with self.assertRaisesRegex(FirecrackerError, "exited early"):
|
|
FirecrackerProcessManager._wait_for_socket(
|
|
self.manager, self.child, Path(self.vm.socket), Path(self.vm.log)
|
|
)
|
|
is_socket.assert_not_called()
|
|
|
|
def test_socket_wait_does_not_accept_a_regular_file(self) -> None:
|
|
Path(self.vm.socket).touch()
|
|
with self.assertRaisesRegex(FirecrackerError, "timed out waiting"):
|
|
FirecrackerProcessManager._wait_for_socket(
|
|
self.manager, self.child, Path(self.vm.socket), Path(self.vm.log)
|
|
)
|
|
|
|
def test_socket_timeout_occurs_after_identity_publication_and_confirms_cleanup(self) -> None:
|
|
self.wait.side_effect = FirecrackerError("timed out waiting for Firecracker API socket")
|
|
self.exited.side_effect = [False, True]
|
|
on_spawn = Mock()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "timed out waiting"):
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
on_spawn.assert_called_once_with(ProcessInfo(PID, "42", BOOT_ID))
|
|
self.assertEqual(self.vm.pid, PID)
|
|
|
|
|
|
class FirecrackerLegacyAdoptionTests(ProcessTestCase):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
self.runtime = self.manager._settings.vm_dir(self.vm.id)
|
|
self.runtime.mkdir(parents=True, mode=0o700)
|
|
self.runtime.parent.chmod(0o755)
|
|
self.vm = make_vm(self.runtime, boot_id=None)
|
|
self.binary = self.manager._settings.firecracker_binary
|
|
self.binary.parent.mkdir(mode=0o755)
|
|
self.binary.touch(mode=0o755)
|
|
listener = socket.socket(socket.AF_UNIX)
|
|
self.addCleanup(listener.close)
|
|
listener.bind(self.vm.socket)
|
|
|
|
# Production runs as root; preserve real file types/modes in unprivileged tests.
|
|
real_lstat = Path.lstat
|
|
|
|
def root_lstat(path: Path) -> os.stat_result:
|
|
metadata = list(real_lstat(path))
|
|
metadata[4] = 0
|
|
# Model protected system ancestors independently of the caller's
|
|
# TMPDIR permissions. Unsafe-path fixtures live inside self.directory.
|
|
if path in self.directory.parents:
|
|
metadata[0] &= ~0o022
|
|
return os.stat_result(metadata)
|
|
|
|
self.root_lstat = root_lstat
|
|
self.enterContext(patch.object(Path, "lstat", autospec=True, side_effect=root_lstat))
|
|
self.executable = self.enterContext(
|
|
patch.object(Path, "readlink", autospec=True, return_value=self.binary)
|
|
)
|
|
self.samefile = self.enterContext(patch.object(Path, "samefile", autospec=True, return_value=True))
|
|
self.expected_cmdline = b"\0".join(
|
|
(os.fsencode(self.binary), b"--api-sock", os.fsencode(self.vm.socket), b"")
|
|
)
|
|
self.cmdline = self.enterContext(
|
|
patch.object(Path, "read_bytes", autospec=True, return_value=self.expected_cmdline)
|
|
)
|
|
|
|
def test_verified_v1_identity_can_be_persisted_and_explicitly_stopped(self) -> None:
|
|
self.vm.status = "running"
|
|
legacy = self.vm.to_dict()
|
|
for name in (
|
|
"boot_id", "operation", "disk_ready", "runtime_owned", "tap_owned",
|
|
"ports", "network", "bridge", "name", "spawn_pending",
|
|
):
|
|
legacy.pop(name)
|
|
self.vm = VmRecord.from_dict(legacy)
|
|
previous = self.vm.to_dict()
|
|
self.assertEqual(self.manager.observe(self.vm), "unknown")
|
|
with self.assertRaisesRegex(FirecrackerError, "identity is ambiguous"):
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertTrue(self.manager.adopt_legacy_identity(self.vm))
|
|
|
|
self.assertEqual(self.vm.to_dict(), previous | {"boot_id": BOOT_ID})
|
|
self.signal_pidfd.assert_not_called()
|
|
self.kill.assert_not_called()
|
|
self.close.assert_called_once_with(PIDFD)
|
|
persisted = VmRecord.from_dict(self.vm.to_dict())
|
|
self.assertEqual(self.manager.observe(persisted), "alive")
|
|
self.exited.side_effect = [False, True]
|
|
self.manager.terminate(persisted)
|
|
self.signal_pidfd.assert_called_once_with(PIDFD, signal.SIGTERM)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
def test_pidfd_pins_the_process_before_any_identity_read_until_final_exit_check(self) -> None:
|
|
events = Mock()
|
|
for name, boundary in (
|
|
("open", self.open_pidfd), ("stat", self.stat), ("exe", self.executable),
|
|
("samefile", self.samefile), ("cmdline", self.cmdline), ("boot", self.boot_id),
|
|
("exited", self.exited), ("close", self.close),
|
|
):
|
|
events.attach_mock(boundary, name)
|
|
self.close.side_effect = lambda _fd: self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.assertTrue(self.manager.adopt_legacy_identity(self.vm))
|
|
|
|
proc = Path(f"/proc/{PID}")
|
|
self.assertEqual(events.mock_calls, [
|
|
call.open(PID), call.stat(PID), call.exe(proc / "exe"),
|
|
call.samefile(proc / "exe", self.binary), call.cmdline(proc / "cmdline"),
|
|
call.boot(), call.exited(PIDFD), call.close(PIDFD),
|
|
])
|
|
|
|
def test_only_missing_boot_id_with_a_valid_pid_and_original_token_is_eligible(self) -> None:
|
|
for boot_id, pid, start_time in (
|
|
(BOOT_ID, PID, "42"), (OLD_BOOT_ID, PID, "42"), ("", PID, "42"),
|
|
(None, None, "42"), (None, 0, "42"), (None, -1, "42"),
|
|
(None, 2**32 + PID, "42"), (None, True, "42"),
|
|
(None, PID, None), (None, PID, ""), (None, PID, "invalid"),
|
|
(None, PID, "-42"), (None, PID, " 42"), (None, PID, "42\0"),
|
|
(None, PID, "\u0664\u0662"), (None, PID, 42),
|
|
):
|
|
with self.subTest(boot=boot_id, pid=pid, start_time=start_time):
|
|
vm = replace(self.vm, boot_id=boot_id, pid=pid, process_start_time=start_time)
|
|
previous = vm.to_dict()
|
|
self.assertFalse(self.manager.adopt_legacy_identity(vm))
|
|
self.assertEqual(vm.to_dict(), previous)
|
|
|
|
self.open_pidfd.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
self.stat.assert_not_called()
|
|
|
|
def test_missing_mismatched_or_dead_proc_identity_is_never_adopted(self) -> None:
|
|
for process_stat in (None, ("S", "99"), ("Z", "42"), ("X", "42"), ("x", "42")):
|
|
with self.subTest(stat=process_stat):
|
|
self.stat.return_value = process_stat
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.assertEqual(self.close.call_count, 5)
|
|
self.cmdline.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_unavailable_or_denied_pidfd_is_not_replaced_by_a_pid_probe(self) -> None:
|
|
for error in (ProcessLookupError(), PermissionError(), OSError(errno.ENOSYS, "unsupported")):
|
|
with self.subTest(error=error):
|
|
self.open_pidfd.side_effect = error
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.stat.assert_not_called()
|
|
self.kill.assert_not_called()
|
|
self.close.assert_not_called()
|
|
|
|
def test_executable_must_match_both_the_configured_path_and_inode(self) -> None:
|
|
for executable, samefile in (
|
|
(Path("/usr/bin/sleep"), True),
|
|
(Path(str(self.binary) + " (deleted)"), True),
|
|
(Path("firecracker"), True),
|
|
(self.binary, False),
|
|
):
|
|
with self.subTest(executable=executable, samefile=samefile):
|
|
self.executable.return_value = executable
|
|
self.samefile.return_value = samefile
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.cmdline.assert_not_called()
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_cmdline_must_match_all_three_arguments_and_nul_boundaries_exactly(self) -> None:
|
|
binary, api_socket = str(self.binary), self.vm.socket
|
|
commands = [
|
|
["firecracker", "--api-sock", api_socket],
|
|
["/usr/bin/sleep", "--api-sock", api_socket],
|
|
[binary, "--api-sock", api_socket + ".other"],
|
|
[binary, "--api-sock", str(self.runtime.parent / "other-vm" / "firecracker.sock")],
|
|
[binary, "--api-sock", "firecracker.sock"],
|
|
[binary, "--api-sock", api_socket, "--extra"],
|
|
[binary, f"--api-sock={api_socket}"],
|
|
[binary, api_socket, "--api-sock"],
|
|
[binary, "--api-sock"],
|
|
["/bin/sh", binary, "--api-sock", api_socket],
|
|
]
|
|
invalid_cmdlines = [
|
|
b"\0".join(os.fsencode(argument) for argument in command) + b"\0"
|
|
for command in commands
|
|
] + [b"", self.expected_cmdline[:-1], self.expected_cmdline + b"\0"]
|
|
for cmdline in invalid_cmdlines:
|
|
with self.subTest(cmdline=cmdline):
|
|
self.cmdline.return_value = cmdline
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_socket_record_must_be_the_exact_protected_path_for_its_vm(self) -> None:
|
|
for vm_id, api_socket in (
|
|
(self.vm.id, "firecracker.sock"),
|
|
(self.vm.id, str(self.directory / "firecracker.sock")),
|
|
(self.vm.id, str(self.runtime) + "/../vm-test/firecracker.sock"),
|
|
(self.vm.id, str(self.runtime) + "//firecracker.sock"),
|
|
("another-vm", self.vm.socket),
|
|
("../vm-test", str(self.directory / "vm-test/firecracker.sock")),
|
|
(".", str(self.runtime.parent / "firecracker.sock")),
|
|
("", str(self.runtime.parent / "firecracker.sock")),
|
|
):
|
|
with self.subTest(vm_id=vm_id, socket=api_socket):
|
|
vm = replace(self.vm, id=vm_id, socket=api_socket)
|
|
self.assertFalse(self.manager.adopt_legacy_identity(vm))
|
|
self.assertIsNone(vm.boot_id)
|
|
|
|
self.cmdline.assert_not_called()
|
|
self.assertTrue(Path(self.vm.socket).exists())
|
|
|
|
def test_missing_regular_or_symlink_socket_is_not_adopted(self) -> None:
|
|
socket_path = Path(self.vm.socket)
|
|
for kind in ("missing", "regular", "symlink"):
|
|
with self.subTest(kind=kind):
|
|
socket_path.unlink(missing_ok=True)
|
|
if kind == "regular":
|
|
socket_path.touch()
|
|
elif kind == "symlink":
|
|
socket_path.symlink_to(self.binary)
|
|
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.cmdline.assert_not_called()
|
|
|
|
def test_unprotected_or_symlinked_directories_and_binary_are_not_adopted(self) -> None:
|
|
for path, mode, uid in (
|
|
(self.directory, stat.S_IFDIR | 0o777, 0),
|
|
(self.runtime.parent, stat.S_IFDIR | 0o775, 0),
|
|
(self.runtime, stat.S_IFDIR | 0o755, 0),
|
|
(self.binary.parent, stat.S_IFDIR | 0o777, 0),
|
|
(self.runtime.parent, stat.S_IFLNK | 0o777, 0),
|
|
(self.directory, stat.S_IFDIR | 0o700, 1001),
|
|
(self.runtime, stat.S_IFDIR | 0o700, 1001),
|
|
(self.binary, stat.S_IFREG | 0o777, 0),
|
|
(self.binary, stat.S_IFREG | 0o755, 1001),
|
|
(self.binary, stat.S_IFLNK | 0o777, 0),
|
|
(Path(self.vm.socket), stat.S_IFSOCK | 0o700, 1001),
|
|
):
|
|
with self.subTest(path=path, mode=oct(mode), uid=uid):
|
|
def untrusted_lstat(candidate: Path) -> os.stat_result:
|
|
metadata = list(self.root_lstat(candidate))
|
|
if candidate == path:
|
|
metadata[0], metadata[4] = mode, uid
|
|
return os.stat_result(metadata)
|
|
|
|
with patch.object(Path, "lstat", new=untrusted_lstat):
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.cmdline.assert_not_called()
|
|
|
|
def test_unreadable_process_or_filesystem_evidence_does_not_adopt(self) -> None:
|
|
for method in ("lstat", "readlink", "samefile", "read_bytes"):
|
|
with self.subTest(method=method):
|
|
with patch.object(Path, method, side_effect=PermissionError("denied")):
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.assertEqual(self.close.call_count, 4)
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_boot_exit_and_handle_errors_leave_identity_unmodified(self) -> None:
|
|
for name, failure in (
|
|
("boot", patch.object(self.manager, "_boot_id", return_value=None)),
|
|
("exited", patch.object(process_module, "_pidfd_exited", return_value=True)),
|
|
("poll", patch.object(process_module, "_pidfd_exited", side_effect=OSError("poll failed"))),
|
|
("close", patch.object(process_module.os, "close", side_effect=OSError("close failed"))),
|
|
):
|
|
with self.subTest(boundary=name), failure:
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
self.assertIsNone(self.vm.boot_id)
|
|
|
|
self.signal_pidfd.assert_not_called()
|
|
|
|
def test_exit_during_proc_reads_is_detected_on_the_original_pidfd(self) -> None:
|
|
def read_and_exit(_path: Path) -> bytes:
|
|
self.exited.return_value = True
|
|
return self.expected_cmdline
|
|
|
|
self.cmdline.side_effect = read_and_exit
|
|
|
|
self.assertFalse(self.manager.adopt_legacy_identity(self.vm))
|
|
|
|
self.assertIsNone(self.vm.boot_id)
|
|
self.exited.assert_called_once_with(PIDFD)
|
|
self.close.assert_called_once_with(PIDFD)
|
|
|
|
|
|
class FirecrackerProcessBoundaryTests(unittest.TestCase):
|
|
def test_public_host_boot_id_delegates_without_caching(self) -> None:
|
|
manager = FirecrackerProcessManager(Settings())
|
|
with patch.object(manager, "_boot_id", side_effect=[BOOT_ID, None]) as boot_id:
|
|
self.assertEqual(manager.host_boot_id(), BOOT_ID)
|
|
self.assertIsNone(manager.host_boot_id())
|
|
self.assertEqual(boot_id.call_args_list, [call(), call()])
|
|
|
|
def test_proc_stat_handles_spaces_and_parentheses_in_the_command(self) -> None:
|
|
stat = f"{PID} (a tricky (name) with ) parentheses) S " + " ".join(["0"] * 18 + ["42", "0"])
|
|
manager = FirecrackerProcessManager(Settings())
|
|
with patch.object(Path, "read_text", return_value=stat):
|
|
self.assertEqual(manager._process_stat(PID), ("S", "42"))
|
|
self.assertEqual(manager.process_start_time(PID), "42")
|
|
|
|
def test_unreadable_or_malformed_proc_stat_has_no_identity(self) -> None:
|
|
manager = FirecrackerProcessManager(Settings())
|
|
for stat in (
|
|
"",
|
|
"12345 broken",
|
|
"12345 (name) S 0",
|
|
"12345 (name) ? " + "0 " * 20,
|
|
"12345 name) S " + "0 " * 20,
|
|
"99999 (name) S " + "0 " * 20,
|
|
):
|
|
with self.subTest(stat=stat), patch.object(Path, "read_text", return_value=stat):
|
|
self.assertIsNone(manager.process_start_time(PID))
|
|
for error in (PermissionError(), FileNotFoundError(), UnicodeError()):
|
|
with self.subTest(error=error), patch.object(Path, "read_text", side_effect=error):
|
|
self.assertIsNone(manager.process_start_time(PID))
|
|
|
|
def test_proc_start_time_must_be_numeric(self) -> None:
|
|
stat = f"{PID} (name) S " + " ".join(["0"] * 18 + ["invalid"])
|
|
with patch.object(Path, "read_text", return_value=stat):
|
|
self.assertIsNone(FirecrackerProcessManager(Settings()).process_start_time(PID))
|
|
|
|
def test_boot_id_is_read_from_the_kernel_and_empty_or_unreadable_is_unknown(self) -> None:
|
|
with patch.object(Path, "read_text", return_value=f"{BOOT_ID}\n") as read:
|
|
self.assertEqual(FirecrackerProcessManager._boot_id(), BOOT_ID)
|
|
read.assert_called_once_with(encoding="ascii")
|
|
with patch.object(Path, "read_text", return_value="\n"):
|
|
self.assertIsNone(FirecrackerProcessManager._boot_id())
|
|
with patch.object(Path, "read_text", side_effect=PermissionError):
|
|
self.assertIsNone(FirecrackerProcessManager._boot_id())
|
|
|
|
def test_native_pidfd_bindings_are_preferred(self) -> None:
|
|
with (
|
|
patch.object(os, "pidfd_open", return_value=PIDFD, create=True) as open_pidfd,
|
|
patch.object(signal, "pidfd_send_signal", create=True) as send_signal,
|
|
patch.object(process_module, "_libc_pidfd") as libc,
|
|
):
|
|
self.assertEqual(process_module._open_pidfd(PID), PIDFD)
|
|
process_module._signal_pidfd(PIDFD, signal.SIGTERM)
|
|
|
|
open_pidfd.assert_called_once_with(PID, 0)
|
|
send_signal.assert_called_once_with(PIDFD, signal.SIGTERM)
|
|
libc.assert_not_called()
|
|
|
|
def test_libc_pidfd_bindings_cover_python_builds_without_native_bindings(self) -> None:
|
|
libc = SimpleNamespace(pidfd_open=Mock(return_value=PIDFD), pidfd_send_signal=Mock(return_value=0))
|
|
with (
|
|
patch.object(os, "pidfd_open", None, create=True),
|
|
patch.object(signal, "pidfd_send_signal", None, create=True),
|
|
patch.object(process_module.ctypes, "CDLL", return_value=libc),
|
|
):
|
|
self.assertEqual(process_module._open_pidfd(PID), PIDFD)
|
|
process_module._signal_pidfd(PIDFD, signal.SIGTERM)
|
|
|
|
libc.pidfd_open.assert_called_once_with(PID, 0)
|
|
libc.pidfd_send_signal.assert_called_once_with(PIDFD, signal.SIGTERM, None, 0)
|
|
|
|
def test_libc_pidfd_errors_preserve_errno(self) -> None:
|
|
libc = SimpleNamespace(pidfd_open=Mock(return_value=-1))
|
|
with (
|
|
patch.object(os, "pidfd_open", None, create=True),
|
|
patch.object(process_module.ctypes, "CDLL", return_value=libc),
|
|
patch.object(process_module.ctypes, "get_errno", return_value=errno.ESRCH),
|
|
):
|
|
with self.assertRaises(ProcessLookupError):
|
|
process_module._open_pidfd(PID)
|
|
|
|
def test_missing_libc_pidfd_binding_fails_closed(self) -> None:
|
|
with (
|
|
patch.object(os, "pidfd_open", None, create=True),
|
|
patch.object(process_module.ctypes, "CDLL", return_value=SimpleNamespace()),
|
|
):
|
|
with self.assertRaises(OSError) as raised:
|
|
process_module._open_pidfd(PID)
|
|
self.assertEqual(raised.exception.errno, errno.ENOSYS)
|
|
|
|
def test_invalid_pidfd_poll_events_are_errors_not_exit_confirmation(self) -> None:
|
|
poller = Mock()
|
|
poller.poll.return_value = [(PIDFD, process_module.select.POLLNVAL)]
|
|
with patch.object(process_module.select, "poll", return_value=poller):
|
|
with self.assertRaises(OSError):
|
|
process_module._pidfd_exited(PIDFD)
|
|
|
|
|
|
class FirecrackerOwnedChildTests(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
temporary_directory = tempfile.TemporaryDirectory()
|
|
self.addCleanup(temporary_directory.cleanup)
|
|
self.directory = Path(temporary_directory.name)
|
|
self.children: list[subprocess.Popen] = []
|
|
self.addCleanup(self.cleanup_children)
|
|
try:
|
|
pidfd = process_module._open_pidfd(os.getpid())
|
|
try:
|
|
process_module._signal_pidfd(pidfd, 0)
|
|
finally:
|
|
os.close(pidfd)
|
|
except (OSError, NotImplementedError) as error:
|
|
self.skipTest(f"safe pidfd support unavailable: {error}")
|
|
self.manager = FirecrackerProcessManager(
|
|
Settings(base=self.directory, api_socket_timeout_s=1, terminate_timeout_s=0.1),
|
|
popen=self.spawn_child,
|
|
)
|
|
self.vm = make_vm(self.directory, pid=None, start_time=None, boot_id=None)
|
|
self.ignore_term = False
|
|
self.create_socket = True
|
|
|
|
def spawn_child(self, command, **kwargs) -> subprocess.Popen:
|
|
script = (
|
|
"import signal, socket, sys, time\n"
|
|
"if sys.argv[2] == 'ignore': signal.signal(signal.SIGTERM, signal.SIG_IGN)\n"
|
|
"if sys.argv[3] == 'socket':\n"
|
|
" listener = socket.socket(socket.AF_UNIX)\n"
|
|
" listener.bind(sys.argv[1])\n"
|
|
" listener.listen()\n"
|
|
"time.sleep(10)\n"
|
|
)
|
|
child = subprocess.Popen(
|
|
[
|
|
sys.executable, "-B", "-c", script, command[-1],
|
|
"ignore" if self.ignore_term else "default",
|
|
"socket" if self.create_socket else "no-socket",
|
|
],
|
|
**kwargs,
|
|
)
|
|
self.children.append(child)
|
|
return child
|
|
|
|
def cleanup_children(self) -> None:
|
|
for child in self.children:
|
|
if child.poll() is None:
|
|
# These are exclusively the harmless children created by this test.
|
|
child.kill()
|
|
child.wait(timeout=5)
|
|
|
|
def test_real_child_start_stop_and_restart_keep_the_retained_disk(self) -> None:
|
|
disk = self.directory / "rootfs.ext4"
|
|
disk.write_bytes(b"retained guest writes")
|
|
published = []
|
|
for _ in range(2):
|
|
info = self.manager.start(self.vm, on_spawn=published.append)
|
|
self.assertEqual(info.pid, self.children[-1].pid)
|
|
self.assertEqual(info.boot_id, self.manager._boot_id())
|
|
self.assertEqual(self.manager.observe(self.vm), "alive")
|
|
self.assertTrue(Path(self.vm.socket).is_socket())
|
|
|
|
self.manager.terminate(self.vm)
|
|
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.assertIsNotNone(self.children[-1].returncode)
|
|
self.assertNotIn(info.pid, self.manager._children)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.assertEqual(disk.read_bytes(), b"retained guest writes")
|
|
self.assertEqual(len(published), 2)
|
|
self.assertNotEqual(published[0].pid, published[1].pid)
|
|
|
|
def test_real_child_is_reaped_when_persistence_fails(self) -> None:
|
|
failure = RuntimeError("save failed")
|
|
|
|
def on_spawn(info: ProcessInfo) -> None:
|
|
self.assertEqual((self.vm.pid, self.vm.process_start_time, self.vm.boot_id), (info.pid, info.start_time, info.boot_id))
|
|
raise failure
|
|
|
|
with self.assertRaises(RuntimeError) as raised:
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertIs(raised.exception, failure)
|
|
self.assertEqual(self.vm.pid, self.children[0].pid)
|
|
self.assertIsNotNone(self.vm.process_start_time)
|
|
self.assertIsNotNone(self.children[0].returncode)
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
|
|
def test_real_popen_exec_failure_restores_a_proven_gone_record(self) -> None:
|
|
manager = FirecrackerProcessManager(self.manager._settings)
|
|
self.vm.operation = "boot"
|
|
previous = self.vm.to_dict()
|
|
on_spawn = Mock()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "could not start Firecracker"):
|
|
manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
self.assertEqual(self.vm.to_dict(), previous)
|
|
self.assertEqual(manager.observe(self.vm), "gone")
|
|
self.assertEqual(manager._children, {})
|
|
on_spawn.assert_not_called()
|
|
|
|
def test_real_child_ignoring_term_is_killed_and_reaped_before_socket_removal(self) -> None:
|
|
self.ignore_term = True
|
|
self.manager.start(self.vm)
|
|
|
|
self.manager.terminate(self.vm, timeout=0)
|
|
|
|
self.assertEqual(self.children[0].returncode, -signal.SIGKILL)
|
|
self.assertFalse(Path(self.vm.socket).exists())
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
|
|
def test_real_socket_timeout_cleans_up_and_reaps_the_child(self) -> None:
|
|
self.create_socket = False
|
|
self.ignore_term = True
|
|
on_spawn = Mock()
|
|
|
|
with self.assertRaisesRegex(FirecrackerError, "timed out waiting"):
|
|
self.manager.start(self.vm, on_spawn=on_spawn)
|
|
|
|
on_spawn.assert_called_once()
|
|
self.assertIsNotNone(self.vm.pid)
|
|
self.assertEqual(self.children[0].returncode, -signal.SIGKILL)
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
|
|
def test_real_unreaped_zombie_is_gone_without_signaling(self) -> None:
|
|
child = subprocess.Popen([sys.executable, "-B", "-c", "pass"])
|
|
self.children.append(child)
|
|
self.vm.pid = child.pid
|
|
self.vm.process_start_time = self.manager.process_start_time(child.pid)
|
|
self.vm.boot_id = self.manager._boot_id()
|
|
os.waitid(os.P_PID, child.pid, os.WEXITED | os.WNOWAIT)
|
|
|
|
with patch.object(process_module, "_signal_pidfd") as send_signal:
|
|
self.assertEqual(self.manager.observe(self.vm), "gone")
|
|
self.manager.terminate(self.vm)
|
|
|
|
send_signal.assert_not_called()
|
|
child.wait(timeout=5)
|