Author SHA1 Message Date
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 SobhandClaude Sonnet 4.6 2f7eabf034 fix(cluster): bind RPC on main LAN addresses so serve_v2 aggregator can reach peers
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 3s
serve_v2::peer_rpc_addr derives each peer's RPC socket as lan_addr.ip():port+1.
Architect and Tank were bound on their 10G fabric NICs (10.10.0.9/10.10.0.10)
but the peer entries only carry the main-LAN gossip address, causing the
aggregator to time out when probing Tank and present a stale cert to Morpheus.
Changing bind_rpc_lan to the main-LAN addresses (10.0.0.13/10.0.0.14) fixes both.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-23 14:43:19 +00:00
Omar SobhandClaude Sonnet 4.6 7902c2e395 feat(fleet): switch all nodes to new-gen clawstor with centralized dashboard-v2
Build with clawstor cache / Cargo build (clawstor-cached) (push) Failing after 10m3s
- All three node configs (architect/tank/morpheus) now include [cluster]
  section with correct 10G fabric IPs, mTLS TLS paths, and blob store root.
  Architect binds gossip on 10.0.0.13:7701 and RPC on 10.10.0.9:7702 (10G
  to Tank); Morpheus uses LAN 10.0.0.5 (no direct 10G).

- claw-store.service: updated description, adds clawstor-deploy to
  ReadWritePaths, removes NoNewPrivileges (needed for sudo zfs snapshot).

- claw-store-serve.service: adds --v2-static-dir /usr/share/claw-store/v2
  so the aggregator serves dashboard-v2 ("clawstor · command center") at /v2/.

- claw-fuse.service: new unit, uses correct --data-dir + --mount flags.

