test(tf): add configurable binary providers

This commit is contained in:
andrussal 2026-05-20 08:56:49 +02:00
parent 4854942dce
commit 14fd7130be
23 changed files with 1192 additions and 119 deletions

93
Cargo.lock generated
View File

@ -1073,8 +1073,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@ -1084,9 +1086,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"wasip2",
"wasm-bindgen",
]
[[package]]
@ -1283,6 +1287,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots 1.0.6",
]
[[package]]
@ -1800,6 +1805,12 @@ version = "0.4.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lru-slab"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maplit"
version = "1.0.2"
@ -2683,6 +2694,61 @@ dependencies = [
"tracing",
]
[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.2",
"thiserror 2.0.18",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
dependencies = [
"bytes",
"getrandom 0.3.4",
"lru-slab",
"rand 0.9.3",
"ring",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.18",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.2",
"tracing",
"windows-sys 0.60.2",
]
[[package]]
name = "quote"
version = "1.0.44"
@ -2898,11 +2964,14 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-rustls",
"hyper-tls",
"hyper-util",
"js-sys",
@ -2910,6 +2979,8 @@ dependencies = [
"native-tls",
"percent-encoding",
"pin-project-lite",
"quinn",
"rustls",
"rustls-pki-types",
"serde",
"serde_json",
@ -2917,6 +2988,7 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-native-tls",
"tokio-rustls",
"tower",
"tower-http",
"tower-service",
@ -2924,6 +2996,7 @@ dependencies = [
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"webpki-roots 1.0.6",
]
[[package]]
@ -2986,6 +3059,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "rustc-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
[[package]]
name = "rustc_version"
version = "0.4.1"
@ -3054,6 +3133,7 @@ version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"web-time",
"zeroize",
]
@ -3593,15 +3673,16 @@ version = "0.1.0"
dependencies = [
"async-trait",
"fs_extra",
"reqwest",
"serde",
"serde_yaml",
"sha2",
"tempfile",
"testing-framework-core",
"thiserror 2.0.18",
"tokio",
"tokio-retry",
"tracing",
"which",
]
[[package]]
@ -4223,6 +4304,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"

View File

@ -28,7 +28,7 @@ impl LocalBinaryApp for KvEnv {
}
fn local_process_spec() -> LocalProcessSpec {
LocalProcessSpec::new("KVSTORE_NODE_BIN", "kvstore-node").with_rust_log("kvstore_node=info")
LocalProcessSpec::new("KVSTORE_NODE_BIN").with_rust_log("kvstore_node=info")
}
fn render_local_config(config: &KvNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -30,8 +30,7 @@ impl LocalBinaryApp for MetricsCounterEnv {
}
fn local_process_spec() -> LocalProcessSpec {
LocalProcessSpec::new("METRICS_COUNTER_NODE_BIN", "metrics-counter-node")
.with_rust_log("metrics_counter_node=info")
LocalProcessSpec::new("METRICS_COUNTER_NODE_BIN").with_rust_log("metrics_counter_node=info")
}
fn render_local_config(config: &MetricsCounterNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -35,10 +35,7 @@ impl LocalDeployerEnv for NatsEnv {
}
fn local_process_spec() -> Option<LocalProcessSpec> {
Some(
LocalProcessSpec::new("NATS_SERVER_BIN", "nats-server")
.with_config_file("nats.conf", "-c"),
)
Some(LocalProcessSpec::new("NATS_SERVER_BIN").with_config_file("nats.conf", "-c"))
}
fn render_local_config(config: &NatsNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -80,9 +80,7 @@ impl LocalDeployerEnv for OpenRaftKvEnv {
}
fn local_process_spec() -> Option<LocalProcessSpec> {
Some(
LocalProcessSpec::new("OPENRAFT_KV_NODE_BIN", "openraft-kv-node").with_rust_log("info"),
)
Some(LocalProcessSpec::new("OPENRAFT_KV_NODE_BIN").with_rust_log("info"))
}
fn render_local_config(config: &OpenRaftKvNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -28,7 +28,7 @@ impl LocalBinaryApp for PubSubEnv {
}
fn local_process_spec() -> LocalProcessSpec {
LocalProcessSpec::new("PUBSUB_NODE_BIN", "pubsub-node").with_rust_log("pubsub_node=info")
LocalProcessSpec::new("PUBSUB_NODE_BIN").with_rust_log("pubsub_node=info")
}
fn render_local_config(config: &PubSubNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -28,7 +28,7 @@ impl LocalBinaryApp for QueueEnv {
}
fn local_process_spec() -> LocalProcessSpec {
LocalProcessSpec::new("QUEUE_NODE_BIN", "queue-node").with_rust_log("queue_node=info")
LocalProcessSpec::new("QUEUE_NODE_BIN").with_rust_log("queue_node=info")
}
fn render_local_config(config: &QueueNodeConfig) -> Result<Vec<u8>, DynError> {

View File

@ -15,12 +15,13 @@ workspace = true
[dependencies]
async-trait = "0.1"
fs_extra = "1.3"
reqwest = { features = ["blocking", "rustls-tls"], workspace = true }
serde = { workspace = true }
serde_yaml = { workspace = true }
sha2 = "0.10"
tempfile = { workspace = true }
testing-framework-core = { path = "../../core" }
thiserror = { workspace = true }
tokio = { workspace = true }
tokio-retry = "0.3"
tracing = { workspace = true }
which = "6.0"

View File

@ -1,73 +0,0 @@
use std::{env, path::PathBuf};
use tracing::{debug, info};
pub struct BinaryConfig {
/// Env var that overrides binary path.
pub env_var: &'static str,
/// Binary name expected on PATH.
pub binary_name: &'static str,
/// Repository-local fallback path when PATH lookup fails.
pub fallback_path: &'static str,
}
pub struct BinaryResolver;
impl BinaryResolver {
#[must_use]
pub fn resolve_path(config: &BinaryConfig) -> PathBuf {
if let Some(path) = Self::resolve_from_env(config) {
return path;
}
if let Some(path) = Self::resolve_from_path(config.binary_name) {
return path;
}
Self::fallback_path(config.binary_name, config.fallback_path)
}
fn which_on_path(bin: &str) -> Option<PathBuf> {
let path_env = env::var_os("PATH")?;
env::split_paths(&path_env)
.map(|p| p.join(bin))
.find(|candidate| candidate.is_file())
}
fn resolve_from_env(config: &BinaryConfig) -> Option<PathBuf> {
let path = env::var_os(config.env_var).map(PathBuf::from)?;
info!(
env = config.env_var,
binary = config.binary_name,
path = %path.display(),
"resolved binary from env override"
);
Some(path)
}
fn resolve_from_path(binary_name: &str) -> Option<PathBuf> {
let path = Self::which_on_path(binary_name)?;
info!(
binary = binary_name,
path = %path.display(),
"resolved binary from PATH"
);
Some(path)
}
fn fallback_path(binary_name: &str, fallback_path: &str) -> PathBuf {
let fallback = PathBuf::from(fallback_path);
debug!(
binary = binary_name,
path = %fallback.display(),
"falling back to binary path"
);
fallback
}
}

View File

@ -0,0 +1,32 @@
//! Per-process cache for resolved binary paths.
//!
//! Provider resolution can involve filesystem scans, downloads, or Cargo
//! builds. The local runner may render launch specs repeatedly during one test
//! process, so successful resolutions are cached by the expanded request key.
use std::{
collections::HashMap,
path::PathBuf,
sync::{Mutex, OnceLock},
};
static RESOLVED_BINARIES: OnceLock<Mutex<HashMap<String, PathBuf>>> = OnceLock::new();
/// Shared in-process cache for provider results.
pub(super) struct BinaryCache;
impl BinaryCache {
pub(super) fn get(key: &str) -> Option<PathBuf> {
let cache = RESOLVED_BINARIES.get_or_init(|| Mutex::new(HashMap::new()));
let guard = cache.lock().expect("binary cache mutex poisoned");
guard.get(key).cloned()
}
pub(super) fn insert(key: String, path: PathBuf) {
let cache = RESOLVED_BINARIES.get_or_init(|| Mutex::new(HashMap::new()));
let mut guard = cache.lock().expect("binary cache mutex poisoned");
guard.insert(key, path);
}
}

View File

@ -0,0 +1,70 @@
//! Cross-process lock used by providers that materialize binaries.
//!
//! Cargo builds and downloads can be requested by multiple local test
//! processes at the same time. The lock keeps those processes from writing the
//! same target/cache path concurrently.
use std::{
fs, io,
path::{Path, PathBuf},
thread,
time::{Duration, Instant},
};
use super::types::BinaryProviderError;
const LOCK_RETRY_DELAY: Duration = Duration::from_millis(200);
const LOCK_TIMEOUT: Duration = Duration::from_secs(10 * 60);
/// File-backed lock removed automatically when dropped.
pub(super) struct BinaryProviderLock {
/// Path of the lock file currently owned by this process.
path: PathBuf,
}
impl BinaryProviderLock {
pub(super) fn acquire(path: &Path) -> Result<Self, BinaryProviderError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|source| BinaryProviderError::Io {
path: parent.to_owned(),
source,
})?;
}
let started = Instant::now();
loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
{
Ok(_) => {
return Ok(Self {
path: path.to_owned(),
});
}
Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
if started.elapsed() >= LOCK_TIMEOUT {
return Err(BinaryProviderError::LockTimeout {
path: path.to_owned(),
});
}
thread::sleep(LOCK_RETRY_DELAY);
}
Err(source) => {
return Err(BinaryProviderError::Io {
path: path.to_owned(),
source,
});
}
}
}
}
}
impl Drop for BinaryProviderLock {
fn drop(&mut self) {
drop(fs::remove_file(&self.path));
}
}

View File

@ -0,0 +1,64 @@
//! Local binary resolution for process-based test deployments.
//!
//! A local node process has exactly one binary provider selected at launch
//! time. That provider owns its configuration and returns the executable path
//! used by [`LocalProcessSpec`](crate::LocalProcessSpec).
mod cache;
mod lock;
mod providers;
#[cfg(test)]
mod tests;
mod types;
use std::path::PathBuf;
use cache::BinaryCache;
pub(super) use types::optional_path_display;
pub use types::{
BinaryProviderError, BinaryProviderRef, BuildBinaryProvider, BuildCommand,
DownloadBinaryProvider, DownloadChecksum, DownloadUrl, EnvBinaryProvider,
FallbackBinaryProvider, PathBinaryProvider,
};
/// Resolves an executable path for a local process.
///
/// Implementations return `Ok(None)` when they are valid but cannot resolve a
/// binary in the current environment. The default [`resolve`](Self::resolve)
/// method turns that into a launch error, while [`FallbackBinaryProvider`] uses
/// it to try several providers in order.
pub trait BinaryProvider: Send + Sync {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError>;
fn display(&self) -> String;
fn cache_key(&self) -> String;
/// Resolves this provider into an executable path.
///
/// Resolution is cached per process so repeated node starts using the same
/// provider config do not rebuild, redownload, or rediscover the same
/// binary.
fn resolve(&self) -> Result<PathBuf, BinaryProviderError> {
let cache_key = self.cache_key();
if let Some(path) = BinaryCache::get(&cache_key) {
return Ok(path);
}
let path = self.resolve_uncached()?;
BinaryCache::insert(cache_key, path.clone());
Ok(path)
}
fn resolve_uncached(&self) -> Result<PathBuf, BinaryProviderError> {
if let Some(path) = self.try_resolve()? {
return Ok(path);
}
Err(BinaryProviderError::NotFound {
provider: self.display(),
})
}
}

View File

@ -0,0 +1,115 @@
//! Command build provider.
//!
//! This provider delegates binary preparation to a command supplied by the test
//! or app integration. The command may run Cargo, fetch from an internal cache,
//! invoke a project-specific build script, or do anything else needed to
//! produce the expected executable path.
use std::{
env,
path::{Path, PathBuf},
process::Command,
};
use tracing::info;
use crate::binary::{
BinaryProvider, BinaryProviderError, BuildBinaryProvider, lock::BinaryProviderLock,
optional_path_display,
};
impl BinaryProvider for BuildBinaryProvider {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError> {
let output_path = self.output_path();
let _lock = BinaryProviderLock::acquire(&self.lock_path())?;
self.run_build()?;
self.ensure_output_exists(&output_path)?;
Ok(Some(output_path))
}
fn display(&self) -> String {
"build".to_owned()
}
fn cache_key(&self) -> String {
format!(
"build:{}:{}:{}",
self.command.display(),
self.output_path.display(),
optional_path_display(&self.working_dir)
)
}
}
impl BuildBinaryProvider {
fn run_build(&self) -> Result<(), BinaryProviderError> {
info!(
command = self.command.display(),
workspace = %self.workspace_dir().display(),
"building binary"
);
let status = self
.command()
.status()
.map_err(|source| BinaryProviderError::Io {
path: self.workspace_dir(),
source,
})?;
if !status.success() {
return Err(BinaryProviderError::BuildFailed {
status: status.to_string(),
});
}
Ok(())
}
fn ensure_output_exists(&self, output_path: &Path) -> Result<(), BinaryProviderError> {
if output_path.is_file() {
return Ok(());
}
Err(BinaryProviderError::MissingBuildOutput {
path: output_path.to_owned(),
})
}
fn command(&self) -> Command {
let mut command = Command::new(&self.command.program);
command
.current_dir(self.workspace_dir())
.args(&self.command.args);
command
}
fn output_path(&self) -> PathBuf {
if self.output_path.is_absolute() {
return self.output_path.clone();
}
self.workspace_dir().join(&self.output_path)
}
fn lock_path(&self) -> PathBuf {
let lock_file_name = self
.output_path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("binary");
self.lock_dir
.clone()
.unwrap_or_else(|| self.workspace_dir().join(".tf-binaries"))
.join(format!("{lock_file_name}.lock"))
}
fn workspace_dir(&self) -> PathBuf {
self.working_dir
.clone()
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| PathBuf::from(".")))
}
}

