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. --- src/hook.rs | 499 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 499 insertions(+) create mode 100644 src/hook.rs (limited to 'src/hook.rs') diff --git a/src/hook.rs b/src/hook.rs new file mode 100644 index 0000000..53e1e18 --- /dev/null +++ b/src/hook.rs @@ -0,0 +1,499 @@ +use crate::build::copy_with_tail; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; +use tokio::io::AsyncWriteExt as _; +use tokio::io::BufWriter; +use tokio::process::Command; +use tracing::debug; + +#[cfg(not(test))] +const HOOK_TIMEOUT: Duration = Duration::from_secs(30); +#[cfg(test)] +const HOOK_TIMEOUT: Duration = Duration::from_secs(2); + +/// Size of the in-memory tail buffer for stderr (last 256 bytes). +/// Used for error context in `HookResult` without reading the full file. +const STDERR_TAIL_SIZE: usize = 256; + +/// Result of a post-deploy hook execution. +/// +/// Stdout and stderr are streamed to temporary files on disk during execution. +/// Callers should pass these paths to `logs::save_hook_log()` for composition. +pub struct HookResult { + pub command: Vec, + pub stdout_file: PathBuf, + pub stderr_file: PathBuf, + pub last_stderr: String, + pub exit_code: Option, + pub duration: Duration, + pub success: bool, +} + +/// Execute a post-deploy hook command. +/// +/// Runs the command directly (no shell), with a minimal environment and a timeout. +/// Stdout and stderr are streamed to the provided temporary files. +/// Always returns a `HookResult` — never an `Err` — so callers can always log the outcome. +#[allow( + clippy::implicit_hasher, + clippy::large_futures, + clippy::too_many_arguments +)] +pub async fn run_post_deploy_hook( + command: &[String], + site_name: &str, + build_dir: &Path, + public_dir: &Path, + timestamp: &str, + env: &HashMap, + stdout_file: &Path, + stderr_file: &Path, +) -> HookResult { + let start = Instant::now(); + + let Some(executable) = command.first() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr: "empty command".to_owned(), + exit_code: None, + duration: start.elapsed(), + success: false, + }; + }; + let args = command.get(1..).unwrap_or_default(); + + let path_env = std::env::var("PATH").unwrap_or_else(|_| "/usr/bin:/bin".to_owned()); + let home_env = std::env::var("HOME").unwrap_or_else(|_| "/nonexistent".to_owned()); + + let child = Command::new(executable) + .args(args) + .current_dir(build_dir) + .kill_on_drop(true) + .env_clear() + .envs(env) + .env("PATH", &path_env) + .env("HOME", &home_env) + .env("LANG", "C.UTF-8") + .env("WITRYNA_SITE", site_name) + .env("WITRYNA_BUILD_DIR", build_dir.as_os_str()) + .env("WITRYNA_PUBLIC_DIR", public_dir.as_os_str()) + .env("WITRYNA_BUILD_TIMESTAMP", timestamp) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn(); + + let mut child = match child { + Ok(c) => c, + Err(e) => { + // Spawn failure — create empty temp files so log composition works + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr: format!("failed to spawn hook: {e}"), + exit_code: None, + duration: start.elapsed(), + success: false, + }; + } + }; + + debug!(cmd = ?command, "hook process spawned"); + + let (last_stderr, exit_code, success) = + stream_hook_output(&mut child, stdout_file, stderr_file).await; + + HookResult { + command: command.to_vec(), + stdout_file: stdout_file.to_path_buf(), + stderr_file: stderr_file.to_path_buf(), + last_stderr, + exit_code, + duration: start.elapsed(), + success, + } +} + +/// Stream hook stdout/stderr to disk and wait for completion with timeout. +/// +/// Returns `(last_stderr, exit_code, success)`. On any setup or I/O failure, +/// returns an error description in `last_stderr` with `success = false`. +#[allow(clippy::large_futures)] +async fn stream_hook_output( + child: &mut tokio::process::Child, + stdout_file: &Path, + stderr_file: &Path, +) -> (String, Option, bool) { + let Some(stdout_pipe) = child.stdout.take() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return ("missing stdout pipe".to_owned(), None, false); + }; + let Some(stderr_pipe) = child.stderr.take() else { + let _ = tokio::fs::File::create(stdout_file).await; + let _ = tokio::fs::File::create(stderr_file).await; + return ("missing stderr pipe".to_owned(), None, false); + }; + + let stdout_writer = match tokio::fs::File::create(stdout_file).await { + Ok(f) => BufWriter::new(f), + Err(e) => { + let _ = tokio::fs::File::create(stderr_file).await; + return ( + format!("failed to create stdout temp file: {e}"), + None, + false, + ); + } + }; + let stderr_writer = match tokio::fs::File::create(stderr_file).await { + Ok(f) => BufWriter::new(f), + Err(e) => { + return ( + format!("failed to create stderr temp file: {e}"), + None, + false, + ); + } + }; + + let mut stdout_writer = stdout_writer; + let mut stderr_writer = stderr_writer; + + #[allow(clippy::large_futures)] + match tokio::time::timeout(HOOK_TIMEOUT, async { + let (stdout_res, stderr_res, wait_result) = tokio::join!( + copy_with_tail(stdout_pipe, &mut stdout_writer, 0), + copy_with_tail(stderr_pipe, &mut stderr_writer, STDERR_TAIL_SIZE), + child.wait(), + ); + (stdout_res, stderr_res, wait_result) + }) + .await + { + Ok((stdout_res, stderr_res, Ok(status))) => { + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + + let last_stderr = match stderr_res { + Ok((_, tail)) => String::from_utf8_lossy(&tail).into_owned(), + Err(_) => String::new(), + }; + // stdout_res error is non-fatal for hook result + let _ = stdout_res; + + (last_stderr, status.code(), status.success()) + } + Ok((_, _, Err(e))) => { + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + (format!("hook I/O error: {e}"), None, false) + } + Err(_) => { + // Timeout — kill the child + let _ = child.kill().await; + let _ = stdout_writer.flush().await; + let _ = stderr_writer.flush().await; + (String::new(), None, false) + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::indexing_slicing, clippy::large_futures)] +mod tests { + use super::*; + use std::collections::HashMap; + use tempfile::TempDir; + use tokio::fs; + + fn cmd(args: &[&str]) -> Vec { + args.iter().map(std::string::ToString::to_string).collect() + } + + #[tokio::test] + async fn hook_success() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["echo", "hello"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + assert_eq!(result.exit_code, Some(0)); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("hello")); + } + + #[tokio::test] + async fn hook_failure_exit_code() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["false"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + assert_eq!(result.exit_code, Some(1)); + } + + #[tokio::test] + async fn hook_timeout() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["sleep", "10"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + // Timeout path sets last_stderr to empty string — error context is in the log + assert!(result.last_stderr.is_empty()); + } + + #[tokio::test] + async fn hook_env_vars() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let env = HashMap::from([ + ("MY_VAR".to_owned(), "my_value".to_owned()), + ("DEPLOY_TARGET".to_owned(), "staging".to_owned()), + ]); + let public_dir = tmp.path().join("current"); + let result = run_post_deploy_hook( + &cmd(&["env"]), + "my-site", + tmp.path(), + &public_dir, + "20260202-120000-000000", + &env, + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + assert!(stdout.contains("WITRYNA_SITE=my-site")); + assert!(stdout.contains("WITRYNA_BUILD_TIMESTAMP=20260202-120000-000000")); + assert!(stdout.contains("WITRYNA_BUILD_DIR=")); + assert!(stdout.contains("WITRYNA_PUBLIC_DIR=")); + assert!(stdout.contains("PATH=")); + assert!(stdout.contains("HOME=")); + assert!(stdout.contains("LANG=C.UTF-8")); + assert!(stdout.contains("MY_VAR=my_value")); + assert!(stdout.contains("DEPLOY_TARGET=staging")); + + // Verify no unexpected env vars leak through + let lines: Vec<&str> = stdout.lines().collect(); + for line in &lines { + let key = line.split('=').next().unwrap_or(""); + assert!( + [ + "PATH", + "HOME", + "LANG", + "WITRYNA_SITE", + "WITRYNA_BUILD_DIR", + "WITRYNA_PUBLIC_DIR", + "WITRYNA_BUILD_TIMESTAMP", + "MY_VAR", + "DEPLOY_TARGET", + ] + .contains(&key), + "unexpected env var: {line}" + ); + } + } + + #[tokio::test] + async fn hook_nonexistent_command() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["/nonexistent-binary-xyz"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(!result.success); + assert!(result.last_stderr.contains("failed to spawn hook")); + } + + #[tokio::test] + async fn hook_large_output_streams_to_disk() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + // Generate output larger than old MAX_OUTPUT_BYTES (256 KB) — now unbounded to disk + let result = run_post_deploy_hook( + &cmd(&["sh", "-c", "yes | head -c 300000"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + // All 300000 bytes should be on disk (no truncation) + let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); + assert_eq!(stdout_len, 300_000); + } + + #[tokio::test] + async fn hook_current_dir() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["pwd"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + // Canonicalize to handle /tmp -> /private/tmp on macOS + let expected = std::fs::canonicalize(tmp.path()).unwrap(); + let actual = stdout.trim(); + let actual_canonical = std::fs::canonicalize(actual).unwrap_or_default(); + assert_eq!(actual_canonical, expected); + } + + #[tokio::test] + async fn hook_large_stdout_no_deadlock() { + // Writes 128 KB to stdout, exceeding the ~64 KB OS pipe buffer. + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&["sh", "-c", "dd if=/dev/zero bs=1024 count=128 2>/dev/null"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout_len = fs::metadata(&stdout_tmp).await.unwrap().len(); + assert_eq!(stdout_len, 128 * 1024); + } + + #[tokio::test] + async fn hook_large_stderr_no_deadlock() { + // Writes 128 KB to stderr, covering the other pipe. + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let result = run_post_deploy_hook( + &cmd(&[ + "sh", + "-c", + "dd if=/dev/zero bs=1024 count=128 >&2 2>/dev/null", + ]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &HashMap::new(), + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stderr_len = fs::metadata(&stderr_tmp).await.unwrap().len(); + assert_eq!(stderr_len, 128 * 1024); + } + + #[tokio::test] + async fn hook_user_env_does_not_override_reserved() { + let tmp = TempDir::new().unwrap(); + let stdout_tmp = tmp.path().join("stdout.tmp"); + let stderr_tmp = tmp.path().join("stderr.tmp"); + + let env = HashMap::from([("PATH".to_owned(), "/should-not-appear".to_owned())]); + let result = run_post_deploy_hook( + &cmd(&["env"]), + "test-site", + tmp.path(), + &tmp.path().join("current"), + "ts", + &env, + &stdout_tmp, + &stderr_tmp, + ) + .await; + + assert!(result.success); + let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); + // PATH should be the system value, not the user override + assert!(!stdout.contains("PATH=/should-not-appear")); + assert!(stdout.contains("PATH=")); + } +} -- cgit v1.2.3