diff options
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 370 |
1 files changed, 299 insertions, 71 deletions
diff --git a/src/main.rs b/src/main.rs index b153297..ea0b033 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,65 +1,62 @@ use anyhow::{Context as _, Result, bail}; -use clap::Parser as _; -use tracing::{info, warn}; -use tracing_subscriber::EnvFilter; +use log::{info, warn}; use witryna::cli::{Cli, Command}; use witryna::config; use witryna::logs::{self, DeploymentStatus}; -use witryna::{pipeline, server}; +use witryna::state::BuildEntry; +use witryna::{cleanup, pipeline, publish, server, state}; #[tokio::main] async fn main() -> Result<()> { - let cli = Cli::parse(); - let config_path = config::discover_config(cli.config.as_deref())?; + let cli: Cli = argh::from_env(); + let config_path = config::discover_config(cli.command.config())?; match cli.command { - Command::Serve => run_serve(config_path).await, - Command::Validate => run_validate(config_path).await, - Command::Run { site, verbose } => run_run(config_path, site, verbose).await, - Command::Status { site, json } => run_status(config_path, site, json).await, + Command::Serve(_) => run_serve(config_path).await, + Command::Validate(_) => run_validate(config_path).await, + Command::Run(cmd) => Box::pin(run_run(config_path, cmd.site, cmd.verbose)).await, + Command::Status(cmd) => run_status(config_path, cmd.site, cmd.json).await, + Command::Switch(cmd) => run_switch(config_path, cmd.site, cmd.build).await, + Command::Cleanup(cmd) => run_cleanup(config_path, cmd.site, cmd.keep).await, } } async fn run_serve(config_path: std::path::PathBuf) -> Result<()> { let config = config::Config::load(&config_path).await?; - // Initialize tracing with configured log level + // Initialize logger with configured log level // RUST_LOG env var takes precedence if set - let filter = EnvFilter::try_from_default_env() - .unwrap_or_else(|_| EnvFilter::new(config.log_level_filter().to_string())); - tracing_subscriber::fmt().with_env_filter(filter).init(); + witryna::logger::Logger::init(config.log_level_filter()); info!( - listen_address = %config.listen_address, - container_runtime = %config.container_runtime, - base_dir = %config.base_dir.display(), - log_dir = %config.log_dir.display(), - log_level = %config.log_level, - sites_count = config.sites.len(), - "loaded configuration" + "loaded configuration: listen={} runtime={} base_dir={} log_dir={} log_level={} sites={}", + config.listen_address, + config.container_runtime, + config.base_dir.display(), + config.log_dir.display(), + config.log_level, + config.sites.len(), ); for site in &config.sites { if site.webhook_token.is_empty() { warn!( - name = %site.name, - "webhook authentication disabled (no token configured)" + "[{}] webhook authentication disabled (no token configured)", + site.name, ); } if let Some(interval) = site.poll_interval { info!( - name = %site.name, - repo_url = %site.repo_url, - branch = %site.branch, - poll_interval_secs = interval.as_secs(), - "configured site with polling" + "[{}] configured site with polling: repo={} branch={} poll_interval_secs={}", + site.name, + site.repo_url, + site.branch, + interval.as_secs(), ); } else { info!( - name = %site.name, - repo_url = %site.repo_url, - branch = %site.branch, - "configured site (webhook-only)" + "[{}] configured site (webhook-only): repo={} branch={}", + site.name, site.repo_url, site.branch, ); } } @@ -89,13 +86,13 @@ async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bo })? .clone(); - // Initialize tracing: compact stderr, DEBUG when verbose - let level = if verbose { "debug" } else { "info" }; - let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); - tracing_subscriber::fmt() - .with_env_filter(filter) - .with_writer(std::io::stderr) - .init(); + // Initialize logger: DEBUG when verbose + let level = if verbose { + log::LevelFilter::Debug + } else { + log::LevelFilter::Info + }; + witryna::logger::Logger::init(level); eprintln!( "Building site: {} (repo: {}, branch: {})", @@ -106,7 +103,7 @@ async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bo .git_timeout .unwrap_or(witryna::git::GIT_TIMEOUT_DEFAULT); - let result = pipeline::run_build( + let result = Box::pin(pipeline::run_build( &site_name, &site, &config.base_dir, @@ -115,7 +112,7 @@ async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bo config.max_builds_to_keep, git_timeout, verbose, - ) + )) .await?; eprintln!( @@ -142,36 +139,38 @@ async fn run_status( bail!("site '{}' not found in {}", name, config_path.display()); } + let sites: Vec<&str> = match &site_filter { + Some(name) => vec![name.as_str()], + None => config.sites.iter().map(|s| s.name.as_str()).collect(), + }; + let mut statuses: Vec<DeploymentStatus> = Vec::new(); - match &site_filter { - Some(name) => { - // Show last 10 deployments for a single site - let site_logs = logs::list_site_logs(&config.log_dir, name).await?; - for (ts, path) in site_logs.into_iter().take(10) { - let ds = logs::get_deployment_status(&config.log_dir, name, &ts, &path).await?; - statuses.push(ds); - } + for site_name in &sites { + let st = state::load_state(&config.base_dir, site_name).await; + + if st.builds.is_empty() { + statuses.push(DeploymentStatus { + site_name: (*site_name).to_owned(), + timestamp: "-".to_owned(), + git_commit: "-".to_owned(), + duration: "-".to_owned(), + status: "-".to_owned(), + log: "(no builds)".to_owned(), + current_build: String::new(), + }); + continue; } - None => { - // Show latest deployment for each site - for site in &config.sites { - let site_logs = logs::list_site_logs(&config.log_dir, &site.name).await?; - if let Some((ts, path)) = site_logs.into_iter().next() { - let ds = logs::get_deployment_status(&config.log_dir, &site.name, &ts, &path) - .await?; - statuses.push(ds); - } else { - statuses.push(DeploymentStatus { - site_name: site.name.clone(), - timestamp: "-".to_owned(), - git_commit: "-".to_owned(), - duration: "-".to_owned(), - status: "-".to_owned(), - log: "(no builds)".to_owned(), - }); - } - } + + // Single-site filter: show all builds. Overview: show only latest. + let builds = if site_filter.is_some() { + st.builds.iter().collect::<Vec<_>>() + } else { + st.builds.iter().take(1).collect::<Vec<_>>() + }; + + for entry in builds { + statuses.push(build_entry_to_status(site_name, entry, &st.current)); } } @@ -187,6 +186,153 @@ async fn run_status( Ok(()) } +#[allow(clippy::print_stderr)] // CLI output goes to stderr +async fn run_switch( + config_path: std::path::PathBuf, + site_name: String, + build_timestamp: String, +) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + if config.find_site(&site_name).is_none() { + bail!( + "site '{}' not found in {}", + site_name, + config_path.display() + ); + } + + let builds_dir = config.base_dir.join("builds").join(&site_name); + + if !builds_dir.exists() { + bail!("no builds found for site '{site_name}'"); + } + + if !cleanup::looks_like_timestamp(&build_timestamp) { + bail!("'{build_timestamp}' is not a valid build timestamp"); + } + + let build_dir = builds_dir.join(&build_timestamp); + if !build_dir.is_dir() { + let available = cleanup::list_build_timestamps(&builds_dir).await?; + if available.is_empty() { + bail!("no builds found for site '{site_name}'"); + } + let mut sorted = available; + sorted.sort_by(|a, b| b.cmp(a)); + bail!( + "build '{}' not found for site '{}'\navailable builds:\n {}", + build_timestamp, + site_name, + sorted.join("\n ") + ); + } + + let current_link = builds_dir.join("current"); + publish::atomic_symlink_update(&build_dir, ¤t_link).await?; + state::set_current(&config.base_dir, &site_name, &build_timestamp).await; + + eprintln!("switched {site_name} to build {build_timestamp}"); + Ok(()) +} + +#[allow(clippy::print_stderr)] // CLI output goes to stderr +async fn run_cleanup( + config_path: std::path::PathBuf, + site_filter: Option<String>, + keep: Option<u32>, +) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + if let Some(name) = &site_filter + && config.find_site(name).is_none() + { + bail!("site '{}' not found in {}", name, config_path.display()); + } + + if keep == Some(0) { + bail!("--keep 0 would delete all builds; refusing"); + } + + let max_to_keep = keep.unwrap_or(config.max_builds_to_keep); + + if max_to_keep == 0 { + eprintln!("cleanup disabled (max_builds_to_keep is 0; use --keep N to override)"); + return Ok(()); + } + + let sites: Vec<&str> = match &site_filter { + Some(name) => vec![name.as_str()], + None => config.sites.iter().map(|s| s.name.as_str()).collect(), + }; + + let mut total_builds: u32 = 0; + let mut total_logs: u32 = 0; + + for site_name in &sites { + let result = + cleanup::cleanup_old_builds(&config.base_dir, &config.log_dir, site_name, max_to_keep) + .await + .with_context(|| format!("cleanup failed for site '{site_name}'"))?; + + if result.builds_removed > 0 || result.logs_removed > 0 { + eprintln!( + "{site_name}: removed {} build(s), {} log(s)", + result.builds_removed, result.logs_removed + ); + } else { + eprintln!("{site_name}: nothing to clean"); + } + + total_builds += result.builds_removed; + total_logs += result.logs_removed; + } + + if sites.len() > 1 { + eprintln!("total: {total_builds} build(s), {total_logs} log(s) removed"); + } + + Ok(()) +} + +/// Convert a `BuildEntry` to a `DeploymentStatus` for display. +/// +/// For "building" entries, computes elapsed time from `started_at`. +fn build_entry_to_status(site_name: &str, entry: &BuildEntry, current: &str) -> DeploymentStatus { + let duration = if entry.status == "building" { + elapsed_since(&entry.started_at) + } else { + entry.duration.clone() + }; + + let git_commit = if entry.git_commit.is_empty() { + "-".to_owned() + } else { + entry.git_commit.clone() + }; + + DeploymentStatus { + site_name: site_name.to_owned(), + timestamp: entry.timestamp.clone(), + git_commit, + duration, + status: entry.status.clone(), + log: entry.log.clone(), + current_build: current.to_owned(), + } +} + +/// Compute human-readable elapsed time from an ISO 8601 timestamp. +fn elapsed_since(started_at: &str) -> String { + let Some(start) = witryna::time::parse_rfc3339(started_at) else { + return "-".to_owned(); + }; + let Ok(elapsed) = start.elapsed() else { + return "-".to_owned(); + }; + logs::format_duration(elapsed) +} + fn format_status_table(statuses: &[DeploymentStatus]) -> String { use std::fmt::Write as _; @@ -200,14 +346,19 @@ fn format_status_table(statuses: &[DeploymentStatus]) -> String { let mut out = String::new(); let _ = writeln!( out, - "{:<site_width$} {:<11} {:<7} {:<8} {:<24} LOG", + " {:<site_width$} {:<11} {:<7} {:<8} {:<24} LOG", "SITE", "STATUS", "COMMIT", "DURATION", "TIMESTAMP" ); for s in statuses { + let marker = if !s.current_build.is_empty() && s.timestamp == s.current_build { + "+" + } else { + " " + }; let _ = writeln!( out, - "{:<site_width$} {:<11} {:<7} {:<8} {:<24} {}", + "{marker} {:<site_width$} {:<11} {:<7} {:<8} {:<24} {}", s.site_name, s.status, s.git_commit, s.duration, s.timestamp, s.log ); } @@ -247,7 +398,6 @@ mod tests { base_dir: PathBuf::from("/var/lib/witryna"), log_dir: PathBuf::from("/var/log/witryna"), log_level: "info".to_owned(), - rate_limit_per_minute: 10, max_builds_to_keep: 5, git_timeout: None, sites, @@ -342,6 +492,7 @@ mod tests { duration: duration.to_owned(), status: status.to_owned(), log: log.to_owned(), + current_build: String::new(), } } @@ -419,4 +570,81 @@ mod tests { let output = format_status_table(&statuses); assert!(output.contains("hook failed")); } + + #[test] + fn format_status_table_current_build_marker() { + let mut ds = test_deployment( + "my-site", + "success", + "abc123d", + "45s", + "20260126-143000-123456", + "/logs/my-site/20260126-143000-123456.log", + ); + ds.current_build = "20260126-143000-123456".to_owned(); + let output = format_status_table(&[ds]); + + // The matching row should start with "+" + let data_line = output.lines().nth(1).unwrap(); + assert!( + data_line.starts_with('+'), + "row should start with '+', got: {data_line}" + ); + } + + #[test] + fn format_status_table_no_marker_when_no_current() { + let ds = test_deployment( + "my-site", + "success", + "abc123d", + "45s", + "20260126-143000-123456", + "/logs/my-site/20260126-143000-123456.log", + ); + let output = format_status_table(&[ds]); + + let data_line = output.lines().nth(1).unwrap(); + assert!( + data_line.starts_with(' '), + "row should start with space when no current_build, got: {data_line}" + ); + } + + #[test] + fn format_status_table_marker_only_on_matching_row() { + let mut ds1 = test_deployment( + "my-site", + "success", + "abc123d", + "45s", + "20260126-143000-123456", + "/logs/1.log", + ); + ds1.current_build = "20260126-143000-123456".to_owned(); + + let mut ds2 = test_deployment( + "my-site", + "failed", + "def4567", + "30s", + "20260126-150000-000000", + "/logs/2.log", + ); + ds2.current_build = "20260126-143000-123456".to_owned(); + + let output = format_status_table(&[ds1, ds2]); + let lines: Vec<&str> = output.lines().collect(); + + assert!( + lines[1].starts_with('+'), + "matching row should have +: {}", + lines[1] + ); + assert!( + lines[2].starts_with(' '), + "non-matching row should have space: {}", + lines[2] + ); + } } |