View File

@ -0,0 +1,169 @@
//! Download provider.
//!
//! This provider fetches an executable into a local cache, optionally validates
//! a SHA-256 checksum, and marks the downloaded file executable on Unix.
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt as _;
use std::{
collections::hash_map::DefaultHasher,
env, fs,
hash::{Hash as _, Hasher as _},
path::{Path, PathBuf},
};
use reqwest::blocking;
use sha2::{Digest as _, Sha256};
use tracing::info;
use crate::binary::{
BinaryProvider, BinaryProviderError, DownloadBinaryProvider, DownloadUrl,
lock::BinaryProviderLock, optional_path_display,
};
impl BinaryProvider for DownloadBinaryProvider {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError> {
let url = self.url.resolve()?;
let path = self.cached_binary_path(&url)?;
let _lock = BinaryProviderLock::acquire(&self.lock_path(&url))?;
if path.is_file() {
return Ok(Some(path));
}
let bytes = self.download_bytes(&url)?;
self.verify_checksum(&path, &bytes)?;
self.write_binary(&path, &bytes)?;
Ok(Some(path))
}
fn display(&self) -> String {
"download".to_owned()
}
fn cache_key(&self) -> String {
format!(
"download:{}:{}",
self.url.cache_key(),
optional_path_display(&self.cache_dir)
)
}
}
impl DownloadUrl {
fn cache_key(&self) -> String {
match self {
Self::Fixed(url) => url.clone(),
Self::Env(env_var) => format!("env:{env_var}"),
}
}
}
impl DownloadBinaryProvider {
fn cached_binary_path(&self, url: &str) -> Result<PathBuf, BinaryProviderError> {
let cache_dir = self.cache_dir();
fs::create_dir_all(&cache_dir).map_err(|source| BinaryProviderError::Io {
path: cache_dir.clone(),
source,
})?;
Ok(cache_dir.join(self.download_file_name(url)))
}
fn download_bytes(&self, url: &str) -> Result<Vec<u8>, BinaryProviderError> {
info!(url, "downloading binary");
blocking::get(url)
.map_err(|source| BinaryProviderError::Download {
url: url.to_owned(),
source,
})?
.error_for_status()
.map_err(|source| BinaryProviderError::Download {
url: url.to_owned(),
source,
})?
.bytes()
.map(|bytes| bytes.to_vec())
.map_err(|source| BinaryProviderError::Download {
url: url.to_owned(),
source,
})
}
fn write_binary(&self, path: &Path, bytes: &[u8]) -> Result<(), BinaryProviderError> {
fs::write(path, bytes).map_err(|source| BinaryProviderError::Io {
path: path.to_owned(),
source,
})?;
self.make_executable(path)
}
fn verify_checksum(&self, path: &Path, bytes: &[u8]) -> Result<(), BinaryProviderError> {
let Some(expected) = self.sha256.as_ref().and_then(|checksum| checksum.resolve()) else {
return Ok(());
};
let actual = self.encode_sha256(bytes);
if expected == actual {
return Ok(());
}
Err(BinaryProviderError::ChecksumMismatch {
path: path.to_owned(),
expected,
actual,
})
}
fn cache_dir(&self) -> PathBuf {
self.cache_dir.clone().unwrap_or_else(|| {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join("target")
.join(".tf-binaries")
})
}
fn lock_path(&self, url: &str) -> PathBuf {
self.cache_dir()
.join(format!("{}.lock", self.download_file_name(url)))
}
#[cfg(unix)]
fn make_executable(&self, path: &Path) -> Result<(), BinaryProviderError> {
let mut permissions = fs::metadata(path)
.map_err(|source| BinaryProviderError::Io {
path: path.to_owned(),
source,
})?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions).map_err(|source| BinaryProviderError::Io {
path: path.to_owned(),
source,
})
}
#[cfg(not(unix))]
fn make_executable(&self, _path: &Path) -> Result<(), BinaryProviderError> {
Ok(())
}
fn encode_sha256(&self, bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn download_file_name(&self, url: &str) -> String {
let mut hasher = DefaultHasher::new();
url.hash(&mut hasher);
format!("binary-{:x}", hasher.finish())
}
}

