From 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Thu, 22 Jan 2026 22:07:32 +0100 Subject: witryna 0.1.0 — initial release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minimalist Git-based static site deployment orchestrator. Webhook-triggered builds in Podman/Docker containers with atomic symlink publishing, SIGHUP hot-reload, and zero-downtime deploys. See README.md for usage, CHANGELOG.md for details. --- tests/integration/harness.rs | 356 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 356 insertions(+) create mode 100644 tests/integration/harness.rs (limited to 'tests/integration/harness.rs') diff --git a/tests/integration/harness.rs b/tests/integration/harness.rs new file mode 100644 index 0000000..c015fa8 --- /dev/null +++ b/tests/integration/harness.rs @@ -0,0 +1,356 @@ +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 +} -- cgit v1.2.3