Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ path = "src/lib.rs"
default = ["threshold-engine", "qemu-backend", "tui"]
threshold-engine = []
qemu-backend = ["dep:rustix", "dep:uuid", "dep:virt"]
# In-process VM stand-in used by component tests. Not part of a production build.
fake-backend = []
# TUI (`iothread-tui` binary), enabled by default.
tui = ["dep:anyhow", "dep:ratatui", "dep:crossterm"]

Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ unit-test:
.PHONY: component-test
component-test:
cargo build --locked --no-default-features \
--features threshold-engine
--features threshold-engine,fake-backend
python3 -m pytest tests/component

.PHONY: test
Expand Down
289 changes: 289 additions & 0 deletions src/backends/fake/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,289 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Nutanix, Inc.
//
// Author: Leonardo Forchini <leonardo.forchini@nutanix.com>

//! File-backed backend for component tests.
//!
//! Instead of requiring a running QEMU instance, this backend manages

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads like we still use QEMU except that we're not running it. I assume you mean that we use a backend that doesn't require QEMU at all. The fact that it's file-based is secondary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also add a paragraph how this backend behaves? E.g. whether you pre-configure some performance counter values that it will return when queried by the controller etc. I feel like test backend is a more suitable name, and it could be used as a n example backend as well, could you document this in the README?

//! state through JSON files in `state_dir`. This allows for the external
//! Python component testing harness to manipulate "VM"s as necessary.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't explain what happens after the harness has modified the files, does the backend blindly re-read everything when asked by the controller?

//! Similarly, controller actuations are preserved in `calls.json`, which
//! the harness can use in assertions.
//!
//! VM JSON files consist of the following which are used to construct a
//! [`ThreadPoolSnapshot`]:
//! * thread_count - Number of worker threads assigned to this VM
//! * vcpu_count - Number of vCPUs
//! * read_io_count - Cumulative read IO operations
//! * write_io_count - Cumulative write IO operations
//! * other_io_count - Cumulative other IO operations
//!
//! As well as some control fields:
//! * fail_snapshot - An error should be injected when attempting to get VMs
//! ThreadPoolSnapshot.
//!
//! `call.json` is used to give the external testing harness visibility over
//! [`InstanceClient::set_thread_count`] calls by the controller. It keeps
//! track of the following:
//! * vm - Actuated VM
//! * thread_count - New VM worker thread count

use std::{
fs::{self, File, OpenOptions},
io::{Error as IoError, Write},
sync::Arc,
};

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use crate::{
backends::{Backend, BackendClientError, BackendRegistration},
config::ConfigError,
instance::{Instance, InstanceClient, InstancePerfSample, ThreadPoolSnapshot},
path::PrefixedPath,
};

const BACKEND_NAME: &str = "fake";

fn build_fake_backend(dir: &PrefixedPath) -> Result<Box<dyn Backend>, BackendClientError> {
Ok(Box::new(FakeBackend::from_config_dir(dir)?))
}

#[linkme::distributed_slice(crate::backends::BACKENDS)]
static FAKE_BACKEND: BackendRegistration = BackendRegistration {
name: BACKEND_NAME,
build: build_fake_backend,
};

/// `<backend_config_dir>/fake.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct FakeConfig {
/// Directory of `<vm-id>.json` files and `calls.json`.
state_dir: PrefixedPath,
}

impl FakeConfig {
/// Read `fake.json` when it exists.
fn from_dir(dir: &PrefixedPath) -> Result<Option<Self>, ConfigError> {
let path = dir.join("fake.json");
if !path.exists() {
return Ok(None);
}

let data = fs::read_to_string(&path)?;
Ok(Some(serde_json::from_str(&data)?))
}
}

/// One component-test VM, as stored on disk.
///
/// Missing fields take [`VmFile::default`]: one thread, one vCPU, and
/// `managed` so the controller may scale the VM.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
struct VmFile {
thread_count: u32,
vcpu_count: u32,
per_thread_util: f64,
/// First-snapshot ownership. `true` lets the controller scale the VM.
managed: bool,
fail_snapshot: bool,
read_io_count: u64,
write_io_count: u64,
other_io_count: u64,
}

impl Default for VmFile {
fn default() -> Self {
Self {
thread_count: 1,
vcpu_count: 1,
per_thread_util: 0.0,
managed: true,
fail_snapshot: false,
read_io_count: 0,
write_io_count: 0,
other_io_count: 0,
}
}
}

/// Fleet adapter that discovers JSON VM files.
pub struct FakeBackend {
cfg: Option<FakeConfig>,
}

impl FakeBackend {
/// Build from `<dir>/fake.json`, or an empty inventory when it is absent.
pub fn from_config_dir(dir: &PrefixedPath) -> Result<Self, ConfigError> {
Ok(Self {
cfg: FakeConfig::from_dir(dir)?,
})
}
}

#[async_trait]
impl Backend for FakeBackend {
fn name(&self) -> &'static str {
BACKEND_NAME
}

