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 { 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::().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, 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; } }