Author SHA1 Message Date
osobhandClaude Sonnet 5 fe815db981 deploy: add safe-shutdown-prep.sh for hardware maintenance
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 2s
Node-local script an operator runs before powering a node off for
parts replacement. Guards against shutting down mid-build or with
un-pushed sync jobs, takes a final snapshot + replicates to cold,
stops the maintenance timers and dashboard, gracefully stops the
daemon (giving gossip its TimeoutStopSec=60 window to announce
departure to peers instead of relying on the 10s failure-detector
timeout), and cleanly unmounts FUSE before declaring the node safe
to power off. --dry-run runs every check for real but only prints
what the stop/unmount steps would do.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 14:29:16 -07:00
osobh 4132937021 Merge pull request 'dashboard-v2: fix asset base path to match backend /v2 mount' (#108) from fix-dashboard-v2-base-path into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 4s
2026-07-31 20:17:05 +00:00
osobhandClaude Sonnet 5 334cd068d2 dashboard-v2: fix asset base path to match backend /v2 mount
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 6s
Console errors on the live dashboard (architect:7700) turned out to
be two separate problems layered together:

1. The deployed static bundle wasn't built from this repo at all --
   it called /api/v2/hot-refs, /api/v2/anomalies, and
   /api/v2/node/*/metrics-history, none of which exist anywhere in
   this codebase on any branch (checked via `git log --all -S`).
   Someone built and shipped a frontend straight to
   /usr/share/claw-store/v2 without ever committing the source.

2. Separately, this repo's own committed vite.config.ts had a latent
   bug: `base: '/clawstor/'`, contradicting its own comment ("served
   by claw-store serve under /v2/*") and the actual backend mount in
   serve.rs (`nest_service("/v2", ...)`). Checked `tailscale serve
   status` on tank + architect -- neither has ever proxied a
   /clawstor path, so that base would have 404'd every asset the
   moment anyone rebuilt and redeployed from source.

Fix: base = '/v2/', matching the real mount. Rebuilt and redeployed
to tank + architect (orphaned build backed up to
/usr/share/claw-store/v2.bak-orphaned on both).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 13:16:53 -07:00
osobh f30ea04ab6 Merge pull request 'Phase 9 R1: repo-ensure RPC + fleet-health fixes' (#107) from phase-9-r1a-repo-ensure-rpc into main
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
2026-07-31 20:05:11 +00:00
osobhandClaude Sonnet 5 bb24c77676 Include self in dashboard-v2 fleet aggregation
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
V2State::from_config built its peer fan-out list solely from
[[cluster.peers]], which by definition never includes the local
node. Result: hitting a given node's /api/v2/fleet directly always
omitted that node from its own fleet view, even when perfectly
healthy -- looked like "node X is missing" from the dashboard when
X just never queried itself.

Fix: synthesize a self PeerEntry from the node's own gossip bind
address and include it in the fan-out, same as any other peer.
peer_rpc_addr()'s existing +1 port convention resolves it to the
same bind_rpc_lan/bind_rpc_tailscale the daemon already listens on.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 13:01:02 -07:00
osobhandClaude Sonnet 5 75d822d0f2 Fail fast when cluster gossip bootstrap fails
Bind failures at startup are almost always a boot-time race against
DHCP/network-online (bind address not yet assigned to the interface).
Previously the daemon caught the error and kept running in a degraded
state with no gossip, RPC, or Prometheus endpoint and no visible
failure signal. Now it propagates the error so the process exits and
systemd's Restart=on-failure retries once the network is actually up.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-07-31 11:48:40 -07:00
Omar Sobh dd2b90872a Phase 9 R1c: daemon wiring + integration test
Build with clawstor cache / Cargo build (clawstor-cached) (pull_request) Failing after 3s
- Wire RpcRouter::with_repo_root at daemon startup in services.rs
  using <blob_store_root>/repos. Nodes with no blob_store_root
  still return NotConfigured (unchanged).
- Add end-to-end ensure→cached→release integration test that seeds
  a bare git repo in a tempdir and exercises the real git-clone
  path. Marked #[ignore] so CI runners without git skip silently;
  runs green locally.
2026-07-15 04:11:58 -07:00
Omar Sobh 5c9bc7eb9c Phase 9 R1b: aggregator fan-out for RepoEnsure/RepoRelease
Layers HTTP over the R1a per-peer primitive so external callers
(clawmates, gitea runners, ops tooling) speak one URL to the
aggregator instead of dialing every peer.

Endpoints (require v2 auth, same middleware as tags/sessions):
  POST /api/v2/repos/ensure   {url, git_ref, workspace?}
  POST /api/v2/repos/release  {url, git_ref, workspace?}

Namespaced tokens are pinned to their own workspace (workspace omitted
in body → derived from token; explicit mismatch → 403). Admin/open
callers must supply workspace explicitly.

Reply shape mirrors the tag fan-out (FanoutReply) with per-peer
{peer, ok, path?, head_sha?, cached?, removed?, error?}. all_ok is
true iff every peer succeeded.

Also adds client wrappers call_repo_ensure/call_repo_release in
cluster/rpc/client.rs used by the aggregator's fan-out.
2026-07-15 04:08:35 -07:00
Omar Sobh 5c1d962bf2 Phase 9 R1a fixup: drop dead if-let wrapping around mkdir 2026-07-15 03:48:24 -07:00
Omar Sobh a3fe1d147c Phase 9 R1a: peer RepoEnsure/RepoRelease RPC (0x1e/0x1f)
New per-peer RPCs to shallow-clone a (url, git_ref) under a caller-
provided workspace namespace, and to release the checkout. Fleet
fan-out via the aggregator ships separately in R1b.

- src/cluster/repo_ensure.rs: request/reply types + derive_path
  (traversal-safe, blake3-hashed url segment), ensure_repo (cache
  hit → rev-parse HEAD, else remove-and-reclone with 5-min timeout),
  release_repo (idempotent rm)
- src/cluster/rpc.rs: Method::RepoEnsure=0x1e, RepoRelease=0x1f,
  RpcRouter::with_repo_root builder, two dispatch arms returning
  NotConfigured when repo_root is unset
- src/cluster.rs: pub mod repo_ensure
- src/actions.rs: fix pre-existing test-only Config init missing the
  aggregator field (unblocks lib tests)

Tests: 6 unit tests covering path derivation determinism, ref/url
independence, traversal safety, and sanitizer edge cases. All pass.
2026-07-15 03:42:27 -07:00
5 changed files with 280 additions and 26 deletions
+10 -10
View File
@@ -7,7 +7,7 @@ use crate::manifest::Manifest;
use crate::snapshot; use crate::snapshot;
use crate::sync::{SyncQueue, drain_sync_queue}; use crate::sync::{SyncQueue, drain_sync_queue};
use crate::zfs::SystemZfs; use crate::zfs::SystemZfs;
use anyhow::Result; use anyhow::{Context, Result};
use chrono::Utc; use chrono::Utc;
use sysinfo::{ProcessRefreshKind, RefreshKind, System}; use sysinfo::{ProcessRefreshKind, RefreshKind, System};
use tokio::time::{interval, Duration}; use tokio::time::{interval, Duration};
@@ -36,7 +36,14 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
let hot_dir = cfg.hot.path.clone(); let hot_dir = cfg.hot.path.clone();
let hot_max_bytes = cfg.hot.max_gb.saturating_mul(1024 * 1024 * 1024); let hot_max_bytes = cfg.hot.max_gb.saturating_mul(1024 * 1024 * 1024);
let blob_root = cluster_cfg.blob_store_root.clone(); let blob_root = cluster_cfg.blob_store_root.clone();
match ClusterServices::start( // Fail fast rather than degrade silently: a bind failure here is
// almost always a boot-time race against DHCP/network-online
// (the bind address isn't assigned to the interface yet). The
// systemd unit has `Restart=on-failure`; exiting lets it retry
// a few seconds later once the network is actually up, instead
// of leaving the daemon running indefinitely with no gossip,
// RPC, or Prometheus endpoint and no visible failure state.
let svc = ClusterServices::start(
cluster_cfg, cluster_cfg,
cfg.node.name.clone(), cfg.node.name.clone(),
hot_dir, hot_dir,
@@ -44,8 +51,7 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
blob_root, blob_root,
) )
.await .await
{ .context("starting cluster services")?;
Ok(svc) => {
tracing::info!( tracing::info!(
rpc_enabled = svc.rpc_enabled(), rpc_enabled = svc.rpc_enabled(),
blob_store_enabled = svc.blob_store_enabled(), blob_store_enabled = svc.blob_store_enabled(),
@@ -54,12 +60,6 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
); );
Some(svc) Some(svc)
} }
Err(e) => {
tracing::error!(error = %e, "cluster services failed to start; continuing without cluster");
None
}
}
}
None => { None => {
tracing::info!("no [cluster] section in config; running standalone"); tracing::info!("no [cluster] section in config; running standalone");
None None
+22 -1
View File
@@ -91,9 +91,30 @@ impl V2State {
.join("aggregator-sessions.json"); .join("aggregator-sessions.json");
let sessions = SessionStore::load(sessions_path) let sessions = SessionStore::load(sessions_path)
.map_err(|e| anyhow::anyhow!("loading session store: {e}"))?; .map_err(|e| anyhow::anyhow!("loading session store: {e}"))?;
// Bug fix 2026-07-31: the fleet view previously never included
// the node actually serving the dashboard — `cluster.peers` is
// by definition every *other* node, so hitting a given node's
// `/api/v2/fleet` directly silently dropped that node from its
// own view (looked like "node X is missing" from the UI, even
// though X was perfectly healthy — it just never queried
// itself). Fix: synthesize a self `PeerEntry` from our own
// gossip bind address and include it in the fan-out list, same
// as any other peer. `peer_rpc_addr` derives the RPC port from
// `lan_addr`/`tailscale_addr` via the fleet's +1 convention, so
// this resolves to the same `bind_rpc_lan`/`bind_rpc_tailscale`
// the daemon actually listens on.
let self_peer = PeerEntry {
name: cfg.node.name.clone(),
zone: cluster.zone.clone(),
lan_addr: cluster.bind_lan,
tailscale_addr: cluster.bind_tailscale,
};
let mut peers = cluster.peers.clone();
peers.push(self_peer);
Ok(Self { Ok(Self {
aggregator_name: cfg.node.name.clone(), aggregator_name: cfg.node.name.clone(),
peers: cluster.peers.clone(), peers,
client: std::sync::Arc::new(client), client: std::sync::Arc::new(client),
default_rpc_port_offset: 1, default_rpc_port_offset: 1,
api_token: cfg.api_token.clone(), api_token: cfg.api_token.clone(),
+1 -1
View File
@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/nodecard.tsx","./src/components/projectspanel.tsx","./src/components/stattile.tsx","./src/components/storagebar.tsx","./src/lib/api.ts","./src/pages/commandcenter.tsx","./src/pages/nodedetail.tsx","./src/pages/reftrackingpage.tsx","./src/pages/storagebrowser.tsx"],"version":"6.0.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/components/NodeCard.tsx","./src/components/ProjectsPanel.tsx","./src/components/StatTile.tsx","./src/components/StorageBar.tsx","./src/lib/api.ts","./src/pages/CommandCenter.tsx","./src/pages/NodeDetail.tsx","./src/pages/RefTrackingPage.tsx","./src/pages/StorageBrowser.tsx"],"version":"6.0.3"}
+9 -7
View File
@@ -6,13 +6,15 @@ import react from '@vitejs/plugin-react';
// During local dev the daemon proxies /api/v2/* on :7700 so // During local dev the daemon proxies /api/v2/* on :7700 so
// `vite dev` on :5173 can hit it via server.proxy. // `vite dev` on :5173 can hit it via server.proxy.
export default defineConfig({ export default defineConfig({
// Absolute base tied to the deploy path. Prior try was `./` // Absolute base matching the backend's actual mount point
// (fully relative) which broke when the user hit `/clawstor` // (`serve.rs` nests the v2 static dir at `/v2` via
// without trailing slash — browser resolves `./assets/…` // `nest_service("/v2", …)`). A prior `/clawstor/` base assumed a
// against `/clawstor` treated as a file, gives `/assets/…`, // Tailscale Serve path mapping that was never actually configured
// 404 from Tailscale. Absolute `/clawstor/` sidesteps the // on any node (checked `tailscale serve status` on tank +
// slash / no-slash ambiguity. // architect: neither proxies a `/clawstor` path) — that base
base: '/clawstor/', // silently broke direct `:7700/v2/` access, the only access
// pattern that's actually live.
base: '/v2/',
plugins: [react()], plugins: [react()],
server: { server: {
port: 5173, port: 5173,
+231
View File
@@ -0,0 +1,231 @@
#!/usr/bin/env bash
# safe-shutdown-prep.sh — bring a clawstor node to a clean, safe stop
# before hardware maintenance (parts replacement, drive swap, etc.).
#
# Run this ON the node you're about to power off. It does NOT power
# the machine off itself — the last line of output tells you the
# command to run for that, once everything above it is clean.
#
# What it does, in order:
# 1. Refuse to proceed if a cargo/rustc build is active against a
# tracked project's warm_path (unless --force).
# 2. Refuse to proceed if the sync queue has pending jobs peers
# haven't received yet (unless --force). Gives it one chance to
# drain via `claw-store sync <project>` before failing.
# 3. Take a final ZFS snapshot of the warm tier + replicate it to
# the configured cold peer, and wait for both to finish.
# 4. Stop the four maintenance timers (scrub/gc/ref-sweep/
# snapshot-rotate) so nothing fires mid-shutdown or immediately
# after next boot before you've verified the node.
# 5. Stop claw-store-serve.service (dashboard) — no data risk, just
# tidy.
# 6. Stop claw-store.service gracefully. The unit's
# TimeoutStopSec=60 gives the daemon's SIGTERM handler room to
# let gossip announce this node's departure to peers before the
# process exits — skipping this step means peers only notice via
# the failure detector's dead_node_grace_period (10s) instead of
# an immediate clean departure.
# 7. Stop claw-fuse.service and verify the mount is actually gone
# (retries a lazy unmount if the clean one doesn't take).
# 8. Sync filesystem buffers and print zpool health for the warm
# tier's pool — warns (does not block) if the pool is degraded,
# since that's independently worth knowing before you touch
# hardware.
#
# Flags:
# --force Skip the active-build and pending-sync guards.
# Everything else (steps 3-8) still runs.
# --export-zpool Additionally `zpool export` the warm-tier pool
# at the end — only do this if you're physically
# removing the storage drives, not for e.g. a RAM
# or PSU swap. Requires a matching `zpool import`
# after the node is back up before claw-store.service
# will find its data again.
# --skip-replicate Skip step 3 (snapshot + replicate). Use only if
# you already know cold tier is current, or this
# node has no [replication] configured.
# --dry-run Run every check (steps 1-2) and the snapshot/
# replicate (step 3) for real, but only print what
# steps 4-8 (stop timers/services, unmount, zpool
# export) would do instead of doing them. Use this
# first to verify the script sees your node's
# actual state correctly before trusting it live.
set -uo pipefail
CONFIG=${CLAWSTOR_CONFIG:-/etc/claw-store/config.toml}
BIN=${CLAWSTOR_BIN:-/usr/local/bin/claw-store}
SYNC_QUEUE=/var/lib/claw-store/sync-queue.toml
FORCE=0
EXPORT_ZPOOL=0
SKIP_REPLICATE=0
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--force) FORCE=1 ;;
--export-zpool) EXPORT_ZPOOL=1 ;;
--skip-replicate) SKIP_REPLICATE=1 ;;
--dry-run) DRY_RUN=1 ;;
*) echo "unknown flag: $arg" >&2; exit 2 ;;
esac
done
run() {
# Gate an actual state-changing command behind --dry-run.
if [ "$DRY_RUN" -eq 1 ]; then
echo " [dry-run] would run: $*"
return 0
fi
"$@"
}
hr() { printf '%.0s─' {1..66}; echo; }
step() { hr; echo "$1"; hr; }
ok() { echo "$1"; }
warn() { echo " ! $1"; }
fail() { echo "$1" >&2; }
NODE=$(hostname)
echo "safe-shutdown-prep — $NODE$(date -Iseconds)"
# ── 1. Active builds ────────────────────────────────────────────────
step "checking for active cargo/rustc builds"
ACTIVE=$(pgrep -af 'cargo|rustc' | grep -v "safe-shutdown-prep\|grep" || true)
if [ -n "$ACTIVE" ]; then
echo "$ACTIVE" | sed 's/^/ /'
if [ "$FORCE" -eq 1 ]; then
warn "active build(s) found — continuing anyway (--force)"
else
fail "active build(s) found on this node. A build in progress against"
fail "the warm tier can be interrupted mid-write by an unmount/shutdown."
fail "Wait for it to finish, or re-run with --force to proceed anyway."
exit 1
fi
else
ok "no active cargo/rustc processes"
fi
# ── 2. Sync queue ────────────────────────────────────────────────────
step "checking sync queue for pending peer pushes"
if [ -f "$SYNC_QUEUE" ]; then
DEPTH=$(grep -c '^project = ' "$SYNC_QUEUE" 2>/dev/null); DEPTH=${DEPTH:-0}
else
DEPTH=0
fi
if [ "$DEPTH" -gt 0 ]; then
warn "$DEPTH pending sync job(s) in $SYNC_QUEUE — attempting to drain"
PROJECTS=$(grep '^project = ' "$SYNC_QUEUE" | sed 's/project = "\(.*\)"/\1/')
for p in $PROJECTS; do
echo " syncing $p ..."
"$BIN" --config "$CONFIG" sync "$p" || warn "sync failed for $p"
done
DEPTH_AFTER=$(grep -c '^project = ' "$SYNC_QUEUE" 2>/dev/null); DEPTH_AFTER=${DEPTH_AFTER:-0}
if [ "$DEPTH_AFTER" -gt 0 ]; then
if [ "$FORCE" -eq 1 ]; then
warn "$DEPTH_AFTER job(s) still pending — continuing anyway (--force)"
else
fail "$DEPTH_AFTER sync job(s) still pending after drain attempt."
fail "Peers may be unreachable, or the push is failing for another"
fail "reason. Re-run with --force to shut down anyway (those changes"
fail "will catch up once this node is back and the daemon retries)."
exit 1
fi
else
ok "sync queue drained"
fi
else
ok "sync queue empty"
fi
# ── 3. Final snapshot + replicate to cold ───────────────────────────
if [ "$SKIP_REPLICATE" -eq 1 ]; then
step "skipping snapshot + replicate (--skip-replicate)"
else
step "taking final snapshot + replicating to cold tier"
if "$BIN" --config "$CONFIG" snapshot; then
ok "snapshot created"
else
warn "snapshot command failed — check output above"
fi
if "$BIN" --config "$CONFIG" replicate; then
ok "replication to cold tier complete"
else
warn "replicate command failed or not configured — check output above"
warn "([replication] section may be absent on this node; that's fine)"
fi
fi
# ── 4. Stop maintenance timers ──────────────────────────────────────
step "stopping maintenance timers"
for t in clawstor-scrub clawstor-gc clawstor-ref-sweep clawstor-snapshot-rotate; do
run systemctl --user stop "$t.timer" 2>/dev/null && ok "$t.timer stopped" || warn "$t.timer not running or not found"
done
# ── 5. Stop dashboard ────────────────────────────────────────────────
step "stopping claw-store-serve.service"
if systemctl is-active --quiet claw-store-serve.service 2>/dev/null; then
run sudo systemctl stop claw-store-serve.service && ok "stopped" || fail "failed to stop"
else
ok "not running"
fi
# ── 6. Stop daemon (gossip departure) ───────────────────────────────
step "stopping claw-store.service (gossip will announce departure)"
if systemctl is-active --quiet claw-store.service 2>/dev/null; then
run sudo systemctl stop claw-store.service && ok "stopped cleanly" || fail "failed to stop — check 'systemctl status claw-store.service'"
else
ok "not running"
fi
# ── 7. Unmount FUSE ──────────────────────────────────────────────────
step "unmounting FUSE"
if systemctl is-active --quiet claw-fuse.service 2>/dev/null; then
run sudo systemctl stop claw-fuse.service
sleep 1
fi
if [ "$DRY_RUN" -eq 1 ]; then
if mount | grep -q "type fuse.clawstor"; then
warn "still mounted (expected — nothing was actually stopped in --dry-run)"
else
ok "already unmounted"
fi
else
if mount | grep -q "type fuse.clawstor"; then
warn "still mounted after service stop — trying lazy unmount"
sudo umount -l ~/clawstor-mount 2>/dev/null
sleep 1
fi
if mount | grep -q "type fuse.clawstor"; then
fail "FUSE mount would not come down: $(mount | grep 'fuse.clawstor')"
fail "Do not power off until this is resolved — an unclean FUSE"
fail "unmount can leave a stale mountpoint that needs manual cleanup"
fail "on next boot."
exit 1
else
ok "unmounted"
fi
fi
# ── 8. Flush + pool health ──────────────────────────────────────────
step "flushing filesystem buffers"
sync
ok "sync complete"
POOL=$(mount | awk '/on \/slab / {print $1}')
if [ -n "$POOL" ]; then
step "zpool health check ($POOL)"
zpool status -x "$POOL" 2>&1 | sed 's/^/ /'
if [ "$EXPORT_ZPOOL" -eq 1 ]; then
step "exporting $POOL (--export-zpool)"
run sudo zpool export "$POOL" && ok "exported — remember: zpool import $POOL after reboot" || fail "export failed"
fi
fi
hr
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY RUN COMPLETE — nothing was actually stopped or unmounted."
echo "Re-run without --dry-run when ready to actually prep for shutdown."
else
echo "SAFE TO POWER OFF — run: sudo shutdown -h now"
fi
hr