Phase 1a: cluster module + LAN-first peer probe

First cut of the v2 distributed FS. Captures the architecture design
in ARCHITECTURE-v2.md and lands the smallest useful new capability:
probing cluster peers with a LAN-first policy so subsequent transport
+ gossip layers (Phase 1b, 1c) can build on a real routing decision.

New:
- ARCHITECTURE-v2.md: zones (fabric-10g/lan-1g/roaming), tier
  lifecycle (hot/warm/cold), fingerprint-keyed build cache design,
  smart-clean policy, phase plan, explicit non-goals.
- claw-store/src/cluster.rs: RouteKind, RouteWinner, LanFirstProbe.
  LAN 200ms timeout, Tailscale 500ms fallback. 8 tests use real
  TCP listeners on 127.0.0.1 (no mocks); cover happy path,
  fall-through, both-fail, single-address, and elapsed reporting.
- claw-store/src/config.rs: ClusterConfig + PeerEntry with
  validation (bind-address presence, no duplicate peer names,
  per-peer reachable address required). Optional at top level so
  pre-v2 configs still load unchanged. 6 new tests.
- claw-store/src/main.rs: `claw-store cluster-probe <peer>` CLI
  subcommand that reads config, resolves the peer, probes, prints
  the winning route + elapsed time.

All 16 new tests pass. Existing 45 pass. Sole failure
(hot::tests::test_project_target_size_bytes) is a pre-existing
macOS-only issue with `du -sb`; Linux CI unaffected.

Follow-on Phase 1 cuts (subsequent sessions):
- 1b: chitchat SWIM gossip for live membership state
- 1c: quinn QUIC transport with fleet-CA mTLS
- 1d: `claw-store cluster status` — live membership view

