diff options
| author | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-15 21:27:00 +0100 |
|---|---|---|
| committer | Dawid Rycerz <dawid@rycerz.xyz> | 2026-02-15 21:27:00 +0100 |
| commit | ce0dbf6b249956700c6a1705bf4ad85a09d53e8c (patch) | |
| tree | d7c3236807cfbf75d7f3a355eb5df5a5e2cc4ad7 /src/state.rs | |
| parent | 064a1d01c5c14f5ecc032fa9b8346a4a88b893f6 (diff) | |
Switch, cleanup, and status CLI commands. Persistent build state via
state.json. Post-deploy hooks on success and failure with
WITRYNA_BUILD_STATUS. Dependency diet (axum→tiny_http, clap→argh,
tracing→log). Drop built-in rate limiting. Nix flake with NixOS module.
Arch Linux PKGBUILD. Centralized version management.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'src/state.rs')
| -rw-r--r-- | src/state.rs | 311 |
1 files changed, 311 insertions, 0 deletions
diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..be4e981 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,311 @@ +use anyhow::Result; +use log::warn; +use std::path::Path; + +/// A single build record within the site state. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BuildEntry { + /// Build phase: "building", "success", "failed", "hook failed". + pub status: String, + /// Build timestamp (YYYYMMDD-HHMMSS-microseconds). + pub timestamp: String, + /// ISO 8601 UTC when the build started (for elapsed time calculation). + pub started_at: String, + /// Short git commit hash, or empty string if unknown. + pub git_commit: String, + /// Human-readable duration ("45s", "2m 30s"), empty while building. + pub duration: String, + /// Path to the build log file. + pub log: String, +} + +/// Persistent per-site build state, written to `{base_dir}/builds/{site}/state.json`. +/// +/// Contains the full build history and the currently active build timestamp. +/// The CLI `status` command reads only this file — no log parsing needed. +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] +pub struct SiteState { + /// Timestamp of the currently active build (empty if none). + pub current: String, + /// All builds, newest first. + pub builds: Vec<BuildEntry>, +} + +/// Atomically write site state to `{base_dir}/builds/{site_name}/state.json`. +/// +/// Uses temp-file + rename for atomic writes. Creates parent directories +/// if they don't exist. Errors are non-fatal — callers should log and continue. +/// +/// # Errors +/// +/// Returns an error if directory creation, JSON serialization, or the atomic write/rename fails. +pub async fn save_state(base_dir: &Path, site_name: &str, state: &SiteState) -> Result<()> { + let builds_dir = base_dir.join("builds").join(site_name); + tokio::fs::create_dir_all(&builds_dir).await?; + + let state_path = builds_dir.join("state.json"); + let tmp_path = builds_dir.join("state.json.tmp"); + + let json = serde_json::to_string_pretty(state)?; + tokio::fs::write(&tmp_path, json.as_bytes()).await?; + tokio::fs::rename(&tmp_path, &state_path).await?; + + Ok(()) +} + +/// Load site state from `{base_dir}/builds/{site_name}/state.json`. +/// +/// Returns the default empty state if the file is missing or cannot be parsed. +pub async fn load_state(base_dir: &Path, site_name: &str) -> SiteState { + let state_path = base_dir.join("builds").join(site_name).join("state.json"); + + let Ok(content) = tokio::fs::read_to_string(&state_path).await else { + return SiteState::default(); + }; + + match serde_json::from_str(&content) { + Ok(state) => state, + Err(e) => { + warn!( + "[{site_name}] malformed state.json: {e} (path={})", + state_path.display() + ); + SiteState::default() + } + } +} + +/// Add a new build entry to the front of the builds list. Best-effort. +pub async fn push_build(base_dir: &Path, site_name: &str, entry: BuildEntry) { + let mut state = load_state(base_dir, site_name).await; + state.builds.insert(0, entry); + if let Err(e) = save_state(base_dir, site_name, &state).await { + warn!("[{site_name}] failed to write state after push_build: {e}"); + } +} + +/// Update the most recent build entry in-place. Best-effort. +/// +/// Does nothing if the builds list is empty. +pub async fn update_latest_build( + base_dir: &Path, + site_name: &str, + updater: impl FnOnce(&mut BuildEntry), +) { + let mut state = load_state(base_dir, site_name).await; + if let Some(entry) = state.builds.first_mut() { + updater(entry); + if let Err(e) = save_state(base_dir, site_name, &state).await { + warn!("[{site_name}] failed to write state after update_latest_build: {e}"); + } + } +} + +/// Set the currently active build timestamp. Best-effort. +pub async fn set_current(base_dir: &Path, site_name: &str, timestamp: &str) { + let mut state = load_state(base_dir, site_name).await; + state.current = timestamp.to_owned(); + if let Err(e) = save_state(base_dir, site_name, &state).await { + warn!("[{site_name}] failed to write state after set_current: {e}"); + } +} + +/// Remove build entries whose timestamps match any in `timestamps`. Best-effort. +pub async fn remove_builds(base_dir: &Path, site_name: &str, timestamps: &[String]) { + if timestamps.is_empty() { + return; + } + let mut state = load_state(base_dir, site_name).await; + let before = state.builds.len(); + state.builds.retain(|b| !timestamps.contains(&b.timestamp)); + if state.builds.len() == before { + return; // nothing changed + } + if let Err(e) = save_state(base_dir, site_name, &state).await { + warn!("[{site_name}] failed to write state after remove_builds: {e}"); + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::test_support::{cleanup, temp_dir}; + + fn test_entry() -> BuildEntry { + BuildEntry { + status: "building".to_owned(), + timestamp: "20260210-120000-000000".to_owned(), + started_at: "2026-02-10T12:00:00Z".to_owned(), + git_commit: "abc123d".to_owned(), + duration: String::new(), + log: "/var/log/witryna/my-site/20260210-120000-000000.log".to_owned(), + } + } + + fn test_state() -> SiteState { + SiteState { + current: String::new(), + builds: vec![test_entry()], + } + } + + #[tokio::test] + async fn save_and_load_roundtrip() { + let base_dir = temp_dir("state-test").await; + let state = test_state(); + + save_state(&base_dir, "my-site", &state).await.unwrap(); + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.builds.len(), 1); + let b = &loaded.builds[0]; + assert_eq!(b.status, "building"); + assert_eq!(b.timestamp, "20260210-120000-000000"); + assert_eq!(b.started_at, "2026-02-10T12:00:00Z"); + assert_eq!(b.git_commit, "abc123d"); + assert_eq!(b.duration, ""); + assert!(b.log.contains("20260210-120000-000000.log")); + assert_eq!(loaded.current, ""); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn load_state_missing_file_returns_default() { + let base_dir = temp_dir("state-test").await; + + let loaded = load_state(&base_dir, "nonexistent").await; + assert!(loaded.builds.is_empty()); + assert_eq!(loaded.current, ""); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn load_state_malformed_json_returns_default() { + let base_dir = temp_dir("state-test").await; + let state_dir = base_dir.join("builds").join("bad-site"); + tokio::fs::create_dir_all(&state_dir).await.unwrap(); + tokio::fs::write(state_dir.join("state.json"), "not valid json{{{") + .await + .unwrap(); + + let loaded = load_state(&base_dir, "bad-site").await; + assert!(loaded.builds.is_empty()); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn save_state_atomic_no_tmp_left() { + let base_dir = temp_dir("state-test").await; + let state = test_state(); + + save_state(&base_dir, "my-site", &state).await.unwrap(); + + let tmp_path = base_dir + .join("builds") + .join("my-site") + .join("state.json.tmp"); + assert!(!tmp_path.exists(), "temp file should not remain"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn push_build_prepends() { + let base_dir = temp_dir("state-test").await; + + let entry1 = test_entry(); + push_build(&base_dir, "my-site", entry1).await; + + let mut entry2 = test_entry(); + entry2.timestamp = "20260210-130000-000000".to_owned(); + push_build(&base_dir, "my-site", entry2).await; + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.builds.len(), 2); + assert_eq!(loaded.builds[0].timestamp, "20260210-130000-000000"); + assert_eq!(loaded.builds[1].timestamp, "20260210-120000-000000"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn update_latest_build_modifies_first() { + let base_dir = temp_dir("state-test").await; + + push_build(&base_dir, "my-site", test_entry()).await; + + update_latest_build(&base_dir, "my-site", |e| { + e.status = "success".to_owned(); + e.duration = "30s".to_owned(); + }) + .await; + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.builds[0].status, "success"); + assert_eq!(loaded.builds[0].duration, "30s"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn set_current_updates_field() { + let base_dir = temp_dir("state-test").await; + + push_build(&base_dir, "my-site", test_entry()).await; + set_current(&base_dir, "my-site", "20260210-120000-000000").await; + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.current, "20260210-120000-000000"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn remove_builds_prunes_entries() { + let base_dir = temp_dir("state-test").await; + + let mut e1 = test_entry(); + e1.timestamp = "20260210-100000-000000".to_owned(); + let mut e2 = test_entry(); + e2.timestamp = "20260210-110000-000000".to_owned(); + let mut e3 = test_entry(); + e3.timestamp = "20260210-120000-000000".to_owned(); + + push_build(&base_dir, "my-site", e3).await; + push_build(&base_dir, "my-site", e2).await; + push_build(&base_dir, "my-site", e1).await; + + remove_builds( + &base_dir, + "my-site", + &[ + "20260210-100000-000000".to_owned(), + "20260210-120000-000000".to_owned(), + ], + ) + .await; + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.builds.len(), 1); + assert_eq!(loaded.builds[0].timestamp, "20260210-110000-000000"); + + cleanup(&base_dir).await; + } + + #[tokio::test] + async fn remove_builds_empty_list_is_noop() { + let base_dir = temp_dir("state-test").await; + + push_build(&base_dir, "my-site", test_entry()).await; + remove_builds(&base_dir, "my-site", &[]).await; + + let loaded = load_state(&base_dir, "my-site").await; + assert_eq!(loaded.builds.len(), 1); + + cleanup(&base_dir).await; + } +} |