View File

@ -0,0 +1,45 @@
//! Env-var provider.
//!
//! This provider is the explicit override path: the configured env var must
//! contain a concrete executable path. Missing or non-file values are treated
//! as unresolved so a fallback provider can continue.
use std::{env, path::PathBuf};
use tracing::{debug, info};
use crate::binary::{BinaryProvider, BinaryProviderError, EnvBinaryProvider};
impl BinaryProvider for EnvBinaryProvider {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError> {
let Some(path) = env::var_os(&self.env_var).map(PathBuf::from) else {
return Ok(None);
};
if !path.is_file() {
debug!(
env = self.env_var,
path = %path.display(),
"binary env override does not point to a file"
);
return Ok(None);
}
info!(
env = self.env_var,
path = %path.display(),
"resolved binary from env override"
);
Ok(Some(path))
}
fn display(&self) -> String {
"env".to_owned()
}
fn cache_key(&self) -> String {
format!("env:{}", self.env_var)
}
}

View File

@ -0,0 +1,37 @@
//! Fallback provider.
//!
//! A fallback is still a single configured provider from the process launch
//! perspective. It simply composes several concrete providers and returns the
//! first executable path they can produce.
use std::path::PathBuf;
use crate::binary::{BinaryProvider, BinaryProviderError, FallbackBinaryProvider};
impl BinaryProvider for FallbackBinaryProvider {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError> {
for provider in &self.providers {
if let Some(path) = provider.try_resolve()? {
return Ok(Some(path));
}
}
Ok(None)
}
fn display(&self) -> String {
self.providers
.iter()
.map(|provider| provider.display())
.collect::<Vec<_>>()
.join(",")
}
fn cache_key(&self) -> String {
self.providers
.iter()
.map(|provider| provider.cache_key())
.collect::<Vec<_>>()
.join(",")
}
}

