Files
clawstor/claw-store/src/cluster.rs
T
Omar Sobh 523b22f148 Phase 5j: Prometheus /metrics endpoint
Adds a tiny axum-served HTTP endpoint that exposes the same
CacheMetrics counters that back GetMetrics + gossip, in Prometheus
text exposition format (v0.0.4). Enable per node by setting
`cluster.prom_bind` in the daemon config.

* metrics.rs: MetricsReply::to_prometheus() emits one HELP + TYPE +
  sample line per counter. started_unix is a gauge; everything else is
  a counter. Preallocates ~1 KiB so no reallocs mid-format.
* prom.rs (new): PromServer::bind spins up axum on a TcpListener,
  serves GET /metrics, returns 404 elsewhere. Graceful shutdown via
  oneshot channel; abort() variant for the sync-drop path in
  ClusterServices. Snapshots on every scrape (no cache) — Relaxed
  atomic loads are cheap enough that even 1 Hz is sub-microsecond.
* config.rs: new optional `cluster.prom_bind: SocketAddr` field.
  Default None means no server; typical value is 127.0.0.1:7702.
* services.rs: wires PromServer into ClusterServices when both a
  router and prom_bind exist. Warns (doesn't fail) if prom_bind is set
  without RPC — nothing would ever change on a scrape.

+8 tests:
- metrics: to_prometheus emits every counter with correct type; zeros
  still produce valid exposition (fresh daemon scrape)
- prom: content-type is text/plain; version=0.0.4; live updates
  between requests (no cache); unknown paths 404; shutdown stops
  serving
- services: end-to-end scrape returns router-driven counters;
  prom_addr() is None when unconfigured (no accidentally-leaked port)

Raw-TCP HTTP client in tests instead of pulling in reqwest — 30 lines
of tokio::net + string split for GET / read-to-close is small enough
to justify not adding a dep.

249 tests pass (+8 from Phase 5i). Pre-existing macOS `du -sb` failure
unchanged.
2026-07-12 04:36:34 -07:00

281 lines
9.4 KiB
Rust

//! Cluster peer routing and gossip.
//!
//! Two submodules of the v2 distributed FS:
//! * this file — LAN-first peer probe (Phase 1a).
//! * [`gossip`] — chitchat SWIM membership + KV state broadcast (Phase 1b).
//!
//! Every fleet node has two potential endpoints: a LAN socket (fast, direct,
//! no WireGuard framing) and a Tailscale socket (fallback, always reachable
//! when the tailnet is up). This file races LAN first and falls through to
//! Tailscale, returning the winning route so the caller can cache it for
//! the session.
//!
//! Cache the returned [`RouteWinner`] and re-probe on transport error or
//! after ~5 min so route changes (node moved networks, LAN NIC came back)
//! propagate without a daemon restart.
pub mod blob;
pub mod build_cache;
pub mod client_config;
pub mod gossip;
pub mod metrics;
pub mod prom;
pub mod refs;
pub mod rpc;
pub mod services;
pub mod tags;
pub mod transport;
use crate::config::PeerEntry;
use anyhow::{bail, Result};
use std::net::SocketAddr;
use std::time::{Duration, Instant};
use tokio::net::TcpStream;
use tokio::time::timeout;
/// Which network route reached a peer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteKind {
/// Direct LAN socket. No WireGuard overhead.
Lan,
/// Tailscale-provided socket. WireGuard-encapsulated.
Tailscale,
}
impl RouteKind {
/// Short lowercase label suitable for logs, metrics, config.
pub fn as_str(self) -> &'static str {
match self {
RouteKind::Lan => "lan",
RouteKind::Tailscale => "tailscale",
}
}
}
/// A successful probe result: which address answered, which route kind it was,
/// and how long the probe took from start to first-success (useful for logs).
#[derive(Debug, Clone)]
pub struct RouteWinner {
pub addr: SocketAddr,
pub kind: RouteKind,
pub elapsed: Duration,
}
/// LAN-first probe.
///
/// Attempts the peer's LAN address with a tight timeout; on any error or
/// timeout, falls through to the Tailscale address with a longer timeout
/// (Tailscale may need to set up a direct WireGuard tunnel on first probe).
/// Errors only when neither route succeeds.
pub struct LanFirstProbe {
lan_timeout: Duration,
tailscale_timeout: Duration,
}
impl Default for LanFirstProbe {
/// LAN 200ms, Tailscale 500ms. Rationale: on a healthy LAN the TCP SYN
/// round-trip is sub-millisecond; anything slower than 200ms means the
/// LAN route is not usable and we should fall through immediately.
/// Tailscale needs headroom for WireGuard handshakes and DERP fallback.
fn default() -> Self {
Self {
lan_timeout: Duration::from_millis(200),
tailscale_timeout: Duration::from_millis(500),
}
}
}
impl LanFirstProbe {
/// Probe using default timeouts (200ms LAN, 500ms Tailscale).
pub fn new() -> Self {
Self::default()
}
/// Probe with custom timeouts. Useful for tests and for tuning per
/// deployment shape.
pub fn with_timeouts(lan: Duration, tailscale: Duration) -> Self {
Self {
lan_timeout: lan,
tailscale_timeout: tailscale,
}
}
/// Probe a peer. Returns the first route (LAN preferred) that accepts a
/// TCP connection within its per-route timeout. Errors when neither
/// route succeeds, or when the peer has no addresses configured.
pub async fn probe(&self, peer: &PeerEntry) -> Result<RouteWinner> {
let start = Instant::now();
if let Some(lan) = peer.lan_addr {
if try_connect(lan, self.lan_timeout).await.is_ok() {
return Ok(RouteWinner {
addr: lan,
kind: RouteKind::Lan,
elapsed: start.elapsed(),
});
}
}
if let Some(ts) = peer.tailscale_addr {
if try_connect(ts, self.tailscale_timeout).await.is_ok() {
return Ok(RouteWinner {
addr: ts,
kind: RouteKind::Tailscale,
elapsed: start.elapsed(),
});
}
}
bail!(
"peer {} has no reachable address (lan={:?}, tailscale={:?})",
peer.name,
peer.lan_addr,
peer.tailscale_addr,
);
}
}
/// Attempt a TCP connect with a deadline. Returns Ok on successful handshake,
/// Err on connection error or timeout.
async fn try_connect(addr: SocketAddr, deadline: Duration) -> Result<()> {
match timeout(deadline, TcpStream::connect(addr)).await {
Ok(Ok(_stream)) => Ok(()),
Ok(Err(e)) => Err(anyhow::Error::from(e)),
Err(_) => bail!("connect to {} timed out after {:?}", addr, deadline),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::net::TcpListener;
/// Bind a TCP listener on an OS-assigned port, then drop it. The returned
/// address points at a port that's very likely unbound. Tiny race window
/// between drop and the caller's probe (microseconds); in practice tests
/// pass reliably. If ever flaky, we upgrade to a strict SO_LINGER=0 trick.
async fn free_but_unbound_addr() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
drop(listener);
addr
}
/// Bind a listener and spawn an accept loop so it stays alive across probes.
/// Returns the listener's local address.
async fn spawn_accepting_listener() -> SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
loop {
if listener.accept().await.is_err() {
return;
}
}
});
addr
}
fn peer_with(lan: Option<SocketAddr>, ts: Option<SocketAddr>) -> PeerEntry {
PeerEntry {
name: "test-peer".into(),
zone: "test-zone".into(),
lan_addr: lan,
tailscale_addr: ts,
}
}
#[tokio::test]
async fn probe_prefers_lan_when_available() {
let lan = spawn_accepting_listener().await;
// Tailscale unreachable — must not be attempted when LAN succeeds.
let ts = free_but_unbound_addr().await;
let probe = LanFirstProbe::new();
let win = probe.probe(&peer_with(Some(lan), Some(ts))).await.unwrap();
assert_eq!(win.kind, RouteKind::Lan);
assert_eq!(win.addr, lan);
}
#[tokio::test]
async fn probe_falls_through_to_tailscale_when_lan_fails() {
let lan_dead = free_but_unbound_addr().await;
let ts = spawn_accepting_listener().await;
// Short LAN timeout so the test runs fast even if the OS holds the
// failed connect in a queue rather than refusing immediately.
let probe =
LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(500));
let win = probe
.probe(&peer_with(Some(lan_dead), Some(ts)))
.await
.unwrap();
assert_eq!(win.kind, RouteKind::Tailscale);
assert_eq!(win.addr, ts);
}
#[tokio::test]
async fn probe_errors_when_both_routes_fail() {
let lan_dead = free_but_unbound_addr().await;
let ts_dead = free_but_unbound_addr().await;
let probe =
LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(50));
let err = probe
.probe(&peer_with(Some(lan_dead), Some(ts_dead)))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("no reachable address"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn probe_uses_only_tailscale_when_lan_absent() {
let ts = spawn_accepting_listener().await;
let probe = LanFirstProbe::new();
let win = probe.probe(&peer_with(None, Some(ts))).await.unwrap();
assert_eq!(win.kind, RouteKind::Tailscale);
assert_eq!(win.addr, ts);
}
#[tokio::test]
async fn probe_uses_only_lan_when_tailscale_absent() {
let lan = spawn_accepting_listener().await;
let probe = LanFirstProbe::new();
let win = probe.probe(&peer_with(Some(lan), None)).await.unwrap();
assert_eq!(win.kind, RouteKind::Lan);
assert_eq!(win.addr, lan);
}
#[tokio::test]
async fn probe_errors_when_no_addresses_configured() {
// PeerEntry::validate would reject this at config-load time, but the
// probe must still handle it gracefully (defense in depth).
let probe = LanFirstProbe::new();
let err = probe
.probe(&peer_with(None, None))
.await
.unwrap_err()
.to_string();
assert!(
err.contains("no reachable address"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn probe_records_elapsed_time_under_timeout() {
let lan = spawn_accepting_listener().await;
let probe = LanFirstProbe::new();
let win = probe.probe(&peer_with(Some(lan), None)).await.unwrap();
assert!(
win.elapsed < Duration::from_millis(500),
"probe should complete quickly on a live local listener; took {:?}",
win.elapsed
);
}
#[test]
fn route_kind_as_str_matches_variants() {
assert_eq!(RouteKind::Lan.as_str(), "lan");
assert_eq!(RouteKind::Tailscale.as_str(), "tailscale");
}
}