Every file well under the 1300-line ceiling
(cluster.rs 268, config.rs 428, main.rs 450).
This commit is contained in:
Omar Sobh
2026-07-11 21:30:50 -07:00
parent aefa1cce58
commit 5c19c60292
6 changed files with 828 additions and 4 deletions
+278 -3
View File
@@ -1,5 +1,7 @@
use anyhow::{Context, Result};
use anyhow::{bail, Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashSet;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
@@ -51,6 +53,88 @@ pub struct PeerConfig {
pub user: String,
}
/// One peer node the cluster knows about. Every peer carries up to two reachable
/// endpoints: a LAN socket (fast path, tried first) and a Tailscale socket
/// (fallback, always reachable when the tailnet is up). At least one must be set.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct PeerEntry {
/// Stable human-facing name (e.g. `"architect"`). Must be unique in the config.
pub name: String,
/// Zone tag — one of `"fabric-10g"`, `"lan-1g"`, `"roaming"`, or a custom label.
pub zone: String,
/// LAN socket. Omit when this peer has no LAN presence from our perspective
/// (e.g. a roaming laptop reachable only via Tailscale).
#[serde(default)]
pub lan_addr: Option<SocketAddr>,
/// Tailscale socket. Omit only if the peer is LAN-only.
#[serde(default)]
pub tailscale_addr: Option<SocketAddr>,
}
impl PeerEntry {
/// Sanity check: at least one address must be reachable.
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
bail!("peer entry with empty name");
}
if self.zone.is_empty() {
bail!("peer {} has empty zone", self.name);
}
if self.lan_addr.is_none() && self.tailscale_addr.is_none() {
bail!(
"peer {} has no reachable address (both lan_addr and tailscale_addr unset)",
self.name
);
}
Ok(())
}
}
/// Cluster membership configuration. Optional at the top level so existing
/// single-node deployments (pre-v2) keep loading. Once present, describes the
/// local node's zone + bind addresses, and enumerates known peers.
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct ClusterConfig {
/// This node's zone tag.
pub zone: String,
/// LAN listen socket (typically `0.0.0.0:7701`). Omit on roaming nodes.
#[serde(default)]
pub bind_lan: Option<SocketAddr>,
/// Tailscale listen socket (Tailscale IP + port). Omit on strictly-LAN nodes.
#[serde(default)]
pub bind_tailscale: Option<SocketAddr>,
/// Static seed list of peers. Runtime membership (Phase 1b) will extend this
/// via gossip; the config list bootstraps discovery.
#[serde(default)]
pub peers: Vec<PeerEntry>,
}
impl ClusterConfig {
/// Sanity check the cluster config: at least one bind address, no duplicate
/// peer names, every peer has at least one address.
pub fn validate(&self) -> Result<()> {
if self.zone.is_empty() {
bail!("cluster.zone is empty");
}
if self.bind_lan.is_none() && self.bind_tailscale.is_none() {
bail!("cluster has no bind address (both bind_lan and bind_tailscale unset)");
}
let mut names = HashSet::new();
for peer in &self.peers {
peer.validate()?;
if !names.insert(peer.name.as_str()) {
bail!("duplicate peer name in cluster.peers: {}", peer.name);
}
}
Ok(())
}
/// Look up a peer by name.
pub fn peer(&self, name: &str) -> Option<&PeerEntry> {
self.peers.iter().find(|p| p.name == name)
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config {
pub node: NodeConfig,
@@ -59,6 +143,10 @@ pub struct Config {
pub cold: Option<ColdConfig>,
pub replication: Option<ReplicationConfig>,
pub peer: Option<PeerConfig>,
/// v2 cluster membership (peers, zones, bind addresses). Optional so pre-v2
/// configs still load; once populated, `Config::load` calls `validate()`.
#[serde(default)]
pub cluster: Option<ClusterConfig>,
/// Optional Bearer token required on all HTTP POST endpoints.
/// Set to a long random string, e.g. `openssl rand -hex 32`.
/// If absent, POST endpoints are unauthenticated (internal-network use only).
@@ -70,8 +158,14 @@ impl Config {
pub fn load(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading config at {}", path.display()))?;
toml::from_str(&content)
.with_context(|| format!("parsing config at {}", path.display()))
let cfg: Config = toml::from_str(&content)
.with_context(|| format!("parsing config at {}", path.display()))?;
if let Some(cluster) = cfg.cluster.as_ref() {
cluster
.validate()
.with_context(|| format!("validating cluster config in {}", path.display()))?;
}
Ok(cfg)
}
pub fn default_path() -> PathBuf {
@@ -119,6 +213,187 @@ peer_user = "osobh"
assert_eq!(cfg.cold.unwrap().retain_weeks, 12);
}
#[test]
fn test_pre_v2_config_still_loads_without_cluster_section() {
// Backwards compatibility: any config that worked before v2 must still
// parse cleanly with `cluster` absent.
let toml = r#"
[node]
name = "tank"
role = "secondary"
[hot]
path = "/hot/targets"
max_gb = 200
stale_hours = 48
[warm]
projects_path = "/slab/projects"
zfs_dataset = "slab/projects"
snapshot_retain_hours = 24
snapshot_retain_days = 7
snapshot_retain_weeks = 4
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.cluster.is_none(), "cluster must be optional");
}
#[test]
fn test_cluster_config_parses_with_peers() {
let toml = r#"
[node]
name = "tank"
role = "secondary"
[hot]
path = "/hot/targets"
max_gb = 200
stale_hours = 48
[warm]
projects_path = "/slab/projects"
zfs_dataset = "slab/projects"
snapshot_retain_hours = 24
snapshot_retain_days = 7
snapshot_retain_weeks = 4
[cluster]
zone = "fabric-10g"
bind_lan = "10.0.0.14:7701"
bind_tailscale = "100.64.1.2:7701"
[[cluster.peers]]
name = "architect"
zone = "fabric-10g"
lan_addr = "10.0.0.13:7701"
tailscale_addr = "100.64.1.3:7701"
[[cluster.peers]]
name = "morpheus"
zone = "lan-1g"
lan_addr = "192.168.1.50:7701"
tailscale_addr = "100.64.1.4:7701"
[[cluster.peers]]
name = "laptop"
zone = "roaming"
tailscale_addr = "100.64.1.5:7701"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
let cluster = cfg.cluster.expect("cluster section present");
assert_eq!(cluster.zone, "fabric-10g");
assert_eq!(cluster.peers.len(), 3);
let architect = cluster.peer("architect").expect("architect peer");
assert_eq!(architect.zone, "fabric-10g");
assert_eq!(
architect.lan_addr.unwrap(),
"10.0.0.13:7701".parse::<SocketAddr>().unwrap()
);
let laptop = cluster.peer("laptop").expect("laptop peer");
assert!(
laptop.lan_addr.is_none(),
"roaming peer has no LAN address"
);
assert!(laptop.tailscale_addr.is_some());
cluster.validate().expect("valid cluster config");
}
#[test]
fn test_peer_without_addresses_fails_validation() {
let peer = PeerEntry {
name: "ghost".into(),
zone: "fabric-10g".into(),
lan_addr: None,
tailscale_addr: None,
};
let err = peer.validate().unwrap_err().to_string();
assert!(
err.contains("ghost") && err.contains("no reachable address"),
"unexpected error: {err}"
);
}
#[test]
fn test_cluster_without_bind_addresses_fails_validation() {
let cluster = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: None,
bind_tailscale: None,
peers: vec![],
};
let err = cluster.validate().unwrap_err().to_string();
assert!(
err.contains("no bind address"),
"unexpected error: {err}"
);
}
#[test]
fn test_cluster_duplicate_peer_names_fails_validation() {
let cluster = ClusterConfig {
zone: "fabric-10g".into(),
bind_lan: Some("10.0.0.14:7701".parse().unwrap()),
bind_tailscale: None,
peers: vec![
PeerEntry {
name: "architect".into(),
zone: "fabric-10g".into(),
lan_addr: Some("10.0.0.13:7701".parse().unwrap()),
tailscale_addr: None,
},
PeerEntry {
name: "architect".into(),
zone: "fabric-10g".into(),
lan_addr: Some("10.0.0.14:7701".parse().unwrap()),
tailscale_addr: None,
},
],
};
let err = cluster.validate().unwrap_err().to_string();
assert!(
err.contains("duplicate peer name"),
"unexpected error: {err}"
);
}
#[test]
fn test_cluster_config_load_invokes_validate() {
// Same shape as a real config file but written to a tempfile so the
// full path — read, parse, validate — is exercised.
let toml = r#"
[node]
name = "tank"
role = "secondary"
[hot]
path = "/hot/targets"
max_gb = 200
stale_hours = 48
[warm]
projects_path = "/slab/projects"
zfs_dataset = "slab/projects"
snapshot_retain_hours = 24
snapshot_retain_days = 7
snapshot_retain_weeks = 4
[cluster]
zone = "fabric-10g"
[[cluster.peers]]
name = "architect"
zone = "fabric-10g"
"#;
let tmp = tempfile::NamedTempFile::new().unwrap();
std::fs::write(tmp.path(), toml).unwrap();
let err = Config::load(tmp.path()).unwrap_err().to_string();
// Fails because cluster has no bind address AND peer has no reachable address.
assert!(err.contains("validating cluster config"), "err: {err}");
}
#[test]
fn test_load_secondary_config() {
let toml = r#"