View File

@ -0,0 +1,7 @@
//! Provider execution implementations.
mod build;
mod download;
mod env;
mod fallback;
mod path;

View File

@ -0,0 +1,40 @@
//! Explicit path provider.
//!
//! This provider uses an absolute executable path supplied by the test or app
//! integration. It does not search the filesystem or inspect the process
//! `PATH`, which keeps CI and mixed-version cluster setups deterministic.
use std::path::PathBuf;
use tracing::info;
use crate::binary::{BinaryProvider, BinaryProviderError, PathBinaryProvider};
impl BinaryProvider for PathBinaryProvider {
fn try_resolve(&self) -> Result<Option<PathBuf>, BinaryProviderError> {
if !self.path.is_absolute() {
return Err(BinaryProviderError::RelativePath {
path: self.path.clone(),
});
}
if !self.path.is_file() {
return Ok(None);
}
info!(
path = %self.path.display(),
"resolved binary from configured path"
);
Ok(Some(self.path.clone()))
}
fn display(&self) -> String {
"path".to_owned()
}
fn cache_key(&self) -> String {
format!("path:{}", self.path.display())
}
}

View File

@ -0,0 +1,200 @@
use std::{
fs,
io::{Read as _, Write as _},
net::TcpListener,
path::Path,
sync::Arc,
thread,
};
use sha2::{Digest as _, Sha256};
use tempfile::TempDir;
use super::{
BinaryProvider, BinaryProviderError, BinaryProviderRef, BuildBinaryProvider, BuildCommand,
DownloadBinaryProvider, DownloadChecksum, DownloadUrl, FallbackBinaryProvider,
PathBinaryProvider,
};
#[test]
fn resolves_configured_absolute_path() {
let temp = TempDir::new().expect("temp dir");
let binary = temp.path().join("node");
write_file(&binary, b"binary");
let path = PathBinaryProvider::new(&binary)
.resolve()
.expect("path provider resolves");
assert_eq!(path, binary);
}
#[test]
fn rejects_relative_configured_path() {
let error = PathBinaryProvider::new("relative-node")
.resolve()
.expect_err("relative path is rejected");
assert!(matches!(error, BinaryProviderError::RelativePath { .. }));
}
#[test]
fn resolves_first_available_fallback_provider() {
let temp = TempDir::new().expect("temp dir");
let binary = temp.path().join("node");
write_file(&binary, b"binary");
let providers: Vec<BinaryProviderRef> = vec![
Arc::new(PathBinaryProvider::new(temp.path().join("missing-node"))),
Arc::new(PathBinaryProvider::new(&binary)),
];
let provider = FallbackBinaryProvider::new(providers);
let path = provider.resolve().expect("fallback provider resolves");
assert_eq!(path, binary);
}
#[test]
fn runs_build_command_and_returns_output_path() {
let temp = TempDir::new().expect("temp dir");
let output = temp.path().join("built-node");
let script = temp.path().join("build.sh");
write_file(
&script,
format!("#!/bin/sh\nprintf built > '{}'\n", output.display()).as_bytes(),
);
let provider = BuildBinaryProvider {
command: BuildCommand::new("sh").with_args([script.to_string_lossy().to_string()]),
output_path: output.clone(),
working_dir: Some(temp.path().to_owned()),
lock_dir: Some(temp.path().join("locks")),
};
let path = provider.resolve().expect("build provider resolves");
assert_eq!(path, output);
assert_eq!(fs::read(path).expect("built file"), b"built");
}
#[test]
fn build_provider_runs_even_when_output_exists() {
let temp = TempDir::new().expect("temp dir");
let output = temp.path().join("built-node");
let script = temp.path().join("build.sh");
write_file(&output, b"old");
write_file(
&script,
format!("#!/bin/sh\nprintf new > '{}'\n", output.display()).as_bytes(),
);
let provider = BuildBinaryProvider {
command: BuildCommand::new("sh").with_args([script.to_string_lossy().to_string()]),
output_path: output.clone(),
working_dir: Some(temp.path().to_owned()),
lock_dir: Some(temp.path().join("locks")),
};
let path = provider.resolve().expect("build provider resolves");
assert_eq!(path, output);
assert_eq!(fs::read(path).expect("built file"), b"new");
}
#[test]
fn fails_when_build_command_does_not_create_output() {
let temp = TempDir::new().expect("temp dir");
let output = temp.path().join("missing-node");
let provider = BuildBinaryProvider {
command: BuildCommand::new("sh").with_args(["-c", "true"]),
output_path: output,
working_dir: Some(temp.path().to_owned()),
lock_dir: Some(temp.path().join("locks")),
};
let error = provider
.resolve()
.expect_err("missing build output is rejected");
assert!(matches!(
error,
BinaryProviderError::MissingBuildOutput { .. }
));
}
#[test]
fn downloads_binary_from_minimal_http_server() {
let temp = TempDir::new().expect("temp dir");
let body = b"downloaded-node";
let server = SingleResponseServer::start(body);
let provider = DownloadBinaryProvider {
url: DownloadUrl::Fixed(server.url()),
sha256: Some(DownloadChecksum::Fixed(sha256_hex(body))),
cache_dir: Some(temp.path().join("cache")),
};
let path = provider.resolve().expect("download provider resolves");
assert_eq!(fs::read(path).expect("downloaded file"), body);
}
#[test]
fn rejects_download_checksum_mismatch() {
let temp = TempDir::new().expect("temp dir");
let server = SingleResponseServer::start(b"downloaded-node");
let provider = DownloadBinaryProvider {
url: DownloadUrl::Fixed(server.url()),
sha256: Some(DownloadChecksum::Fixed("00".repeat(32))),
cache_dir: Some(temp.path().join("cache")),
};
let error = provider
.resolve()
.expect_err("checksum mismatch is rejected");
assert!(matches!(
error,
BinaryProviderError::ChecksumMismatch { .. }
));
}
fn write_file(path: &Path, contents: &[u8]) {
fs::write(path, contents).expect("write file");
}
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
struct SingleResponseServer {
addr: String,
}
impl SingleResponseServer {
fn start(body: &'static [u8]) -> Self {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind test http server");
let addr = listener.local_addr().expect("server addr").to_string();
thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("accept one request");
let mut buffer = [0; 1024];
let _ = stream.read(&mut buffer);
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream
.write_all(response.as_bytes())
.expect("write headers");
stream.write_all(body).expect("write body");
});
Self { addr }
}
fn url(&self) -> String {
format!("http://{}/binary", self.addr)
}
}

