feat: daemon event loop and full CLI (init, status, gc, snapshot, restore, replicate)

This commit is contained in:
Omar Sobh
2026-06-16 03:55:22 +00:00
parent 11c32df3cd
commit 7ae1c4f950
2 changed files with 287 additions and 2 deletions
+83
View File
@@ -0,0 +1,83 @@
use crate::config::Config;
use crate::hot;
use crate::manifest::Manifest;
use crate::snapshot;
use crate::zfs::SystemZfs;
use anyhow::Result;
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use tokio::time::{interval, Duration};
pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
tracing::info!("claw-store daemon starting on node '{}'", cfg.node.name);
let zfs = SystemZfs;
let manifest_path = Manifest::default_path();
let mut poll_tick = interval(Duration::from_secs(300));
let mut snap_tick = interval(Duration::from_secs(3600));
let mut repl_tick = interval(Duration::from_secs(86400));
loop {
tokio::select! {
_ = poll_tick.tick() => {
update_active_projects(&mut manifest)?;
let used = hot::total_used_gb(&manifest)?;
if used > cfg.hot.max_gb as f64 * 0.9 {
tracing::warn!("hot tier {:.1}GB / {}GB — running GC", used, cfg.hot.max_gb);
hot::gc_stale_targets(&manifest, cfg.hot.stale_hours)?;
hot::gc_by_space(&mut manifest, cfg.hot.max_gb as f64)?;
manifest.save(&manifest_path)?;
}
}
_ = snap_tick.tick() => {
let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string();
tracing::info!("taking snapshot {}", ts);
if let Err(e) = snapshot::run_snapshot_cycle(
&zfs, &cfg.warm.zfs_dataset, &ts,
cfg.warm.snapshot_retain_hours as usize,
cfg.warm.snapshot_retain_days as usize,
cfg.warm.snapshot_retain_weeks as usize,
) {
tracing::error!("snapshot failed: {:#}", e);
}
}
_ = repl_tick.tick() => {
if let Some(rep) = &cfg.replication {
if let (Some(host), Some(user), Some(dest)) = (
&rep.send_to_host, &rep.send_to_user, &rep.cold_dataset_on_peer
) {
tracing::info!("replicating warm → cold on {}", host);
if let Err(e) = snapshot::replicate_to_cold(
&zfs, &cfg.warm.zfs_dataset, user, host, dest
) {
tracing::error!("replication failed: {:#}", e);
}
}
}
}
}
}
}
fn update_active_projects(manifest: &mut Manifest) -> Result<()> {
let mut sys = System::new_with_specifics(
RefreshKind::new().with_processes(ProcessRefreshKind::everything())
);
sys.refresh_processes();
let now = chrono::Utc::now();
for process in sys.processes().values() {
let cmd = process.exe().map(|p| p.to_string_lossy().to_string()).unwrap_or_default();
if cmd.contains("cargo") || cmd.contains("rustc") {
let cwd = process.root().map(|p| p.to_path_buf());
if let Some(cwd) = cwd {
for project in &mut manifest.projects {
if cwd.starts_with(&project.warm_path) {
project.last_active = Some(now);
project.last_build = Some(now);
}
}
}
}
}
Ok(())
}
+204 -2
View File
@@ -1,5 +1,207 @@
mod cargo_init;
mod config; mod config;
mod daemon;
mod hot;
mod manifest;
mod restore;
mod snapshot;
mod zfs;
fn main() { use anyhow::{Context, Result};
println!("claw-store 0.1.0"); use clap::{Parser, Subcommand};
use config::Config;
use manifest::{Manifest, Project};
use std::path::PathBuf;
use zfs::{SystemZfs, ZfsOps};
#[derive(Parser)]
#[command(name = "claw-store", about = "Fleet storage tier manager", version)]
struct Cli {
#[arg(long, default_value = "/etc/claw-store/config.toml")]
config: PathBuf,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Register a project: write .cargo/config.toml pointing to hot tier
Init {
name: String,
#[arg(long)]
repo: Option<String>,
},
/// Run the background daemon (snapshot cron, GC, replication)
Daemon,
/// Show tier usage, active projects, recent snapshots
Status,
/// Run hot tier GC manually
Gc,
/// Take a ZFS snapshot of the warm tier now
Snapshot,
/// List available snapshots for a project
ListSnapshots { project: String },
/// Restore a project from a snapshot
Restore { project: String, snapshot: String },
/// Trigger nightly replication to cold tier now
Replicate,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()))
.init();
let cli = Cli::parse();
let cfg = Config::load(&cli.config)
.with_context(|| format!("loading config from {}", cli.config.display()))?;
let manifest_path = Manifest::default_path();
let mut manifest = Manifest::load(&manifest_path)?;
let zfs = SystemZfs;
match cli.cmd {
Cmd::Init { name, repo } => cmd_init(&cfg, &mut manifest, &manifest_path, &name, repo.as_deref())?,
Cmd::Daemon => daemon::run(cfg, manifest).await?,
Cmd::Status => cmd_status(&cfg, &manifest, &zfs)?,
Cmd::Gc => cmd_gc(&cfg, &mut manifest)?,
Cmd::Snapshot => cmd_snapshot(&cfg, &zfs)?,
Cmd::ListSnapshots { project } => cmd_list_snapshots(&cfg, &zfs, &project)?,
Cmd::Restore { project, snapshot } => cmd_restore(&cfg, &zfs, &project, &snapshot)?,
Cmd::Replicate => cmd_replicate(&cfg, &zfs)?,
}
Ok(())
}
fn cmd_init(
cfg: &Config,
manifest: &mut Manifest,
manifest_path: &std::path::Path,
name: &str,
repo: Option<&str>,
) -> Result<()> {
let warm = cfg.warm.projects_path.join(name);
let hot_target = cfg.hot.path.join(name);
if let Some(url) = repo {
if !warm.exists() {
println!("Cloning {}{}", url, warm.display());
let out = std::process::Command::new("git")
.args(["clone", url, warm.to_str().unwrap()])
.status()?;
anyhow::ensure!(out.success(), "git clone failed");
}
} else {
std::fs::create_dir_all(&warm)?;
}
std::fs::create_dir_all(&hot_target)?;
cargo_init::write_cargo_config(&warm, &hot_target)?;
manifest.upsert(Project {
name: name.into(),
warm_path: warm.clone(),
hot_target_path: hot_target,
last_build: None,
last_active: None,
});
manifest.save(manifest_path)?;
println!("registered: {}", name);
println!(" source → {}", warm.display());
println!(" target/ → {}/", cfg.hot.path.join(name).display());
Ok(())
}
fn cmd_status(cfg: &Config, manifest: &Manifest, zfs: &SystemZfs) -> Result<()> {
println!("=== claw-store status — {} ===\n", cfg.node.name);
println!("HOT tier: {}", cfg.hot.path.display());
let used = hot::total_used_gb(manifest).unwrap_or(0.0);
println!(" used: {:.1} GB / {} GB max\n", used, cfg.hot.max_gb);
println!("WARM tier: {}", cfg.warm.projects_path.display());
let snaps = zfs.list_snapshots(&cfg.warm.zfs_dataset).unwrap_or_default();
println!(" snapshots: {}\n", snaps.len());
if let Some(cold) = &cfg.cold {
println!("COLD tier: {}", cold.archive_path.display());
}
println!("Projects ({}):", manifest.projects.len());
for p in &manifest.projects {
let active = p
.last_active
.map(|t| format!("{}", t.format("%Y-%m-%d %H:%M")))
.unwrap_or_else(|| "never".into());
println!(" {} (last active: {})", p.name, active);
}
Ok(())
}
fn cmd_gc(cfg: &Config, manifest: &mut Manifest) -> Result<()> {
let stale = hot::gc_stale_targets(manifest, cfg.hot.stale_hours)?;
if stale.is_empty() {
println!("Nothing to evict.");
return Ok(());
}
for name in &stale {
println!(" evicted (stale): {}", name);
}
let space = hot::gc_by_space(manifest, cfg.hot.max_gb as f64)?;
for name in &space {
println!(" evicted (space): {}", name);
}
manifest.save(&Manifest::default_path())?;
Ok(())
}
fn cmd_snapshot(cfg: &Config, zfs: &SystemZfs) -> Result<()> {
let ts = chrono::Utc::now().format("%Y-%m-%d-%H%M").to_string();
snapshot::run_snapshot_cycle(
zfs,
&cfg.warm.zfs_dataset,
&ts,
cfg.warm.snapshot_retain_hours as usize,
cfg.warm.snapshot_retain_days as usize,
cfg.warm.snapshot_retain_weeks as usize,
)?;
println!("Snapshot {} taken.", ts);
Ok(())
}
fn cmd_list_snapshots(cfg: &Config, zfs: &SystemZfs, _project: &str) -> Result<()> {
let snaps = zfs.list_snapshots(&cfg.warm.zfs_dataset)?;
if snaps.is_empty() {
println!("No snapshots found.");
return Ok(());
}
for s in &snaps {
println!(" {}", s);
}
Ok(())
}
fn cmd_restore(cfg: &Config, zfs: &SystemZfs, project: &str, snap: &str) -> Result<()> {
let full_snap = format!("{}@{}", cfg.warm.zfs_dataset, snap);
let found = restore::find_snapshot(zfs, &cfg.warm.zfs_dataset, snap)?;
anyhow::ensure!(found.is_some(), "snapshot '{}' not found", snap);
let path = restore::restore_project(zfs, project, &full_snap, &cfg.warm.zfs_dataset)?;
println!("Restored to: {}", path.display());
println!(
"Cleanup: zfs destroy {}/{}-restore-{}",
cfg.warm.zfs_dataset, project, snap
);
Ok(())
}
fn cmd_replicate(cfg: &Config, zfs: &SystemZfs) -> Result<()> {
let rep = cfg
.replication
.as_ref()
.context("no replication config — this node does not replicate")?;
let host = rep.send_to_host.as_ref().context("send_to_host not set")?;
let user = rep.send_to_user.as_ref().context("send_to_user not set")?;
let dest = rep
.cold_dataset_on_peer
.as_ref()
.context("cold_dataset_on_peer not set")?;
snapshot::replicate_to_cold(zfs, &cfg.warm.zfs_dataset, user, host, dest)?;
println!("Replication to {}@{} complete.", user, host);
Ok(())
} }