diff --git a/Cargo.lock b/Cargo.lock index 5c00aab..43c4c5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/examples/kvstore/testing/integration/src/local_env.rs b/examples/kvstore/testing/integration/src/local_env.rs index 0450439..82e8778 100644 --- a/examples/kvstore/testing/integration/src/local_env.rs +++ b/examples/kvstore/testing/integration/src/local_env.rs @@ -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, DynError> { diff --git a/examples/metrics_counter/testing/integration/src/local_env.rs b/examples/metrics_counter/testing/integration/src/local_env.rs index 712c194..cb58b72 100644 --- a/examples/metrics_counter/testing/integration/src/local_env.rs +++ b/examples/metrics_counter/testing/integration/src/local_env.rs @@ -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, DynError> { diff --git a/examples/nats/testing/integration/src/local_env.rs b/examples/nats/testing/integration/src/local_env.rs index fc74370..b715da2 100644 --- a/examples/nats/testing/integration/src/local_env.rs +++ b/examples/nats/testing/integration/src/local_env.rs @@ -35,10 +35,7 @@ impl LocalDeployerEnv for NatsEnv { } fn local_process_spec() -> Option { - 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, DynError> { diff --git a/examples/openraft_kv/testing/integration/src/local_env.rs b/examples/openraft_kv/testing/integration/src/local_env.rs index 2cf05fa..c01183f 100644 --- a/examples/openraft_kv/testing/integration/src/local_env.rs +++ b/examples/openraft_kv/testing/integration/src/local_env.rs @@ -80,9 +80,7 @@ impl LocalDeployerEnv for OpenRaftKvEnv { } fn local_process_spec() -> Option { - 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, DynError> { diff --git a/examples/pubsub/testing/integration/src/local_env.rs b/examples/pubsub/testing/integration/src/local_env.rs index 03bbdad..3e120aa 100644 --- a/examples/pubsub/testing/integration/src/local_env.rs +++ b/examples/pubsub/testing/integration/src/local_env.rs @@ -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, DynError> { diff --git a/examples/queue/testing/integration/src/local_env.rs b/examples/queue/testing/integration/src/local_env.rs index 90649f8..747a3a7 100644 --- a/examples/queue/testing/integration/src/local_env.rs +++ b/examples/queue/testing/integration/src/local_env.rs @@ -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, DynError> { diff --git a/testing-framework/deployers/local/Cargo.toml b/testing-framework/deployers/local/Cargo.toml index 20c5add..b83b792 100644 --- a/testing-framework/deployers/local/Cargo.toml +++ b/testing-framework/deployers/local/Cargo.toml @@ -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" diff --git a/testing-framework/deployers/local/src/binary.rs b/testing-framework/deployers/local/src/binary.rs deleted file mode 100644 index b023b07..0000000 --- a/testing-framework/deployers/local/src/binary.rs +++ /dev/null @@ -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 { - 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 { - 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 { - 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 - } -} diff --git a/testing-framework/deployers/local/src/binary/cache.rs b/testing-framework/deployers/local/src/binary/cache.rs new file mode 100644 index 0000000..70ba022 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/cache.rs @@ -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>> = OnceLock::new(); + +/// Shared in-process cache for provider results. +pub(super) struct BinaryCache; + +impl BinaryCache { + pub(super) fn get(key: &str) -> Option { + 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); + } +} diff --git a/testing-framework/deployers/local/src/binary/lock.rs b/testing-framework/deployers/local/src/binary/lock.rs new file mode 100644 index 0000000..220da35 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/lock.rs @@ -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 { + 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)); + } +} diff --git a/testing-framework/deployers/local/src/binary/mod.rs b/testing-framework/deployers/local/src/binary/mod.rs new file mode 100644 index 0000000..24f326a --- /dev/null +++ b/testing-framework/deployers/local/src/binary/mod.rs @@ -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, 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 { + 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 { + if let Some(path) = self.try_resolve()? { + return Ok(path); + } + + Err(BinaryProviderError::NotFound { + provider: self.display(), + }) + } +} diff --git a/testing-framework/deployers/local/src/binary/providers/build.rs b/testing-framework/deployers/local/src/binary/providers/build.rs new file mode 100644 index 0000000..5cec303 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/build.rs @@ -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, 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("."))) + } +} diff --git a/testing-framework/deployers/local/src/binary/providers/download.rs b/testing-framework/deployers/local/src/binary/providers/download.rs new file mode 100644 index 0000000..24a4882 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/download.rs @@ -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, 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 { + 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, 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()) + } +} diff --git a/testing-framework/deployers/local/src/binary/providers/env.rs b/testing-framework/deployers/local/src/binary/providers/env.rs new file mode 100644 index 0000000..d2d279b --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/env.rs @@ -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, 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) + } +} diff --git a/testing-framework/deployers/local/src/binary/providers/fallback.rs b/testing-framework/deployers/local/src/binary/providers/fallback.rs new file mode 100644 index 0000000..854822f --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/fallback.rs @@ -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, 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::>() + .join(",") + } + + fn cache_key(&self) -> String { + self.providers + .iter() + .map(|provider| provider.cache_key()) + .collect::>() + .join(",") + } +} diff --git a/testing-framework/deployers/local/src/binary/providers/mod.rs b/testing-framework/deployers/local/src/binary/providers/mod.rs new file mode 100644 index 0000000..7cd40db --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/mod.rs @@ -0,0 +1,7 @@ +//! Provider execution implementations. + +mod build; +mod download; +mod env; +mod fallback; +mod path; diff --git a/testing-framework/deployers/local/src/binary/providers/path.rs b/testing-framework/deployers/local/src/binary/providers/path.rs new file mode 100644 index 0000000..ef5463e --- /dev/null +++ b/testing-framework/deployers/local/src/binary/providers/path.rs @@ -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, 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()) + } +} diff --git a/testing-framework/deployers/local/src/binary/tests.rs b/testing-framework/deployers/local/src/binary/tests.rs new file mode 100644 index 0000000..e846e96 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/tests.rs @@ -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 = 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) + } +} diff --git a/testing-framework/deployers/local/src/binary/types.rs b/testing-framework/deployers/local/src/binary/types.rs new file mode 100644 index 0000000..50c41c5 --- /dev/null +++ b/testing-framework/deployers/local/src/binary/types.rs @@ -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; + +#[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, +} + +impl FallbackBinaryProvider { + #[must_use] + pub fn new(providers: impl IntoIterator) -> 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::>(), + ) + .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) -> 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) -> 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, +} + +impl BuildCommand { + #[must_use] + pub fn new(program: impl Into) -> Self { + Self { + program: program.into(), + args: Vec::new(), + } + } + + #[must_use] + pub fn with_args(mut self, args: impl IntoIterator>) -> 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::>() + .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, + /// Directory used for the build lock file. + /// + /// If unset, the lock is placed under `.tf-binaries` in `working_dir`. + pub lock_dir: Option, +} + +impl BuildBinaryProvider { + #[must_use] + pub fn new(command: BuildCommand, output_path: impl Into) -> 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, + /// 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, +} + +impl DownloadBinaryProvider { + #[must_use] + pub fn from_url(url: impl Into) -> 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 { + 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 { + 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) -> 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, + }, +} diff --git a/testing-framework/deployers/local/src/env/helpers.rs b/testing-framework/deployers/local/src/env/helpers.rs index 7d1d3d5..010e0b0 100644 --- a/testing-framework/deployers/local/src/env/helpers.rs +++ b/testing-framework/deployers/local/src/env/helpers.rs @@ -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( config: &T, binary_env_var: &str, - binary_name: &str, rust_log: &str, ) -> Result { 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, spec: &LocalProcessSpec, ) -> Result { - 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 - }) -} diff --git a/testing-framework/deployers/local/src/env/mod.rs b/testing-framework/deployers/local/src/env/mod.rs index bbf36ea..9f52e5f 100644 --- a/testing-framework/deployers/local/src/env/mod.rs +++ b/testing-framework/deployers/local/src/env/mod.rs @@ -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: &::NodeConfig, + _label: &str, + ) -> Option { + 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: &::NodeConfig, _dir: &Path, - _label: &str, + label: &str, ) -> Result { - 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)?; diff --git a/testing-framework/deployers/local/src/lib.rs b/testing-framework/deployers/local/src/lib.rs index 33c73b7..14eda23 100644 --- a/testing-framework/deployers/local/src/lib.rs +++ b/testing-framework/deployers/local/src/lib.rs @@ -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,