summaryrefslogtreecommitdiff
path: root/src/pipeline.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-15 21:27:00 +0100
commitce0dbf6b249956700c6a1705bf4ad85a09d53e8c (patch)
treed7c3236807cfbf75d7f3a355eb5df5a5e2cc4ad7 /src/pipeline.rs
parent064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (diff)
feat: witryna 0.2.0HEADv0.2.0main
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 <noreply@anthropic.com>
Diffstat (limited to 'src/pipeline.rs')
-rw-r--r--src/pipeline.rs234
1 files changed, 178 insertions, 56 deletions
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<PipelineResult> {
- 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,
&timestamp,
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,
+ &timestamp,
+ "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,
- &timestamp,
- &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, 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,
+ &timestamp,
+ "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, &timestamp).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<String, String>,
+) -> Option<bool> {
+ 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;
}