summaryrefslogtreecommitdiff
path: root/src/main.rs
diff options
context:
space:
mode:
authorDawid Rycerz <dawid@rycerz.xyz>2026-01-22 22:07:32 +0100
committerDawid Rycerz <dawid@rycerz.xyz>2026-02-10 18:44:26 +0100
commit064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (patch)
treea2023f9ccd297ed8a41a3a0cc5699c2add09244d /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.rs422
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"));
+ }
+}