View File

@ -0,0 +1,271 @@
use std::{env, fmt, iter, path::PathBuf, sync::Arc};
use thiserror::Error;
/// Shared provider handle used by local process specs.
pub type BinaryProviderRef = Arc<dyn crate::binary::BinaryProvider>;
#[derive(Clone)]
pub struct FallbackBinaryProvider {
/// Providers tried from first to last.
///
/// This is useful when a test wants "prefer explicit override, otherwise
/// build/download" behavior while still configuring one provider on the
/// process spec.
pub providers: Vec<BinaryProviderRef>,
}
impl FallbackBinaryProvider {
#[must_use]
pub fn new(providers: impl IntoIterator<Item = BinaryProviderRef>) -> Self {
Self {
providers: providers.into_iter().collect(),
}
}
}
impl fmt::Debug for FallbackBinaryProvider {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("FallbackBinaryProvider")
.field(
"providers",
&self
.providers
.iter()
.map(|provider| provider.display())
.collect::<Vec<_>>(),
)
.finish()
}
}
#[derive(Clone, Debug)]
pub struct EnvBinaryProvider {
/// Env var expected to contain a full executable path.
pub env_var: String,
}
impl EnvBinaryProvider {
#[must_use]
pub fn new(env_var: impl Into<String>) -> Self {
Self {
env_var: env_var.into(),
}
}
}
#[derive(Clone, Debug)]
pub struct PathBinaryProvider {
/// Absolute executable path selected by the test/app config.
pub path: PathBuf,
}
impl PathBinaryProvider {
#[must_use]
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
}
/// User-supplied command used by [`BuildBinaryProvider`].
#[derive(Clone, Debug)]
pub struct BuildCommand {
/// Program executed by the build provider.
pub program: String,
/// Arguments passed to `program`.
pub args: Vec<String>,
}
impl BuildCommand {
#[must_use]
pub fn new(program: impl Into<String>) -> Self {
Self {
program: program.into(),
args: Vec::new(),
}
}
#[must_use]
pub fn with_args(mut self, args: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.args = args.into_iter().map(Into::into).collect();
self
}
pub(super) fn display(&self) -> String {
iter::once(self.program.as_str())
.chain(self.args.iter().map(String::as_str))
.collect::<Vec<_>>()
.join(" ")
}
}
#[derive(Clone, Debug)]
pub struct BuildBinaryProvider {
/// Command that prepares the executable.
///
/// The command is intentionally not Cargo-specific. Tests can point this at
/// a shell script, Make target, Cargo invocation, remote cache fetch, or
/// any other project-specific build step.
pub command: BuildCommand,
/// Executable path expected to exist after `command` completes.
pub output_path: PathBuf,
/// Working directory for the build command.
///
/// If unset, the current process directory is used.
pub working_dir: Option<PathBuf>,
/// Directory used for the build lock file.
///
/// If unset, the lock is placed under `.tf-binaries` in `working_dir`.
pub lock_dir: Option<PathBuf>,
}
impl BuildBinaryProvider {
#[must_use]
pub fn new(command: BuildCommand, output_path: impl Into<PathBuf>) -> Self {
Self {
command,
output_path: output_path.into(),
working_dir: None,
lock_dir: None,
}
}
}
#[derive(Clone, Debug)]
pub struct DownloadBinaryProvider {
/// Download source used to fetch the executable.
pub url: DownloadUrl,
/// Optional SHA-256 checksum validated before the binary is accepted.
pub sha256: Option<DownloadChecksum>,
/// Directory used to store downloaded binaries and provider lock files.
///
/// If unset, downloads are cached under `target/.tf-binaries` in the
/// current process directory.
pub cache_dir: Option<PathBuf>,
}
impl DownloadBinaryProvider {
#[must_use]
pub fn from_url(url: impl Into<String>) -> Self {
Self {
url: DownloadUrl::Fixed(url.into()),
sha256: None,
cache_dir: None,
}
}
}
#[derive(Clone, Debug)]
pub enum DownloadUrl {
/// Fixed URL embedded in the provider config.
Fixed(String),
/// Env var containing the URL.
Env(String),
}
impl DownloadUrl {
pub(super) fn resolve(&self) -> Result<String, BinaryProviderError> {
match self {
Self::Fixed(url) => Ok(url.clone()),
Self::Env(env_var) => {
env::var(env_var).map_err(|_| BinaryProviderError::MissingDownloadUrl {
env_var: env_var.clone(),
})
}
}
}
}
#[derive(Clone, Debug)]
pub enum DownloadChecksum {
/// Fixed expected SHA-256 checksum.
Fixed(String),
/// Env var containing the expected SHA-256 checksum.
Env(String),
}
impl DownloadChecksum {
pub(super) fn resolve(&self) -> Option<String> {
match self {
Self::Fixed(checksum) => Some(checksum.to_ascii_lowercase()),
Self::Env(env_var) => env::var(env_var)
.ok()
.map(|checksum| checksum.to_ascii_lowercase()),
}
}
}
pub(crate) fn optional_path_display(path: &Option<PathBuf>) -> String {
path.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_default()
}
#[derive(Debug, Error)]
pub enum BinaryProviderError {
/// The selected provider completed without producing an executable path.
#[error("binary could not be resolved by provider {provider}")]
NotFound {
/// Human-readable provider name or fallback provider chain.
provider: String,
},
/// Build command returned a non-zero status.
#[error("build command failed with status {status}")]
BuildFailed {
/// Build command exit status.
status: String,
},
/// Build command succeeded but did not create the configured output file.
#[error("build command did not produce configured binary output {path:?}")]
MissingBuildOutput {
/// Expected output path configured on the build provider.
path: PathBuf,
},
/// Download provider was selected but no URL was configured.
#[error("download provider requires env var {env_var} to contain a binary URL")]
MissingDownloadUrl {
/// Env var expected to contain the download URL.
env_var: String,
},
/// Configured path provider received a relative path.
#[error("binary path must be absolute: {path:?}")]
RelativePath {
/// Relative path rejected by the path provider.
path: PathBuf,
},
/// HTTP download failed or returned an error status.
#[error("failed to download binary from {url}: {source}")]
Download {
/// URL that failed.
url: String,
#[source]
/// HTTP client error.
source: reqwest::Error,
},
/// Downloaded bytes did not match the configured SHA-256 checksum.
#[error("downloaded binary sha256 mismatch for {path:?}: expected {expected}, got {actual}")]
ChecksumMismatch {
/// Cache path the downloaded binary would have been written to.
path: PathBuf,
/// Configured lowercase SHA-256 checksum.
expected: String,
/// Actual lowercase SHA-256 checksum of the downloaded bytes.
actual: String,
},
/// Filesystem operation failed while preparing or resolving a binary.
#[error("failed to prepare binary path {path:?}: {source}")]
Io {
/// Path involved in the failing filesystem operation.
path: PathBuf,
#[source]
/// Underlying filesystem error.
source: std::io::Error,
},
/// Another test process held the provider lock for too long.
#[error("timed out waiting for binary provider lock {path:?}")]
LockTimeout {
/// Lock file path that could not be acquired.
path: PathBuf,
},
}

