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
60 changes: 34 additions & 26 deletions src/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1077,14 +1077,16 @@ mod tests {
};

use async_trait::async_trait;
use test_log::test;
use rstest::rstest;

use super::*;
use crate::{
backends::BackendClientError,
engines::{EngineTickContext, ScalingEngine},
instance::{InstanceClient, ThreadPoolSnapshot},
per_vm_log::PerVmLog,
test::MockDir,
test::mockdir::mockdir,
};
use tracing_subscriber::layer::SubscriberExt;

Expand Down Expand Up @@ -1140,11 +1142,11 @@ mod tests {

/// Test that actuation will not raise the pool above the VM vCPU
/// count.
#[test(tokio::test)]
async fn actuation_enforces_vcpu_cap() {
let state_dir = tempfile::tempdir().unwrap();
#[rstest]
#[tokio::test]
async fn actuation_enforces_vcpu_cap(#[from(mockdir)] _mockdir: MockDir) {
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
cooldown_secs: 0,
..Default::default()
};
Expand Down Expand Up @@ -1203,11 +1205,13 @@ mod tests {
// things.
/// Test that actuation clamps thread targets to configured min/max
/// and blocks scale-up when host CPU is above the ceiling.
#[test(tokio::test)]
async fn actuation_enforces_controller_bounds_and_host_ceiling() {
let state_dir = tempfile::tempdir().unwrap();
#[rstest]
#[tokio::test]
async fn actuation_enforces_controller_bounds_and_host_ceiling(
#[from(mockdir)] _mockdir: MockDir,
) {
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
min_thread_count: 2,
max_thread_count: 3,
host_cpu_scale_up_ceiling: 0.5,
Expand Down Expand Up @@ -1273,11 +1277,11 @@ mod tests {
/// Test that a performance-validation revert arms cooldown so the next
/// ordinary up/down cannot immediately undo it (the 4→3→4 flap).
#[cfg(feature = "threshold-engine")]
#[test(tokio::test)]
async fn revert_arms_cooldown_blocking_immediate_retry() {
let state_dir = tempfile::tempdir().unwrap();
#[rstest]
#[tokio::test]
async fn revert_arms_cooldown_blocking_immediate_retry(#[from(mockdir)] _mockdir: MockDir) {
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
cooldown_secs: 30,
min_thread_count: 1,
max_thread_count: 8,
Expand Down Expand Up @@ -1330,11 +1334,11 @@ mod tests {
/// Test that revert itself is not blocked by an existing cooldown, so a
/// failed validation can still roll the pool back.
#[cfg(feature = "threshold-engine")]
#[test(tokio::test)]
async fn revert_bypasses_existing_cooldown() {
let state_dir = tempfile::tempdir().unwrap();
#[rstest]
#[tokio::test]
async fn revert_bypasses_existing_cooldown(#[from(mockdir)] _mockdir: MockDir) {
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
cooldown_secs: 60,
min_thread_count: 1,
max_thread_count: 8,
Expand Down Expand Up @@ -1407,11 +1411,13 @@ mod tests {

/// Test that `set_all` applies a target to every VM and reports how
/// many failed when some clients error.
#[rstest]
#[tokio::test]
async fn test_set_all_attempts_every_vm_and_reports_partial_failure() {
let state_dir = tempfile::tempdir().unwrap();
async fn test_set_all_attempts_every_vm_and_reports_partial_failure(
#[from(mockdir)] _mockdir: MockDir,
) {
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
..Default::default()
};
let engine = Box::new(default_threshold_engine());
Expand Down Expand Up @@ -1564,14 +1570,14 @@ mod tests {

/// Test that discovery sync adds new VMs, retains existing ones,
/// and removes disappeared ones.
#[rstest]
#[tokio::test]
async fn sync_instances_adds_retains_and_removes() {
async fn sync_instances_adds_retains_and_removes(#[from(mockdir)] _mockdir: MockDir) {
let added = Arc::new(AtomicUsize::new(0));
let removed = Arc::new(AtomicUsize::new(0));
let evaluated = Arc::new(AtomicUsize::new(0));
let state_dir = tempfile::tempdir().unwrap();
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
..Default::default()
};
let mut controller = Controller::new(
Expand Down Expand Up @@ -1641,14 +1647,16 @@ mod tests {

/// Test that one tick refreshes state, runs the engine, and drops
/// instances that fail refresh.
#[rstest]
#[tokio::test]
async fn tick_refreshes_evaluates_and_drops_failed_instances() {
async fn tick_refreshes_evaluates_and_drops_failed_instances(
#[from(mockdir)] _mockdir: MockDir,
) {
let added = Arc::new(AtomicUsize::new(0));
let removed = Arc::new(AtomicUsize::new(0));
let evaluated = Arc::new(AtomicUsize::new(0));
let state_dir = tempfile::tempdir().unwrap();
let cfg = Config {
vm_state_path: PrefixedPath::new(&state_dir.path().join("ownership.json")),
vm_state_path: PrefixedPath::new("/ownership.json"),
..Default::default()
};
let mut controller = Controller::new(
Expand Down
2 changes: 2 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ pub mod path;
pub mod per_vm_log;
pub mod rolling;
pub mod state;
#[cfg(test)]
pub(crate) mod test;

pub(crate) trait ProcessExt {
fn from_pid(pid: i32) -> procfs::ProcResult<procfs::process::Process>;
Expand Down
19 changes: 17 additions & 2 deletions src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,20 +69,35 @@ impl fmt::Display for PrefixedPath {
}
}

static IO_THREAD_CONTROLLER_ROOT_PATH: LazyLock<std::path::PathBuf> = LazyLock::new(|| {
static ROOT_PATH: LazyLock<std::path::PathBuf> = LazyLock::new(|| {
std::path::PathBuf::from(
env::var("IO_THREAD_CONTROLLER_ROOT_PATH").unwrap_or(String::from("/")),
)
});

/// Returns the path new `util::Path`s will have prefixed. In non-test
/// configurations, this will be either `/` or the contents of the
/// `IO_THREAD_CONTROLLER_ROOT_PATH` env var.
///
/// In test configurations, this may differ per thread to support concurrently
/// running unit tests.
pub(crate) fn effective_root() -> std::path::PathBuf {
#[cfg(test)]
if let Some(root) = crate::test::mockdir::ROOT_OVERRIDE.with(|slot| slot.borrow().clone()) {
return root;
}

ROOT_PATH.clone()
}

impl PrefixedPath {
pub fn new<S>(s: &S) -> Self
where
S: AsRef<OsStr> + ?Sized,
{
let mut pb = std::path::PathBuf::from(s);
if pb.is_absolute() {
pb = IO_THREAD_CONTROLLER_ROOT_PATH.join(pb.strip_prefix("/").expect("absolute path"))
pb = effective_root().join(pb.strip_prefix("/").expect("absolute path"))
}
Self { path: pb }
}
Expand Down
32 changes: 19 additions & 13 deletions src/per_vm_log.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,17 +169,19 @@ impl Visit for Record {

#[cfg(test)]
mod tests {
use rstest::rstest;
use tracing_subscriber::{filter::LevelFilter, layer::SubscriberExt};

use crate::test::{MockDir, mockdir::mockdir};

use super::*;

/// Test that only `vm`-tagged events reach the per-VM file, rendered
/// as message then fields and without the `vm` field.
#[test]
fn test_writes_vm_tagged_events_without_vm_field() {
let dir = tempfile::tempdir().unwrap();
#[rstest]
fn test_writes_vm_tagged_events_without_vm_field(#[from(mockdir)] _mockdir: MockDir) {
let log = PerVmLog::default();
log.set_dir(Some(PrefixedPath::new(dir.path())));
log.set_dir(Some(PrefixedPath::new("/")));
let subscriber = tracing_subscriber::registry().with(log.clone());
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: VM, vm = "vm-a", "status=1 2 3");
Expand All @@ -201,7 +203,10 @@ mod tests {
"event=vm_added"
]
);
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 1);
assert_eq!(
std::fs::read_dir(PrefixedPath::new("/")).unwrap().count(),
1
);
}

#[derive(Clone, Default)]
Expand All @@ -220,11 +225,10 @@ mod tests {

/// Test that per-VM-only events stay out of the main log, and that the
/// main log's level does not drop them from per-VM files.
#[test]
fn test_per_vm_target_bypasses_main_log() {
let dir = tempfile::tempdir().unwrap();
#[rstest]
fn test_per_vm_target_bypasses_main_log(#[from(mockdir)] _mockdir: MockDir) {
let log = PerVmLog::default();
log.set_dir(Some(PrefixedPath::new(dir.path())));
log.set_dir(Some(PrefixedPath::new("/")));
let main_log = Buffer::default();
let writer = main_log.clone();
let subscriber = tracing_subscriber::registry()
Expand All @@ -247,15 +251,17 @@ mod tests {
}

/// Test that nothing is written while per-VM files are disabled.
#[test]
fn test_disabled_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
#[rstest]
fn test_disabled_writes_nothing(#[from(mockdir)] _mockdir: MockDir) {
let log = PerVmLog::default();
let subscriber = tracing_subscriber::registry().with(log.clone());
tracing::subscriber::with_default(subscriber, || {
tracing::info!(target: VM, vm = "vm-a", "status=1");
});
assert!(log.path("vm-a").is_none());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
assert_eq!(
std::fs::read_dir(PrefixedPath::new("/")).unwrap().count(),
0
);
}
}
118 changes: 118 additions & 0 deletions src/test/mockdir.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Nutanix, Inc.
//
// Author: Leonardo Forchini <leonardo.forchini@nutanix.com>
//
//! `tempfile::TempDir` wrapper which controls the prefix of `util::Path`s.

use std::{cell::RefCell, path::PathBuf};

use rstest::fixture;

thread_local! {
pub static ROOT_OVERRIDE: RefCell<Option<PathBuf>> = const { RefCell::new(None) };
}

/// Scoped root override for tests that access absolute paths through
/// `util::Path`.
pub(crate) struct RootOverride {
previous: Option<std::path::PathBuf>,
}

impl Drop for RootOverride {
fn drop(&mut self) {
ROOT_OVERRIDE.with(|root| {
root.replace(self.previous.take());
});
}
}

/// Override the root used by `util::Path` on the current test thread.
pub(crate) fn override_root(root: impl Into<PathBuf>) -> RootOverride {
let previous = ROOT_OVERRIDE.with(|slot| slot.replace(Some(root.into())));
RootOverride { previous }
}

/// Temporary mock root installed as the current test thread's `util::Path`
/// prefix.
pub(crate) struct MockDir {
path: std::path::PathBuf,
_root_override: RootOverride,
_dir: tempfile::TempDir,
}

impl MockDir {
pub(crate) fn new() -> Self {
let dir = tempfile::tempdir().unwrap();
let path = dir.path();

Self {
path: path.to_path_buf(),
_root_override: override_root(path),
_dir: dir,
}
}
}

/// Create a new `MockDir` as an `rstest` fixture
#[fixture]
pub(crate) fn mockdir() -> MockDir {
MockDir::new()
}

#[cfg(test)]
mod tests {
use std::path::Path;

use rstest::rstest;

use super::*;
use crate::path::{PrefixedPath, effective_root};

/// Ensure that a new `MockDir` actually changes the effective root.
#[test]
fn test_mockdir_changes_effective_root() {
let old_root = effective_root();

let _mockdir = MockDir::new();
assert_ne!(old_root, effective_root());
}

/// Ensure new `util::Paths`s use a given `MockDir`'s prefix.
#[rstest]
fn test_mockdir_modifies_util_paths(mockdir: MockDir) {
let util_path = PrefixedPath::from("/etc/passwd");
assert_ne!(mockdir.path, Path::new("/"));
assert_eq!(util_path.to_path_buf(), mockdir.path.join("etc/passwd"));
}

/// Ensure paths created before a new `MockDir` are not prefixed with its
/// dir.
// TODO is this the behaviour we want? MockDirs will mostly be used as
// fixtures so I don't think it should come up too often.
#[test]
fn test_mockdir_preserves_existing_paths() {
let util_path = PrefixedPath::from("/etc/passwd");

let mockdir = MockDir::new();
assert_ne!(util_path.to_path_buf(), mockdir.path.join("etc/passwd"));
}

/// Ensure once a `MockDir` goes out of scope the effective root is
/// restored.
#[test]
fn test_mockdir_reverts_on_drop() {
let _outer = MockDir::new();
let outer_path = effective_root();

let inner_path = {
let _inner = MockDir::new();
effective_root()
};

let post_drop_path = effective_root();

assert_ne!(outer_path, inner_path);
assert_eq!(outer_path, post_drop_path);
}
}
9 changes: 9 additions & 0 deletions src/test/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// SPDX-License-Identifier: BSD-3-Clause
// Copyright (c) 2026 Nutanix, Inc.
//
// Author: Leonardo Forchini <leonardo.forchini@nutanix.com>
//

pub mod mockdir;

pub use mockdir::*;
Loading