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