use governor::{Quota, RateLimiter}; use std::collections::HashMap; use std::num::NonZeroU32; use std::path::PathBuf; use std::sync::Arc; use tempfile::TempDir; use tokio::net::TcpListener; use tokio::sync::{RwLock, oneshot}; use witryna::build_guard::BuildScheduler; use witryna::config::{BuildOverrides, Config, SiteConfig}; use witryna::polling::PollingManager; use witryna::server::AppState; /// A running test server with its own temp directory and shutdown handle. pub struct TestServer { pub base_url: String, pub state: AppState, /// Kept alive for RAII cleanup of the config file written during startup. #[allow(dead_code)] pub tempdir: TempDir, shutdown_tx: Option>, } impl TestServer { /// Start a new test server with the given config. /// Binds to `127.0.0.1:0` (OS-assigned port). pub async fn start(config: Config) -> Self { Self::start_with_rate_limit(config, 1000).await } /// Start a new test server with a specific rate limit. pub async fn start_with_rate_limit(mut config: Config, rate_limit: u32) -> Self { let tempdir = TempDir::new().expect("failed to create temp dir"); let config_path = tempdir.path().join("witryna.toml"); // Write a minimal config file so SIGHUP reload has something to read let config_toml = build_config_toml(&config); tokio::fs::write(&config_path, &config_toml) .await .expect("failed to write test config"); config .resolve_secrets() .await .expect("failed to resolve secrets"); let quota = Quota::per_minute(NonZeroU32::new(rate_limit).expect("rate limit must be > 0")); let state = AppState { config: Arc::new(RwLock::new(config)), config_path: Arc::new(config_path), build_scheduler: Arc::new(BuildScheduler::new()), rate_limiter: Arc::new(RateLimiter::dashmap(quota)), polling_manager: Arc::new(PollingManager::new()), }; let listener = TcpListener::bind("127.0.0.1:0") .await .expect("failed to bind to random port"); let port = listener.local_addr().unwrap().port(); let base_url = format!("http://127.0.0.1:{port}"); let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>(); let server_state = state.clone(); tokio::spawn(async move { witryna::test_support::run_server(server_state, listener, async { let _ = shutdown_rx.await; }) .await .expect("server failed"); }); Self { base_url, state, tempdir, shutdown_tx: Some(shutdown_tx), } } /// Get an async reqwest client. pub fn client() -> reqwest::Client { reqwest::Client::new() } /// Build a URL for the given path. pub fn url(&self, path: &str) -> String { format!("{}/{}", self.base_url, path.trim_start_matches('/')) } /// Shut down the server gracefully. pub fn shutdown(&mut self) { if let Some(tx) = self.shutdown_tx.take() { let _ = tx.send(()); } } } impl Drop for TestServer { fn drop(&mut self) { self.shutdown(); } } /// Build a default test config pointing to the given base dir. pub fn test_config(base_dir: PathBuf) -> Config { let log_dir = base_dir.join("logs"); Config { listen_address: "127.0.0.1:0".to_owned(), container_runtime: "podman".to_owned(), base_dir, log_dir, log_level: "debug".to_owned(), rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites: vec![], } } /// Build a test config with a single site. pub fn test_config_with_site(base_dir: PathBuf, site: SiteConfig) -> Config { let log_dir = base_dir.join("logs"); Config { listen_address: "127.0.0.1:0".to_owned(), container_runtime: detect_container_runtime(), base_dir, log_dir, log_level: "debug".to_owned(), rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites: vec![site], } } /// Build a test config with multiple sites. pub fn test_config_with_sites(base_dir: PathBuf, sites: Vec) -> Config { let log_dir = base_dir.join("logs"); Config { listen_address: "127.0.0.1:0".to_owned(), container_runtime: detect_container_runtime(), base_dir, log_dir, log_level: "debug".to_owned(), rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites, } } /// Builder for test `SiteConfig` instances. /// /// Replaces `simple_site`, `site_with_overrides`, `site_with_hook`, and /// `site_with_cache` with a single fluent API. pub struct SiteBuilder { name: String, repo_url: String, token: String, webhook_token_file: Option, image: Option, command: Option, public: Option, cache_dirs: Option>, post_deploy: Option>, env: Option>, container_workdir: Option, } impl SiteBuilder { pub fn new(name: &str, repo_url: &str, token: &str) -> Self { Self { name: name.to_owned(), repo_url: repo_url.to_owned(), token: token.to_owned(), webhook_token_file: None, image: None, command: None, public: None, cache_dirs: None, post_deploy: None, env: None, container_workdir: None, } } /// Set complete build overrides (image, command, public dir). pub fn overrides(mut self, image: &str, command: &str, public: &str) -> Self { self.image = Some(image.to_owned()); self.command = Some(command.to_owned()); self.public = Some(public.to_owned()); self } pub fn webhook_token_file(mut self, path: PathBuf) -> Self { self.webhook_token_file = Some(path); self } pub fn post_deploy(mut self, hook: Vec) -> Self { self.post_deploy = Some(hook); self } pub fn env(mut self, env_vars: HashMap) -> Self { self.env = Some(env_vars); self } pub fn cache_dirs(mut self, dirs: Vec) -> Self { self.cache_dirs = Some(dirs); self } #[allow(dead_code)] pub fn container_workdir(mut self, path: &str) -> Self { self.container_workdir = Some(path.to_owned()); self } pub fn build(self) -> SiteConfig { SiteConfig { name: self.name, repo_url: self.repo_url, branch: "main".to_owned(), webhook_token: self.token, webhook_token_file: self.webhook_token_file, build_overrides: BuildOverrides { image: self.image, command: self.command, public: self.public, }, poll_interval: None, build_timeout: None, cache_dirs: self.cache_dirs, post_deploy: self.post_deploy, env: self.env, container_memory: None, container_cpus: None, container_pids_limit: None, container_network: "none".to_owned(), git_depth: None, container_workdir: self.container_workdir, config_file: None, } } } /// Start a server with a single pre-configured site for simple tests. /// /// Uses `my-site` with token `secret-token` — suitable for auth, 404, and basic endpoint tests. pub async fn server_with_site() -> TestServer { let dir = tempfile::tempdir().unwrap().keep(); let site = SiteBuilder::new("my-site", "https://example.com/repo.git", "secret-token").build(); TestServer::start(test_config_with_site(dir, site)).await } /// Detect the first available container runtime. fn detect_container_runtime() -> String { for runtime in &["podman", "docker"] { if std::process::Command::new(runtime) .args(["info"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .map(|s| s.success()) .unwrap_or(false) { return (*runtime).to_owned(); } } // Fallback — tests that need a runtime will skip themselves "podman".to_owned() } /// Serialize a Config into a minimal TOML string for writing to disk. fn build_config_toml(config: &Config) -> String { use std::fmt::Write as _; let runtime_line = format!("container_runtime = \"{}\"\n", config.container_runtime); let mut toml = format!( r#"listen_address = "{}" {}base_dir = "{}" log_dir = "{}" log_level = "{}" rate_limit_per_minute = {} max_builds_to_keep = {} "#, config.listen_address, runtime_line, config.base_dir.display(), config.log_dir.display(), config.log_level, config.rate_limit_per_minute, config.max_builds_to_keep, ); if let Some(timeout) = config.git_timeout { let _ = writeln!(toml, "git_timeout = \"{}s\"", timeout.as_secs()); } for site in &config.sites { let _ = writeln!(toml, "\n[[sites]]"); let _ = writeln!(toml, "name = \"{}\"", site.name); let _ = writeln!(toml, "repo_url = \"{}\"", site.repo_url); let _ = writeln!(toml, "branch = \"{}\"", site.branch); if !site.webhook_token.is_empty() { let _ = writeln!(toml, "webhook_token = \"{}\"", site.webhook_token); } if let Some(path) = &site.webhook_token_file { let _ = writeln!(toml, "webhook_token_file = \"{}\"", path.display()); } if let Some(image) = &site.build_overrides.image { let _ = writeln!(toml, "image = \"{image}\""); } if let Some(command) = &site.build_overrides.command { let _ = writeln!(toml, "command = \"{command}\""); } if let Some(public) = &site.build_overrides.public { let _ = writeln!(toml, "public = \"{public}\""); } if let Some(interval) = site.poll_interval { let _ = writeln!(toml, "poll_interval = \"{}s\"", interval.as_secs()); } if let Some(timeout) = site.build_timeout { let _ = writeln!(toml, "build_timeout = \"{}s\"", timeout.as_secs()); } if let Some(depth) = site.git_depth { let _ = writeln!(toml, "git_depth = {depth}"); } if let Some(workdir) = &site.container_workdir { let _ = writeln!(toml, "container_workdir = \"{workdir}\""); } if let Some(dirs) = &site.cache_dirs { let quoted: Vec<_> = dirs.iter().map(|d| format!("\"{d}\"")).collect(); let _ = writeln!(toml, "cache_dirs = [{}]", quoted.join(", ")); } if let Some(hook) = &site.post_deploy { let quoted: Vec<_> = hook.iter().map(|a| format!("\"{a}\"")).collect(); let _ = writeln!(toml, "post_deploy = [{}]", quoted.join(", ")); } if let Some(env_vars) = &site.env { let _ = writeln!(toml, "\n[sites.env]"); for (key, value) in env_vars { let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); let _ = writeln!(toml, "{key} = \"{escaped}\""); } } } toml }