Repository navigation
Add a fake backend for component tests #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| //! state through JSON files in `state_dir`. This allows for the external | ||
| //! Python component testing harness to manipulate "VM"s as necessary. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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))?; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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) {} | ||
| } | ||
There was a problem hiding this comment.
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.