diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-01-22 22:07:32 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-10 18:44:26 +0100 |
| commit | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch) | |
| tree | a2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/pipeline.rs | |
witryna 0.1.0 — initial releasev0.1.0
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.
Diffstat (limited to 'src/pipeline.rs')
| -rw-r--r-- | src/pipeline.rs | 328 |
1 files changed, 328 insertions, 0 deletions
diff --git a/src/pipeline.rs b/src/pipeline.rs new file mode 100644 index 0000000..5827ad7 --- /dev/null +++ b/src/pipeline.rs @@ -0,0 +1,328 @@ +use crate::config::SiteConfig; +use crate::logs::{BuildExitStatus, BuildLogMeta}; +use crate::{build, cleanup, git, hook, logs, publish, repo_config}; +use anyhow::Result; +use chrono::Utc; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; +use tracing::{error, info, warn}; + +/// Result of a successful pipeline run. +pub struct PipelineResult { + pub build_dir: PathBuf, + pub log_file: PathBuf, + pub timestamp: String, + pub duration: Duration, +} + +/// Run the complete build pipeline: git sync → build → publish. +/// +/// This is the core pipeline logic shared by both the HTTP server and the CLI +/// `run` command. The server wraps this with `BuildGuard` for concurrency +/// control; the CLI calls it directly. +/// +/// # Errors +/// +/// Returns an error on git sync failure, config load failure, build failure, +/// or publish failure. Post-deploy hook and cleanup failures are non-fatal +/// (logged as warnings). +#[allow(clippy::too_many_arguments, clippy::too_many_lines)] +pub async fn run_build( + site_name: &str, + site: &SiteConfig, + base_dir: &Path, + log_dir: &Path, + container_runtime: &str, + max_builds_to_keep: u32, + git_timeout: Duration, + verbose: bool, +) -> Result<PipelineResult> { + let timestamp = Utc::now().format("%Y%m%d-%H%M%S-%f").to_string(); + let start_time = Instant::now(); + + let clone_dir = base_dir.join("clones").join(site_name); + + // 1. Sync git repository + info!(%site_name, "syncing repository"); + if let Err(e) = git::sync_repo( + &site.repo_url, + &site.branch, + &clone_dir, + git_timeout, + site.git_depth.unwrap_or(git::GIT_DEPTH_DEFAULT), + ) + .await + { + error!(%site_name, error = %e, "git sync failed"); + save_build_log_for_error( + log_dir, + site_name, + ×tamp, + start_time, + None, + "git-sync", + &e.to_string(), + ) + .await; + return Err(e.context("git sync failed")); + } + + // Get git commit hash for logging + let git_commit = logs::get_git_commit(&clone_dir).await; + + // 2. Load repo config (witryna.yaml) with overrides from witryna.toml + let repo_config = match repo_config::RepoConfig::load_with_overrides( + &clone_dir, + &site.build_overrides, + site.config_file.as_deref(), + ) + .await + { + Ok(config) => config, + Err(e) => { + error!(%site_name, error = %e, "failed to load repo config"); + save_build_log_for_error( + log_dir, + site_name, + ×tamp, + start_time, + git_commit, + "config-load", + &e.to_string(), + ) + .await; + return Err(e.context("failed to load repo config")); + } + }; + + // 3. Prepare cache volumes + let cache_volumes = match &site.cache_dirs { + Some(dirs) if !dirs.is_empty() => { + let mut volumes = Vec::with_capacity(dirs.len()); + for dir in dirs { + let sanitized = crate::config::sanitize_cache_dir_name(dir); + let host_path = base_dir.join("cache").join(site_name).join(&sanitized); + if let Err(e) = tokio::fs::create_dir_all(&host_path).await { + error!(%site_name, path = %host_path.display(), error = %e, "failed to create cache directory"); + anyhow::bail!("failed to create cache directory: {e}"); + } + volumes.push((dir.clone(), host_path)); + } + let mount_list: Vec<_> = volumes + .iter() + .map(|(c, h)| format!("{}:{}", h.display(), c)) + .collect(); + info!(%site_name, mounts = ?mount_list, "mounting cache volumes"); + volumes + } + _ => Vec::new(), + }; + + // 4. Execute build — stream output to temp files + let site_log_dir = log_dir.join(site_name); + if let Err(e) = tokio::fs::create_dir_all(&site_log_dir).await { + error!(%site_name, error = %e, "failed to create log directory"); + anyhow::bail!("failed to create log directory: {e}"); + } + let stdout_tmp = site_log_dir.join(format!("{timestamp}-stdout.tmp")); + let stderr_tmp = site_log_dir.join(format!("{timestamp}-stderr.tmp")); + + let env = site.env.clone().unwrap_or_default(); + let timeout = site.build_timeout.unwrap_or(build::BUILD_TIMEOUT_DEFAULT); + let options = build::ContainerOptions { + memory: site.container_memory.clone(), + cpus: site.container_cpus, + pids_limit: site.container_pids_limit, + network: site.container_network.clone(), + workdir: site.container_workdir.clone(), + }; + info!(%site_name, image = %repo_config.image, "running container build"); + let build_result = build::execute( + container_runtime, + &clone_dir, + &repo_config, + &cache_volumes, + &env, + &options, + &stdout_tmp, + &stderr_tmp, + timeout, + verbose, + ) + .await; + + // Determine exit status and extract temp file paths + let (exit_status, build_stdout_file, build_stderr_file, build_duration) = match &build_result { + Ok(result) => ( + BuildExitStatus::Success, + result.stdout_file.clone(), + result.stderr_file.clone(), + result.duration, + ), + Err(e) => e.downcast_ref::<build::BuildFailure>().map_or_else( + || { + ( + BuildExitStatus::Failed { + exit_code: None, + error: e.to_string(), + }, + stdout_tmp.clone(), + stderr_tmp.clone(), + start_time.elapsed(), + ) + }, + |failure| { + ( + BuildExitStatus::Failed { + exit_code: Some(failure.exit_code), + error: failure.to_string(), + }, + failure.stdout_file.clone(), + failure.stderr_file.clone(), + failure.duration, + ) + }, + ), + }; + + // Ensure temp files exist for save_build_log (spawn errors may not create them) + if !build_stdout_file.exists() { + let _ = tokio::fs::File::create(&build_stdout_file).await; + } + if !build_stderr_file.exists() { + let _ = tokio::fs::File::create(&build_stderr_file).await; + } + + // Save build log (always, success or failure) — streams from temp files + let meta = BuildLogMeta { + site_name: site_name.to_owned(), + timestamp: timestamp.clone(), + git_commit: git_commit.clone(), + container_image: repo_config.image.clone(), + duration: build_duration, + exit_status, + }; + + let log_file = + match logs::save_build_log(log_dir, &meta, &build_stdout_file, &build_stderr_file).await { + Ok(path) => path, + Err(e) => { + error!(%site_name, error = %e, "failed to save build log"); + let _ = tokio::fs::remove_file(&build_stdout_file).await; + let _ = tokio::fs::remove_file(&build_stderr_file).await; + // Non-fatal for log save — continue if build succeeded + log_dir.join(site_name).join(format!("{timestamp}.log")) + } + }; + + // If build failed, return error + if let Err(e) = build_result { + error!(%site_name, "build failed"); + return Err(e); + } + + // 5. Publish assets (with same timestamp as log) + info!(%site_name, public = %repo_config.public, "publishing assets"); + let publish_result = publish::publish( + base_dir, + site_name, + &clone_dir, + &repo_config.public, + ×tamp, + ) + .await?; + + info!( + %site_name, + build_dir = %publish_result.build_dir.display(), + timestamp = %publish_result.timestamp, + "deployment completed successfully" + ); + + // 6. Run post-deploy hook (non-fatal) + if let Some(hook_cmd) = &site.post_deploy { + info!(%site_name, "running post-deploy hook"); + let hook_stdout_tmp = site_log_dir.join(format!("{timestamp}-hook-stdout.tmp")); + let hook_stderr_tmp = site_log_dir.join(format!("{timestamp}-hook-stderr.tmp")); + let public_dir = base_dir.join("builds").join(site_name).join("current"); + + let hook_result = hook::run_post_deploy_hook( + hook_cmd, + site_name, + &publish_result.build_dir, + &public_dir, + ×tamp, + &env, + &hook_stdout_tmp, + &hook_stderr_tmp, + ) + .await; + + if let Err(e) = logs::save_hook_log(log_dir, site_name, ×tamp, &hook_result).await { + error!(%site_name, error = %e, "failed to save hook log"); + let _ = tokio::fs::remove_file(&hook_stdout_tmp).await; + let _ = tokio::fs::remove_file(&hook_stderr_tmp).await; + } + + if hook_result.success { + info!(%site_name, "post-deploy hook completed"); + } else { + warn!( + %site_name, + exit_code = ?hook_result.exit_code, + "post-deploy hook failed (non-fatal)" + ); + } + } + + // 7. Cleanup old builds (non-fatal if it fails) + if let Err(e) = + cleanup::cleanup_old_builds(base_dir, log_dir, site_name, max_builds_to_keep).await + { + warn!(%site_name, error = %e, "cleanup failed (non-fatal)"); + } + + let duration = start_time.elapsed(); + Ok(PipelineResult { + build_dir: publish_result.build_dir, + log_file, + timestamp, + duration, + }) +} + +/// Save a build log for errors that occur before the build starts. +async fn save_build_log_for_error( + log_dir: &Path, + site_name: &str, + timestamp: &str, + start_time: Instant, + git_commit: Option<String>, + phase: &str, + error_msg: &str, +) { + let meta = BuildLogMeta { + site_name: site_name.to_owned(), + timestamp: timestamp.to_owned(), + git_commit, + container_image: format!("(failed at {phase})"), + duration: start_time.elapsed(), + exit_status: BuildExitStatus::Failed { + exit_code: None, + error: error_msg.to_owned(), + }, + }; + + let site_log_dir = log_dir.join(site_name); + let _ = tokio::fs::create_dir_all(&site_log_dir).await; + let stdout_tmp = site_log_dir.join(format!("{timestamp}-stdout.tmp")); + let stderr_tmp = site_log_dir.join(format!("{timestamp}-stderr.tmp")); + let _ = tokio::fs::File::create(&stdout_tmp).await; + let _ = tokio::fs::File::create(&stderr_tmp).await; + + if let Err(e) = logs::save_build_log(log_dir, &meta, &stdout_tmp, &stderr_tmp).await { + error!(site_name, error = %e, "failed to save build log"); + let _ = tokio::fs::remove_file(&stdout_tmp).await; + let _ = tokio::fs::remove_file(&stderr_tmp).await; + } +} |
