feat(v0.2.0): atomic+flocked manifest, pinned projects, serve systemd unit

The two biggest pain points coming out of the architecture review:

  1. The manifest at /var/lib/claw-store/projects.toml was the only
     piece of writable state but had no locking, no atomic writes,
     and three concurrent writers (daemon poll tick, every CLI verb,
     and the dashboard shelling out via /api/activate). Two writers
     interleaving silently dropped one of them; a crash mid-write
     left a corrupt half-written TOML that the next reader parsed
     as an empty manifest.

  2. Reboot survival: the dashboard had no systemd unit and was a
     stray hand-launched process. Architect lost its dashboard on
     todays reboot.

This commit lands:

- Manifest::update(path, FnOnce(&mut Manifest)) — locked-atomic
  load-mutate-save in one transaction. Uses libc::flock(LOCK_EX) on
  a sidecar .lock file (so the data file can be replaced by rename
  without invalidating the lock) and tempfile + persist for the
  rename. Concurrent writers serialise; readers see the previous
  state or the new state, never a torn write. Manifest::load uses
  LOCK_SH so it never races a mid-rename.

- Project.pinned: bool with #[serde(default)] so legacy manifests
  parse cleanly. hot::gc_stale_targets and hot::gc_by_space both
  skip pinned projects, with a WARN log when every remaining
  project is pinned but were still over budget — operator intent
  beats space pressure.

- claw-store pin <project> / unpin <project> CLI verbs.
  status command surfaces pin marker (📌).
  activate preserves an existing rows pinned flag so re-activating
  doesnt silently unpin.

- claw-store-serve.service systemd unit. Type=simple, Restart=
  on-failure, RestartSec=15, ProtectSystem=strict + ReadWritePaths
  =/var/lib/claw-store, ProtectHome, NoNewPrivileges, PrivateTmp.

- daemon poll tick reloads the manifest from disk at the start of
  each cycle (so CLI activations between ticks are visible) and
  routes its GC write through Manifest::update (so it cant race a
  concurrent CLI pin).

- libc + tempfile move from dev-deps into runtime deps.
- empty-manifest fallthrough on load (treat "" as default) so a
  half-written tempfile crashed pre-rename doesnt hard-fail the
  daemon next boot.

- 25 tests passing incl. new ones: legacy-toml-parses, update-
  serializes-two-sequential-writers, pinned-survives-stale-gc,
  pinned-survives-space-gc-even-when-lru.

