summaryrefslogtreecommitdiff
path: root/src/pipeline.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/pipeline.rs')
-rw-r--r--src/pipeline.rs328
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,
+ &timestamp,
+ 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,
+ &timestamp,
+ 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,
+ &timestamp,
+ )
+ .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,
+ &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");
+ } 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;
+ }
+}