diff options
Diffstat (limited to 'src/build.rs')
| -rw-r--r-- | src/build.rs | 131 |
1 files changed, 73 insertions, 58 deletions
diff --git a/src/build.rs b/src/build.rs index e887f64..b56e680 100644 --- a/src/build.rs +++ b/src/build.rs @@ -1,11 +1,11 @@ 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 tracing::{debug, info}; use crate::repo_config::RepoConfig; @@ -82,13 +82,13 @@ impl std::error::Error for BuildFailure {} /// /// Used for `--verbose` mode: streams build output to both a temp file (primary) /// and stderr (secondary) simultaneously. -pub(crate) struct TeeWriter<W> { +pub struct TeeWriter<W> { primary: W, secondary: tokio::io::Stderr, } impl<W: AsyncWrite + Unpin> TeeWriter<W> { - pub(crate) const fn new(primary: W, secondary: tokio::io::Stderr) -> Self { + pub const fn new(primary: W, secondary: tokio::io::Stderr) -> Self { Self { primary, secondary } } } @@ -125,56 +125,18 @@ impl<W: AsyncWrite + Unpin> AsyncWrite for TeeWriter<W> { } } -/// 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`]). +/// Build the container CLI arguments for a build invocation. /// -/// # 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( +/// 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<String, String>, options: &ContainerOptions, - stdout_file: &Path, - stderr_file: &Path, - timeout: Duration, - verbose: bool, -) -> Result<BuildResult> { - info!( - image = %repo_config.image, - command = %repo_config.command, - path = %clone_dir.display(), - "executing container build" - ); - - let start = Instant::now(); - - // Build args dynamically to support optional cache volumes +) -> Vec<String> { let mut args = vec![ "run".to_owned(), "--rm".to_owned(), @@ -182,13 +144,13 @@ pub async fn execute( format!("{}:/workspace:Z", clone_dir.display()), ]; - // Add cache volume mounts + // 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)); } - // Add user-defined environment variables + // User-defined environment variables for (key, value) in env { args.push("--env".to_owned()); args.push(format!("{key}={value}")); @@ -203,9 +165,6 @@ pub async fn execute( if runtime == "podman" { args.push("--userns=keep-id".to_owned()); } else { - // Docker: container runs as root but workspace is owned by host UID. - // DAC_OVERRIDE lets root bypass file permission checks. - // Podman doesn't need this because --userns=keep-id maps to the host UID. args.push("--cap-add=DAC_OVERRIDE".to_owned()); } @@ -233,6 +192,59 @@ pub async fn execute( 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<String, String>, + options: &ContainerOptions, + stdout_file: &Path, + stderr_file: &Path, + timeout: Duration, + verbose: bool, +) -> Result<BuildResult> { + 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) @@ -265,7 +277,7 @@ pub async fn execute( 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()); - run_build_process( + Box::pin(run_build_process( child, stdout_pipe, stderr_pipe, @@ -277,12 +289,12 @@ pub async fn execute( clone_dir, "container", timeout, - ) + )) .await } else { let mut stdout_writer = stdout_file_writer; let mut stderr_writer = stderr_file_writer; - run_build_process( + Box::pin(run_build_process( child, stdout_pipe, stderr_pipe, @@ -294,7 +306,7 @@ pub async fn execute( clone_dir, "container", timeout, - ) + )) .await } } @@ -307,7 +319,7 @@ pub async fn execute( /// 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(crate) async fn copy_with_tail<R, W>( +pub async fn copy_with_tail<R, W>( mut reader: R, mut writer: W, tail_size: usize, @@ -386,7 +398,7 @@ where if !status.success() { let exit_code = status.code().unwrap_or(-1); - debug!(exit_code, "{label} build failed"); + debug!("{label} build failed: exit_code={exit_code}"); return Err(BuildFailure { exit_code, stdout_file: stdout_file.to_path_buf(), @@ -398,7 +410,10 @@ where } let duration = start.elapsed(); - debug!(path = %clone_dir.display(), ?duration, "{label} build completed"); + 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(), |