Deployed to Architect, Tank, Morpheus. Fleet CA re-initialized; new certs
signed for all three nodes and distributed. Dashboard accessible at
http://100.104.171.32:7700/v2/ aggregating Tank + Morpheus via /api/v2/fleet.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-07-23 07:19:47 +00: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
10 changed files with 157 additions and 40 deletions
+17 -17
View File
@@ -7,7 +7,7 @@ use crate::manifest::Manifest;
use crate::snapshot;
use crate::sync::{SyncQueue, drain_sync_queue};
use crate::zfs::SystemZfs;
use anyhow::Result;
use anyhow::{Context, Result};
use chrono::Utc;
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
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_max_bytes = cfg.hot.max_gb.saturating_mul(1024 * 1024 * 1024);
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,
cfg.node.name.clone(),
hot_dir,
@@ -44,21 +51,14 @@ pub async fn run(cfg: Config, mut manifest: Manifest) -> Result<()> {
blob_root,
)
.await
{
Ok(svc) => {
tracing::info!(
rpc_enabled = svc.rpc_enabled(),
blob_store_enabled = svc.blob_store_enabled(),
zone = %cluster_cfg.zone,
"cluster services online"
);
Some(svc)
}
Err(e) => {
tracing::error!(error = %e, "cluster services failed to start; continuing without cluster");
None
}
}
.context("starting cluster services")?;
tracing::info!(
rpc_enabled = svc.rpc_enabled(),
blob_store_enabled = svc.blob_store_enabled(),
zone = %cluster_cfg.zone,
"cluster services online"
);
Some(svc)
}
None => {
tracing::info!("no [cluster] section in config; running standalone");
+22 -1
View File
@@ -91,9 +91,30 @@ impl V2State {
.join("aggregator-sessions.json");
let sessions = SessionStore::load(sessions_path)
.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 {
aggregator_name: cfg.node.name.clone(),
peers: cluster.peers.clone(),
peers,
client: std::sync::Arc::new(client),
default_rpc_port_offset: 1,
api_token: cfg.api_token.clone(),
+27
View File
@@ -19,6 +19,33 @@ archive_path = "/data/archive"
zfs_dataset = "data/archive"
retain_weeks = 12
[cluster]
zone = "fabric-10g"
bind_lan = "10.0.0.13:7701"
prom_bind = "0.0.0.0:7703"
bind_rpc_lan = "10.0.0.13:7702"
bind_rpc_tailscale = "100.104.171.32:7702"
blob_store_root = "/home/osobh/clawstor-deploy/data"
[[cluster.peers]]
name = "tank"
zone = "fabric-10g"
lan_addr = "10.0.0.14:7701"
rpc_lan_addr = "10.10.0.10:7702"
tailscale_addr = "100.108.129.81:7702"
[[cluster.peers]]
name = "morpheus"
zone = "lan-1g"
lan_addr = "10.0.0.5:7701"
rpc_lan_addr = "10.0.0.5:7702"
tailscale_addr = "100.123.224.84:7702"
[cluster.tls]
ca_cert = "/home/osobh/clawstor-deploy/tls/ca.crt"
node_cert = "/home/osobh/clawstor-deploy/tls/node.crt"
node_key = "/home/osobh/clawstor-deploy/tls/node.key"
[replication]
receive_from_peer = true
peer_user = "osobh"
+27 -3
View File
@@ -9,11 +9,35 @@ stale_hours = 48
[warm]
projects_path = "/slab/projects"
# No ZFS on morpheus — zfs_dataset is set so the config parses, but snapshot
# timers are not enabled and snapshot commands will log errors and continue.
zfs_dataset = "none"
snapshot_retain_hours = 24
snapshot_retain_days = 7
snapshot_retain_weeks = 4
# No cold tier and no replication — morpheus is a standalone dev node.
[cluster]
zone = "lan-1g"
# Morpheus has no direct 10G to Architect/Tank — use main LAN for all traffic
bind_lan = "10.0.0.5:7701"
prom_bind = "0.0.0.0:7703"
bind_rpc_lan = "10.0.0.5:7702"
bind_rpc_tailscale = "100.123.224.84:7702"
blob_store_root = "/home/osobh/clawstor-deploy/data"
[[cluster.peers]]
name = "architect"
zone = "fabric-10g"
lan_addr = "10.0.0.13:7701"
rpc_lan_addr = "10.0.0.13:7702"
tailscale_addr = "100.104.171.32:7702"
[[cluster.peers]]
name = "tank"
zone = "fabric-10g"
lan_addr = "10.0.0.14:7701"
rpc_lan_addr = "10.0.0.14:7702"
tailscale_addr = "100.108.129.81:7702"
[cluster.tls]
ca_cert = "/home/osobh/clawstor-deploy/tls/ca.crt"
node_cert = "/home/osobh/clawstor-deploy/tls/node.crt"
node_key = "/home/osobh/clawstor-deploy/tls/node.key"
+27 -5
View File
@@ -14,13 +14,35 @@ snapshot_retain_hours = 24
snapshot_retain_days = 7
snapshot_retain_weeks = 4
[cluster]
zone = "fabric-10g"
bind_lan = "10.0.0.14:7701"
prom_bind = "0.0.0.0:7703"
bind_rpc_lan = "10.0.0.14:7702"
bind_rpc_tailscale = "100.108.129.81:7702"
blob_store_root = "/home/osobh/clawstor-deploy/data"
[[cluster.peers]]
name = "architect"
zone = "fabric-10g"
lan_addr = "10.0.0.13:7701"
rpc_lan_addr = "10.10.0.9:7702"
tailscale_addr = "100.104.171.32:7702"
[[cluster.peers]]
name = "morpheus"
zone = "lan-1g"
lan_addr = "10.0.0.5:7701"
rpc_lan_addr = "10.0.0.5:7702"
tailscale_addr = "100.123.224.84:7702"
[cluster.tls]
ca_cert = "/home/osobh/clawstor-deploy/tls/ca.crt"
node_cert = "/home/osobh/clawstor-deploy/tls/node.crt"
node_key = "/home/osobh/clawstor-deploy/tls/node.key"
[replication]
# 10.10.0.9 is architect-fab-tank — the dedicated 10G fabric link between
# the two nodes. Intentionally used for replication to maximise bandwidth;
# architect's primary LAN address is 10.0.0.13.
send_to_host = "10.10.0.9"
send_to_user = "osobh"
cold_dataset_on_peer = "data/archive/tank-projects"
# nightly_at is reserved for future use; replication schedule is currently
# controlled by the claw-store-replicate.timer systemd unit.
nightly_at = "03:30"
+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
// `vite dev` on :5173 can hit it via server.proxy.
export default defineConfig({
// Absolute base tied to the deploy path. Prior try was `./`
// (fully relative) which broke when the user hit `/clawstor`
// without trailing slash — browser resolves `./assets/…`
// against `/clawstor` treated as a file, gives `/assets/…`,
// 404 from Tailscale. Absolute `/clawstor/` sidesteps the
// slash / no-slash ambiguity.
base: '/clawstor/',
// Absolute base matching the backend's actual mount point
// (`serve.rs` nests the v2 static dir at `/v2` via
// `nest_service("/v2", …)`). A prior `/clawstor/` base assumed a
// Tailscale Serve path mapping that was never actually configured
// on any node (checked `tailscale serve status` on tank +
// architect: neither proxies a `/clawstor` path) — that base
// silently broke direct `:7700/v2/` access, the only access
// pattern that's actually live.
base: '/v2/',
plugins: [react()],
server: {
port: 5173,
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=clawstor FUSE mount
After=claw-store.service
Requires=claw-store.service
[Service]
Type=simple
User=osobh
Environment=PATH=/home/osobh/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStartPre=/bin/mkdir -p /home/osobh/clawstor-mount
ExecStart=/usr/local/bin/claw-fuse --data-dir /home/osobh/clawstor-deploy/data --mount /home/osobh/clawstor-mount
ExecStop=/bin/fusermount -u /home/osobh/clawstor-mount
Restart=on-failure
RestartSec=30
[Install]
WantedBy=multi-user.target
+1 -1
View File
@@ -11,7 +11,7 @@ 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
ExecStart=/usr/local/bin/claw-store serve --port 7700 --static-dir /usr/share/claw-store/static --v2-static-dir /usr/share/claw-store/v2
Restart=on-failure
RestartSec=15
Environment=RUST_LOG=info
+9 -5
View File
@@ -1,15 +1,19 @@
[Unit]
Description=claw-store fleet storage daemon
After=zfs-mount.service network.target
Wants=zfs-mount.service
Description=clawstor cluster daemon (gossip + QUIC blob store + ZFS snapshots)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=osobh
Environment=PATH=/home/osobh/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=RUST_LOG=info
ExecStart=/usr/local/bin/claw-store daemon
Restart=on-failure
RestartSec=30
Environment=RUST_LOG=info
RestartSec=15
TimeoutStopSec=60
ProtectSystem=strict
ReadWritePaths=/var/lib/claw-store /hot/targets /slab/projects /home/osobh/clawstor-deploy
[Install]
WantedBy=multi-user.target