From ce0dbf6b249956700c6a1705bf4ad85a09d53e8c Mon Sep 17 00:00:00 2001 From: Dawid Rycerz Date: Sun, 15 Feb 2026 21:27:00 +0100 Subject: feat: witryna 0.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch, cleanup, and status CLI commands. Persistent build state via state.json. Post-deploy hooks on success and failure with WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh, tracing→log). Drop built-in rate limiting. Nix flake with NixOS module. Arch Linux PKGBUILD. Centralized version management. Co-Authored-By: Claude Opus 4.6 --- src/pipeline.rs | 234 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 178 insertions(+), 56 deletions(-) (limited to 'src/pipeline.rs') diff --git a/src/pipeline.rs b/src/pipeline.rs index 5827ad7..21857a8 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,11 +1,12 @@ use crate::config::SiteConfig; use crate::logs::{BuildExitStatus, BuildLogMeta}; -use crate::{build, cleanup, git, hook, logs, publish, repo_config}; +use crate::state::BuildEntry; +use crate::{build, cleanup, git, hook, logs, publish, repo_config, state}; use anyhow::Result; -use chrono::Utc; +use log::{error, info, warn}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; -use tracing::{error, info, warn}; +use std::time::{Duration, Instant, SystemTime}; /// Result of a successful pipeline run. pub struct PipelineResult { @@ -37,13 +38,35 @@ pub async fn run_build( git_timeout: Duration, verbose: bool, ) -> Result { - let timestamp = Utc::now().format("%Y%m%d-%H%M%S-%f").to_string(); + 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"); + info!("[{site_name}] syncing repository"); if let Err(e) = git::sync_repo( &site.repo_url, &site.branch, @@ -53,7 +76,8 @@ pub async fn run_build( ) .await { - error!(%site_name, error = %e, "git sync failed"); + error!("[{site_name}] git sync failed: {e}"); + let duration = start_time.elapsed(); save_build_log_for_error( log_dir, site_name, @@ -64,6 +88,7 @@ pub async fn run_build( &e.to_string(), ) .await; + update_final_state(base_dir, site_name, "failed", "", duration).await; return Err(e.context("git sync failed")); } @@ -80,17 +105,26 @@ pub async fn run_build( { Ok(config) => config, Err(e) => { - error!(%site_name, error = %e, "failed to load repo config"); + 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, + 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")); } }; @@ -103,7 +137,10 @@ pub async fn run_build( 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"); + 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)); @@ -112,7 +149,7 @@ pub async fn run_build( .iter() .map(|(c, h)| format!("{}:{}", h.display(), c)) .collect(); - info!(%site_name, mounts = ?mount_list, "mounting cache volumes"); + info!("[{site_name}] mounting cache volumes: {mount_list:?}"); volumes } _ => Vec::new(), @@ -121,7 +158,7 @@ pub async fn run_build( // 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"); + 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")); @@ -136,8 +173,11 @@ pub async fn run_build( 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( + info!( + "[{site_name}] running container build: image={}", + repo_config.image + ); + let build_result = Box::pin(build::execute( container_runtime, &clone_dir, &repo_config, @@ -148,7 +188,7 @@ pub async fn run_build( &stderr_tmp, timeout, verbose, - ) + )) .await; // Determine exit status and extract temp file paths @@ -207,7 +247,7 @@ pub async fn run_build( 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"); + 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 @@ -215,14 +255,37 @@ pub async fn run_build( } }; - // If build failed, return error + // If build failed, run hook (if configured) then return error if let Err(e) = build_result { - error!(%site_name, "build failed"); + 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, public = %repo_config.public, "publishing assets"); + info!( + "[{site_name}] publishing assets: public={}", + repo_config.public + ); let publish_result = publish::publish( base_dir, site_name, @@ -233,53 +296,50 @@ pub async fn run_build( .await?; info!( - %site_name, - build_dir = %publish_result.build_dir.display(), - timestamp = %publish_result.timestamp, - "deployment completed successfully" + "[{site_name}] deployment completed successfully: build_dir={} timestamp={}", + publish_result.build_dir.display(), + publish_result.timestamp ); // 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"); + 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, - exit_code = ?hook_result.exit_code, - "post-deploy hook failed (non-fatal)" - ); + 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, error = %e, "cleanup failed (non-fatal)"); + warn!("[{site_name}] cleanup failed (non-fatal): {e}"); } let duration = start_time.elapsed(); @@ -291,6 +351,68 @@ pub async fn run_build( }) } +/// 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, @@ -321,7 +443,7 @@ async fn save_build_log_for_error( 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"); + 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; } -- cgit v1.2.3