async fn discover(&self) -> Vec<Arc<Instance>> {
let Some(cfg) = &self.cfg else {
return Vec::new();
};
discover_vms(&cfg.state_dir)
}

fn watch_paths(&self) -> Vec<PrefixedPath> {
self.cfg
.as_ref()
.map(|cfg| vec![cfg.state_dir.clone()])
.unwrap_or_default()
}
}

/// Load every `<vm-id>.json` file in `state_dir`.
#[tracing::instrument(skip(state_dir), fields(directory = %state_dir))]
fn discover_vms(state_dir: &PrefixedPath) -> Vec<Arc<Instance>> {
let entries = fs::read_dir(state_dir).expect("fake backend state directory is not readable");
let mut instances = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we expecting irrelevant files which we should ignore?

}
let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
continue;
};
let Some(vm) = read_vm(&path, id) else {
continue;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ignore the failure?

};
let client = FakeClient {
vm_path: path.clone(),
calls_path: state_dir.join("calls.json").to_path_buf(),
managed: vm.managed,
};
instances.push(Arc::new(Instance::new(
id.to_string(),
PrefixedPath::new(id),
0,
client,
)));
}
instances
}

/// Read one VM file, or log and return `None` when it cannot be parsed.
#[tracing::instrument(skip(path, id), fields(vm = id))]
fn read_vm(path: &std::path::Path, id: &str) -> Option<VmFile> {
let _guard = LockGuard::acquire(&lock_path_for(path)).ok()?;
VmFile::read(path).ok()
}

/// Per-VM client. Paths are real filesystem paths, not logical `util::Path`s.
struct FakeClient {
vm_path: std::path::PathBuf,
calls_path: std::path::PathBuf,
managed: bool,
}

/// `<vm-id>.json` shares `<vm-id>.lock` with the pytest harness.
fn lock_path_for(vm_path: &std::path::Path) -> std::path::PathBuf {
vm_path.with_extension("lock")
}

/// Exclusive `flock` held until dropped. Matches `fcntl.flock(LOCK_EX)`.
struct LockGuard {
file: File,
}

impl LockGuard {
/// Get the lock on the given file. Blocks until it gets the lock.
fn acquire(path: &std::path::Path) -> Result<Self, IoError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(path)?;
file.lock()?;
Ok(Self { file })
}
}

impl Drop for LockGuard {
fn drop(&mut self) {
let _ = self.file.unlock();
}
}

impl VmFile {
fn read(path: &std::path::Path) -> Result<Self, BackendClientError> {
let data = fs::read_to_string(path)?;
Ok(serde_json::from_str(&data)?)
}

fn write_atomic(path: &std::path::Path, vm: &Self) -> Result<(), BackendClientError> {
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, serde_json::to_vec_pretty(vm)?)?;
fs::rename(&tmp, path)?;
Ok(())
}
}

#[async_trait]
impl InstanceClient for FakeClient {
async fn set_thread_count(&self, count: u32) -> Result<(), BackendClientError> {
let _guard = LockGuard::acquire(&lock_path_for(&self.vm_path))?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not lock the VM json file? Or better the entire VM directory? This way we wouldn't need the read/write methods in impl VmFile?

let mut vm = VmFile::read(&self.vm_path)?;
vm.thread_count = count;
VmFile::write_atomic(&self.vm_path, &vm)?;
let mut calls = OpenOptions::new()
.create(true)
.append(true)
.open(&self.calls_path)?;
let line = serde_json::json!({
"vm": self.vm_path.file_stem().and_then(|stem| stem.to_str()).unwrap_or(""),
"threads": count,
});
writeln!(calls, "{line}")?;
Ok(())
}

async fn get_thread_pool_snapshot(&self) -> Result<ThreadPoolSnapshot, BackendClientError> {
let _guard = LockGuard::acquire(&lock_path_for(&self.vm_path))?;
let vm = VmFile::read(&self.vm_path)?;
if vm.fail_snapshot {
return Err(BackendClientError::InvalidState(format!(
"fake snapshot failed for {}",
self.vm_path.display()
)));
}
Ok(ThreadPoolSnapshot {
thread_count: vm.thread_count,
vcpu_count: vm.vcpu_count,
perf: Some(InstancePerfSample {
read_io_count: vm.read_io_count,
write_io_count: vm.write_io_count,
other_io_count: vm.other_io_count,
read_bytes_total: 0,
write_bytes_total: 0,
..Default::default()
}),
per_thread_util: Some(vm.per_thread_util),
})
}

fn initial_pool_is_managed(&self, _thread_count: u32, _vcpu_count: u32) -> bool {
self.managed
}

async fn close(&self) {}
}
2 changes: 2 additions & 0 deletions src/backends/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ use std::{
sync::{Arc, LazyLock, Mutex},
};

#[cfg(feature = "fake-backend")]
pub mod fake;
#[cfg(feature = "qemu-backend")]
pub mod qemu;

Expand Down
Loading
Loading