use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use arcbox_computer_runtime::config::{JailerConfig, RuntimeConfig};
use arcbox_computer_runtime::testkit::agent::FakeAgentFactory;
use arcbox_computer_runtime::testkit::fake_environment;
use arcbox_computer_runtime::{
ComputerEvent, ComputerId, ComputerManager, ComputerSpec, ComputerState, NodeEnvironment,
OutputChunk,
};
use arcbox_vm_driver::testkit::{FakeDriver, FakeNetwork};
use tokio::sync::broadcast;
const DEADLINE: Duration = Duration::from_secs(10);
const SHORT_TMP_ROOT: &str = "/tmp";
pub struct Setup {
jailer: bool,
warm: bool,
cow_probe: bool,
}
impl Setup {
#[must_use]
pub const fn jailed() -> Self {
Self {
jailer: true,
warm: false,
cow_probe: false,
}
}
#[must_use]
pub const fn direct() -> Self {
Self {
jailer: false,
warm: false,
cow_probe: false,
}
}
#[must_use]
pub const fn with_cow_probe(mut self) -> Self {
self.cow_probe = true;
self
}
#[must_use]
pub const fn warm(mut self) -> Self {
self.warm = true;
self
}
pub async fn build(self) -> Fixture {
let dir = tempfile::Builder::new()
.prefix("abx")
.tempdir_in(SHORT_TMP_ROOT)
.expect("a short-pathed data dir");
std::fs::write(dir.path().join("k"), b"kernel").unwrap();
std::fs::write(dir.path().join("r.ext4"), b"rootfs").unwrap();
let mut config = RuntimeConfig::default();
config.firecracker.data_dir = dir.path().to_string_lossy().into_owned();
config.firecracker.warm_create = self.warm;
config.firecracker.dmsetup_candidates = Some(Vec::new());
config.defaults.kernel = dir.path().join("k").to_string_lossy().into_owned();
config.defaults.rootfs = dir.path().join("r.ext4").to_string_lossy().into_owned();
config.firecracker.jailer = self.jailer.then(|| JailerConfig {
uid: nix::unistd::geteuid().as_raw(),
gid: nix::unistd::getegid().as_raw(),
chroot_base_dir: Some(dir.path().join("j").to_string_lossy().into_owned()),
netns: None,
new_pid_ns: false,
cgroup_version: None,
parent_cgroup: None,
});
let ports = Ports {
driver: FakeDriver::new(),
agent: FakeAgentFactory::new(),
cow_probe: self
.cow_probe
.then(|| Arc::new(arcbox_computer_runtime::snapshot_cow::CowTestProbe::default())),
};
let manager = ports.manager(&config).await;
Fixture {
manager,
ports,
config,
dir,
}
}
}
struct Ports {
driver: FakeDriver,
agent: FakeAgentFactory,
cow_probe: Option<Arc<arcbox_computer_runtime::snapshot_cow::CowTestProbe>>,
}
impl Ports {
async fn manager(&self, config: &RuntimeConfig) -> Arc<ComputerManager> {
let mut environment =
fake_environment(config).expect("a copy-on-write manager over the data dir");
if let Some(probe) = &self.cow_probe {
use arcbox_computer_runtime::snapshot_cow::{CowManager, CowOptions};
environment.cow_manager = Arc::new(
CowManager::new_with_test_probe(
CowOptions::new(&config.firecracker.data_dir),
Arc::clone(probe),
)
.expect("a probe-backed cow manager over the data dir"),
);
}
let manager = ComputerManager::new(
config.clone(),
NodeEnvironment {
driver: Arc::new(self.driver.clone()),
network: Arc::new(FakeNetwork::with_startup_cleanup("test-boot")),
agent: Arc::new(self.agent.clone()),
..environment
},
)
.expect("the fakes offer every capability the manager requires")
.into_shared();
let token = manager
.startup_cleanup_token()
.await
.unwrap()
.expect("a fresh process owes a startup cleanup");
settle_network_cleanups(&manager).await;
manager.finalize_startup_cleanup(&token).await.unwrap();
manager
}
}
pub struct Fixture {
pub manager: Arc<ComputerManager>,
ports: Ports,
config: RuntimeConfig,
dir: tempfile::TempDir,
}
impl Fixture {
pub async fn jailed() -> Self {
Setup::jailed().build().await
}
pub async fn direct() -> Self {
Setup::direct().build().await
}
pub const fn driver(&self) -> &FakeDriver {
&self.ports.driver
}
pub const fn agent(&self) -> &FakeAgentFactory {
&self.ports.agent
}
pub fn vm_dir(&self, id: &str) -> PathBuf {
self.dir.path().join("sandboxes").join(id)
}
pub fn record_path(&self, id: &str) -> PathBuf {
self.dir
.path()
.join("sandbox-records")
.join(format!("{id}.json"))
}
pub fn snapshot_catalog(&self) -> arcbox_computer_runtime::snapshot::SnapshotCatalog {
arcbox_computer_runtime::snapshot::SnapshotCatalog::new(self.dir.path().to_str().unwrap())
}
pub fn cow_file(&self, id: &str) -> PathBuf {
use arcbox_computer_runtime::snapshot_cow::{COW_FILE_PREFIX, COW_FILE_SUFFIX};
self.dir
.path()
.join("cow")
.join(format!("{COW_FILE_PREFIX}{id}{COW_FILE_SUFFIX}"))
}
pub async fn restart(self) -> Self {
let Self {
manager,
ports,
config,
dir,
} = self;
drop(manager);
let deadline = tokio::time::Instant::now() + DEADLINE;
while !ports.driver.owned_vms().is_empty() {
assert!(
tokio::time::Instant::now() < deadline,
"the departing process never let its VMs go: {:?}",
ports.driver.owned_vms()
);
tokio::time::sleep(Duration::from_millis(5)).await;
}
let manager = ports.manager(&config).await;
Self {
manager,
ports,
config,
dir,
}
}
pub async fn ready(&self, id: &str) -> ComputerId {
let id = self
.booted(ComputerSpec {
id: Some(id.to_owned()),
..ComputerSpec::default()
})
.await;
self.await_state(&id, ComputerState::Ready).await;
id
}
pub async fn booted(&self, spec: ComputerSpec) -> ComputerId {
let mut events = self.manager.subscribe_events();
let (id, _ip) = self
.manager
.create_computer(spec)
.await
.expect("the create is accepted");
await_action(&mut events, &id, action::READY).await;
id
}
pub async fn await_state(&self, id: &ComputerId, state: ComputerState) {
let deadline = tokio::time::Instant::now() + DEADLINE;
loop {
let seen = self.manager.inspect_computer(id);
match &seen {
Ok(info) if info.state == state => return,
_ if tokio::time::Instant::now() >= deadline => panic!(
"{id} never reached {state}: {}",
match seen {
Ok(info) => info.state.to_string(),
Err(error) => error.to_string(),
}
),
_ => tokio::time::sleep(Duration::from_millis(10)).await,
}
}
}
pub async fn await_gone(&self, id: &ComputerId) {
let deadline = tokio::time::Instant::now() + DEADLINE;
while self.manager.inspect_computer(id).is_ok() {
assert!(
tokio::time::Instant::now() < deadline,
"{id} is still registered"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
pub async fn settle_network_cleanups(&self) -> Vec<String> {
settle_network_cleanups(&self.manager).await
}
pub async fn await_released(&self, id: &ComputerId) {
self.await_state(id, ComputerState::Failed).await;
let journal = self.vm_dir(id).join("state.json");
let deadline = tokio::time::Instant::now() + DEADLINE;
while journal.exists() {
assert!(
tokio::time::Instant::now() < deadline,
"{id} kept its crash journal, so something it held was never released"
);
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
pub async fn run(&self, id: &ComputerId, cmd: &[&str]) -> Vec<u8> {
let mut output = self
.manager
.run_in_computer(
id,
cmd.iter().map(|arg| (*arg).to_owned()).collect(),
HashMap::new(),
String::new(),
String::new(),
false,
None,
0,
)
.await
.expect("a Ready computer runs a workload");
let mut stdout = Vec::new();
while let Some(chunk) = output.recv().await {
if let OutputChunk::Stdout(bytes) = chunk.expect("the output stream stays open") {
stdout.extend_from_slice(&bytes);
}
}
stdout
}
}
pub async fn settle_network_cleanups(manager: &ComputerManager) -> Vec<String> {
let pending = manager.pending_network_cleanups().await.unwrap();
for (id, token) in &pending {
manager
.validate_network_cleanup(id, token)
.await
.expect("the ledger validates its own ticket");
manager
.finalize_network_cleanup(id, token)
.await
.expect("the ticket recycles the address");
}
pending.into_iter().map(|(id, _)| id).collect()
}
pub mod action {
pub const CREATED: &str = "created";
pub const READY: &str = "ready";
pub const RUNNING: &str = "running";
pub const IDLE: &str = "idle";
pub const STOPPED: &str = "stopped";
pub const FAILED: &str = "failed";
pub const REMOVED: &str = "removed";
pub const PAUSING: &str = "pausing";
pub const PAUSED: &str = "paused";
pub const RESUMED: &str = "resumed";
}
pub async fn await_action(
events: &mut broadcast::Receiver<ComputerEvent>,
id: &str,
action: &str,
) -> ComputerEvent {
let deadline = tokio::time::Instant::now() + DEADLINE;
loop {
let event = tokio::time::timeout_at(deadline, events.recv())
.await
.unwrap_or_else(|_| panic!("no {action} for {id} within the deadline"))
.expect("the event stream stays open");
if event.sandbox_id == id && event.action == action {
return event;
}
assert_ne!(
(event.action.as_str(), event.sandbox_id.as_str()),
(self::action::FAILED, id),
"{id} failed instead of reaching {action}: {:?}",
event.attributes
);
}
}
pub fn drain_actions(events: &mut broadcast::Receiver<ComputerEvent>, id: &str) -> Vec<String> {
let mut actions = Vec::new();
while let Ok(event) = events.try_recv() {
if event.sandbox_id == id {
actions.push(event.action);
}
}
actions
}
pub fn never_exits(id: &str) -> ComputerSpec {
ComputerSpec {
id: Some(id.to_owned()),
cmd: vec!["/bin/wedged".into()],
..ComputerSpec::default()
}
}