Version bumped 0.1.0 → 0.2.0.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Omar Sobh
2026-06-28 12:59:41 +00:00
co-authored by Claude Opus 4.7
parent 2fcfb16160
commit af50adec19
7 changed files with 420 additions and 25 deletions
Generated
+2 -1
View File
@@ -256,12 +256,13 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]] [[package]]
name = "claw-store" name = "claw-store"
version = "0.1.0" version = "0.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"chrono", "chrono",
"clap", "clap",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"sysinfo", "sysinfo",
+8 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "claw-store" name = "claw-store"
version = "0.1.0" version = "0.2.0"
edition = "2021" edition = "2021"
[[bin]] [[bin]]
@@ -21,6 +21,13 @@ axum = { version = "0.7", features = ["macros"] }
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }
tokio-stream = "0.1" tokio-stream = "0.1"
serde_json = "1" serde_json = "1"
# v0.2.0 — flock(2) wrapper for atomic+locked manifest writes
# (manifest.rs). Already a transitive dep; declaring it directly
# makes the call site obvious.
libc = "0.2"
# v0.2.0 — sibling-tempfile + rename for atomic manifest persistence.
# Was previously dev-dep only; promoted to main.
tempfile = "3"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+25 -2
View File
@@ -21,12 +21,35 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
loop { loop {
tokio::select! { tokio::select! {
_ = poll_tick.tick() => { _ = poll_tick.tick() => {
// v0.2.0 — reload from disk at the start of each tick
// so any out-of-band CLI activations / dashboard pokes
// since the previous tick are visible. Without this,
// the daemon's in-memory `manifest` drifts from disk
// and its next `save` clobbers anything written meanwhile.
manifest = Manifest::load(&manifest_path).unwrap_or_else(|e| {
tracing::warn!(error = %e, "manifest reload failed; using in-memory copy");
manifest.clone()
});
update_active_projects(&mut manifest)?; update_active_projects(&mut manifest)?;
let used = hot::total_used_gb(&manifest)?; let used = hot::total_used_gb(&manifest)?;
if used > cfg.hot.max_gb as f64 * 0.9 { if used > cfg.hot.max_gb as f64 * 0.9 {
tracing::warn!("hot tier {:.1}GB / {}GB — running GC", used, cfg.hot.max_gb); tracing::warn!("hot tier {:.1}GB / {}GB — running GC", used, cfg.hot.max_gb);
hot::gc_stale_targets(&manifest, cfg.hot.stale_hours)?; // v0.2.0 — locked-atomic update so a concurrent
hot::gc_by_space(&mut manifest, cfg.hot.max_gb as f64)?; // `claw-store pin` can't race the GC writer. The
// closure runs INSIDE the flock so it sees the
// latest disk state.
let stale_hours = cfg.hot.stale_hours;
let max_gb = cfg.hot.max_gb as f64;
let updated = Manifest::update(&manifest_path, |m| {
hot::gc_stale_targets(m, stale_hours)?;
hot::gc_by_space(m, max_gb)?;
Ok(())
})?;
manifest = updated;
} else {
// Even without GC, persist the timestamps that
// `update_active_projects` stamped.
manifest.save(&manifest_path)?; manifest.save(&manifest_path)?;
} }
// Retry any pending sync notifications // Retry any pending sync notifications
+62 -3
View File
@@ -30,6 +30,12 @@ pub fn gc_stale_targets(manifest: &Manifest, stale_hours: u64) -> Result<Vec<Str
let threshold = chrono::Duration::hours(stale_hours as i64); let threshold = chrono::Duration::hours(stale_hours as i64);
let mut evicted = Vec::new(); let mut evicted = Vec::new();
for p in &manifest.projects { for p in &manifest.projects {
// v0.2.0 — operator-pinned projects survive every GC pass, both
// the stale sweep here and the space-pressure LRU below. The
// manifest is authoritative for intent; activation alone isn't.
if p.pinned {
continue;
}
let is_stale = match p.last_active { let is_stale = match p.last_active {
None => true, None => true,
Some(t) => (now - t) > threshold, Some(t) => (now - t) > threshold,
@@ -47,13 +53,29 @@ pub fn gc_by_space(manifest: &mut Manifest, max_gb: f64) -> Result<Vec<String>>
let mut evicted = Vec::new(); let mut evicted = Vec::new();
loop { loop {
let used = total_used_gb(manifest)?; let used = total_used_gb(manifest)?;
if used <= max_gb { break; } if used <= max_gb {
let lru_name = manifest.projects.iter() break;
}
// LRU eviction skips pinned projects — they may NEVER be evicted
// for space pressure. The trade-off: if every non-pinned project
// is gone and we're still over `max_gb`, we stop and log; better
// to over-allocate hot than to violate operator intent.
let lru_name = manifest
.projects
.iter()
.filter(|p| !p.pinned)
.filter(|p| p.hot_target_path.exists()) .filter(|p| p.hot_target_path.exists())
.min_by_key(|p| p.last_active) .min_by_key(|p| p.last_active)
.map(|p| p.name.clone()); .map(|p| p.name.clone());
match lru_name { match lru_name {
None => break, None => {
tracing::warn!(
used_gb = used,
max_gb = max_gb,
"hot tier over budget but every remaining project is pinned"
);
break;
}
Some(name) => { Some(name) => {
if let Some(p) = manifest.projects.iter().find(|p| p.name == name) { if let Some(p) = manifest.projects.iter().find(|p| p.name == name) {
if p.hot_target_path.exists() { if p.hot_target_path.exists() {
@@ -84,6 +106,7 @@ mod tests {
last_build: None, last_build: None,
last_active: None, last_active: None,
last_sync: None, last_sync: None,
pinned: false,
} }
} }
@@ -121,4 +144,40 @@ mod tests {
assert!(evicted.is_empty()); assert!(evicted.is_empty());
assert!(dir.path().join("active-proj").exists()); assert!(dir.path().join("active-proj").exists());
} }
#[test]
fn pinned_projects_survive_stale_gc() {
let dir = TempDir::new().unwrap();
let mut manifest = Manifest::default();
let mut p = make_project(&dir, "pinned-proj");
p.last_active = Some(chrono::Utc::now() - chrono::Duration::hours(500));
p.pinned = true;
manifest.projects.push(p);
let evicted = gc_stale_targets(&manifest, 48).unwrap();
assert!(evicted.is_empty(), "pinned project was evicted: {evicted:?}");
assert!(dir.path().join("pinned-proj").exists());
}
#[test]
fn pinned_projects_survive_space_gc_even_when_lru() {
// Pinned + ancient → would be the natural LRU eviction target.
// Confirm it stays put even when we set max_gb tiny.
let dir = TempDir::new().unwrap();
let mut manifest = Manifest::default();
let mut pinned = make_project(&dir, "pinned");
pinned.last_active = Some(chrono::Utc::now() - chrono::Duration::hours(500));
pinned.pinned = true;
manifest.projects.push(pinned);
let mut hot = make_project(&dir, "hot-but-unpinned");
hot.last_active = Some(chrono::Utc::now());
manifest.projects.push(hot);
// Force eviction: cap below total size.
let evicted = gc_by_space(&mut manifest, 0.0).unwrap();
assert!(
!evicted.iter().any(|n| n == "pinned"),
"pinned was evicted under space pressure"
);
}
} }
+73 -11
View File
@@ -62,6 +62,10 @@ enum Cmd {
#[arg(long)] #[arg(long)]
static_dir: Option<PathBuf>, static_dir: Option<PathBuf>,
}, },
/// Mark a project as pinned — survives every GC pass (stale + LRU)
Pin { project: String },
/// Unmark a project — it's now a normal GC candidate again
Unpin { project: String },
} }
#[tokio::main] #[tokio::main]
@@ -94,10 +98,36 @@ async fn main() -> Result<()> {
Cmd::Replicate => cmd_replicate(&cfg, &zfs)?, Cmd::Replicate => cmd_replicate(&cfg, &zfs)?,
Cmd::Serve { port, static_dir } => Cmd::Serve { port, static_dir } =>
serve::run_server(cfg, manifest, port, static_dir).await?, serve::run_server(cfg, manifest, port, static_dir).await?,
Cmd::Pin { project } => cmd_set_pin(&manifest_path, &project, true)?,
Cmd::Unpin { project } => cmd_set_pin(&manifest_path, &project, false)?,
} }
Ok(()) Ok(())
} }
// ── pin / unpin ──────────────────────────────────────────────────────────────
fn cmd_set_pin(manifest_path: &std::path::Path, project: &str, pinned: bool) -> Result<()> {
let m = Manifest::update(manifest_path, |m| {
match m.get_mut(project) {
Some(p) => {
p.pinned = pinned;
Ok(())
}
None => bail!(
"no such project in manifest: {project}\n\
(only activated projects can be pinned; run `claw-store activate {project}` first)"
),
}
})?;
let _ = m; // suppress unused — we just want the side effect
println!(
"{}: {}",
if pinned { "pinned" } else { "unpinned" },
project
);
Ok(())
}
// ── activate ───────────────────────────────────────────────────────────────── // ── activate ─────────────────────────────────────────────────────────────────
fn cmd_activate( fn cmd_activate(
@@ -129,15 +159,30 @@ fn cmd_activate(
std::fs::create_dir_all(&hot_target)?; std::fs::create_dir_all(&hot_target)?;
cargo_init::write_cargo_config(&warm, &hot_target)?; cargo_init::write_cargo_config(&warm, &hot_target)?;
manifest.upsert(Project { // Audit followup — use locked, atomic update so a concurrent
// dashboard activation (POSTing to /api/activate which shells out
// back to this same CLI) can't race the writer. `upsert` preserves
// an existing row's `pinned` flag — re-activating a pinned project
// doesn't silently unpin it.
let new_row = Project {
name: project.to_string(), name: project.to_string(),
warm_path: warm.clone(), warm_path: warm.clone(),
hot_target_path: hot_target.clone(), hot_target_path: hot_target.clone(),
last_build: None, last_build: None,
last_active: None, last_active: None,
last_sync: None, last_sync: None,
}); pinned: false,
manifest.save(manifest_path)?; };
Manifest::update(manifest_path, |m| {
let preserved_pin = m.get(project).map(|p| p.pinned).unwrap_or(false);
let mut row = new_row.clone();
row.pinned = preserved_pin;
m.upsert(row);
Ok(())
})?;
// Keep the in-memory `manifest` in sync with disk so downstream
// commands in the same process see the new row.
*manifest = Manifest::load(manifest_path)?;
println!("activated: {}", project); println!("activated: {}", project);
println!(" source → {}", warm.display()); println!(" source → {}", warm.display());
@@ -180,8 +225,11 @@ fn cmd_deactivate(
std::fs::remove_file(&cargo_config)?; std::fs::remove_file(&cargo_config)?;
} }
manifest.projects.retain(|p| p.name != project); Manifest::update(manifest_path, |m| {
manifest.save(manifest_path)?; m.projects.retain(|p| p.name != project);
Ok(())
})?;
*manifest = Manifest::load(manifest_path)?;
println!("deactivated: {} (warm clone kept at {})", project, warm.display()); println!("deactivated: {} (warm clone kept at {})", project, warm.display());
Ok(()) Ok(())
} }
@@ -272,11 +320,14 @@ fn cmd_pull(
sync::pull_project(&warm)?; sync::pull_project(&warm)?;
// Stamp last_sync on the manifest entry if it exists // Stamp last_sync on the manifest entry if it exists.
if let Some(p) = manifest.get_mut(project) { Manifest::update(manifest_path, |m| {
if let Some(p) = m.get_mut(project) {
p.last_sync = Some(chrono::Utc::now()); p.last_sync = Some(chrono::Utc::now());
manifest.save(manifest_path)?;
} }
Ok(())
})?;
*manifest = Manifest::load(manifest_path)?;
println!("pulled: {}", project); println!("pulled: {}", project);
Ok(()) Ok(())
} }
@@ -297,7 +348,12 @@ fn cmd_status(cfg: &Config, manifest: &Manifest, zfs: &SystemZfs) -> Result<()>
if let Some(peer) = &cfg.peer { if let Some(peer) = &cfg.peer {
println!("PEER: {}@{}\n", peer.user, peer.host); println!("PEER: {}@{}\n", peer.user, peer.host);
} }
println!("Active projects ({}):", manifest.projects.len()); let pinned_count = manifest.projects.iter().filter(|p| p.pinned).count();
println!(
"Active projects ({}{}):",
manifest.projects.len(),
if pinned_count > 0 { format!(", {pinned_count} pinned") } else { String::new() },
);
for p in &manifest.projects { for p in &manifest.projects {
let active = p.last_active let active = p.last_active
.map(|t| t.format("%Y-%m-%d %H:%M").to_string()) .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
@@ -305,17 +361,23 @@ fn cmd_status(cfg: &Config, manifest: &Manifest, zfs: &SystemZfs) -> Result<()>
let synced = p.last_sync let synced = p.last_sync
.map(|t| t.format("%Y-%m-%d %H:%M").to_string()) .map(|t| t.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "never".into()); .unwrap_or_else(|| "never".into());
println!(" {} (active: {}, synced: {})", p.name, active, synced); let pin_marker = if p.pinned { " 📌" } else { "" };
println!(" {}{} (active: {}, synced: {})", p.name, pin_marker, active, synced);
} }
Ok(()) Ok(())
} }
fn cmd_gc(cfg: &Config, manifest: &mut Manifest) -> Result<()> { fn cmd_gc(cfg: &Config, manifest: &mut Manifest) -> Result<()> {
let stale = hot::gc_stale_targets(manifest, cfg.hot.stale_hours)?; 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); } for name in &stale { println!(" evicted (stale): {}", name); }
let space = hot::gc_by_space(manifest, cfg.hot.max_gb as f64)?; let space = hot::gc_by_space(manifest, cfg.hot.max_gb as f64)?;
for name in &space { println!(" evicted (space): {}", name); } for name in &space { println!(" evicted (space): {}", name); }
if stale.is_empty() && space.is_empty() {
println!("Nothing to evict.");
return Ok(());
}
// Persist via the locked atomic path — `manifest.save` is now
// flock+rename internally, so this is safe under concurrent writers.
manifest.save(&Manifest::default_path())?; manifest.save(&Manifest::default_path())?;
Ok(()) Ok(())
} }
+218 -4
View File
@@ -1,8 +1,18 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::os::unix::io::AsRawFd;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// A single project tracked by claw-store. Persisted as a row in the
/// manifest at `/var/lib/claw-store/projects.toml`.
///
/// `pinned` (added v0.2.0): operator intent flag — when true, GC skips
/// this project for both stale-eviction (`gc_stale_targets`) and LRU
/// eviction (`gc_by_space`). Defaults to `false` via `#[serde(default)]`
/// so older manifests written by the v0.1.x line still parse cleanly.
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Project { pub struct Project {
pub name: String, // "org/repo" e.g. "quantumclaw/quantum-pulse" pub name: String, // "org/repo" e.g. "quantumclaw/quantum-pulse"
@@ -11,6 +21,8 @@ pub struct Project {
pub last_build: Option<DateTime<Utc>>, pub last_build: Option<DateTime<Utc>>,
pub last_active: Option<DateTime<Utc>>, pub last_active: Option<DateTime<Utc>>,
pub last_sync: Option<DateTime<Utc>>, pub last_sync: Option<DateTime<Utc>>,
#[serde(default)]
pub pinned: bool,
} }
#[derive(Debug, Clone, Deserialize, Serialize, Default)] #[derive(Debug, Clone, Deserialize, Serialize, Default)]
@@ -19,19 +31,84 @@ pub struct Manifest {
} }
impl Manifest { impl Manifest {
/// Read the manifest under a shared (read) flock. Returns
/// `Self::default()` if the file is missing — the old daemon
/// behavior on first boot — but propagates parse errors instead
/// of silently masking them like the pre-v0.2 `unwrap_or_default`
/// pattern at call sites did.
pub fn load(path: &Path) -> Result<Self> { pub fn load(path: &Path) -> Result<Self> {
if !path.exists() { return Ok(Self::default()); } if !path.exists() {
return Ok(Self::default());
}
let _guard = lock_path(path, LockMode::Shared)
.with_context(|| format!("flock(LOCK_SH) on {}", path.display()))?;
let s = std::fs::read_to_string(path) let s = std::fs::read_to_string(path)
.with_context(|| format!("reading manifest at {}", path.display()))?; .with_context(|| format!("reading manifest at {}", path.display()))?;
// An empty file is treated as "no projects yet" (matches the
// not-exists fallthrough above). Bare-init shell ops or a
// half-written tempfile that crashed pre-rename would otherwise
// hard-fail the daemon on next startup.
if s.trim().is_empty() {
return Ok(Self::default());
}
toml::from_str(&s).context("parsing manifest") toml::from_str(&s).context("parsing manifest")
} }
/// Backward-compatible save. Now writes go through a sibling
/// tempfile + rename so a crash mid-write can't leave a corrupt
/// half-written TOML where the next reader silently sees an empty
/// manifest. Takes EX flock on the lock sidecar so a concurrent
/// writer doesn't race the rename. Prefer `update()` for any
/// load-then-mutate path — `save()` alone re-introduces the
/// lost-update window that `update()` closes.
pub fn save(&self, path: &Path) -> Result<()> { pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?; std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
} }
let s = toml::to_string_pretty(self).context("serialising manifest")?; let _guard = lock_path(path, LockMode::Exclusive)
std::fs::write(path, s).context("writing manifest") .with_context(|| format!("flock(LOCK_EX) on {}", path.display()))?;
write_atomic(path, self).context("writing manifest")
}
/// Locked load → mutate → save in one transaction.
///
/// Replaces the v0.1 pattern of `let mut m = load(); m.x(); m.save();`
/// at call sites — that triple was unsafe under any concurrency
/// (daemon's 5-min poll tick, plus the dashboard shelling out to
/// `claw-store activate` which itself did load/mutate/save). Two
/// such triples interleaved would silently drop one writer's
/// changes.
///
/// Takes an EX flock for the whole transaction, reloads from disk
/// AFTER acquiring the lock (so we always operate on the latest
/// state), runs the closure, and atomically renames the resulting
/// tempfile over the destination. All three steps are inside the
/// lock so no other writer can race.
pub fn update(
path: &Path,
f: impl FnOnce(&mut Manifest) -> Result<()>,
) -> Result<Manifest> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("mkdir -p {}", parent.display()))?;
}
let _guard = lock_path(path, LockMode::Exclusive)
.with_context(|| format!("flock(LOCK_EX) on {}", path.display()))?;
let mut m = if path.exists() {
let s = std::fs::read_to_string(path)
.with_context(|| format!("reading manifest at {}", path.display()))?;
if s.trim().is_empty() {
Manifest::default()
} else {
toml::from_str(&s).context("parsing manifest")?
}
} else {
Manifest::default()
};
f(&mut m)?;
write_atomic(path, &m).context("writing manifest")?;
Ok(m)
} }
pub fn get(&self, name: &str) -> Option<&Project> { pub fn get(&self, name: &str) -> Option<&Project> {
@@ -55,6 +132,76 @@ impl Manifest {
} }
} }
// ── flock + atomic rename helpers ────────────────────────────────────────────
enum LockMode {
Shared,
Exclusive,
}
/// RAII flock guard — releases the lock on drop.
struct FlockGuard(File);
impl Drop for FlockGuard {
fn drop(&mut self) {
// Best-effort unlock. The kernel releases automatically on
// close anyway when File drops; we just hint it sooner. Errors
// here are not actionable.
unsafe {
libc::flock(self.0.as_raw_fd(), libc::LOCK_UN);
}
}
}
/// Lock a sidecar `.lock` file (so the data file can be replaced by
/// atomic rename without invalidating the lock). Returns a guard that
/// releases on drop.
fn lock_path(target: &Path, mode: LockMode) -> Result<FlockGuard> {
let lock_path = sidecar_lock_path(target);
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}
let f = OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(&lock_path)
.with_context(|| format!("opening lock {}", lock_path.display()))?;
let flag = match mode {
LockMode::Shared => libc::LOCK_SH,
LockMode::Exclusive => libc::LOCK_EX,
};
let rc = unsafe { libc::flock(f.as_raw_fd(), flag) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("flock");
}
Ok(FlockGuard(f))
}
fn sidecar_lock_path(target: &Path) -> PathBuf {
let mut p = target.as_os_str().to_owned();
p.push(".lock");
PathBuf::from(p)
}
/// Atomic write: serialize → write to sibling tempfile in the same dir
/// → fsync → rename(2) into place. The rename is POSIX-atomic on the
/// same filesystem, so a reader sees either the old contents or the
/// new contents, never a torn half-written TOML.
fn write_atomic(path: &Path, manifest: &Manifest) -> Result<()> {
let s = toml::to_string_pretty(manifest).context("serialising manifest")?;
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let mut tmp = tempfile::Builder::new()
.prefix(".projects.toml.")
.suffix(".new")
.tempfile_in(parent)
.with_context(|| format!("tempfile in {}", parent.display()))?;
tmp.write_all(s.as_bytes()).context("write tempfile")?;
tmp.as_file().sync_all().context("fsync tempfile")?;
tmp.persist(path).map_err(|e| anyhow::anyhow!("rename: {e}"))?;
Ok(())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -70,6 +217,7 @@ mod tests {
last_build: None, last_build: None,
last_active: None, last_active: None,
last_sync: None, last_sync: None,
pinned: false,
}); });
let f = NamedTempFile::new().unwrap(); let f = NamedTempFile::new().unwrap();
m.save(f.path()).unwrap(); m.save(f.path()).unwrap();
@@ -88,8 +236,74 @@ mod tests {
last_build: None, last_build: None,
last_active: None, last_active: None,
last_sync: None, last_sync: None,
pinned: false,
}); });
assert!(m.get("zeroclaw").is_some()); assert!(m.get("zeroclaw").is_some());
assert!(m.get("nonexistent").is_none()); assert!(m.get("nonexistent").is_none());
} }
#[test]
fn legacy_toml_without_pinned_field_parses() {
// Manifest written by v0.1.x. Must still load — pinned defaults
// to false via #[serde(default)]. Without this we'd break every
// existing deployment on upgrade.
let legacy = r#"
[[projects]]
name = "old/repo"
warm_path = "/slab/projects/old/repo"
hot_target_path = "/hot/targets/old/repo"
"#;
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), legacy).unwrap();
let m = Manifest::load(f.path()).unwrap();
assert_eq!(m.projects.len(), 1);
assert!(!m.projects[0].pinned);
}
#[test]
fn update_serializes_two_sequential_writers() {
let f = tempfile::NamedTempFile::new().unwrap();
// Seed.
Manifest::update(f.path(), |m| {
m.upsert(Project {
name: "alpha".into(),
warm_path: "/w/alpha".into(),
hot_target_path: "/h/alpha".into(),
last_build: None,
last_active: None,
last_sync: None,
pinned: false,
});
Ok(())
})
.unwrap();
// Two sequential updates — each should see the previous
// committed state. With the broken v0.1 load/mutate/save
// pattern these would race; here they serialize via flock.
Manifest::update(f.path(), |m| {
m.upsert(Project {
name: "beta".into(),
warm_path: "/w/beta".into(),
hot_target_path: "/h/beta".into(),
last_build: None,
last_active: None,
last_sync: None,
pinned: true,
});
Ok(())
})
.unwrap();
Manifest::update(f.path(), |m| {
if let Some(p) = m.get_mut("alpha") {
p.pinned = true;
}
Ok(())
})
.unwrap();
let m = Manifest::load(f.path()).unwrap();
assert_eq!(m.projects.len(), 2);
assert!(m.projects.iter().all(|p| p.pinned));
}
} }
+29
View File
@@ -0,0 +1,29 @@
[Unit]
# v0.2.0 — the HTTP+SSE API + React dashboard. Until now this ran as a
# hand-launched `claw-store serve` from a login shell — survived if the
# shell exited (orphaned to init), died on reboot. This unit makes the
# dashboard a first-class service that comes back the same way the
# daemon does.
Description=claw-store HTTP API + dashboard
After=network-online.target claw-store.service
Wants=network-online.target
[Service]
Type=simple
User=osobh
ExecStart=/usr/local/bin/claw-store serve --port 7700 --static-dir /usr/share/claw-store/static
Restart=on-failure
RestartSec=15
Environment=RUST_LOG=info
# Dashboard is read-mostly + shells out to the local CLI for mutations;
# no network egress / no privileged ops needed. Lock down what we don't
# use so a future RCE in axum can't pivot.
ProtectSystem=strict
ReadWritePaths=/var/lib/claw-store
ProtectHome=true
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target