use anyhow::{Context as _, Result, bail}; use log::{info, warn}; use witryna::cli::{Cli, Command}; use witryna::config; use witryna::logs::{self, DeploymentStatus}; use witryna::state::BuildEntry; use witryna::{cleanup, pipeline, publish, server, state}; #[tokio::main] async fn main() -> Result<()> { 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(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 logger with configured log level // RUST_LOG env var takes precedence if set witryna::logger::Logger::init(config.log_level_filter()); info!( "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!( "[{}] webhook authentication disabled (no token configured)", site.name, ); } if let Some(interval) = site.poll_interval { info!( "[{}] configured site with polling: repo={} branch={} poll_interval_secs={}", site.name, site.repo_url, site.branch, interval.as_secs(), ); } else { info!( "[{}] configured site (webhook-only): repo={} branch={}", site.name, site.repo_url, site.branch, ); } } server::run(config, config_path).await } #[allow(clippy::print_stderr)] // CLI validation output goes to stderr async fn run_validate(config_path: std::path::PathBuf) -> Result<()> { let config = config::Config::load(&config_path).await?; eprintln!("{}", format_validate_summary(&config, &config_path)); Ok(()) } #[allow(clippy::print_stderr)] // CLI output goes to stderr async fn run_run(config_path: std::path::PathBuf, site_name: String, verbose: bool) -> Result<()> { let config = config::Config::load(&config_path).await?; let site = config .find_site(&site_name) .with_context(|| { format!( "site '{}' not found in {}", site_name, config_path.display() ) })? .clone(); // 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: {})", site_name, site.repo_url, site.branch ); let git_timeout = config .git_timeout .unwrap_or(witryna::git::GIT_TIMEOUT_DEFAULT); let result = Box::pin(pipeline::run_build( &site_name, &site, &config.base_dir, &config.log_dir, &config.container_runtime, config.max_builds_to_keep, git_timeout, verbose, )) .await?; eprintln!( "Build succeeded in {} — {}", logs::format_duration(result.duration), result.build_dir.display(), ); eprintln!("Log: {}", result.log_file.display()); Ok(()) } #[allow(clippy::print_stdout)] // CLI status output goes to stdout (pipeable) async fn run_status( config_path: std::path::PathBuf, site_filter: Option, json: bool, ) -> 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()); } 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 = Vec::new(); 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; } // Single-site filter: show all builds. Overview: show only latest. let builds = if site_filter.is_some() { st.builds.iter().collect::>() } else { st.builds.iter().take(1).collect::>() }; for entry in builds { statuses.push(build_entry_to_status(site_name, entry, &st.current)); } } if json { #[allow(clippy::expect_used)] // DeploymentStatus serialization cannot fail let output = serde_json::to_string_pretty(&statuses) .expect("DeploymentStatus serialization cannot fail"); println!("{output}"); } else { print!("{}", format_status_table(&statuses)); } 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, keep: Option, ) -> 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 _; let site_width = statuses .iter() .map(|s| s.site_name.len()) .max() .unwrap_or(4) .max(4); let mut out = String::new(); let _ = writeln!( out, " {: String { use std::fmt::Write as _; let mut out = String::new(); let _ = writeln!(out, "Configuration valid: {}", path.display()); let _ = writeln!(out, " Listen: {}", config.listen_address); let _ = writeln!(out, " Runtime: {}", config.container_runtime); let _ = write!(out, " Sites: {}", config.sites.len()); for site in &config.sites { let _ = write!( out, "\n - {} ({}, branch: {})", site.name, site.repo_url, site.branch ); } out } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; use std::path::PathBuf; use witryna::config::{BuildOverrides, Config, SiteConfig}; use witryna::logs::DeploymentStatus; fn test_config(sites: Vec) -> Config { Config { listen_address: "127.0.0.1:8080".to_owned(), container_runtime: "podman".to_owned(), base_dir: PathBuf::from("/var/lib/witryna"), log_dir: PathBuf::from("/var/log/witryna"), log_level: "info".to_owned(), max_builds_to_keep: 5, git_timeout: None, sites, } } fn test_site(name: &str, repo_url: &str, branch: &str) -> SiteConfig { SiteConfig { name: name.to_owned(), repo_url: repo_url.to_owned(), branch: branch.to_owned(), webhook_token: "token".to_owned(), webhook_token_file: None, build_overrides: BuildOverrides::default(), poll_interval: None, build_timeout: None, cache_dirs: None, post_deploy: None, env: None, container_memory: None, container_cpus: None, container_pids_limit: None, container_network: "none".to_owned(), git_depth: None, container_workdir: None, config_file: None, } } #[test] fn validate_summary_single_site() { let config = test_config(vec![test_site( "my-site", "https://github.com/user/my-site.git", "main", )]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Configuration valid: witryna.toml")); assert!(output.contains("Listen: 127.0.0.1:8080")); assert!(output.contains("Runtime: podman")); assert!(output.contains("Sites: 1")); assert!(output.contains("my-site (https://github.com/user/my-site.git, branch: main)")); } #[test] fn validate_summary_multiple_sites() { let config = test_config(vec![ test_site("site-one", "https://github.com/user/site-one.git", "main"), test_site( "site-two", "https://github.com/user/site-two.git", "develop", ), ]); let output = format_validate_summary(&config, &PathBuf::from("/etc/witryna.toml")); assert!(output.contains("Sites: 2")); assert!(output.contains("site-one (https://github.com/user/site-one.git, branch: main)")); assert!( output.contains("site-two (https://github.com/user/site-two.git, branch: develop)") ); } #[test] fn validate_summary_no_sites() { let config = test_config(vec![]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Sites: 0")); assert!(!output.contains(" -")); } #[test] fn validate_summary_runtime_shows_value() { let config = test_config(vec![]); let output = format_validate_summary(&config, &PathBuf::from("witryna.toml")); assert!(output.contains("Runtime: podman")); } // --- format_status_table tests --- fn test_deployment( site_name: &str, status: &str, commit: &str, duration: &str, timestamp: &str, log: &str, ) -> DeploymentStatus { DeploymentStatus { site_name: site_name.to_owned(), timestamp: timestamp.to_owned(), git_commit: commit.to_owned(), duration: duration.to_owned(), status: status.to_owned(), log: log.to_owned(), current_build: String::new(), } } #[test] fn format_status_table_single_site_success() { let statuses = vec![test_deployment( "my-site", "success", "abc123d", "45s", "20260126-143000-123456", "/var/log/witryna/my-site/20260126-143000-123456.log", )]; let output = format_status_table(&statuses); assert!(output.contains("SITE")); assert!(output.contains("STATUS")); assert!(output.contains("my-site")); assert!(output.contains("success")); assert!(output.contains("abc123d")); assert!(output.contains("45s")); } #[test] fn format_status_table_no_builds() { let statuses = vec![test_deployment( "empty-site", "-", "-", "-", "-", "(no builds)", )]; let output = format_status_table(&statuses); assert!(output.contains("empty-site")); assert!(output.contains("(no builds)")); } #[test] fn format_status_table_multiple_sites() { let statuses = vec![ test_deployment( "site-one", "success", "abc123d", "45s", "20260126-143000-123456", "/logs/site-one/20260126-143000-123456.log", ), test_deployment( "site-two", "failed", "def456", "2m 0s", "20260126-160000-000000", "/logs/site-two/20260126-160000-000000.log", ), ]; let output = format_status_table(&statuses); assert!(output.contains("site-one")); assert!(output.contains("site-two")); assert!(output.contains("success")); assert!(output.contains("failed")); } #[test] fn format_status_table_hook_failed() { let statuses = vec![test_deployment( "hook-site", "hook failed", "abc123d", "12s", "20260126-143000-123456", "/logs/hook-site/20260126-143000-123456.log", )]; 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] ); } }