View File

@ -1,4 +1,4 @@
use std::{collections::HashMap, path::PathBuf};
use std::{collections::HashMap, sync::Arc};
use serde::Serialize;
use testing_framework_core::{
@ -10,6 +10,7 @@ use testing_framework_core::{
};
use crate::{
binary::{BinaryProvider, BinaryProviderRef, EnvBinaryProvider},
env::LocalBuildContext,
process::{LaunchSpec, NodeEndpointPort, NodeEndpoints, ProcessSpawnError},
};
@ -95,13 +96,11 @@ impl LocalPeerNode {
}
}
#[derive(Clone, Default)]
/// Standard local process description for one node binary plus one config file.
#[derive(Clone)]
pub struct LocalProcessSpec {
/// Environment variable that points to the node binary.
pub binary_env_var: String,
/// Fallback binary name resolved from `PATH` or `target/`.
pub binary_name: String,
/// Binary preparation and resolution policy.
pub binary: BinaryProviderRef,
/// Config file name written into the temp launch directory.
pub config_file_name: String,
/// CLI flag used to point the process at `config_file_name`.
@ -115,10 +114,9 @@ pub struct LocalProcessSpec {
impl LocalProcessSpec {
/// Creates a standard binary+config local process spec.
#[must_use]
pub fn new(binary_env_var: &str, binary_name: &str) -> Self {
pub fn new(binary_env_var: &str) -> Self {
Self {
binary_env_var: binary_env_var.to_owned(),
binary_name: binary_name.to_owned(),
binary: Arc::new(EnvBinaryProvider::new(binary_env_var)),
config_file_name: "config.yaml".to_owned(),
config_arg: "--config".to_owned(),
extra_args: Vec::new(),
@ -153,6 +151,20 @@ impl LocalProcessSpec {
self.extra_args.extend(args);
self
}
/// Overrides the binary provider used by this process.
#[must_use]
pub fn with_binary_provider(mut self, binary: impl BinaryProvider + 'static) -> Self {
self.binary = Arc::new(binary);
self
}
/// Overrides the binary provider with an already shared provider handle.
#[must_use]
pub fn with_binary_provider_ref(mut self, binary: BinaryProviderRef) -> Self {
self.binary = binary;
self
}
}
/// Preallocates `count` local TCP ports for later use.
@ -369,12 +381,11 @@ pub fn text_config_launch_spec(
pub fn default_yaml_launch_spec<T: Serialize>(
config: &T,
binary_env_var: &str,
binary_name: &str,
rust_log: &str,
) -> Result<LaunchSpec, DynError> {
yaml_config_launch_spec(
config,
&LocalProcessSpec::new(binary_env_var, binary_name).with_rust_log(rust_log),
&LocalProcessSpec::new(binary_env_var).with_rust_log(rust_log),
)
}
@ -392,7 +403,7 @@ pub(crate) fn rendered_config_launch_spec(
rendered_config: Vec<u8>,
spec: &LocalProcessSpec,
) -> Result<LaunchSpec, DynError> {
let binary = resolve_binary(spec);
let binary = spec.binary.resolve()?;
let mut args = vec![spec.config_arg.clone(), spec.config_file_name.clone()];
args.extend(spec.extra_args.iter().cloned());
@ -406,20 +417,3 @@ pub(crate) fn rendered_config_launch_spec(
env: spec.env.clone(),
})
}
fn resolve_binary(spec: &LocalProcessSpec) -> PathBuf {
std::env::var(&spec.binary_env_var)
.map(PathBuf::from)
.or_else(|_| which::which(&spec.binary_name))
.unwrap_or_else(|_| {
let mut path = std::env::current_dir().unwrap_or_default();
let mut debug = path.clone();
debug.push(format!("target/debug/{}", spec.binary_name));
if debug.exists() {
return debug;
}
path.push(format!("target/release/{}", spec.binary_name));
path
})
}

View File

@ -239,6 +239,18 @@ where
None
}
/// Returns the local process description for this concrete node config.
///
/// Apps that need mixed-version or mixed-binary clusters can override this
/// hook and choose a different binary provider per node while keeping the
/// standard launch-spec rendering path.
fn local_process_spec_for_node(
_config: &<Self as Application>::NodeConfig,
_label: &str,
) -> Option<LocalProcessSpec> {
Self::local_process_spec()
}
/// Serializes a local node config into the file bytes written next to the
/// spawned process.
fn render_local_config(
@ -251,9 +263,9 @@ where
fn build_launch_spec(
config: &<Self as Application>::NodeConfig,
_dir: &Path,
_label: &str,
label: &str,
) -> Result<LaunchSpec, DynError> {
let spec = Self::local_process_spec().ok_or_else(|| {
let spec = Self::local_process_spec_for_node(config, label).ok_or_else(|| {
std::io::Error::other("build_launch_spec is not implemented for this app")
})?;
let rendered = Self::render_local_config(config)?;

View File

@ -6,7 +6,11 @@ mod manual;
mod node_control;
pub mod process;
pub use binary::{BinaryConfig, BinaryResolver};
pub use binary::{
BinaryProvider, BinaryProviderError, BinaryProviderRef, BuildBinaryProvider, BuildCommand,
DownloadBinaryProvider, DownloadChecksum, DownloadUrl, EnvBinaryProvider,
FallbackBinaryProvider, PathBinaryProvider,
};
pub use deployer::{ProcessDeployer, ProcessDeployerError};
pub use env::{
BuiltNodeConfig, LocalBinaryApp, LocalBuildContext, LocalDeployerEnv, LocalNodePorts,