use anyhow::{Context as _, Result}; use log::{debug, info}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::process::Stdio; use std::time::{Duration, Instant}; use tokio::io::{AsyncWrite, AsyncWriteExt as _, BufWriter}; use tokio::process::Command; use crate::repo_config::RepoConfig; /// Optional container resource limits and network mode. /// /// Passed from `SiteConfig` to `execute()` to inject `--memory`, `--cpus`, /// `--pids-limit`, and `--network` flags into the container command. #[derive(Debug)] pub struct ContainerOptions { pub memory: Option, pub cpus: Option, pub pids_limit: Option, pub network: String, pub workdir: Option, } impl Default for ContainerOptions { fn default() -> Self { Self { memory: None, cpus: None, pids_limit: None, network: "bridge".to_owned(), workdir: None, } } } /// Default timeout for build operations. pub const BUILD_TIMEOUT_DEFAULT: Duration = Duration::from_secs(600); // 10 minutes /// Size of the in-memory tail buffer for stderr (last 1 KB). /// Used for `BuildFailure::Display` after streaming to disk. const STDERR_TAIL_SIZE: usize = 1024; /// Result of a build execution. /// /// Stdout and stderr are streamed to temporary files on disk during the build. /// Callers should pass these paths to `logs::save_build_log()` for composition. #[derive(Debug)] pub struct BuildResult { pub stdout_file: PathBuf, pub stderr_file: PathBuf, pub duration: Duration, } /// Error from a failed build command. /// /// Carries structured exit code and file paths to captured output. /// `last_stderr` holds the last 1 KB of stderr for the `Display` impl. #[derive(Debug)] pub struct BuildFailure { pub exit_code: i32, pub stdout_file: PathBuf, pub stderr_file: PathBuf, pub last_stderr: String, pub duration: Duration, } impl std::fmt::Display for BuildFailure { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!( f, "build failed with exit code {}: {}", self.exit_code, self.last_stderr.trim() ) } } impl std::error::Error for BuildFailure {} /// Writer that duplicates all writes to both a primary and secondary writer. /// /// Used for `--verbose` mode: streams build output to both a temp file (primary) /// and stderr (secondary) simultaneously. pub struct TeeWriter { primary: W, secondary: tokio::io::Stderr, } impl TeeWriter { pub const fn new(primary: W, secondary: tokio::io::Stderr) -> Self { Self { primary, secondary } } } impl AsyncWrite for TeeWriter { fn poll_write( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &[u8], ) -> std::task::Poll> { // Write to primary first let poll = std::pin::Pin::new(&mut self.primary).poll_write(cx, buf); if let std::task::Poll::Ready(Ok(n)) = &poll { // Best-effort write to secondary (stderr) — same bytes let _ = std::pin::Pin::new(&mut self.secondary).poll_write(cx, &buf[..*n]); } poll } fn poll_flush( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { let _ = std::pin::Pin::new(&mut self.secondary).poll_flush(cx); std::pin::Pin::new(&mut self.primary).poll_flush(cx) } fn poll_shutdown( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, ) -> std::task::Poll> { let _ = std::pin::Pin::new(&mut self.secondary).poll_shutdown(cx); std::pin::Pin::new(&mut self.primary).poll_shutdown(cx) } } /// Build the container CLI arguments for a build invocation. /// /// Assembles the full `run --rm ...` argument list including volume mounts, /// environment variables, resource limits, and the build command. fn build_container_args( runtime: &str, clone_dir: &Path, repo_config: &RepoConfig, cache_volumes: &[(String, PathBuf)], env: &HashMap, options: &ContainerOptions, ) -> Vec { let mut args = vec![ "run".to_owned(), "--rm".to_owned(), "--volume".to_owned(), format!("{}:/workspace:Z", clone_dir.display()), ]; // Cache volume mounts for (container_path, host_path) in cache_volumes { args.push("--volume".to_owned()); args.push(format!("{}:{}:Z", host_path.display(), container_path)); } // User-defined environment variables for (key, value) in env { args.push("--env".to_owned()); args.push(format!("{key}={value}")); } let workdir = match &options.workdir { Some(subdir) => format!("/workspace/{subdir}"), None => "/workspace".to_owned(), }; args.extend(["--workdir".to_owned(), workdir, "--cap-drop=ALL".to_owned()]); if runtime == "podman" { args.push("--userns=keep-id".to_owned()); } else { args.push("--cap-add=DAC_OVERRIDE".to_owned()); } // Resource limits if let Some(memory) = &options.memory { args.push("--memory".to_owned()); args.push(memory.clone()); } if let Some(cpus) = options.cpus { args.push("--cpus".to_owned()); args.push(cpus.to_string()); } if let Some(pids) = options.pids_limit { args.push("--pids-limit".to_owned()); args.push(pids.to_string()); } // Network mode args.push(format!("--network={}", options.network)); args.extend([ repo_config.image.clone(), "sh".to_owned(), "-c".to_owned(), repo_config.command.clone(), ]); args } /// Execute a containerized build for a site. /// /// Stdout and stderr are streamed to the provided temporary files on disk /// instead of being buffered in memory. This removes unbounded memory usage /// for container builds. /// /// # Arguments /// * `runtime` - Container runtime to use ("podman" or "docker") /// * `clone_dir` - Path to the cloned repository /// * `repo_config` - Build configuration from witryna.yaml /// * `cache_volumes` - Pairs of (`container_path`, `host_path`) for persistent cache mounts /// * `env` - User-defined environment variables to pass into the container via `--env` /// * `options` - Optional container resource limits and network mode /// * `stdout_file` - Temp file path for captured stdout /// * `stderr_file` - Temp file path for captured stderr /// * `timeout` - Maximum duration before killing the build /// * `verbose` - When true, also stream build output to stderr in real-time /// /// # Errors /// /// Returns an error if the container command times out, fails to execute, /// or exits with a non-zero status code (as a [`BuildFailure`]). /// /// # Security /// - Uses typed arguments (no shell interpolation) per OWASP guidelines /// - Mounts clone directory as read-write (needed for build output) /// - Runs with minimal capabilities #[allow(clippy::implicit_hasher, clippy::too_many_arguments)] pub async fn execute( runtime: &str, clone_dir: &Path, repo_config: &RepoConfig, cache_volumes: &[(String, PathBuf)], env: &HashMap, options: &ContainerOptions, stdout_file: &Path, stderr_file: &Path, timeout: Duration, verbose: bool, ) -> Result { info!( "executing container build: image={} command={} path={}", repo_config.image, repo_config.command, clone_dir.display() ); let start = Instant::now(); let args = build_container_args(runtime, clone_dir, repo_config, cache_volumes, env, options); // Spawn with piped stdout/stderr for streaming (OWASP: no shell interpolation) let mut child = Command::new(runtime) .args(&args) .kill_on_drop(true) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn() .context("failed to spawn container build")?; let stdout_pipe = child .stdout .take() .ok_or_else(|| anyhow::anyhow!("missing stdout pipe"))?; let stderr_pipe = child .stderr .take() .ok_or_else(|| anyhow::anyhow!("missing stderr pipe"))?; let stdout_file_writer = BufWriter::new( tokio::fs::File::create(stdout_file) .await .with_context(|| format!("failed to create {}", stdout_file.display()))?, ); let stderr_file_writer = BufWriter::new( tokio::fs::File::create(stderr_file) .await .with_context(|| format!("failed to create {}", stderr_file.display()))?, ); if verbose { let mut stdout_tee = TeeWriter::new(stdout_file_writer, tokio::io::stderr()); let mut stderr_tee = TeeWriter::new(stderr_file_writer, tokio::io::stderr()); Box::pin(run_build_process( child, stdout_pipe, stderr_pipe, &mut stdout_tee, &mut stderr_tee, start, stdout_file, stderr_file, clone_dir, "container", timeout, )) .await } else { let mut stdout_writer = stdout_file_writer; let mut stderr_writer = stderr_file_writer; Box::pin(run_build_process( child, stdout_pipe, stderr_pipe, &mut stdout_writer, &mut stderr_writer, start, stdout_file, stderr_file, clone_dir, "container", timeout, )) .await } } /// Copy from reader to writer, keeping the last `tail_size` bytes in memory. /// Returns `(total_bytes_copied, tail_buffer)`. /// /// When `tail_size` is 0, skips tail tracking entirely (used for stdout /// where we don't need a tail). The tail buffer is used to provide a /// meaningful error message in `BuildFailure::Display` without reading /// the entire stderr file back into memory. #[allow(clippy::indexing_slicing)] // buf[..n] bounded by read() return value pub async fn copy_with_tail( mut reader: R, mut writer: W, tail_size: usize, ) -> std::io::Result<(u64, Vec)> where R: tokio::io::AsyncRead + Unpin, W: tokio::io::AsyncWrite + Unpin, { use tokio::io::AsyncReadExt as _; let mut buf = [0_u8; 8192]; let mut total: u64 = 0; let mut tail: Vec = Vec::new(); loop { let n = reader.read(&mut buf).await?; if n == 0 { break; } writer.write_all(&buf[..n]).await?; total += n as u64; if tail_size > 0 { tail.extend_from_slice(&buf[..n]); if tail.len() > tail_size { let excess = tail.len() - tail_size; tail.drain(..excess); } } } Ok((total, tail)) } /// Shared build-process loop: stream stdout/stderr through writers, handle timeout and exit status. #[allow(clippy::too_many_arguments)] async fn run_build_process( mut child: tokio::process::Child, stdout_pipe: tokio::process::ChildStdout, stderr_pipe: tokio::process::ChildStderr, stdout_writer: &mut W1, stderr_writer: &mut W2, start: Instant, stdout_file: &Path, stderr_file: &Path, clone_dir: &Path, label: &str, timeout: Duration, ) -> Result where W1: AsyncWrite + Unpin, W2: AsyncWrite + Unpin, { #[allow(clippy::large_futures)] let Ok((stdout_res, stderr_res, wait_res)) = tokio::time::timeout(timeout, async { let (stdout_res, stderr_res, wait_res) = 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_res) }) .await else { let _ = child.kill().await; anyhow::bail!("{label} build timed out after {}s", timeout.as_secs()); }; stdout_res.context("failed to stream stdout")?; let (_, stderr_tail) = stderr_res.context("failed to stream stderr")?; stdout_writer.flush().await?; stderr_writer.flush().await?; let status = wait_res.context(format!("{label} build I/O error"))?; let last_stderr = String::from_utf8_lossy(&stderr_tail).into_owned(); if !status.success() { let exit_code = status.code().unwrap_or(-1); debug!("{label} build failed: exit_code={exit_code}"); return Err(BuildFailure { exit_code, stdout_file: stdout_file.to_path_buf(), stderr_file: stderr_file.to_path_buf(), last_stderr, duration: start.elapsed(), } .into()); } let duration = start.elapsed(); debug!( "{label} build completed: path={} duration={duration:?}", clone_dir.display() ); Ok(BuildResult { stdout_file: stdout_file.to_path_buf(), stderr_file: stderr_file.to_path_buf(), duration, }) } #[cfg(test)] #[allow( clippy::unwrap_used, clippy::indexing_slicing, clippy::large_futures, clippy::print_stderr )] mod tests { use super::*; use crate::test_support::{cleanup, temp_dir}; use tokio::fs; use tokio::process::Command as TokioCommand; /// Check if a container runtime is available and its daemon is running. async fn container_runtime_available(runtime: &str) -> bool { TokioCommand::new(runtime) .args(["info"]) .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .await .map(|s| s.success()) .unwrap_or(false) } /// Get the first available container runtime. async fn get_runtime() -> Option { for runtime in &["podman", "docker"] { if container_runtime_available(runtime).await { return Some((*runtime).to_owned()); } } None } // --- copy_with_tail() unit tests --- #[tokio::test] async fn copy_with_tail_small_input() { let input = b"hello"; let mut output = Vec::new(); let (total, tail) = copy_with_tail(&input[..], &mut output, 1024).await.unwrap(); assert_eq!(total, 5); assert_eq!(tail, b"hello"); assert_eq!(output, b"hello"); } #[tokio::test] async fn copy_with_tail_large_input() { // Input larger than tail_size — only last N bytes kept let input: Vec = (0_u8..=255).cycle().take(2048).collect(); let mut output = Vec::new(); let (total, tail) = copy_with_tail(&input[..], &mut output, 512).await.unwrap(); assert_eq!(total, 2048); assert_eq!(tail.len(), 512); assert_eq!(&tail[..], &input[2048 - 512..]); assert_eq!(output, input); } #[tokio::test] async fn copy_with_tail_zero_tail() { let input = b"data"; let mut output = Vec::new(); let (total, tail) = copy_with_tail(&input[..], &mut output, 0).await.unwrap(); assert_eq!(total, 4); assert!(tail.is_empty()); assert_eq!(output, b"data"); } // --- ContainerOptions workdir tests --- #[tokio::test] async fn execute_custom_workdir_runs_from_subdir() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-workdir-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); // Create a subdirectory with a marker file let subdir = temp.join("packages").join("frontend"); fs::create_dir_all(&subdir).await.unwrap(); fs::write(subdir.join("marker.txt"), "subdir-marker") .await .unwrap(); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "cat marker.txt".to_owned(), public: "dist".to_owned(), }; let options = ContainerOptions { workdir: Some("packages/frontend".to_owned()), ..ContainerOptions::default() }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &options, &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_ok(), "build should succeed: {result:?}"); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!( stdout.contains("subdir-marker"), "should read marker from subdir, got: {stdout}" ); cleanup(&temp).await; } // --- execute() container tests (Tier 2) --- #[tokio::test] async fn execute_simple_command_success() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "echo 'hello world'".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_ok(), "build should succeed: {result:?}"); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!(stdout.contains("hello world")); cleanup(&temp).await; } #[tokio::test] async fn execute_creates_output_files() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "mkdir -p dist && echo 'content' > dist/index.html".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_ok(), "build should succeed: {result:?}"); // Verify output file was created let output_file = temp.join("dist/index.html"); assert!(output_file.exists(), "output file should exist"); let content = fs::read_to_string(&output_file).await.unwrap(); assert!(content.contains("content")); cleanup(&temp).await; } #[tokio::test] async fn execute_failing_command_returns_error() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "exit 1".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_err(), "build should fail"); let err = result.unwrap_err().to_string(); assert!(err.contains("exit code 1")); cleanup(&temp).await; } #[tokio::test] async fn execute_command_with_stderr() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "echo 'error message' >&2 && exit 1".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_err(), "build should fail"); let err = result.unwrap_err().to_string(); assert!(err.contains("error message")); cleanup(&temp).await; } #[tokio::test] async fn execute_invalid_image_returns_error() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "nonexistent-image-xyz-12345:latest".to_owned(), command: "echo hello".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_err(), "build should fail for invalid image"); cleanup(&temp).await; } #[tokio::test] async fn execute_workdir_is_correct() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); // Create a file in the temp dir to verify we can see it fs::write(temp.join("marker.txt"), "test-marker") .await .unwrap(); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "cat marker.txt".to_owned(), public: "dist".to_owned(), }; let result = execute( &runtime, &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_ok(), "build should succeed: {result:?}"); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!(stdout.contains("test-marker")); cleanup(&temp).await; } #[tokio::test] async fn execute_invalid_runtime_returns_error() { let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "echo hello".to_owned(), public: "dist".to_owned(), }; let result = execute( "nonexistent-runtime-xyz", &temp, &repo_config, &[], &HashMap::new(), &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_err(), "build should fail for invalid runtime"); cleanup(&temp).await; } #[tokio::test] async fn execute_with_env_vars_passes_to_container() { let Some(runtime) = get_runtime().await else { eprintln!("Skipping test: no container runtime available"); return; }; let temp = temp_dir("build-test").await; let stdout_tmp = temp.join("stdout.tmp"); let stderr_tmp = temp.join("stderr.tmp"); let repo_config = RepoConfig { image: "alpine:latest".to_owned(), command: "printenv MY_VAR".to_owned(), public: "dist".to_owned(), }; let env = HashMap::from([("MY_VAR".to_owned(), "my_value".to_owned())]); let result = execute( &runtime, &temp, &repo_config, &[], &env, &ContainerOptions::default(), &stdout_tmp, &stderr_tmp, BUILD_TIMEOUT_DEFAULT, false, ) .await; assert!(result.is_ok(), "build should succeed: {result:?}"); let stdout = fs::read_to_string(&stdout_tmp).await.unwrap(); assert!( stdout.contains("my_value"), "stdout should contain env var value, got: {stdout}", ); cleanup(&temp).await; } }