diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-01-22 22:07:32 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-10 18:44:26 +0100 |
| commit | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch) | |
| tree | a2023f9ccd297ed8a41a3a0cc5699c2add09244d /src/main.rs | |
witryna 0.1.0 — initial releasev0.1.0
Minimalist Git-based static site deployment orchestrator.
Webhook-triggered builds in Podman/Docker containers with atomic
symlink publishing, SIGHUP hot-reload, and zero-downtime deploys.
See README.md for usage, CHANGELOG.md for details.
Diffstat (limited to 'src/main.rs')
| -rw-r--r-- | src/main.rs | 422 |
1 files changed, 422 insertions, 0 deletions
diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b153297 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,422 @@ +use anyhow::{Context as _, Result, bail}; +use clap::Parser as _; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; +use witryna::cli::{Cli, Command}; +use witryna::config; +use witryna::logs::{self, DeploymentStatus}; +use witryna::{pipeline, server}; + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + let config_path = config::discover_config(cli.config.as_deref())?; + + 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, + } +} + +async fn run_serve(config_path: std::path::PathBuf) -> Result<()> { + let config = config::Config::load(&config_path).await?; + + // Initialize tracing 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(); + + 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" + ); + + for site in &config.sites { + if site.webhook_token.is_empty() { + warn!( + name = %site.name, + "webhook authentication disabled (no token configured)" + ); + } + 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" + ); + } else { + info!( + name = %site.name, + repo_url = %site.repo_url, + branch = %site.branch, + "configured site (webhook-only)" + ); + } + } + + 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 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(); + + 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 = 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<String>, + 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 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); + } + } + 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(), + }); + } + } + } + } + + 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(()) +} + +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, + "{:<site_width$} {:<11} {:<7} {:<8} {:<24} LOG", + "SITE", "STATUS", "COMMIT", "DURATION", "TIMESTAMP" + ); + + for s in statuses { + let _ = writeln!( + out, + "{:<site_width$} {:<11} {:<7} {:<8} {:<24} {}", + s.site_name, s.status, s.git_commit, s.duration, s.timestamp, s.log + ); + } + + out +} + +fn format_validate_summary(config: &config::Config, path: &std::path::Path) -> 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<SiteConfig>) -> 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(), + rate_limit_per_minute: 10, + 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(), + } + } + + #[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")); + } +} |
