use std::collections::{HashMap, HashSet};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use arcbox_vm_driver::net::{AttachMode, GuestNetwork, NetworkIdentity, NetworkLease};
use arcbox_vm_driver::{
JailRecord, ProcessRecord, ShutdownMode, VmDriver, VmHandle, VmId, VmRecord, VmState,
};
use serde::{Deserialize, Serialize};
use tracing::{info, warn};
use super::policy::recovery::{self, JournalEvidence, RecoveryAction, SweepAction};
use super::record::{PersistPhase, SandboxRecord, SandboxRecordStore, SandboxTransition};
use super::{LeaseExt, SandboxState};
use crate::config::RuntimeConfig;
use crate::error::{Result, VmmError};
use crate::lifecycle::actor::{Deadlines, Seeded};
use crate::lifecycle::runtime::ComputerRuntime;
use crate::snapshot_cow::{
COW_FILE_PREFIX, COW_FILE_SUFFIX, CowHandle, CowManager, DM_NAME_PREFIX,
};
const ADOPT_TIMEOUT: Duration = Duration::from_secs(10);
const STATE_FILE: &str = "state.json";
const AGENT_RESTART_ERROR: &str = "sandbox runtime was cleaned after agent restart";
pub(super) const POOL_SLOT_PREFIX: &str = "pool-";
#[derive(Debug, Serialize, Deserialize)]
struct CowRecord {
dm_name: String,
dm_device: String,
cow_loop: String,
cow_file: PathBuf,
template_path: PathBuf,
}
impl CowRecord {
fn from_handle(handle: &CowHandle) -> Self {
Self {
dm_name: handle.dm_name.clone(),
dm_device: handle.dm_device.clone(),
cow_loop: handle.cow_loop.clone(),
cow_file: handle.cow_file.clone(),
template_path: handle.template_path.clone(),
}
}
fn to_handle(&self) -> CowHandle {
CowHandle {
dm_name: self.dm_name.clone(),
dm_device: self.dm_device.clone(),
cow_loop: self.cow_loop.clone(),
cow_file: self.cow_file.clone(),
template_path: self.template_path.clone(),
}
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct SandboxStateRecord {
pub id: String,
#[serde(default)]
pub pid: Option<i32>,
#[serde(default)]
pub network: Option<JournaledAllocation>,
#[serde(default)]
cow: Option<CowRecord>,
#[serde(default)]
pub jailer: bool,
#[serde(default)]
pub restore_origin_dir: Option<PathBuf>,
#[serde(default)]
pub pool_slot_id: Option<String>,
#[serde(default)]
pub attach_mode: Option<JournaledAttachMode>,
#[serde(default)]
pub net_invariant: bool,
#[serde(default)]
pub api_socket: Option<PathBuf>,
#[serde(default)]
pub jail: Option<JournaledJail>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournaledJail {
pub root: PathBuf,
pub uid: u32,
pub gid: u32,
}
impl From<JailRecord> for JournaledJail {
fn from(jail: JailRecord) -> Self {
Self {
root: jail.root,
uid: jail.uid,
gid: jail.gid,
}
}
}
impl From<JournaledJail> for JailRecord {
fn from(jail: JournaledJail) -> Self {
Self {
root: jail.root,
uid: jail.uid,
gid: jail.gid,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct JournaledVmm {
pub api_socket: Option<PathBuf>,
pub jail: Option<JournaledJail>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum JournaledAttachMode {
Invariant,
LegacySnapshot,
}
impl From<AttachMode> for JournaledAttachMode {
fn from(mode: AttachMode) -> Self {
match mode {
AttachMode::Invariant => Self::Invariant,
AttachMode::LegacySnapshot => Self::LegacySnapshot,
}
}
}
impl From<JournaledAttachMode> for AttachMode {
fn from(mode: JournaledAttachMode) -> Self {
match mode {
JournaledAttachMode::Invariant => Self::Invariant,
JournaledAttachMode::LegacySnapshot => Self::LegacySnapshot,
}
}
}
#[derive(Clone, Copy)]
pub struct JournaledLease<'a> {
lease: &'a NetworkLease,
mode: AttachMode,
invariant_identity: bool,
}
impl<'a> JournaledLease<'a> {
pub fn cold_boot(lease: &'a NetworkLease, baked_invariant_ip: bool) -> Self {
Self {
lease,
mode: AttachMode::Invariant,
invariant_identity: baked_invariant_ip,
}
}
pub fn from_snapshot(lease: &'a NetworkLease, net_invariant: bool) -> Self {
Self {
lease,
mode: super::attach_mode(net_invariant),
invariant_identity: net_invariant,
}
}
}
fn tap_name_for(ip: std::net::Ipv4Addr) -> String {
let octets = ip.octets();
format!("vmtap{}-{}", octets[2], octets[3])
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct JournaledAllocation {
pub tap_name: String,
pub ip_address: std::net::Ipv4Addr,
#[serde(default = "default_prefix_len")]
pub prefix_len: u8,
pub gateway: std::net::Ipv4Addr,
pub mac_address: String,
pub dns_servers: Vec<String>,
#[serde(default)]
pub cleanup_token: String,
}
const fn default_prefix_len() -> u8 {
16
}
impl SandboxStateRecord {
pub fn new(
id: &str,
pid: Option<i32>,
network: Option<JournaledLease<'_>>,
cow: Option<&CowHandle>,
config: &RuntimeConfig,
restore_origin_dir: Option<&Path>,
) -> Result<Self> {
let allocation = network
.map(|net| legacy_allocation(net.lease, &config.network.dns))
.transpose()?;
Ok(Self {
id: id.to_owned(),
pid,
network: allocation,
cow: cow.map(CowRecord::from_handle),
jailer: config.firecracker.jailer.is_some(),
restore_origin_dir: restore_origin_dir.map(Path::to_path_buf),
pool_slot_id: None,
attach_mode: network.map(|net| net.mode.into()),
net_invariant: network.is_some_and(|net| net.invariant_identity),
api_socket: None,
jail: None,
})
}
pub fn with_vmm(mut self, vmm: Option<JournaledVmm>) -> Self {
let vmm = vmm.unwrap_or_default();
self.api_socket = vmm.api_socket;
self.jail = vmm.jail;
self
}
pub fn lease(&self) -> Result<Option<NetworkLease>> {
self.network
.as_ref()
.map(|allocation| {
Ok(NetworkLease {
vm: VmId::new(self.id.as_str())?,
ip: allocation.ip_address.into(),
prefix_len: allocation.prefix_len,
gateway: allocation.gateway.into(),
mac: allocation.mac_address.parse().map_err(VmmError::from)?,
cleanup_token: allocation.cleanup_token.clone(),
})
})
.transpose()
}
pub fn with_pool_slot(mut self, slot_id: Option<&str>) -> Self {
self.pool_slot_id = slot_id.map(str::to_owned);
self
}
pub fn resource_owner(&self) -> &str {
self.pool_slot_id.as_deref().unwrap_or(&self.id)
}
}
fn legacy_allocation(lease: &NetworkLease, dns: &[String]) -> Result<JournaledAllocation> {
let ip = lease.ipv4()?;
Ok(JournaledAllocation {
tap_name: tap_name_for(ip),
ip_address: ip,
prefix_len: lease.prefix_len,
gateway: lease.gateway_ipv4()?,
mac_address: lease.mac.to_string(),
dns_servers: dns.to_vec(),
cleanup_token: lease.cleanup_token.clone(),
})
}
pub fn write_state_record(vm_dir: &Path, record: &SandboxStateRecord) -> Result<()> {
let bytes = serde_json::to_vec_pretty(record)?;
arcbox_atomic_file::write(&vm_dir.join(STATE_FILE), &bytes)?;
Ok(())
}
pub(super) fn create_runtime_dir(vm_dir: &Path) -> Result<()> {
let parent = vm_dir.parent().ok_or_else(|| {
crate::error::VmmError::Config(format!(
"sandbox runtime directory has no parent: {}",
vm_dir.display()
))
})?;
let data_dir = parent.parent().ok_or_else(|| {
crate::error::VmmError::Config(format!(
"sandbox runtime parent has no parent: {}",
parent.display()
))
})?;
std::fs::create_dir_all(vm_dir)?;
std::fs::set_permissions(data_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700))?;
std::fs::set_permissions(vm_dir, std::fs::Permissions::from_mode(0o700))?;
std::fs::File::open(parent)?.sync_all()?;
std::fs::File::open(data_dir)?.sync_all()?;
Ok(())
}
pub fn clear_state_record(vm_dir: &Path) -> Result<()> {
match std::fs::remove_file(vm_dir.join(STATE_FILE)) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
std::fs::File::open(vm_dir)?.sync_all()?;
Ok(())
}
pub(super) struct AdoptedSandbox {
handle: Arc<dyn VmHandle>,
lease: Option<NetworkLease>,
identity: Option<NetworkIdentity>,
cow_handle: Option<CowHandle>,
pool_slot_id: Option<String>,
net_invariant: bool,
}
#[derive(Default)]
struct SkippedJournals {
ids: HashSet<String>,
owners: HashSet<String>,
}
impl SkippedJournals {
fn hold(&mut self, dir: &Path, record: &SandboxStateRecord) {
if let Some(name) = dir.file_name().and_then(|name| name.to_str()) {
self.ids.insert(name.to_owned());
self.owners.insert(name.to_owned());
}
self.owners.insert(record.id.clone());
self.owners.insert(record.resource_owner().to_owned());
if let Some(cow) = &record.cow {
if let Some(owner) = cow.dm_name.strip_prefix(DM_NAME_PREFIX) {
self.owners.insert(owner.to_owned());
}
if let Some(rest) = cow
.cow_file
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix(COW_FILE_PREFIX))
{
self.owners.insert(
rest.strip_suffix(COW_FILE_SUFFIX)
.unwrap_or(rest)
.to_owned(),
);
}
}
}
}
pub(super) struct OrphanSweep {
ids: HashSet<String>,
adopted: HashMap<String, AdoptedSandbox>,
skipped: HashSet<String>,
runtime_dirs: Vec<PathBuf>,
}
impl SweptRuntime {
#[cfg(test)]
fn nothing_kept() -> Self {
Self {
swept: HashSet::new(),
adopted: HashMap::new(),
skipped: HashSet::new(),
}
}
}
impl OrphanSweep {
fn empty() -> Self {
Self {
ids: HashSet::new(),
adopted: HashMap::new(),
skipped: HashSet::new(),
runtime_dirs: Vec::new(),
}
}
pub(super) fn take_runtime(&mut self) -> SweptRuntime {
SweptRuntime {
swept: std::mem::take(&mut self.ids),
adopted: std::mem::take(&mut self.adopted),
skipped: std::mem::take(&mut self.skipped),
}
}
}
pub(super) async fn sweep_orphans(
config: &RuntimeConfig,
driver: &dyn VmDriver,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
snapshots: &crate::snapshot::SnapshotCatalog,
store: &SandboxRecordStore,
) -> Result<OrphanSweep> {
snapshots.sweep_incomplete();
let sandboxes_dir = PathBuf::from(&config.firecracker.data_dir).join("sandboxes");
let entries = match std::fs::read_dir(&sandboxes_dir) {
Ok(entries) => entries,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
return Ok(OrphanSweep::empty());
}
Err(error) => return Err(error.into()),
};
let mut records = Vec::new();
let mut skipped = SkippedJournals::default();
for entry in entries {
let entry = entry?;
let dir = entry.path();
if !entry.file_type()?.is_dir() {
continue;
}
let state_path = dir.join(STATE_FILE);
let record: SandboxStateRecord = match std::fs::read(&state_path) {
Ok(bytes) => serde_json::from_slice(&bytes)?,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
Err(error) => return Err(error.into()),
};
let usable = validate_state_record(config, &sandboxes_dir, &dir, &record)
.and_then(|()| VmId::new(record.id.as_str()).map_err(VmmError::from))
.and_then(|_| VmId::new(record.resource_owner()).map_err(VmmError::from));
if let Err(error) = usable {
warn!(
sandbox_id = %record.id,
path = %dir.display(),
%error,
"skipping an unusable crash journal: its VM is left alone and every \
resource it names is held out of this sweep's reap"
);
skipped.hold(&dir, &record);
if let Some(allocation) = &record.network {
network.hold_address(allocation.ip_address.into());
}
continue;
}
records.push((dir, record));
}
records.sort_by(|(_, left), (_, right)| left.id.cmp(&right.id));
let durable = store.load_all()?;
let retained = recovery::retained_ids(
durable
.iter()
.map(|record| (record.id.as_str(), record.phase)),
);
let phases: HashMap<&str, _> = durable
.iter()
.map(|record| (record.id.as_str(), record.phase))
.collect();
let mut adopted = HashMap::new();
let reaped = reap_orphans(
&records,
&mut adopted,
&retained,
&skipped.owners,
&phases,
config,
driver,
network,
cow_manager,
)
.await;
let (swept, runtime_dirs) = match reaped {
Ok(reaped) => reaped,
Err(error) => {
release_reclaimed(&mut adopted, network, cow_manager).await;
return Err(error);
}
};
Ok(OrphanSweep {
ids: swept,
adopted,
skipped: skipped.ids,
runtime_dirs,
})
}
#[allow(
clippy::too_many_arguments,
reason = "the sweep's whole resource set, threaded rather than re-derived"
)]
async fn reap_orphans(
records: &[(PathBuf, SandboxStateRecord)],
adopted: &mut HashMap<String, AdoptedSandbox>,
retained: &HashSet<String>,
held: &HashSet<String>,
phases: &HashMap<&str, super::record::PersistPhase>,
config: &RuntimeConfig,
driver: &dyn VmDriver,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
) -> Result<(HashSet<String>, Vec<PathBuf>)> {
let isolation = super::isolation_spec(config)?;
for (dir, record) in records {
if let Some(reclaimed) = adopt_or_kill(
driver,
network,
cow_manager,
dir,
record,
phases.get(record.id.as_str()).copied(),
)
.await?
{
adopted.insert(record.id.clone(), reclaimed);
}
}
let mut live_devices: HashSet<String> = records
.iter()
.filter(|(_, record)| adopted.contains_key(&record.id))
.map(|(_, record)| record.resource_owner().to_owned())
.collect();
live_devices.extend(held.iter().cloned());
let mut keep_cow_files = retained.clone();
keep_cow_files.extend(live_devices.iter().cloned());
cow_manager.reconcile_stale(&keep_cow_files, &live_devices)?;
let mut swept = HashSet::new();
let mut runtime_dirs = Vec::new();
for (dir, record) in records {
if adopted.contains_key(&record.id) {
continue;
}
if recovery::sweep_action(&record.id, retained) == SweepAction::DropStaleJournal {
info!(sandbox_id = %record.id, "dropping stale pause journal, keeping retained state");
clear_state_record(dir)?;
continue;
}
info!(sandbox_id = %record.id, "reconciling orphaned sandbox");
if let Some(cow) = &record.cow {
cow_manager.teardown_checked(&cow.to_handle()).await?;
}
if let Some(lease) = record.lease()? {
network.quarantine(lease).await.map_err(VmmError::from)?;
}
if let Some(adopt) = driver.adopt() {
adopt
.discard_area(&vm_record(driver, dir, record)?, &isolation)
.await?;
}
runtime_dirs.push(dir.clone());
swept.insert(record.id.clone());
}
Ok((swept, runtime_dirs))
}
pub(super) async fn finalize_sweep(sweep: OrphanSweep) -> Result<()> {
for dir in sweep.runtime_dirs {
remove_dir_if_present(&dir).await?;
}
Ok(())
}
pub(super) struct SweptRuntime {
swept: HashSet<String>,
adopted: HashMap<String, AdoptedSandbox>,
skipped: HashSet<String>,
}
pub(super) fn normalize_durable_records(
store: &SandboxRecordStore,
data_dir: &Path,
sweep: Option<&mut SweptRuntime>,
built: &mut Vec<RecoveredComputer>,
) -> Result<()> {
let inactive = built;
let mut nothing_adopted = HashMap::new();
let nothing_skipped = HashSet::new();
let (swept, adopted, skipped) = match sweep {
Some(sweep) => (Some(&sweep.swept), &mut sweep.adopted, &sweep.skipped),
None => (None, &mut nothing_adopted, ¬hing_skipped),
};
for record in store.load_all()? {
let evidence = match &swept {
None => JournalEvidence::Unchecked,
Some(_) if adopted.contains_key(&record.id) => JournalEvidence::Adopted,
Some(ids) if ids.contains(&record.id) => JournalEvidence::Swept,
Some(_) if skipped.contains(&record.id) => JournalEvidence::Unchecked,
Some(_) => JournalEvidence::Unjournaled,
};
match recovery::plan(record.phase, evidence) {
RecoveryAction::LeaveResumable => {}
RecoveryAction::RefuseUnjournaled => {
return Err(crate::error::VmmError::Unavailable(format!(
"sandbox {} is {} but has no cleanup journal",
record.id,
record.phase.as_str()
)));
}
RecoveryAction::RefuseAdopted => {
return Err(crate::error::VmmError::Unavailable(format!(
"sandbox {} is {}, a phase the startup sweep must not adopt",
record.id,
record.phase.as_str()
)));
}
RecoveryAction::Fail => {
let record = store
.transition(
&record.id,
record.generation,
SandboxTransition::Failed(AGENT_RESTART_ERROR.into()),
)?
.confirmed("sandbox restart normalization")?;
inactive.push(RecoveredComputer::reinstated(inactive_instance(
record,
SandboxState::Failed,
data_dir,
)));
}
RecoveryAction::Reinstate(state) => {
let computer = match adopted.remove(&record.id) {
Some(reclaimed) => RecoveredComputer {
runtime: adopted_instance(record, state, data_dir, reclaimed),
seeded: Seeded::Adopted,
},
None => {
RecoveredComputer::reinstated(inactive_instance(record, state, data_dir))
}
};
inactive.push(computer);
}
RecoveryAction::FinishRemove => {
store
.finish_remove(&record.id, record.generation)?
.confirmed("sandbox removal recovery")?;
}
}
}
Ok(())
}
pub(super) async fn release_unclaimed(
runtime: &mut SweptRuntime,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
) {
release_reclaimed(&mut runtime.adopted, network, cow_manager).await;
}
async fn release_reclaimed(
adopted: &mut HashMap<String, AdoptedSandbox>,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
) {
for (id, adopted) in adopted.drain() {
release_one(
&id,
&adopted.handle,
adopted.cow_handle.as_ref(),
adopted.lease,
network,
cow_manager,
)
.await;
}
}
pub(super) async fn release_instances(
instances: &mut Vec<RecoveredComputer>,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
) {
for computer in instances.drain(..) {
let mut instance = computer.runtime;
let Some(handle) = instance.handle.take() else {
continue;
};
release_one(
&instance.id,
&handle,
instance.cow_handle.as_ref(),
instance.network.take(),
network,
cow_manager,
)
.await;
}
}
async fn kill_or_hand_over(id: &str, handle: &Arc<dyn VmHandle>) -> Result<()> {
let Err(error) = handle.shutdown(ShutdownMode::Kill).await else {
return Ok(());
};
if let Some(detach) = handle.detach()
&& let Err(error) = detach.detach().await
{
warn!(sandbox_id = %id, %error, "handing the vmm over failed; dropping it kills it");
}
Err(error.into())
}
async fn release_one(
id: &str,
handle: &Arc<dyn VmHandle>,
cow_handle: Option<&CowHandle>,
lease: Option<NetworkLease>,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
) {
warn!(sandbox_id = %id, "releasing a reclaimed sandbox that recovery did not take");
if let Err(error) = kill_or_hand_over(id, handle).await {
warn!(
sandbox_id = %id, %error,
"killing the reclaimed vmm failed; leaving it, its disk and its address for the next sweep"
);
return;
}
if let Some(cow_handle) = cow_handle
&& let Err(error) = cow_manager.teardown_checked(cow_handle).await
{
warn!(sandbox_id = %id, %error, "releasing the reclaimed disk overlay failed");
}
if let Some(lease) = lease
&& let Err(error) = network.quarantine(lease).await
{
warn!(sandbox_id = %id, %error, "quarantining the reclaimed lease failed");
}
}
pub(super) struct RecoveredComputer {
runtime: ComputerRuntime,
seeded: Seeded,
}
impl RecoveredComputer {
fn reinstated(runtime: ComputerRuntime) -> Self {
let seeded = Seeded::Recovered(phase_of(runtime.state));
Self { runtime, seeded }
}
}
const fn phase_of(state: SandboxState) -> PersistPhase {
match state {
SandboxState::Paused | SandboxState::Pausing => PersistPhase::Paused,
SandboxState::Stopped | SandboxState::Stopping => PersistPhase::Stopped,
_ => PersistPhase::Failed,
}
}
pub(super) fn seed_computers(
recovered: Vec<RecoveredComputer>,
computers: &crate::sandbox::Computers,
services: &Arc<crate::lifecycle::flows::ComputerServices>,
timers_enabled: &tokio::sync::watch::Receiver<bool>,
) {
for RecoveredComputer { runtime, seeded } in recovered {
let id = runtime.id.clone();
let deadlines = Deadlines {
ttl: runtime.ttl_deadline,
idle_timeout_seconds: runtime.spec.idle_timeout_seconds,
on_idle: runtime.spec.on_idle,
};
let generation = runtime.record_generation;
match crate::sandbox::reserve_actor(computers, &id, runtime) {
Ok(reservation) => {
reservation.spawn(crate::sandbox::ActorSpawn {
services: Arc::clone(services),
timers_enabled: timers_enabled.clone(),
generation,
deadlines,
launch: crate::lifecycle::flows::Launch::Reinstated,
seeded,
});
}
Err(error) => {
warn!(sandbox_id = %id, %error, "a recovered computer's id was already claimed");
}
}
}
}
fn inactive_instance(
record: SandboxRecord,
state: SandboxState,
data_dir: &Path,
) -> ComputerRuntime {
let vm_dir = data_dir.join("sandboxes").join(&record.id);
let mut instance = ComputerRuntime::new_with_generation(
record.id,
record.effective_spec,
None,
vm_dir,
record.generation,
);
instance.state = state;
instance.created_at = record.created_at;
instance.error = record.error;
instance.ttl_deadline = record.ttl_deadline;
if state == SandboxState::Paused {
instance.pause_snapshot_id = record.pause_snapshot_id;
instance.paused_at = record.paused_at;
}
instance
}
fn adopted_instance(
record: SandboxRecord,
state: SandboxState,
data_dir: &Path,
adopted: AdoptedSandbox,
) -> ComputerRuntime {
let AdoptedSandbox {
handle,
lease,
identity,
cow_handle,
pool_slot_id,
net_invariant,
} = adopted;
let mut instance = inactive_instance(record, state, data_dir);
instance.handle = Some(handle);
instance.network = lease;
instance.net_identity = identity;
instance.cow_handle = cow_handle;
instance.pool_slot_id = pool_slot_id;
instance.net_invariant = net_invariant;
instance
}
async fn adopt_or_kill(
driver: &dyn VmDriver,
network: &dyn GuestNetwork,
cow_manager: &CowManager,
runtime_dir: &Path,
record: &SandboxStateRecord,
phase: Option<super::record::PersistPhase>,
) -> Result<Option<AdoptedSandbox>> {
let Some(adopt) = driver.adopt() else {
return Ok(None);
};
let vm_record = vm_record(driver, runtime_dir, record)?;
let adopted = tokio::time::timeout(ADOPT_TIMEOUT, adopt.adopt(&vm_record))
.await
.map_err(|_| {
VmmError::Process(format!(
"sandbox {}: the driver did not find or reconnect to its vmm within {}s",
record.id,
ADOPT_TIMEOUT.as_secs()
))
})??;
let Some(handle) = adopted else {
return Ok(None);
};
let handle: Arc<dyn VmHandle> = Arc::from(handle);
match reclaim(network, cow_manager, record, phase, &handle).await {
Ok(reclaimed) => {
info!(sandbox_id = %record.id, vm = %vm_record.id, "reclaimed a sandbox whose vm outlived its agent");
Ok(Some(reclaimed))
}
Err(reason) => {
info!(
sandbox_id = %record.id, vm = %vm_record.id, %reason,
"killing the orphaned vmm"
);
kill_or_hand_over(&record.id, &handle).await?;
Ok(None)
}
}
}
fn vm_record(
driver: &dyn VmDriver,
runtime_dir: &Path,
record: &SandboxStateRecord,
) -> Result<VmRecord> {
Ok(VmRecord {
id: VmId::new(record.resource_owner())?,
driver: driver.name().to_owned(),
runtime_dir: runtime_dir.to_path_buf(),
process: record
.pid
.and_then(|pid| u32::try_from(pid).ok())
.map(|pid| ProcessRecord {
pid,
api_socket: record.api_socket.clone(),
jail: record.jail.clone().map(JailRecord::from),
}),
})
}
async fn reclaim(
network: &dyn GuestNetwork,
cow_manager: &CowManager,
record: &SandboxStateRecord,
phase: Option<super::record::PersistPhase>,
handle: &Arc<dyn VmHandle>,
) -> std::result::Result<AdoptedSandbox, String> {
match handle.state() {
VmState::Running => {}
state => return Err(format!("its vm is {state}")),
}
if handle.vsock().is_none() {
return Err("its vmm's api never answered, so only a kill can be driven through it".into());
}
match phase {
Some(super::record::PersistPhase::Ready) => {}
Some(phase) => return Err(format!("its durable record is {}", phase.as_str())),
None => return Err("it has no durable record".into()),
}
let lease = record.lease().map_err(|error| error.to_string())?;
let mode = match (&lease, record.attach_mode) {
(Some(_), None) => {
return Err(
"its journal predates adoption and does not say how its network attaches".into(),
);
}
(_, mode) => mode.map(AttachMode::from),
};
let identity = match (&lease, mode) {
(Some(lease), Some(mode)) => {
network
.adopt(lease, mode)
.await
.map_err(|error| format!("its network could not be re-established: {error}"))?;
Some(network.identity(lease, mode))
}
_ => None,
};
let cow_handle = record.cow.as_ref().map(CowRecord::to_handle);
if let Some(cow_handle) = &cow_handle {
cow_manager
.adopt(cow_handle)
.map_err(|error| format!("its disk overlay could not be re-registered: {error}"))?;
}
Ok(AdoptedSandbox {
handle: Arc::clone(handle),
lease,
identity,
cow_handle,
pool_slot_id: record.pool_slot_id.clone(),
net_invariant: record.net_invariant,
})
}
async fn remove_dir_if_present(path: &Path) -> Result<()> {
match tokio::fs::remove_dir_all(path).await {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error.into()),
}
}
fn validate_state_record(
config: &RuntimeConfig,
sandboxes_dir: &Path,
directory: &Path,
record: &SandboxStateRecord,
) -> Result<()> {
super::validate_id("sandbox id", &record.id)?;
if directory.file_name().and_then(|name| name.to_str()) != Some(record.id.as_str()) {
return Err(crate::error::VmmError::Config(format!(
"sandbox cleanup record id {} does not match directory {}",
record.id,
directory.display()
)));
}
if let Some(network) = &record.network {
let expected = tap_name_for(network.ip_address);
if network.tap_name != expected {
return Err(crate::error::VmmError::Config(format!(
"sandbox {} cleanup record has unexpected TAP {}",
record.id, network.tap_name
)));
}
}
if let Some(slot_id) = &record.pool_slot_id {
super::validate_id("pool slot id", slot_id)?;
if !slot_id.starts_with(POOL_SLOT_PREFIX) {
return Err(crate::error::VmmError::Config(format!(
"sandbox {} cleanup record has non-pool slot id {slot_id}",
record.id
)));
}
}
if let Some(cow) = &record.cow {
let owner = record.resource_owner();
let expected_name = format!("{DM_NAME_PREFIX}{owner}");
let expected_file = Path::new(&config.firecracker.data_dir)
.join("cow")
.join(format!("{COW_FILE_PREFIX}{owner}{COW_FILE_SUFFIX}"));
if cow.dm_name != expected_name
|| cow.dm_device != format!("/dev/mapper/{expected_name}")
|| cow.cow_file != expected_file
{
return Err(crate::error::VmmError::Config(format!(
"sandbox {} cleanup record has invalid CoW resources",
record.id
)));
}
}
if let Some(origin) = &record.restore_origin_dir {
let origin_id = origin.file_name().and_then(|name| name.to_str());
if origin.parent() != Some(sandboxes_dir) || origin_id.is_none() {
return Err(crate::error::VmmError::Config(format!(
"sandbox {} restore origin escapes {}",
record.id,
sandboxes_dir.display()
)));
}
super::validate_id("restore origin sandbox id", origin_id.unwrap())?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::super::SandboxSpec;
use super::super::record::{PersistPhase, ProvisionIntent, SandboxProvisionOutcome};
use super::*;
const LEGACY_RECORD: &str = r#"{
"id": "box",
"pid": 4242,
"network": {
"tap_name": "vmtap0-7",
"ip_address": "172.20.0.7",
"prefix_len": 16,
"gateway": "172.20.0.1",
"mac_address": "02:fc:00:00:00:07",
"dns_servers": ["1.1.1.1"],
"cleanup_token": "gen-1"
},
"cow": null,
"jailer": true,
"restore_origin_dir": null,
"pool_slot_id": null
}"#;
#[test]
fn a_pre_port_journal_loads_and_is_written_back_unchanged() {
let record: SandboxStateRecord = serde_json::from_str(LEGACY_RECORD).unwrap();
let lease = record.lease().unwrap().expect("the record holds a lease");
assert_eq!(lease.vm.as_str(), "box");
assert_eq!(lease.ip, "172.20.0.7".parse::<std::net::IpAddr>().unwrap());
assert_eq!(lease.prefix_len, 16);
assert_eq!(
lease.gateway,
"172.20.0.1".parse::<std::net::IpAddr>().unwrap()
);
assert_eq!(lease.mac.to_string(), "02:fc:00:00:00:07");
assert_eq!(lease.cleanup_token, "gen-1");
let mut config = RuntimeConfig::default();
config.network.dns = vec!["1.1.1.1".into()];
config.firecracker.jailer = Some(crate::config::JailerConfig {
uid: 0,
gid: 0,
chroot_base_dir: None,
netns: None,
new_pid_ns: false,
cgroup_version: None,
parent_cgroup: None,
});
let written = SandboxStateRecord::new(
"box",
Some(4242),
Some(JournaledLease::cold_boot(&lease, true)),
None,
&config,
None,
)
.unwrap();
let mut value = serde_json::to_value(&written).unwrap();
let fields = value.as_object_mut().unwrap();
assert_eq!(
fields.remove("attach_mode"),
Some(serde_json::json!("invariant"))
);
assert_eq!(
fields.remove("net_invariant"),
Some(serde_json::json!(true))
);
assert_eq!(fields.remove("api_socket"), Some(serde_json::Value::Null));
assert_eq!(fields.remove("jail"), Some(serde_json::Value::Null));
assert_eq!(
value,
serde_json::from_str::<serde_json::Value>(LEGACY_RECORD).unwrap()
);
}
#[test]
fn a_journal_without_a_prefix_len_or_cleanup_token_loads() {
const OLDEST_RECORD: &str = r#"{
"id": "box",
"pid": 4242,
"network": {
"tap_name": "vmtap0-7",
"ip_address": "172.20.0.7",
"gateway": "172.20.0.1",
"mac_address": "02:fc:00:00:00:07",
"dns_servers": ["1.1.1.1"]
},
"jailer": true
}"#;
let record: SandboxStateRecord = serde_json::from_str(OLDEST_RECORD).unwrap();
let network = record.network.as_ref().expect("the record holds a network");
assert_eq!(network.prefix_len, 16);
assert_eq!(network.cleanup_token, "");
let lease = record.lease().unwrap().expect("the record holds a lease");
assert_eq!(lease.prefix_len, 16);
assert_eq!(lease.cleanup_token, "");
}
#[test]
fn a_journal_without_an_attach_mode_loads_as_not_adoptable() {
let record: SandboxStateRecord = serde_json::from_str(LEGACY_RECORD).unwrap();
assert_eq!(record.attach_mode, None);
assert!(!record.net_invariant);
}
#[test]
fn a_cold_boot_journals_the_invariant_mode_even_without_the_invariant_identity() {
let lease = NetworkLease {
vm: VmId::new("box").unwrap(),
ip: "172.20.0.7".parse().unwrap(),
prefix_len: 16,
gateway: "172.20.0.1".parse().unwrap(),
mac: "02:fc:00:00:00:07".parse().unwrap(),
cleanup_token: "gen-1".into(),
};
let config = RuntimeConfig::default();
for baked in [true, false] {
let record = SandboxStateRecord::new(
"box",
None,
Some(JournaledLease::cold_boot(&lease, baked)),
None,
&config,
None,
)
.unwrap();
assert_eq!(record.attach_mode, Some(JournaledAttachMode::Invariant));
assert_eq!(record.net_invariant, baked);
}
let legacy = SandboxStateRecord::new(
"box",
None,
Some(JournaledLease::from_snapshot(&lease, false)),
None,
&config,
None,
)
.unwrap();
assert_eq!(
legacy.attach_mode,
Some(JournaledAttachMode::LegacySnapshot)
);
assert!(!legacy.net_invariant);
}
fn record_in_phase(store: &SandboxRecordStore, id: &str, phase: PersistPhase) -> SandboxRecord {
let spec = SandboxSpec {
id: Some(id.into()),
..SandboxSpec::default()
};
let record = match store.provision_intent(id, "create-key", spec).unwrap() {
ProvisionIntent::Created(record) => record,
other => panic!("expected a new record, got {other:?}"),
};
let generation = record.generation;
match phase {
PersistPhase::Creating => return record,
PersistPhase::Failed => {
store
.transition(
id,
generation,
SandboxTransition::Failed("original failure".into()),
)
.unwrap();
}
PersistPhase::Removing => {
store
.transition(
id,
generation,
SandboxTransition::Starting(SandboxProvisionOutcome {
ip_address: "192.0.2.2".into(),
}),
)
.unwrap();
store
.transition(id, generation, SandboxTransition::Removing)
.unwrap();
}
phase => {
store
.transition(
id,
generation,
SandboxTransition::Starting(SandboxProvisionOutcome {
ip_address: "192.0.2.2".into(),
}),
)
.unwrap();
match phase {
PersistPhase::Starting => {}
PersistPhase::Ready => {
store
.transition(id, generation, SandboxTransition::Ready)
.unwrap();
}
PersistPhase::Stopping | PersistPhase::Stopped => {
store
.transition(id, generation, SandboxTransition::Stopping)
.unwrap();
if phase == PersistPhase::Stopped {
store
.transition(id, generation, SandboxTransition::Stopped)
.unwrap();
}
}
PersistPhase::Pausing | PersistPhase::Paused | PersistPhase::Resuming => {
store
.transition(id, generation, SandboxTransition::Ready)
.unwrap();
store
.transition(id, generation, SandboxTransition::Pausing)
.unwrap();
if phase != PersistPhase::Pausing {
store
.transition(
id,
generation,
SandboxTransition::Paused {
snapshot_id: "snap".into(),
},
)
.unwrap();
}
if phase == PersistPhase::Resuming {
store
.transition(id, generation, SandboxTransition::Resuming)
.unwrap();
}
}
_ => unreachable!("phase handled above"),
}
}
}
store.load(id).unwrap().unwrap()
}
#[test]
fn state_record_roundtrips_through_json() {
let record = SandboxStateRecord {
id: "sb-1".into(),
pid: Some(42),
network: None,
cow: Some(CowRecord {
dm_name: "arcbox-snap-sb-1".into(),
dm_device: "/dev/mapper/arcbox-snap-sb-1".into(),
cow_loop: "/dev/loop7".into(),
cow_file: "/var/lib/arcbox/cow/sb-1".into(),
template_path: "/var/lib/arcbox/sandbox/rootfs.ext4".into(),
}),
jailer: true,
restore_origin_dir: None,
pool_slot_id: Some("pool-1".into()),
attach_mode: Some(JournaledAttachMode::Invariant),
net_invariant: true,
api_socket: Some("/srv/jailer/firecracker/sb-1/root/run/firecracker.socket".into()),
jail: Some(JournaledJail {
root: "/srv/jailer/firecracker/sb-1/root".into(),
uid: 123,
gid: 456,
}),
};
let bytes = serde_json::to_vec(&record).unwrap();
let parsed: SandboxStateRecord = serde_json::from_slice(&bytes).unwrap();
assert_eq!(parsed.id, "sb-1");
assert_eq!(parsed.pid, Some(42));
assert_eq!(
parsed.api_socket.as_deref(),
Some(Path::new(
"/srv/jailer/firecracker/sb-1/root/run/firecracker.socket"
))
);
assert_eq!(
parsed.jail,
Some(JournaledJail {
root: "/srv/jailer/firecracker/sb-1/root".into(),
uid: 123,
gid: 456,
})
);
assert!(parsed.jailer);
assert_eq!(parsed.pool_slot_id.as_deref(), Some("pool-1"));
assert_eq!(parsed.resource_owner(), "pool-1");
let handle = parsed.cow.unwrap().to_handle();
assert_eq!(handle.dm_name, "arcbox-snap-sb-1");
}
#[test]
fn the_adopt_record_carries_the_journaled_vmm() {
use arcbox_vm_driver::testkit::FakeDriver;
let socket = Path::new("/srv/jailer/firecracker/box/root/run/firecracker.socket");
let journaled = SandboxStateRecord {
id: "box".into(),
pid: Some(4242),
network: None,
cow: None,
jailer: true,
restore_origin_dir: None,
pool_slot_id: None,
attach_mode: None,
net_invariant: false,
api_socket: Some(socket.to_path_buf()),
jail: Some(JournaledJail {
root: "/srv/jailer/firecracker/box/root".into(),
uid: 123,
gid: 456,
}),
};
let record = vm_record(&FakeDriver::new(), Path::new("/data/box"), &journaled).unwrap();
let process = record.process.expect("a journaled pid is a process");
assert_eq!(process.pid, 4242);
assert_eq!(process.api_socket.as_deref(), Some(socket));
assert_eq!(
process.jail,
Some(arcbox_vm_driver::JailRecord {
root: "/srv/jailer/firecracker/box/root".into(),
uid: 123,
gid: 456,
})
);
}
#[tokio::test]
async fn a_jailed_vmms_journal_carries_its_jail_from_the_driver_to_the_adopt() {
use arcbox_vm_driver::testkit::FakeDriver;
use arcbox_vm_driver::{IsolationSpec, VmId};
let dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let isolation = IsolationSpec::Jailer {
uid: 7,
gid: 8,
chroot_base: dir.path().join("jail"),
netns: None,
new_pid_ns: false,
cgroup: None,
};
let prepared = driver
.prepare()
.unwrap()
.prepare(&VmId::new("box").unwrap(), &isolation, dir.path())
.await
.unwrap();
let journaled = SandboxStateRecord::new(
"box",
crate::sandbox::journaled_pid(&*prepared),
None,
None,
&RuntimeConfig::default(),
None,
)
.unwrap()
.with_vmm(crate::sandbox::journaled_vmm(&*prepared));
write_state_record(dir.path(), &journaled).unwrap();
let bytes = std::fs::read(dir.path().join(STATE_FILE)).unwrap();
let reloaded: SandboxStateRecord = serde_json::from_slice(&bytes).unwrap();
let process = vm_record(&driver, dir.path(), &reloaded)
.unwrap()
.process
.expect("a journaled pid is a process");
assert_eq!(
process.jail.map(|jail| jail.root),
Some(dir.path().join("jail/box"))
);
}
#[test]
fn cleanup_record_validation_keys_cow_resources_by_the_pool_slot() {
let config = RuntimeConfig::default();
let sandboxes_dir = Path::new("/var/lib/firecracker-vmm/sandboxes");
let directory = sandboxes_dir.join("sb-1");
let cow_for = |owner: &str| CowRecord {
dm_name: format!("arcbox-snap-{owner}"),
dm_device: format!("/dev/mapper/arcbox-snap-{owner}"),
cow_loop: "/dev/loop7".into(),
cow_file: Path::new(&config.firecracker.data_dir)
.join("cow")
.join(format!("arcbox-cow-{owner}.img")),
template_path: "/var/lib/arcbox/sandbox/rootfs.ext4".into(),
};
let record = |slot: Option<&str>, cow_owner: &str| SandboxStateRecord {
id: "sb-1".into(),
pid: None,
network: None,
cow: Some(cow_for(cow_owner)),
jailer: true,
restore_origin_dir: None,
pool_slot_id: slot.map(str::to_owned),
attach_mode: None,
net_invariant: false,
api_socket: None,
jail: None,
};
validate_state_record(
&config,
sandboxes_dir,
&directory,
&record(Some("pool-abc"), "pool-abc"),
)
.unwrap();
assert!(
validate_state_record(
&config,
sandboxes_dir,
&directory,
&record(Some("pool-abc"), "sb-1"),
)
.is_err()
);
assert!(
validate_state_record(
&config,
sandboxes_dir,
&directory,
&record(Some("other-abc"), "other-abc"),
)
.is_err()
);
validate_state_record(&config, sandboxes_dir, &directory, &record(None, "sb-1")).unwrap();
}
#[test]
fn clearing_state_record_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(STATE_FILE), b"journal").unwrap();
clear_state_record(tmp.path()).unwrap();
clear_state_record(tmp.path()).unwrap();
assert!(!tmp.path().join(STATE_FILE).exists());
}
#[test]
fn startup_normalizes_crash_phases_and_restores_inactive_records() {
let data_dir = tempfile::tempdir().unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
for (id, phase) in [
("creating", PersistPhase::Creating),
("starting", PersistPhase::Starting),
("ready", PersistPhase::Ready),
("stopping", PersistPhase::Stopping),
("stopped", PersistPhase::Stopped),
("failed", PersistPhase::Failed),
("removing", PersistPhase::Removing),
] {
record_in_phase(&store, id, phase);
}
let mut inactive = Vec::new();
normalize_durable_records(&store, data_dir.path(), None, &mut inactive).unwrap();
let inactive: HashMap<_, _> = inactive
.into_iter()
.map(|instance| (instance.runtime.id.clone(), instance))
.collect();
assert_eq!(inactive.len(), 5);
for id in ["starting", "ready", "stopping"] {
let record = store.load(id).unwrap().unwrap();
assert_eq!(record.phase, PersistPhase::Failed);
assert_eq!(record.error.as_deref(), Some(AGENT_RESTART_ERROR));
let instance = &inactive[id];
assert_eq!(instance.runtime.state, SandboxState::Failed);
assert_eq!(instance.runtime.error.as_deref(), Some(AGENT_RESTART_ERROR));
assert_eq!(instance.runtime.record_generation, Some(record.generation));
assert!(instance.runtime.prepared.is_none());
assert!(instance.runtime.handle.is_none());
assert!(instance.runtime.network.is_none());
}
assert_eq!(inactive["stopped"].runtime.state, SandboxState::Stopped);
assert_eq!(inactive["failed"].runtime.state, SandboxState::Failed);
assert_eq!(
inactive["failed"].runtime.error.as_deref(),
Some("original failure")
);
assert_eq!(
store.load("creating").unwrap().unwrap().phase,
PersistPhase::Creating
);
assert!(store.load("removing").unwrap().is_none());
assert!(!inactive.contains_key("removing"));
}
#[test]
fn paused_records_survive_restart_while_interrupted_transitions_fail() {
let data_dir = tempfile::tempdir().unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "clean", PersistPhase::Paused);
record_in_phase(&store, "mid-pause", PersistPhase::Pausing);
record_in_phase(&store, "mid-resume", PersistPhase::Resuming);
let mut inactive = Vec::new();
normalize_durable_records(
&store,
data_dir.path(),
Some(&mut SweptRuntime::nothing_kept()),
&mut inactive,
)
.unwrap();
let inactive: HashMap<_, _> = inactive
.into_iter()
.map(|instance| (instance.runtime.id.clone(), instance))
.collect();
let clean = &inactive["clean"].runtime;
assert_eq!(clean.state, SandboxState::Paused);
assert_eq!(clean.pause_snapshot_id.as_deref(), Some("snap"));
assert!(clean.paused_at.is_some());
assert_eq!(
store.load("clean").unwrap().unwrap().phase,
PersistPhase::Paused
);
for id in ["mid-pause", "mid-resume"] {
assert_eq!(inactive[id].runtime.state, SandboxState::Failed, "{id}");
assert_eq!(
store.load(id).unwrap().unwrap().phase,
PersistPhase::Failed,
"{id}"
);
}
}
#[tokio::test]
async fn stale_pause_journal_is_dropped_and_retained_state_survives() {
let data_dir = tempfile::tempdir().unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "napper", PersistPhase::Paused);
let vm_dir = data_dir.path().join("sandboxes").join("napper");
std::fs::create_dir_all(&vm_dir).unwrap();
std::fs::write(vm_dir.join(STATE_FILE), br#"{"id": "napper"}"#).unwrap();
let parked_rootfs = vm_dir.join(super::super::pause::PAUSED_ROOTFS_FILE);
std::fs::write(&parked_rootfs, b"disk").unwrap();
let cow_dir = data_dir.path().join("cow");
std::fs::create_dir_all(&cow_dir).unwrap();
let cow_file = cow_dir.join("arcbox-cow-napper.img");
std::fs::write(&cow_file, b"overlay").unwrap();
drop(store);
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let environment = crate::testkit::fake_environment(&config).unwrap();
let manager = super::super::SandboxManager::new(config, environment).unwrap();
manager.await_reconcile().await.unwrap();
assert!(!vm_dir.join(STATE_FILE).exists());
assert!(parked_rootfs.exists());
assert!(cow_file.exists());
let napper = manager.snapshot(&"napper".to_owned()).unwrap();
assert_eq!(napper.state, SandboxState::Paused);
assert_eq!(napper.pause_snapshot_id.as_deref(), Some("snap"));
}
#[tokio::test]
async fn startup_sweep_kills_the_journaled_vmm_through_adopt() {
use arcbox_vm_driver::testkit::FakeDriver;
use arcbox_vm_driver::{BootSpec, ConsoleSpec, IsolationSpec, VmSpec, VmState};
let data_dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let vm_dir = data_dir.path().join("sandboxes").join("orphan");
std::fs::create_dir_all(&vm_dir).unwrap();
let vm = driver
.boot(
VmSpec {
id: VmId::new("orphan").unwrap(),
cpus: 1,
memory_mib: 128,
boot: BootSpec::Kernel {
image: "/vmlinux".into(),
cmdline: String::new(),
initrd: None,
},
disks: vec![],
nics: vec![],
vsock: None,
shares: vec![],
console: ConsoleSpec::Off,
balloon: false,
entropy: false,
dirty_tracking: false,
isolation: IsolationSpec::None,
},
&vm_dir,
)
.await
.unwrap();
vm.detach().unwrap().detach().await.unwrap();
let pid = vm.record().process.map(|process| process.pid).unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
write_state_record(
&vm_dir,
&SandboxStateRecord::new(
"orphan",
Some(i32::try_from(pid).unwrap()),
None,
None,
&config,
None,
)
.unwrap(),
)
.unwrap();
let manager = super::super::SandboxManager::new(
config.clone(),
crate::NodeEnvironment {
driver: std::sync::Arc::new(driver),
..crate::testkit::fake_environment(&config).unwrap()
},
)
.unwrap();
manager.await_reconcile().await.unwrap();
assert_eq!(
vm.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9)),
"the orphaned vm is killed through its adopted handle"
);
assert!(!vm_dir.join(STATE_FILE).exists(), "the journal is cleared");
}
struct RefusesShutdown(Arc<dyn VmHandle>);
#[async_trait::async_trait]
impl VmHandle for RefusesShutdown {
fn id(&self) -> &VmId {
self.0.id()
}
fn record(&self) -> VmRecord {
self.0.record()
}
fn state(&self) -> VmState {
self.0.state()
}
fn events(&self) -> tokio::sync::broadcast::Receiver<arcbox_vm_driver::VmEvent> {
self.0.events()
}
async fn shutdown(
&self,
_mode: ShutdownMode,
) -> arcbox_vm_driver::Result<arcbox_vm_driver::ExitStatus> {
Err(arcbox_vm_driver::Error::Driver {
driver: "fake",
message: "the reaper did not see it exit".into(),
source: None,
})
}
fn detach(&self) -> Option<&dyn arcbox_vm_driver::Detach> {
self.0.detach()
}
}
struct AdoptionCase {
vsock: bool,
phase: PersistPhase,
journal_attach_mode: bool,
network_adopts: bool,
}
impl AdoptionCase {
fn live() -> Self {
Self {
vsock: true,
phase: PersistPhase::Ready,
journal_attach_mode: true,
network_adopts: true,
}
}
}
async fn boot_previous_vm(
driver: &arcbox_vm_driver::testkit::FakeDriver,
vm_dir: &Path,
id: &str,
vsock: bool,
handed_over: bool,
) -> Box<dyn VmHandle> {
use arcbox_vm_driver::{BootSpec, ConsoleSpec, IsolationSpec, VmSpec, VsockSpec};
std::fs::create_dir_all(vm_dir).unwrap();
let vm = driver
.boot(
VmSpec {
id: VmId::new(id).unwrap(),
cpus: 1,
memory_mib: 128,
boot: BootSpec::Kernel {
image: "/vmlinux".into(),
cmdline: String::new(),
initrd: None,
},
disks: vec![],
nics: vec![],
vsock: vsock.then_some(VsockSpec { guest_cid: 3 }),
shares: vec![],
console: ConsoleSpec::Off,
balloon: false,
entropy: false,
dirty_tracking: false,
isolation: IsolationSpec::None,
},
vm_dir,
)
.await
.unwrap();
if handed_over {
vm.detach().unwrap().detach().await.unwrap();
}
vm
}
async fn sweep_one(
data_dir: &Path,
case: &AdoptionCase,
unjournaled: &[(&str, PersistPhase)],
) -> (
Box<dyn VmHandle>,
super::super::SandboxManager,
std::sync::Arc<arcbox_vm_driver::testkit::FakeNetwork>,
Result<()>,
) {
use arcbox_vm_driver::testkit::{FakeDriver, FakeNetwork};
let driver = FakeDriver::new();
let network = std::sync::Arc::new(FakeNetwork::new());
let vm_dir = data_dir.join("sandboxes").join("keeper");
let vm = boot_previous_vm(&driver, &vm_dir, "keeper", case.vsock, true).await;
let pid = vm.record().process.map(|process| process.pid).unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.to_string_lossy().into_owned();
let store = SandboxRecordStore::new(data_dir).unwrap();
record_in_phase(&store, "keeper", case.phase);
for (id, phase) in unjournaled {
record_in_phase(&store, id, *phase);
}
drop(store);
let lease = NetworkLease {
vm: VmId::new("keeper").unwrap(),
ip: "10.200.0.9".parse().unwrap(),
prefix_len: 16,
gateway: "10.200.0.1".parse().unwrap(),
mac: "02:fc:00:00:00:09".parse().unwrap(),
cleanup_token: "gen-1".into(),
};
let mut record = SandboxStateRecord::new(
"keeper",
Some(i32::try_from(pid).unwrap()),
Some(JournaledLease::cold_boot(&lease, true)),
None,
&config,
None,
)
.unwrap();
if !case.journal_attach_mode {
record.attach_mode = None;
record.net_invariant = false;
}
write_state_record(&vm_dir, &record).unwrap();
if !case.network_adopts {
network.fail_adopt_once();
}
let manager = super::super::SandboxManager::new(
config.clone(),
crate::NodeEnvironment {
driver: std::sync::Arc::new(driver),
network: std::sync::Arc::clone(&network) as std::sync::Arc<dyn GuestNetwork>,
..crate::testkit::fake_environment(&config).unwrap()
},
)
.unwrap();
let reconciled = manager.await_reconcile().await;
(vm, manager, network, reconciled)
}
#[tokio::test]
async fn a_live_ready_sandbox_is_reclaimed_rather_than_killed() {
let data_dir = tempfile::tempdir().unwrap();
let (vm, manager, network, reconciled) =
sweep_one(data_dir.path(), &AdoptionCase::live(), &[]).await;
reconciled.unwrap();
assert_eq!(vm.state(), VmState::Running, "the guest was never killed");
let keeper = manager.snapshot(&"keeper".to_owned()).unwrap();
assert_eq!(keeper.state, SandboxState::Ready);
assert!(keeper.handle.is_some(), "the VM's handle came back");
assert_eq!(
keeper.lease.as_ref().map(|lease| lease.ip.to_string()),
Some("10.200.0.9".to_owned()),
"the lease came back"
);
assert!(keeper.agent.is_some(), "the adopted guest is dialable");
assert_eq!(
network.adopted_mode(&VmId::new("keeper").unwrap()),
Some(AttachMode::Invariant),
"the datapath is re-established as the journal says it was built"
);
assert!(
data_dir
.path()
.join("sandboxes/keeper")
.join(STATE_FILE)
.exists(),
"an adopted journal is live state, not debris"
);
}
#[tokio::test]
async fn an_unusable_journal_is_skipped_and_the_sweep_goes_on() {
let data_dir = tempfile::tempdir().unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let broken_dir = data_dir.path().join("sandboxes").join("broken");
std::fs::create_dir_all(&broken_dir).unwrap();
let stray = SandboxStateRecord::new("not-broken", None, None, None, &config, None).unwrap();
write_state_record(&broken_dir, &stray).unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "broken", PersistPhase::Ready);
drop(store);
let (vm, manager, _network, reconciled) =
sweep_one(data_dir.path(), &AdoptionCase::live(), &[]).await;
reconciled.expect("one unreadable journal does not fail the reconciliation");
assert_eq!(
vm.state(),
VmState::Running,
"the sandbox the sweep could read was still reclaimed"
);
assert_eq!(
manager.snapshot(&"keeper".to_owned()).unwrap().state,
SandboxState::Ready
);
assert_eq!(
manager.snapshot(&"broken".to_owned()).unwrap().state,
SandboxState::Failed,
"the unreadable one is reported, not reconciled"
);
assert!(
broken_dir.join(STATE_FILE).exists(),
"its journal stays on disk for a version that can read it"
);
}
#[tokio::test]
async fn a_journal_the_port_cannot_name_is_skipped_at_the_boundary() {
let data_dir = tempfile::tempdir().unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let legacy = "inst_7f3a";
let legacy_dir = data_dir.path().join("sandboxes").join(legacy);
std::fs::create_dir_all(&legacy_dir).unwrap();
let record = SandboxStateRecord::new(legacy, None, None, None, &config, None).unwrap();
write_state_record(&legacy_dir, &record).unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, legacy, PersistPhase::Ready);
drop(store);
let (vm, manager, _network, reconciled) =
sweep_one(data_dir.path(), &AdoptionCase::live(), &[]).await;
reconciled.expect("a journal the port cannot name does not fail the reconciliation");
assert_eq!(
vm.state(),
VmState::Running,
"the sandbox the sweep could name was still reclaimed"
);
assert_eq!(
manager.snapshot(&legacy.to_owned()).unwrap().state,
SandboxState::Failed,
"the one it could not is reported, not reconciled"
);
assert!(
legacy_dir.join(STATE_FILE).exists(),
"its journal stays on disk for a version that can name it"
);
}
#[tokio::test]
async fn a_skipped_journal_holds_its_disk_and_its_address() {
let data_dir = tempfile::tempdir().unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let held = "10.200.0.2".parse::<std::net::IpAddr>().unwrap();
let lease = NetworkLease {
vm: VmId::new("not-broken").unwrap(),
ip: held,
prefix_len: 16,
gateway: "10.200.0.1".parse().unwrap(),
mac: "02:fc:00:00:00:02".parse().unwrap(),
cleanup_token: "gen-broken".into(),
};
let broken_dir = data_dir.path().join("sandboxes").join("broken");
std::fs::create_dir_all(&broken_dir).unwrap();
let mut stray = SandboxStateRecord::new(
"not-broken",
None,
Some(JournaledLease::cold_boot(&lease, true)),
None,
&config,
None,
)
.unwrap();
let cow_dir = data_dir.path().join("cow");
stray.cow = Some(CowRecord {
dm_name: format!("{DM_NAME_PREFIX}journaled"),
dm_device: format!("/dev/mapper/{DM_NAME_PREFIX}journaled"),
cow_loop: "/dev/loop9".into(),
cow_file: cow_dir.join(format!("{COW_FILE_PREFIX}journaled{COW_FILE_SUFFIX}")),
template_path: "/templates/rootfs.img".into(),
});
write_state_record(&broken_dir, &stray).unwrap();
std::fs::create_dir_all(&cow_dir).unwrap();
for owner in ["broken", "not-broken", "journaled"] {
std::fs::write(
cow_dir.join(format!("{COW_FILE_PREFIX}{owner}{COW_FILE_SUFFIX}")),
b"overlay",
)
.unwrap();
}
let (_vm, _manager, network, reconciled) =
sweep_one(data_dir.path(), &AdoptionCase::live(), &[]).await;
reconciled.expect("one unreadable journal does not fail the reconciliation");
for owner in ["broken", "not-broken", "journaled"] {
assert!(
cow_dir
.join(format!("{COW_FILE_PREFIX}{owner}{COW_FILE_SUFFIX}"))
.exists(),
"the global reap deleted the overlay of a sandbox it could not identify: {owner}"
);
}
let fresh = GuestNetwork::reserve(
&*network,
&VmId::new("fresh").unwrap(),
arcbox_vm_driver::net::NetworkPolicy {
mode: arcbox_vm_driver::net::NetworkMode::Nat,
},
)
.await
.unwrap();
assert_ne!(
fresh.ip, held,
"the address of a guest nobody could identify was handed out again"
);
}
#[tokio::test]
async fn a_skipped_journal_does_not_answer_for_the_id_it_claims() {
let data_dir = tempfile::tempdir().unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let broken_dir = data_dir.path().join("sandboxes").join("broken");
std::fs::create_dir_all(&broken_dir).unwrap();
let stray = SandboxStateRecord::new("ghost", None, None, None, &config, None).unwrap();
write_state_record(&broken_dir, &stray).unwrap();
let (vm, _manager, network, reconciled) = sweep_one(
data_dir.path(),
&AdoptionCase::live(),
&[("ghost", PersistPhase::Ready)],
)
.await;
let error = reconciled.expect_err("an unjournaled live record still refuses normalization");
assert!(
error.to_string().contains("has no cleanup journal"),
"the broken journal spoke for ghost: {error}"
);
assert_eq!(
vm.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9)),
"the reclaimed vm is handed back, not dropped"
);
assert_eq!(
network.adopted_mode(&VmId::new("keeper").unwrap()),
None,
"and so is its lease"
);
}
#[tokio::test]
async fn a_failed_sweep_hands_back_what_it_already_reclaimed() {
use arcbox_vm_driver::testkit::{FakeDriver, FakeNetwork};
let data_dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let network = std::sync::Arc::new(FakeNetwork::new());
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
let keeper_dir = data_dir.path().join("sandboxes").join("keeper");
let keeper = boot_previous_vm(&driver, &keeper_dir, "keeper", true, true).await;
let lease = NetworkLease {
vm: VmId::new("keeper").unwrap(),
ip: "10.200.0.9".parse().unwrap(),
prefix_len: 16,
gateway: "10.200.0.1".parse().unwrap(),
mac: "02:fc:00:00:00:09".parse().unwrap(),
cleanup_token: "gen-1".into(),
};
write_state_record(
&keeper_dir,
&SandboxStateRecord::new(
"keeper",
keeper
.record()
.process
.map(|process| i32::try_from(process.pid).unwrap()),
Some(JournaledLease::cold_boot(&lease, true)),
None,
&config,
None,
)
.unwrap(),
)
.unwrap();
let broken_dir = data_dir.path().join("sandboxes").join("zzz-broken");
let _broken = boot_previous_vm(&driver, &broken_dir, "zzz-broken", true, false).await;
write_state_record(
&broken_dir,
&SandboxStateRecord::new("zzz-broken", None, None, None, &config, None).unwrap(),
)
.unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "keeper", PersistPhase::Ready);
drop(store);
let manager = super::super::SandboxManager::new(
config.clone(),
crate::NodeEnvironment {
driver: std::sync::Arc::new(driver),
network: std::sync::Arc::clone(&network) as std::sync::Arc<dyn GuestNetwork>,
..crate::testkit::fake_environment(&config).unwrap()
},
)
.unwrap();
manager
.await_reconcile()
.await
.expect_err("an orphan the driver cannot adopt fails the sweep");
assert_eq!(
keeper.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9)),
"the reclaimed vm is killed rather than left ownerless"
);
assert_eq!(
network.adopted_mode(&VmId::new("keeper").unwrap()),
None,
"and its lease is handed back rather than left live"
);
}
#[tokio::test]
async fn a_failed_normalization_hands_back_what_the_sweep_reclaimed() {
for peer in ["aaa-ghost", "zzz-ghost"] {
let data_dir = tempfile::tempdir().unwrap();
let (vm, _manager, network, reconciled) = sweep_one(
data_dir.path(),
&AdoptionCase::live(),
&[(peer, PersistPhase::Ready)],
)
.await;
let error = reconciled.expect_err("an unjournaled live record refuses normalization");
assert!(
error.to_string().contains("has no cleanup journal"),
"{peer}: unexpected reconciliation failure: {error}"
);
assert_eq!(
vm.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9)),
"{peer}: the reclaimed vm is killed rather than left ownerless"
);
assert_eq!(
network.adopted_mode(&VmId::new("keeper").unwrap()),
None,
"{peer}: its lease is handed back rather than left live"
);
}
}
#[tokio::test]
async fn a_release_whose_kill_fails_keeps_the_disk_and_the_address() {
use arcbox_vm_driver::testkit::{FakeDriver, FakeNetwork};
let data_dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let network = FakeNetwork::new();
let probe = Arc::new(crate::snapshot_cow::CowTestProbe::default());
let cow_manager = CowManager::new_with_test_probe(
crate::snapshot_cow::CowOptions::new(data_dir.path()),
Arc::clone(&probe),
)
.unwrap();
let vm_dir = data_dir.path().join("sandboxes").join("stuck");
let vm = boot_previous_vm(&driver, &vm_dir, "stuck", true, false).await;
let vm_record = vm.record();
let lease = network
.reserve(
&VmId::new("stuck").unwrap(),
super::super::sandbox_network_policy(),
)
.await
.unwrap();
network.release(lease.clone()).await.unwrap();
network.adopt(&lease, AttachMode::Invariant).await.unwrap();
let mut adopted = HashMap::new();
adopted.insert(
"stuck".to_owned(),
AdoptedSandbox {
handle: Arc::new(RefusesShutdown(Arc::from(vm))),
lease: Some(lease),
identity: None,
cow_handle: Some(CowHandle {
dm_name: "arcbox-snap-stuck".into(),
dm_device: "/dev/mapper/arcbox-snap-stuck".into(),
cow_loop: "/dev/loop7".into(),
cow_file: data_dir.path().join("cow/arcbox-cow-stuck.img"),
template_path: "/rootfs.ext4".into(),
}),
pool_slot_id: None,
net_invariant: true,
},
);
release_reclaimed(&mut adopted, &network, &cow_manager).await;
assert_eq!(
probe.teardown_count(),
0,
"a guest that is still running keeps its disk"
);
assert_eq!(
network.adopted_mode(&VmId::new("stuck").unwrap()),
Some(AttachMode::Invariant),
"and its address"
);
let readopted = driver
.adopt()
.expect("the fake driver adopts")
.adopt(&vm_record)
.await
.unwrap();
assert!(
readopted.is_some(),
"the vm is still there for the next sweep to retry"
);
}
#[tokio::test]
async fn a_kill_that_fails_hands_the_vm_over_instead_of_dropping_it() {
use arcbox_vm_driver::testkit::FakeDriver;
let data_dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let vm_dir = data_dir.path().join("sandboxes").join("stuck");
let vm = boot_previous_vm(&driver, &vm_dir, "stuck", true, false).await;
let vm_record = vm.record();
let handle: Arc<dyn VmHandle> = Arc::new(RefusesShutdown(Arc::from(vm)));
let failed = kill_or_hand_over("stuck", &handle).await;
assert!(
failed.is_err(),
"a vm that is still alive is reported, not swallowed"
);
drop(handle);
let readopted = driver
.adopt()
.expect("the fake driver adopts")
.adopt(&vm_record)
.await
.unwrap();
assert!(
readopted.is_some(),
"the vm was handed over, so letting go of the handle did not kill it"
);
}
#[tokio::test]
async fn every_adoption_predicate_falls_back_to_the_kill() {
for (what, case) in [
(
"a process-only handle whose api never answered",
AdoptionCase {
vsock: false,
..AdoptionCase::live()
},
),
(
"a boot that never reached ready",
AdoptionCase {
phase: PersistPhase::Starting,
..AdoptionCase::live()
},
),
(
"a journal that predates adoption",
AdoptionCase {
journal_attach_mode: false,
..AdoptionCase::live()
},
),
(
"a network that cannot take the lease back",
AdoptionCase {
network_adopts: false,
..AdoptionCase::live()
},
),
] {
let data_dir = tempfile::tempdir().unwrap();
let (vm, manager, _, reconciled) = sweep_one(data_dir.path(), &case, &[]).await;
reconciled.unwrap();
assert_eq!(
vm.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9)),
"{what}: the vm is killed through its adopted handle"
);
assert!(
!data_dir
.path()
.join("sandboxes/keeper")
.join(STATE_FILE)
.exists(),
"{what}: the journal is cleared"
);
let keeper = manager.snapshot(&"keeper".to_owned()).unwrap();
assert_eq!(keeper.state, SandboxState::Failed, "{what}");
assert!(keeper.handle.is_none(), "{what}");
}
}
#[tokio::test]
async fn a_dead_vms_area_goes_whatever_its_journal_says_about_the_jailer() {
use arcbox_vm_driver::testkit::{FakeDriver, FakeNetwork};
for journaled_jailer in [false, true] {
let data_dir = tempfile::tempdir().unwrap();
let driver = FakeDriver::new();
let probe = driver.clone();
let network = FakeNetwork::new();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = data_dir.path().to_string_lossy().into_owned();
config.firecracker.jailer = Some(crate::config::JailerConfig {
uid: 0,
gid: 0,
chroot_base_dir: Some(data_dir.path().join("jail").to_string_lossy().into_owned()),
netns: None,
new_pid_ns: false,
cgroup_version: None,
parent_cgroup: None,
});
let isolation = super::super::isolation_spec(&config).unwrap();
let goner_dir = data_dir.path().join("sandboxes").join("goner");
let goner = boot_previous_vm(&driver, &goner_dir, "goner", false, true).await;
let mut journal =
SandboxStateRecord::new("goner", None, None, None, &config, None).unwrap();
journal.jailer = journaled_jailer;
write_state_record(&goner_dir, &journal).unwrap();
let keeper_dir = data_dir.path().join("sandboxes").join("keeper");
let keeper = boot_previous_vm(&driver, &keeper_dir, "keeper", true, true).await;
write_state_record(
&keeper_dir,
&SandboxStateRecord::new("keeper", None, None, None, &config, None).unwrap(),
)
.unwrap();
let odd_dir = data_dir.path().join("sandboxes").join("zzz-odd");
let _odd = boot_previous_vm(&driver, &odd_dir, "zzz-odd", true, true).await;
write_state_record(
&odd_dir,
&SandboxStateRecord::new("someone-else", None, None, None, &config, None).unwrap(),
)
.unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "goner", PersistPhase::Ready);
record_in_phase(&store, "keeper", PersistPhase::Ready);
let cow_manager =
CowManager::new(crate::snapshot_cow::CowOptions::new(data_dir.path())).unwrap();
let snapshots = crate::snapshot::SnapshotCatalog::new(&config.firecracker.data_dir);
let sweep = sweep_orphans(&config, &driver, &network, &cow_manager, &snapshots, &store)
.await
.expect("the sweep");
assert_eq!(
probe.discarded_areas(),
[(VmId::new("goner").unwrap(), isolation)],
"journaled jailer flag {journaled_jailer}"
);
assert_eq!(
goner.state(),
VmState::Exited(arcbox_vm_driver::ExitStatus::signaled(9))
);
assert!(sweep.adopted.contains_key("keeper"));
assert_eq!(keeper.state(), VmState::Running);
}
}
#[test]
fn active_record_without_cleanup_journal_blocks_normalization() {
let data_dir = tempfile::tempdir().unwrap();
let store = SandboxRecordStore::new(data_dir.path()).unwrap();
record_in_phase(&store, "starting", PersistPhase::Starting);
assert!(matches!(
normalize_durable_records(
&store,
data_dir.path(),
Some(&mut SweptRuntime::nothing_kept()),
&mut Vec::new()
),
Err(crate::error::VmmError::Unavailable(_))
));
assert_eq!(
store.load("starting").unwrap().unwrap().phase,
PersistPhase::Starting
);
}
}