feat: daemon event loop and full CLI (init, status, gc, snapshot, restore, replicate)
This commit is contained in:
@@ -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
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user