Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe815db981 | ||
|
|
4132937021 | ||
|
|
334cd068d2 | ||
|
|
f30ea04ab6 | ||
|
|
bb24c77676 | ||
|
|
75d822d0f2 | ||
|
|
dd2b90872a | ||
|
|
5c9bc7eb9c | ||
|
|
5c1d962bf2 | ||
|
|
a3fe1d147c |
+10
-10
@@ -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
|
||||||
|
|||||||
@@ -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 @@
|
|||||||
{"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"}
|
||||||
@@ -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,
|
||||||
|
|||||||
Executable
+231
@@ -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
|
||||||
Reference in New Issue
Block a user