summaryrefslogtreecommitdiff
path: root/src/hook.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/hook.rs')
-rw-r--r--src/hook.rs499
1 files changed, 499 insertions, 0 deletions
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<String>,
+ pub stdout_file: PathBuf,
+ pub stderr_file: PathBuf,
+ pub last_stderr: String,
+ pub exit_code: Option<i32>,
+ 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<String, String>,
+ 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<i32>, 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<String> {
+ 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="));
+ }
+}