use crate::config::SiteConfig; use crate::logs::{BuildExitStatus, BuildLogMeta}; use crate::state::BuildEntry; use crate::{build, cleanup, git, hook, logs, publish, repo_config, state}; use anyhow::Result; use log::{error, info, warn}; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime}; /// 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 { let now = SystemTime::now(); let timestamp = crate::time::format_build_timestamp(now); let start_time = Instant::now(); let started_at = crate::time::format_rfc3339(now); let log_path_str = log_dir .join(site_name) .join(format!("{timestamp}.log")) .to_string_lossy() .to_string(); // Write "building" state state::push_build( base_dir, site_name, BuildEntry { status: "building".to_owned(), timestamp: timestamp.clone(), started_at: started_at.clone(), git_commit: String::new(), duration: String::new(), log: log_path_str.clone(), }, ) .await; 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}] git sync failed: {e}"); let duration = start_time.elapsed(); save_build_log_for_error( log_dir, site_name, ×tamp, start_time, None, "git-sync", &e.to_string(), ) .await; update_final_state(base_dir, site_name, "failed", "", duration).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}] failed to load repo config: {e}"); let duration = start_time.elapsed(); save_build_log_for_error( log_dir, site_name, ×tamp, start_time, git_commit.clone(), "config-load", &e.to_string(), ) .await; update_final_state( base_dir, site_name, "failed", git_commit.as_deref().unwrap_or(""), duration, ) .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}] failed to create cache directory: path={} {e}", host_path.display() ); 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}] mounting cache volumes: {mount_list:?}"); 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}] failed to create log directory: {e}"); 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}] running container build: image={}", repo_config.image ); let build_result = Box::pin(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::().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}] failed to save build log: {e}"); 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, run hook (if configured) then return error if let Err(e) = build_result { error!("[{site_name}] build failed"); run_hook_if_configured( site, site_name, &clone_dir, base_dir, log_dir, &site_log_dir, ×tamp, "failed", &env, ) .await; update_final_state( base_dir, site_name, "failed", git_commit.as_deref().unwrap_or(""), start_time.elapsed(), ) .await; return Err(e); } // 5. Publish assets (with same timestamp as log) info!( "[{site_name}] publishing assets: public={}", repo_config.public ); let publish_result = publish::publish( base_dir, site_name, &clone_dir, &repo_config.public, ×tamp, ) .await?; info!( "[{site_name}] deployment completed successfully: build_dir={} timestamp={}", publish_result.build_dir.display(), publish_result.timestamp ); // 6. Run post-deploy hook (non-fatal) let mut final_status = "success"; if let Some(hook_success) = run_hook_if_configured( site, site_name, &publish_result.build_dir, base_dir, log_dir, &site_log_dir, ×tamp, "success", &env, ) .await { if hook_success { info!("[{site_name}] post-deploy hook completed"); } else { warn!("[{site_name}] post-deploy hook failed (non-fatal)"); final_status = "hook failed"; } } // Write final state + set current build update_final_state( base_dir, site_name, final_status, git_commit.as_deref().unwrap_or(""), start_time.elapsed(), ) .await; state::set_current(base_dir, site_name, ×tamp).await; // 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}] cleanup failed (non-fatal): {e}"); } let duration = start_time.elapsed(); Ok(PipelineResult { build_dir: publish_result.build_dir, log_file, timestamp, duration, }) } /// Run the post-deploy hook if configured. Returns `Some(success)` if the hook /// ran, or `None` if no hook is configured. #[allow(clippy::too_many_arguments)] async fn run_hook_if_configured( site: &SiteConfig, site_name: &str, build_dir: &Path, base_dir: &Path, log_dir: &Path, site_log_dir: &Path, timestamp: &str, build_status: &str, env: &HashMap, ) -> Option { let hook_cmd = site.post_deploy.as_ref()?; info!("[{site_name}] running post-deploy hook (build_status={build_status})"); 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 = Box::pin(hook::run_post_deploy_hook( hook_cmd, site_name, build_dir, &public_dir, timestamp, build_status, env, &hook_stdout_tmp, &hook_stderr_tmp, )) .await; if let Err(e) = logs::save_hook_log(log_dir, site_name, timestamp, &hook_result).await { error!("[{site_name}] failed to save hook log: {e}"); let _ = tokio::fs::remove_file(&hook_stdout_tmp).await; let _ = tokio::fs::remove_file(&hook_stderr_tmp).await; } Some(hook_result.success) } /// Update the latest build entry with final status, commit, and duration. Best-effort. async fn update_final_state( base_dir: &Path, site_name: &str, status: &str, git_commit: &str, duration: Duration, ) { let s = status.to_owned(); let c = git_commit.to_owned(); let d = logs::format_duration(duration); state::update_latest_build(base_dir, site_name, |e| { e.status = s; e.git_commit = c; e.duration = d; }) .await; } /// 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, 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}] failed to save build log: {e}"); let _ = tokio::fs::remove_file(&stdout_tmp).await; let _ = tokio::fs::remove_file(&stderr_tmp).await; } }