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.
This commit is contained in:
@@ -19,6 +19,7 @@ pub mod build_cache;
|
|||||||
pub mod client_config;
|
pub mod client_config;
|
||||||
pub mod gossip;
|
pub mod gossip;
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
|
pub mod prom;
|
||||||
pub mod refs;
|
pub mod refs;
|
||||||
pub mod rpc;
|
pub mod rpc;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
@@ -198,10 +199,8 @@ mod tests {
|
|||||||
let ts = spawn_accepting_listener().await;
|
let ts = spawn_accepting_listener().await;
|
||||||
// Short LAN timeout so the test runs fast even if the OS holds the
|
// Short LAN timeout so the test runs fast even if the OS holds the
|
||||||
// failed connect in a queue rather than refusing immediately.
|
// failed connect in a queue rather than refusing immediately.
|
||||||
let probe = LanFirstProbe::with_timeouts(
|
let probe =
|
||||||
Duration::from_millis(50),
|
LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(500));
|
||||||
Duration::from_millis(500),
|
|
||||||
);
|
|
||||||
let win = probe
|
let win = probe
|
||||||
.probe(&peer_with(Some(lan_dead), Some(ts)))
|
.probe(&peer_with(Some(lan_dead), Some(ts)))
|
||||||
.await
|
.await
|
||||||
@@ -214,10 +213,8 @@ mod tests {
|
|||||||
async fn probe_errors_when_both_routes_fail() {
|
async fn probe_errors_when_both_routes_fail() {
|
||||||
let lan_dead = free_but_unbound_addr().await;
|
let lan_dead = free_but_unbound_addr().await;
|
||||||
let ts_dead = free_but_unbound_addr().await;
|
let ts_dead = free_but_unbound_addr().await;
|
||||||
let probe = LanFirstProbe::with_timeouts(
|
let probe =
|
||||||
Duration::from_millis(50),
|
LanFirstProbe::with_timeouts(Duration::from_millis(50), Duration::from_millis(50));
|
||||||
Duration::from_millis(50),
|
|
||||||
);
|
|
||||||
let err = probe
|
let err = probe
|
||||||
.probe(&peer_with(Some(lan_dead), Some(ts_dead)))
|
.probe(&peer_with(Some(lan_dead), Some(ts_dead)))
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ use crate::config::ClusterConfig;
|
|||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use chitchat::transport::UdpTransport;
|
use chitchat::transport::UdpTransport;
|
||||||
use chitchat::{
|
use chitchat::{
|
||||||
spawn_chitchat, Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId,
|
spawn_chitchat, Chitchat, ChitchatConfig, ChitchatHandle, ChitchatId, FailureDetectorConfig,
|
||||||
FailureDetectorConfig,
|
|
||||||
};
|
};
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -182,10 +181,7 @@ impl ClusterGossip {
|
|||||||
/// `ClusterGossip` MUST be held for the daemon's lifetime; dropping
|
/// `ClusterGossip` MUST be held for the daemon's lifetime; dropping
|
||||||
/// it aborts the gossip task and the node effectively leaves the
|
/// it aborts the gossip task and the node effectively leaves the
|
||||||
/// cluster (peers observe it as dead within `dead_node_grace_period`).
|
/// cluster (peers observe it as dead within `dead_node_grace_period`).
|
||||||
pub async fn bootstrap(
|
pub async fn bootstrap(cluster: &ClusterConfig, local_name: impl Into<String>) -> Result<Self> {
|
||||||
cluster: &ClusterConfig,
|
|
||||||
local_name: impl Into<String>,
|
|
||||||
) -> Result<Self> {
|
|
||||||
let local_name = local_name.into();
|
let local_name = local_name.into();
|
||||||
if local_name.is_empty() {
|
if local_name.is_empty() {
|
||||||
bail!("cluster gossip requires a non-empty local node name");
|
bail!("cluster gossip requires a non-empty local node name");
|
||||||
@@ -354,11 +350,7 @@ fn get_socket(state: &chitchat::NodeState, key: &str) -> Option<SocketAddr> {
|
|||||||
state.get(key).and_then(|s| s.parse().ok())
|
state.get(key).and_then(|s| s.parse().ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn peer_view_from_state(
|
fn peer_view_from_state(id: &ChitchatId, state: &chitchat::NodeState, alive: bool) -> PeerView {
|
||||||
id: &ChitchatId,
|
|
||||||
state: &chitchat::NodeState,
|
|
||||||
alive: bool,
|
|
||||||
) -> PeerView {
|
|
||||||
// Fall back to the chitchat node_id when the peer hasn't published a
|
// Fall back to the chitchat node_id when the peer hasn't published a
|
||||||
// separate NODE_NAME yet (should be almost never, but keeps startup
|
// separate NODE_NAME yet (should be almost never, but keeps startup
|
||||||
// races graceful).
|
// races graceful).
|
||||||
@@ -369,7 +361,11 @@ fn peer_view_from_state(
|
|||||||
s.split(',')
|
s.split(',')
|
||||||
.filter_map(|p| {
|
.filter_map(|p| {
|
||||||
let t = p.trim();
|
let t = p.trim();
|
||||||
if t.is_empty() { None } else { Some(t.to_string()) }
|
if t.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(t.to_string())
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
@@ -415,11 +411,7 @@ mod tests {
|
|||||||
/// the failure detector has classified it as alive. Phi-accrual needs
|
/// the failure detector has classified it as alive. Phi-accrual needs
|
||||||
/// a handful of heartbeat samples before a newly-discovered node
|
/// a handful of heartbeat samples before a newly-discovered node
|
||||||
/// flips to live — this wait covers that ramp-up.
|
/// flips to live — this wait covers that ramp-up.
|
||||||
async fn wait_until_peer_alive(
|
async fn wait_until_peer_alive(gossip: &ClusterGossip, name: &str, deadline: Duration) -> bool {
|
||||||
gossip: &ClusterGossip,
|
|
||||||
name: &str,
|
|
||||||
deadline: Duration,
|
|
||||||
) -> bool {
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
loop {
|
loop {
|
||||||
if let Some(v) = gossip.peer(name).await {
|
if let Some(v) = gossip.peer(name).await {
|
||||||
@@ -446,6 +438,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
|
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
|
||||||
let id = g.self_chitchat_id().await;
|
let id = g.self_chitchat_id().await;
|
||||||
@@ -465,6 +458,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let err = ClusterGossip::bootstrap(&cfg, "")
|
let err = ClusterGossip::bootstrap(&cfg, "")
|
||||||
.await
|
.await
|
||||||
@@ -484,10 +478,14 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
// ClusterConfig::validate rejects this first — that's what we want:
|
// ClusterConfig::validate rejects this first — that's what we want:
|
||||||
// the daemon should refuse to bootstrap gossip on a malformed config.
|
// the daemon should refuse to bootstrap gossip on a malformed config.
|
||||||
let err_chain = format!("{:#}", ClusterGossip::bootstrap(&cfg, "solo").await.unwrap_err());
|
let err_chain = format!(
|
||||||
|
"{:#}",
|
||||||
|
ClusterGossip::bootstrap(&cfg, "solo").await.unwrap_err()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
err_chain.contains("no bind address"),
|
err_chain.contains("no bind address"),
|
||||||
"expected 'no bind address' in error chain, got: {err_chain}"
|
"expected 'no bind address' in error chain, got: {err_chain}"
|
||||||
@@ -511,6 +509,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
// Node B: uses A as seed.
|
// Node B: uses A as seed.
|
||||||
let cfg_b = ClusterConfig {
|
let cfg_b = ClusterConfig {
|
||||||
@@ -527,6 +526,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
|
let gossip_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
|
||||||
@@ -543,11 +543,9 @@ mod tests {
|
|||||||
// peers as live. Allow a generous 10s since phi-accrual needs a
|
// peers as live. Allow a generous 10s since phi-accrual needs a
|
||||||
// handful of heartbeat samples (500ms gossip interval → typically
|
// handful of heartbeat samples (500ms gossip interval → typically
|
||||||
// 2-4s to flip to alive on first sight).
|
// 2-4s to flip to alive on first sight).
|
||||||
let a_sees_b_alive =
|
let a_sees_b_alive = wait_until_peer_alive(&gossip_a, "b", Duration::from_secs(10)).await;
|
||||||
wait_until_peer_alive(&gossip_a, "b", Duration::from_secs(10)).await;
|
|
||||||
assert!(a_sees_b_alive, "A should see B alive within 10s");
|
assert!(a_sees_b_alive, "A should see B alive within 10s");
|
||||||
let b_sees_a_alive =
|
let b_sees_a_alive = wait_until_peer_alive(&gossip_b, "a", Duration::from_secs(10)).await;
|
||||||
wait_until_peer_alive(&gossip_b, "a", Duration::from_secs(10)).await;
|
|
||||||
assert!(b_sees_a_alive, "B should see A alive within 10s");
|
assert!(b_sees_a_alive, "B should see A alive within 10s");
|
||||||
|
|
||||||
// Verify the state B sees for A matches what A published. RPC
|
// Verify the state B sees for A matches what A published. RPC
|
||||||
@@ -564,10 +562,7 @@ mod tests {
|
|||||||
assert_eq!(b_view_of_a.hot_max_bytes, Some(1_000_000));
|
assert_eq!(b_view_of_a.hot_max_bytes, Some(1_000_000));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
b_view_of_a.warm_projects,
|
b_view_of_a.warm_projects,
|
||||||
vec![
|
vec!["osobh/clawverse".to_string(), "osobh/clawmates".to_string(),]
|
||||||
"osobh/clawverse".to_string(),
|
|
||||||
"osobh/clawmates".to_string(),
|
|
||||||
]
|
|
||||||
);
|
);
|
||||||
assert_eq!(b_view_of_a.hot_fill_ratio(), Some(1024.0 / 1_000_000.0));
|
assert_eq!(b_view_of_a.hot_fill_ratio(), Some(1024.0 / 1_000_000.0));
|
||||||
|
|
||||||
@@ -599,6 +594,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
|
let g = ClusterGossip::bootstrap(&cfg, "solo").await.unwrap();
|
||||||
// Solo cluster — peers() must never include self.
|
// Solo cluster — peers() must never include self.
|
||||||
@@ -626,6 +622,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let cfg_b = ClusterConfig {
|
let cfg_b = ClusterConfig {
|
||||||
zone: "lan-1g".into(),
|
zone: "lan-1g".into(),
|
||||||
@@ -641,6 +638,7 @@ mod tests {
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let g_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
|
let g_a = ClusterGossip::bootstrap(&cfg_a, "a").await.unwrap();
|
||||||
let g_b = ClusterGossip::bootstrap(&cfg_b, "b").await.unwrap();
|
let g_b = ClusterGossip::bootstrap(&cfg_b, "b").await.unwrap();
|
||||||
@@ -767,5 +765,4 @@ mod tests {
|
|||||||
};
|
};
|
||||||
assert_eq!(half_full.hot_fill_ratio(), Some(0.5));
|
assert_eq!(half_full.hot_fill_ratio(), Some(0.5));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,6 +147,115 @@ impl MetricsReply {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Phase 5j: encode this snapshot in the Prometheus text exposition
|
||||||
|
/// format (v0.0.4 — the same one every scraper understands). One
|
||||||
|
/// `# HELP`, one `# TYPE`, one sample line per metric.
|
||||||
|
///
|
||||||
|
/// All counters are exposed as `counter` type (monotonically
|
||||||
|
/// increasing since `started_unix`); the timestamp itself is a
|
||||||
|
/// `gauge`. Line ordering is stable so text-diff comparisons on a
|
||||||
|
/// scrape work — useful for debugging without a Prometheus server.
|
||||||
|
pub fn to_prometheus(&self) -> String {
|
||||||
|
// Preallocate — the output is always small (~1 KiB) but avoids
|
||||||
|
// reallocs mid-format.
|
||||||
|
let mut out = String::with_capacity(1024);
|
||||||
|
fn counter(out: &mut String, name: &str, help: &str, value: u64) {
|
||||||
|
out.push_str("# HELP ");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(help);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str("# TYPE ");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push_str(" counter\n");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&value.to_string());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
fn gauge(out: &mut String, name: &str, help: &str, value: u64) {
|
||||||
|
out.push_str("# HELP ");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(help);
|
||||||
|
out.push('\n');
|
||||||
|
out.push_str("# TYPE ");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push_str(" gauge\n");
|
||||||
|
out.push_str(name);
|
||||||
|
out.push(' ');
|
||||||
|
out.push_str(&value.to_string());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
gauge(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_counters_started_unix",
|
||||||
|
"Unix seconds when this counter set started (reset on daemon restart).",
|
||||||
|
self.started_unix,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_ref_hits_total",
|
||||||
|
"GetRef lookups that returned a cached blob id.",
|
||||||
|
self.get_ref_hits,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_ref_misses_total",
|
||||||
|
"GetRef lookups that missed.",
|
||||||
|
self.get_ref_misses,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_tag_hits_total",
|
||||||
|
"GetTag lookups that returned a value.",
|
||||||
|
self.get_tag_hits,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_tag_misses_total",
|
||||||
|
"GetTag lookups that missed.",
|
||||||
|
self.get_tag_misses,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_blob_get_bytes_total",
|
||||||
|
"Bytes served from the local blob store via BlobGet/GetChunk.",
|
||||||
|
self.blob_get_bytes,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_blob_put_bytes_total",
|
||||||
|
"Bytes ingested into the local blob store via BlobPut.",
|
||||||
|
self.blob_put_bytes,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_chunk_hits_total",
|
||||||
|
"GetChunk requests that returned bytes.",
|
||||||
|
self.get_chunk_hits,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_get_chunk_misses_total",
|
||||||
|
"GetChunk requests for unknown chunks.",
|
||||||
|
self.get_chunk_misses,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_has_chunk_hits_total",
|
||||||
|
"HasChunk probes that found the chunk locally (dedup save).",
|
||||||
|
self.has_chunk_hits,
|
||||||
|
);
|
||||||
|
counter(
|
||||||
|
&mut out,
|
||||||
|
"clawstor_cache_has_chunk_misses_total",
|
||||||
|
"HasChunk probes for chunks not present locally.",
|
||||||
|
self.has_chunk_misses,
|
||||||
|
);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Hit rate for `HasChunk` probes — the dominant signal for how
|
/// Hit rate for `HasChunk` probes — the dominant signal for how
|
||||||
/// much dedup we're saving in partial-sync operations.
|
/// much dedup we're saving in partial-sync operations.
|
||||||
pub fn has_chunk_hit_rate(&self) -> Option<f64> {
|
pub fn has_chunk_hit_rate(&self) -> Option<f64> {
|
||||||
@@ -226,10 +335,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
m.record_get_ref_miss();
|
m.record_get_ref_miss();
|
||||||
let rate = m.snapshot().get_ref_hit_rate().unwrap();
|
let rate = m.snapshot().get_ref_hit_rate().unwrap();
|
||||||
assert!(
|
assert!((rate - 0.75).abs() < 1e-9, "expected 0.75, got {rate}");
|
||||||
(rate - 0.75).abs() < 1e-9,
|
|
||||||
"expected 0.75, got {rate}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -243,6 +349,64 @@ mod tests {
|
|||||||
assert_eq!(round, original);
|
assert_eq!(round, original);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_prometheus_emits_every_counter_with_correct_type() {
|
||||||
|
let m = CacheMetrics::new();
|
||||||
|
for _ in 0..7 {
|
||||||
|
m.record_get_ref_hit();
|
||||||
|
}
|
||||||
|
m.record_get_ref_miss();
|
||||||
|
m.record_get_tag_hit();
|
||||||
|
m.record_blob_get_bytes(4096);
|
||||||
|
m.record_blob_put_bytes(8192);
|
||||||
|
m.record_get_chunk_hit();
|
||||||
|
m.record_get_chunk_miss();
|
||||||
|
m.record_has_chunk_hit();
|
||||||
|
m.record_has_chunk_miss();
|
||||||
|
|
||||||
|
let text = m.snapshot().to_prometheus();
|
||||||
|
|
||||||
|
// Every metric name must appear as a `# TYPE` line so scrapers
|
||||||
|
// classify it correctly. Missing a TYPE line breaks Grafana.
|
||||||
|
for (name, kind) in [
|
||||||
|
("clawstor_counters_started_unix", "gauge"),
|
||||||
|
("clawstor_cache_get_ref_hits_total", "counter"),
|
||||||
|
("clawstor_cache_get_ref_misses_total", "counter"),
|
||||||
|
("clawstor_cache_get_tag_hits_total", "counter"),
|
||||||
|
("clawstor_cache_get_tag_misses_total", "counter"),
|
||||||
|
("clawstor_cache_blob_get_bytes_total", "counter"),
|
||||||
|
("clawstor_cache_blob_put_bytes_total", "counter"),
|
||||||
|
("clawstor_cache_get_chunk_hits_total", "counter"),
|
||||||
|
("clawstor_cache_get_chunk_misses_total", "counter"),
|
||||||
|
("clawstor_cache_has_chunk_hits_total", "counter"),
|
||||||
|
("clawstor_cache_has_chunk_misses_total", "counter"),
|
||||||
|
] {
|
||||||
|
let type_line = format!("# TYPE {name} {kind}");
|
||||||
|
assert!(
|
||||||
|
text.contains(&type_line),
|
||||||
|
"missing type line {type_line} in:\n{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample values land verbatim on their own line.
|
||||||
|
assert!(text.contains("clawstor_cache_get_ref_hits_total 7\n"));
|
||||||
|
assert!(text.contains("clawstor_cache_get_ref_misses_total 1\n"));
|
||||||
|
assert!(text.contains("clawstor_cache_blob_get_bytes_total 4096\n"));
|
||||||
|
assert!(text.contains("clawstor_cache_blob_put_bytes_total 8192\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn to_prometheus_valid_when_all_counters_zero() {
|
||||||
|
// A fresh daemon should still expose a valid scrape — zero
|
||||||
|
// counters are meaningful (they let alerts fire on absence of
|
||||||
|
// change, not just presence of misses).
|
||||||
|
let text = CacheMetrics::new().snapshot().to_prometheus();
|
||||||
|
assert!(text.contains("clawstor_cache_get_ref_hits_total 0\n"));
|
||||||
|
assert!(text.contains("clawstor_cache_blob_get_bytes_total 0\n"));
|
||||||
|
// No trailing garbage or partial lines.
|
||||||
|
assert!(text.ends_with('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn snapshots_across_threads_are_consistent_up_to_relaxed_ordering() {
|
fn snapshots_across_threads_are_consistent_up_to_relaxed_ordering() {
|
||||||
// Fire many increments from N threads and verify the final
|
// Fire many increments from N threads and verify the final
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
//! Prometheus `/metrics` endpoint (Phase 5j).
|
||||||
|
//!
|
||||||
|
//! Exposes the same [`CacheMetrics`] counters that back `GetMetrics`
|
||||||
|
//! and gossip, but in the Prometheus text exposition format so any
|
||||||
|
//! existing scraper can consume them without knowing anything about
|
||||||
|
//! QUIC or chitchat.
|
||||||
|
//!
|
||||||
|
//! # Wire shape
|
||||||
|
//!
|
||||||
|
//! One GET route:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GET /metrics
|
||||||
|
//! → 200 OK, content-type text/plain; version=0.0.4
|
||||||
|
//! <the counters>
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! Every other path returns 404. The scrape is stateless — no auth,
|
||||||
|
//! no session — because the endpoint is intended for `127.0.0.1`
|
||||||
|
//! binding by default, or a Tailscale-only bind at operator choice.
|
||||||
|
//! A reverse proxy in front is the recommended pattern for
|
||||||
|
//! internet-exposed scrapers.
|
||||||
|
|
||||||
|
use crate::cluster::metrics::CacheMetrics;
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use axum::{extract::State, http::header, response::IntoResponse, routing::get, Router};
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
/// Prometheus text exposition v0.0.4 content-type. Every scraper in
|
||||||
|
/// the wild parses this MIME correctly — a plain `text/plain` also
|
||||||
|
/// works but the versioned string is the canonical value.
|
||||||
|
const PROM_CONTENT_TYPE: &str = "text/plain; version=0.0.4; charset=utf-8";
|
||||||
|
|
||||||
|
/// Running Prometheus HTTP server. Drop / [`PromServer::shutdown`] cancels
|
||||||
|
/// the underlying accept loop and closes the listening socket.
|
||||||
|
pub struct PromServer {
|
||||||
|
/// Address we actually bound. Copies of `SocketAddr` are cheap;
|
||||||
|
/// exposing it lets callers log or tests connect without a race.
|
||||||
|
bind_addr: SocketAddr,
|
||||||
|
task: JoinHandle<()>,
|
||||||
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for PromServer {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("PromServer")
|
||||||
|
.field("bind_addr", &self.bind_addr)
|
||||||
|
.finish_non_exhaustive()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PromServer {
|
||||||
|
/// Bind the HTTP server, spawn the accept loop, return. The
|
||||||
|
/// returned handle is the sole owner of the background task; drop
|
||||||
|
/// it (or call [`Self::shutdown`]) to stop serving.
|
||||||
|
///
|
||||||
|
/// `metrics` is shared with the RPC router so both endpoints
|
||||||
|
/// observe the same counters — no risk of drift between the
|
||||||
|
/// `GetMetrics` RPC and a scrape landing at the same instant.
|
||||||
|
pub async fn bind(addr: SocketAddr, metrics: Arc<CacheMetrics>) -> Result<Self> {
|
||||||
|
let listener = TcpListener::bind(addr)
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding prometheus listener at {addr}"))?;
|
||||||
|
let bind_addr = listener
|
||||||
|
.local_addr()
|
||||||
|
.context("reading prometheus listener bound address")?;
|
||||||
|
let router = Router::new()
|
||||||
|
.route("/metrics", get(metrics_handler))
|
||||||
|
.with_state(metrics);
|
||||||
|
let (tx, rx) = oneshot::channel::<()>();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
let shutdown = async move {
|
||||||
|
let _ = rx.await;
|
||||||
|
};
|
||||||
|
if let Err(e) = axum::serve(listener, router)
|
||||||
|
.with_graceful_shutdown(shutdown)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::warn!(error = %e, "prometheus server exited with error");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(Self {
|
||||||
|
bind_addr,
|
||||||
|
task,
|
||||||
|
shutdown_tx: Some(tx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address the server actually bound to. Differs from the
|
||||||
|
/// requested `addr` only when the caller passed a `0` port
|
||||||
|
/// (OS-assigned) — useful in tests.
|
||||||
|
pub fn local_addr(&self) -> SocketAddr {
|
||||||
|
self.bind_addr
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire the graceful-shutdown signal and await the accept task.
|
||||||
|
/// Idempotent — a second call after the receiver's already been
|
||||||
|
/// dropped is a no-op.
|
||||||
|
pub async fn shutdown(mut self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx.take() {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
// Best-effort await. If it panicked we've already logged.
|
||||||
|
let _ = self.task.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Abort the accept task without waiting. Used by `ClusterServices`
|
||||||
|
/// during a synchronous drop where awaiting isn't an option.
|
||||||
|
pub fn abort(self) {
|
||||||
|
if let Some(tx) = self.shutdown_tx {
|
||||||
|
let _ = tx.send(());
|
||||||
|
}
|
||||||
|
self.task.abort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /metrics` handler — snapshot the counters, format, return.
|
||||||
|
///
|
||||||
|
/// Snapshotting on every scrape (rather than caching for N ms) keeps
|
||||||
|
/// counters as fresh as possible; the underlying `Ordering::Relaxed`
|
||||||
|
/// atomic loads are cheap enough that even a 1 Hz scrape cadence is
|
||||||
|
/// well under a microsecond of CPU per request.
|
||||||
|
async fn metrics_handler(State(metrics): State<Arc<CacheMetrics>>) -> impl IntoResponse {
|
||||||
|
let body = metrics.snapshot().to_prometheus();
|
||||||
|
([(header::CONTENT_TYPE, PROM_CONTENT_TYPE)], body)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
/// Raw HTTP/1.1 client: writes one GET, reads the entire response
|
||||||
|
/// until connection close, and splits headers from body on the
|
||||||
|
/// double CRLF. Returns `(status, content_type, body)`.
|
||||||
|
///
|
||||||
|
/// The server closes each response's TCP connection (default for a
|
||||||
|
/// non-keep-alive request), so a bounded read-to-end is safe here.
|
||||||
|
async fn scrape(addr: SocketAddr, path: &str) -> (u16, String, String) {
|
||||||
|
let mut sock = TcpStream::connect(addr).await.unwrap();
|
||||||
|
let req = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||||
|
sock.write_all(req.as_bytes()).await.unwrap();
|
||||||
|
let mut buf = Vec::with_capacity(4096);
|
||||||
|
sock.read_to_end(&mut buf).await.unwrap();
|
||||||
|
let raw = String::from_utf8_lossy(&buf).to_string();
|
||||||
|
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
|
||||||
|
let status = head
|
||||||
|
.lines()
|
||||||
|
.next()
|
||||||
|
.and_then(|l| l.split_whitespace().nth(1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(0);
|
||||||
|
let ct = head
|
||||||
|
.lines()
|
||||||
|
.find_map(|l| {
|
||||||
|
let (k, v) = l.split_once(':')?;
|
||||||
|
if k.eq_ignore_ascii_case("content-type") {
|
||||||
|
Some(v.trim().to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(status, ct, body.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scrape_returns_prometheus_text_with_correct_content_type() {
|
||||||
|
let m = Arc::new(CacheMetrics::new());
|
||||||
|
for _ in 0..3 {
|
||||||
|
m.record_get_ref_hit();
|
||||||
|
}
|
||||||
|
m.record_get_ref_miss();
|
||||||
|
m.record_blob_get_bytes(1024);
|
||||||
|
let bind = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let server = PromServer::bind(bind, m.clone()).await.unwrap();
|
||||||
|
let addr = server.local_addr();
|
||||||
|
assert_ne!(addr.port(), 0, "kernel must assign a real port");
|
||||||
|
|
||||||
|
let (status, ct, body) = scrape(addr, "/metrics").await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
assert!(ct.starts_with("text/plain"), "unexpected content-type {ct}");
|
||||||
|
// Exposition body carries the counters at the values we set.
|
||||||
|
// Body may be chunked (transfer-encoding: chunked) — check via
|
||||||
|
// substring rather than exact equality.
|
||||||
|
assert!(
|
||||||
|
body.contains("clawstor_cache_get_ref_hits_total 3"),
|
||||||
|
"body missing get_ref_hits=3:\n{body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("clawstor_cache_get_ref_misses_total 1"),
|
||||||
|
"body missing get_ref_misses=1:\n{body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("clawstor_cache_blob_get_bytes_total 1024"),
|
||||||
|
"body missing blob_get_bytes=1024:\n{body}"
|
||||||
|
);
|
||||||
|
server.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scrape_shows_live_updates_between_requests() {
|
||||||
|
// Prove there's no cache — a second scrape after an increment
|
||||||
|
// must show the new value.
|
||||||
|
let m = Arc::new(CacheMetrics::new());
|
||||||
|
let bind = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let server = PromServer::bind(bind, m.clone()).await.unwrap();
|
||||||
|
let addr = server.local_addr();
|
||||||
|
|
||||||
|
let (_, _, first) = scrape(addr, "/metrics").await;
|
||||||
|
assert!(first.contains("clawstor_cache_get_ref_hits_total 0"));
|
||||||
|
|
||||||
|
for _ in 0..5 {
|
||||||
|
m.record_get_ref_hit();
|
||||||
|
}
|
||||||
|
let (_, _, second) = scrape(addr, "/metrics").await;
|
||||||
|
assert!(
|
||||||
|
second.contains("clawstor_cache_get_ref_hits_total 5"),
|
||||||
|
"expected 5 after increments:\n{second}"
|
||||||
|
);
|
||||||
|
server.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unknown_paths_return_404() {
|
||||||
|
// Only /metrics is a valid path; anything else is a 404 (no
|
||||||
|
// catch-all router that would tempt someone into treating this
|
||||||
|
// as a general HTTP server).
|
||||||
|
let m = Arc::new(CacheMetrics::new());
|
||||||
|
let bind = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let server = PromServer::bind(bind, m.clone()).await.unwrap();
|
||||||
|
let addr = server.local_addr();
|
||||||
|
let (status, _, _) = scrape(addr, "/not-a-thing").await;
|
||||||
|
assert_eq!(status, 404);
|
||||||
|
server.shutdown().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_stops_serving() {
|
||||||
|
// After shutdown() the port must refuse new connections.
|
||||||
|
let m = Arc::new(CacheMetrics::new());
|
||||||
|
let bind = "127.0.0.1:0".parse().unwrap();
|
||||||
|
let server = PromServer::bind(bind, m.clone()).await.unwrap();
|
||||||
|
let addr = server.local_addr();
|
||||||
|
server.shutdown().await;
|
||||||
|
// Give the OS a beat to release the socket.
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||||
|
// Either the connect fails or the connection is closed with 0
|
||||||
|
// bytes read — both count as "not serving".
|
||||||
|
let res = tokio::time::timeout(
|
||||||
|
std::time::Duration::from_millis(500),
|
||||||
|
TcpStream::connect(addr),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match res {
|
||||||
|
Err(_) => { /* timeout — good */ }
|
||||||
|
Ok(Err(_)) => { /* connect refused — good */ }
|
||||||
|
Ok(Ok(mut sock)) => {
|
||||||
|
// Connected — but a read must return 0 bytes because
|
||||||
|
// the accept loop is gone.
|
||||||
|
let mut buf = [0u8; 16];
|
||||||
|
let n = sock.read(&mut buf).await.unwrap_or(0);
|
||||||
|
assert_eq!(n, 0, "server accepted a connection after shutdown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
use crate::cluster::blob::BlobStore;
|
use crate::cluster::blob::BlobStore;
|
||||||
use crate::cluster::gossip::ClusterGossip;
|
use crate::cluster::gossip::ClusterGossip;
|
||||||
|
use crate::cluster::prom::PromServer;
|
||||||
use crate::cluster::refs::RefStore;
|
use crate::cluster::refs::RefStore;
|
||||||
use crate::cluster::rpc::{serve_connection, RpcRouter};
|
use crate::cluster::rpc::{serve_connection, RpcRouter};
|
||||||
use crate::cluster::tags::TagStore;
|
use crate::cluster::tags::TagStore;
|
||||||
@@ -75,6 +76,10 @@ pub struct ClusterServices {
|
|||||||
/// Phase 5i: periodic cache-metric publisher. `None` when RPC is
|
/// Phase 5i: periodic cache-metric publisher. `None` when RPC is
|
||||||
/// not running (no router → no counters to publish).
|
/// not running (no router → no counters to publish).
|
||||||
cache_metric_task: Option<JoinHandle<()>>,
|
cache_metric_task: Option<JoinHandle<()>>,
|
||||||
|
/// Phase 5j: Prometheus `/metrics` HTTP server. `None` when
|
||||||
|
/// `cluster.prom_bind` was absent or no router exists (nothing to
|
||||||
|
/// scrape).
|
||||||
|
prom_server: Option<PromServer>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl std::fmt::Debug for ClusterServices {
|
impl std::fmt::Debug for ClusterServices {
|
||||||
@@ -161,8 +166,7 @@ impl ClusterServices {
|
|||||||
// present, so an in-process caller (dashboard, tests) can hold
|
// present, so an in-process caller (dashboard, tests) can hold
|
||||||
// it. But we only spawn the accept loop when TLS is up.
|
// it. But we only spawn the accept loop when TLS is up.
|
||||||
let router: Option<Arc<RpcRouter>> = if cluster.tls.is_some() {
|
let router: Option<Arc<RpcRouter>> = if cluster.tls.is_some() {
|
||||||
let mut r =
|
let mut r = RpcRouter::new(gossip.clone(), local_name.clone(), cluster.zone.clone());
|
||||||
RpcRouter::new(gossip.clone(), local_name.clone(), cluster.zone.clone());
|
|
||||||
if let Some(store) = &blob_store {
|
if let Some(store) = &blob_store {
|
||||||
r = r.with_blob_store(store.clone());
|
r = r.with_blob_store(store.clone());
|
||||||
}
|
}
|
||||||
@@ -186,8 +190,7 @@ impl ClusterServices {
|
|||||||
.rpc_lan()
|
.rpc_lan()
|
||||||
.or_else(|| cluster.rpc_tailscale())
|
.or_else(|| cluster.rpc_tailscale())
|
||||||
.context("no RPC bind address (need bind_lan or bind_tailscale)")?;
|
.context("no RPC bind address (need bind_lan or bind_tailscale)")?;
|
||||||
let server = QuicServer::bind(bind, identity)
|
let server = QuicServer::bind(bind, identity).context("binding QUIC RPC server")?;
|
||||||
.context("binding QUIC RPC server")?;
|
|
||||||
let router = router.clone();
|
let router = router.clone();
|
||||||
tracing::info!("cluster RPC server listening on {}", bind);
|
tracing::info!("cluster RPC server listening on {}", bind);
|
||||||
Some(tokio::spawn(async move {
|
Some(tokio::spawn(async move {
|
||||||
@@ -237,6 +240,28 @@ impl ClusterServices {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase 5j: Prometheus `/metrics` server. Only meaningful when
|
||||||
|
// we have a router — otherwise there are no counters to expose.
|
||||||
|
// `prom_bind` unset => no server, no port opened.
|
||||||
|
let prom_server = match (&router, cluster.prom_bind) {
|
||||||
|
(Some(router), Some(addr)) => {
|
||||||
|
let server = PromServer::bind(addr, router.metrics().clone())
|
||||||
|
.await
|
||||||
|
.with_context(|| format!("binding Prometheus /metrics at {addr}"))?;
|
||||||
|
tracing::info!("Prometheus /metrics listening on {}", server.local_addr());
|
||||||
|
Some(server)
|
||||||
|
}
|
||||||
|
(Some(_), None) => {
|
||||||
|
tracing::info!("cluster: prom_bind unset; Prometheus endpoint disabled");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(None, Some(_)) => {
|
||||||
|
tracing::warn!("cluster: prom_bind set but RPC not running; nothing to scrape");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
(None, None) => None,
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
gossip,
|
gossip,
|
||||||
blob_store,
|
blob_store,
|
||||||
@@ -246,9 +271,16 @@ impl ClusterServices {
|
|||||||
accept_task,
|
accept_task,
|
||||||
metric_task,
|
metric_task,
|
||||||
cache_metric_task,
|
cache_metric_task,
|
||||||
|
prom_server,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Address the Prometheus `/metrics` server actually bound to.
|
||||||
|
/// `None` when the server was never started.
|
||||||
|
pub fn prom_addr(&self) -> Option<std::net::SocketAddr> {
|
||||||
|
self.prom_server.as_ref().map(|s| s.local_addr())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a local blob store was configured at start time.
|
/// Whether a local blob store was configured at start time.
|
||||||
pub fn blob_store_enabled(&self) -> bool {
|
pub fn blob_store_enabled(&self) -> bool {
|
||||||
self.blob_store.is_some()
|
self.blob_store.is_some()
|
||||||
@@ -276,6 +308,9 @@ impl ClusterServices {
|
|||||||
if let Some(task) = self.cache_metric_task {
|
if let Some(task) = self.cache_metric_task {
|
||||||
task.abort();
|
task.abort();
|
||||||
}
|
}
|
||||||
|
if let Some(server) = self.prom_server {
|
||||||
|
server.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,7 +440,10 @@ mod tests {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(!svc.rpc_enabled(), "no [cluster.tls] → RPC disabled");
|
assert!(!svc.rpc_enabled(), "no [cluster.tls] → RPC disabled");
|
||||||
assert!(!svc.blob_store_enabled(), "no blob root → blob store disabled");
|
assert!(
|
||||||
|
!svc.blob_store_enabled(),
|
||||||
|
"no blob root → blob store disabled"
|
||||||
|
);
|
||||||
// Gossip must still be functional.
|
// Gossip must still be functional.
|
||||||
let self_id = svc.gossip.self_chitchat_id().await;
|
let self_id = svc.gossip.self_chitchat_id().await;
|
||||||
assert_eq!(self_id.node_id.as_ref(), "test-node");
|
assert_eq!(self_id.node_id.as_ref(), "test-node");
|
||||||
@@ -444,8 +482,7 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&hot_dir).unwrap();
|
std::fs::create_dir_all(&hot_dir).unwrap();
|
||||||
std::fs::write(hot_dir.join("blob"), vec![0u8; 4096]).unwrap();
|
std::fs::write(hot_dir.join("blob"), vec![0u8; 4096]).unwrap();
|
||||||
|
|
||||||
let svc =
|
let svc = ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None)
|
||||||
ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(svc.rpc_enabled());
|
assert!(svc.rpc_enabled());
|
||||||
@@ -532,12 +569,22 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let tmp = tempfile::TempDir::new().unwrap();
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
let svc_a =
|
let svc_a = ClusterServices::start(
|
||||||
ClusterServices::start(&cfg_a, "a".into(), tmp.path().to_path_buf(), 1_000_000, None)
|
&cfg_a,
|
||||||
|
"a".into(),
|
||||||
|
tmp.path().to_path_buf(),
|
||||||
|
1_000_000,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let svc_b =
|
let svc_b = ClusterServices::start(
|
||||||
ClusterServices::start(&cfg_b, "b".into(), tmp.path().to_path_buf(), 1_000_000, None)
|
&cfg_b,
|
||||||
|
"b".into(),
|
||||||
|
tmp.path().to_path_buf(),
|
||||||
|
1_000_000,
|
||||||
|
None,
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
@@ -558,6 +605,90 @@ mod tests {
|
|||||||
svc_b.shutdown();
|
svc_b.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_expose_prometheus_endpoint_when_configured() {
|
||||||
|
// Phase 5j: cluster.prom_bind is honoured, the endpoint serves
|
||||||
|
// live counters that reflect in-process router activity.
|
||||||
|
use crate::cluster::transport::FleetCa;
|
||||||
|
use crate::config::ClusterTlsConfig;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let ca_dir = tmp.path().join("ca");
|
||||||
|
let a_tls_dir = tmp.path().join("a-tls");
|
||||||
|
let ca = FleetCa::generate("test CA").unwrap();
|
||||||
|
ca.save(&ca_dir).unwrap();
|
||||||
|
ca.sign_leaf_to_pem("a", &a_tls_dir).unwrap();
|
||||||
|
|
||||||
|
let cfg = ClusterConfig {
|
||||||
|
zone: "fabric-10g".into(),
|
||||||
|
bind_lan: Some(loopback(next_port())),
|
||||||
|
tls: Some(ClusterTlsConfig {
|
||||||
|
ca_cert: a_tls_dir.join("ca.crt"),
|
||||||
|
node_cert: a_tls_dir.join("node.crt"),
|
||||||
|
node_key: a_tls_dir.join("node.key"),
|
||||||
|
}),
|
||||||
|
prom_bind: Some("127.0.0.1:0".parse().unwrap()),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let hot_dir = tmp.path().join("hot");
|
||||||
|
std::fs::create_dir_all(&hot_dir).unwrap();
|
||||||
|
|
||||||
|
let svc = ClusterServices::start(&cfg, "a".into(), hot_dir, 1_000_000, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let prom = svc.prom_addr().expect("prom server bound");
|
||||||
|
assert_ne!(prom.port(), 0, "kernel must assign a real port");
|
||||||
|
|
||||||
|
// Drive some counters directly through the router.
|
||||||
|
let router = svc.router.as_ref().unwrap();
|
||||||
|
for _ in 0..4 {
|
||||||
|
router.metrics().record_get_ref_hit();
|
||||||
|
}
|
||||||
|
router.metrics().record_blob_get_bytes(2048);
|
||||||
|
|
||||||
|
// Scrape.
|
||||||
|
let mut sock = TcpStream::connect(prom).await.unwrap();
|
||||||
|
let req = format!("GET /metrics HTTP/1.1\r\nHost: {prom}\r\nConnection: close\r\n\r\n");
|
||||||
|
sock.write_all(req.as_bytes()).await.unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
sock.read_to_end(&mut buf).await.unwrap();
|
||||||
|
let body = String::from_utf8_lossy(&buf);
|
||||||
|
assert!(
|
||||||
|
body.contains("clawstor_cache_get_ref_hits_total 4"),
|
||||||
|
"expected 4 hits in scrape: {body}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("clawstor_cache_blob_get_bytes_total 2048"),
|
||||||
|
"expected 2048 blob GET bytes: {body}"
|
||||||
|
);
|
||||||
|
svc.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_leave_prom_server_off_when_unconfigured() {
|
||||||
|
// Absence of prom_bind means no HTTP server; prom_addr() is
|
||||||
|
// None. Guards against accidentally leaking a metrics port.
|
||||||
|
let cfg = ClusterConfig {
|
||||||
|
zone: "fabric-10g".into(),
|
||||||
|
bind_lan: Some(loopback(next_port())),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let tmp = tempfile::TempDir::new().unwrap();
|
||||||
|
let svc = ClusterServices::start(
|
||||||
|
&cfg,
|
||||||
|
"quiet".into(),
|
||||||
|
tmp.path().to_path_buf(),
|
||||||
|
1_000_000,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(svc.prom_addr().is_none(), "prom server must not start");
|
||||||
|
svc.shutdown();
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn services_publish_cache_metrics_to_gossip_on_start() {
|
async fn services_publish_cache_metrics_to_gossip_on_start() {
|
||||||
// Phase 5i: when RPC is up, the initial cache-metric publish
|
// Phase 5i: when RPC is up, the initial cache-metric publish
|
||||||
@@ -601,26 +732,14 @@ mod tests {
|
|||||||
let hot_dir = tmp.path().join("hot");
|
let hot_dir = tmp.path().join("hot");
|
||||||
std::fs::create_dir_all(&hot_dir).unwrap();
|
std::fs::create_dir_all(&hot_dir).unwrap();
|
||||||
|
|
||||||
let svc_a = ClusterServices::start(
|
let svc_a = ClusterServices::start(&cfg_a, "a".into(), hot_dir.clone(), 1_000_000, None)
|
||||||
&cfg_a,
|
|
||||||
"a".into(),
|
|
||||||
hot_dir.clone(),
|
|
||||||
1_000_000,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// Drive counters BEFORE start() would be a chicken-and-egg —
|
// Drive counters BEFORE start() would be a chicken-and-egg —
|
||||||
// instead, drive them AFTER start and check the periodic
|
// instead, drive them AFTER start and check the periodic
|
||||||
// republish. First, verify the initial-publish (all zeros) has
|
// republish. First, verify the initial-publish (all zeros) has
|
||||||
// reached B: the four keys should be Some(0), not None.
|
// reached B: the four keys should be Some(0), not None.
|
||||||
let svc_b = ClusterServices::start(
|
let svc_b = ClusterServices::start(&cfg_b, "b".into(), hot_dir, 1_000_000, None)
|
||||||
&cfg_b,
|
|
||||||
"b".into(),
|
|
||||||
hot_dir,
|
|
||||||
1_000_000,
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|||||||
+18
-10
@@ -6,7 +6,10 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum NodeRole { Primary, Secondary }
|
pub enum NodeRole {
|
||||||
|
Primary,
|
||||||
|
Secondary,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct NodeConfig {
|
pub struct NodeConfig {
|
||||||
@@ -143,6 +146,14 @@ pub struct ClusterConfig {
|
|||||||
/// gossip + peer-status but returns `NotConfigured` for Blob RPCs.
|
/// gossip + peer-status but returns `NotConfigured` for Blob RPCs.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub blob_store_root: Option<PathBuf>,
|
pub blob_store_root: Option<PathBuf>,
|
||||||
|
/// Phase 5j: optional bind address for the Prometheus `/metrics`
|
||||||
|
/// endpoint. When set, the daemon spins up a tiny HTTP server on
|
||||||
|
/// this address serving the same counters exposed via `GetMetrics`
|
||||||
|
/// and gossip. Typical value is `127.0.0.1:7702` (Prometheus scrapes
|
||||||
|
/// via the LAN listener the node advertises to its scrape target
|
||||||
|
/// group). Absent means "no scrape endpoint".
|
||||||
|
#[serde(default)]
|
||||||
|
pub prom_bind: Option<SocketAddr>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the default RPC address for a gossip address: same IP, port + 1.
|
/// Compute the default RPC address for a gossip address: same IP, port + 1.
|
||||||
@@ -182,7 +193,8 @@ impl ClusterConfig {
|
|||||||
/// explicit `bind_rpc_lan` override; otherwise derives from `bind_lan`
|
/// explicit `bind_rpc_lan` override; otherwise derives from `bind_lan`
|
||||||
/// with port + 1.
|
/// with port + 1.
|
||||||
pub fn rpc_lan(&self) -> Option<SocketAddr> {
|
pub fn rpc_lan(&self) -> Option<SocketAddr> {
|
||||||
self.bind_rpc_lan.or_else(|| self.bind_lan.and_then(default_rpc_addr))
|
self.bind_rpc_lan
|
||||||
|
.or_else(|| self.bind_lan.and_then(default_rpc_addr))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The Tailscale address this node listens on for RPC (QUIC).
|
/// The Tailscale address this node listens on for RPC (QUIC).
|
||||||
@@ -367,10 +379,7 @@ tailscale_addr = "100.64.1.5:7701"
|
|||||||
);
|
);
|
||||||
|
|
||||||
let laptop = cluster.peer("laptop").expect("laptop peer");
|
let laptop = cluster.peer("laptop").expect("laptop peer");
|
||||||
assert!(
|
assert!(laptop.lan_addr.is_none(), "roaming peer has no LAN address");
|
||||||
laptop.lan_addr.is_none(),
|
|
||||||
"roaming peer has no LAN address"
|
|
||||||
);
|
|
||||||
assert!(laptop.tailscale_addr.is_some());
|
assert!(laptop.tailscale_addr.is_some());
|
||||||
|
|
||||||
cluster.validate().expect("valid cluster config");
|
cluster.validate().expect("valid cluster config");
|
||||||
@@ -402,12 +411,10 @@ tailscale_addr = "100.64.1.5:7701"
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let err = cluster.validate().unwrap_err().to_string();
|
let err = cluster.validate().unwrap_err().to_string();
|
||||||
assert!(
|
assert!(err.contains("no bind address"), "unexpected error: {err}");
|
||||||
err.contains("no bind address"),
|
|
||||||
"unexpected error: {err}"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -434,6 +441,7 @@ tailscale_addr = "100.64.1.5:7701"
|
|||||||
bind_rpc_tailscale: None,
|
bind_rpc_tailscale: None,
|
||||||
tls: None,
|
tls: None,
|
||||||
blob_store_root: None,
|
blob_store_root: None,
|
||||||
|
prom_bind: None,
|
||||||
};
|
};
|
||||||
let err = cluster.validate().unwrap_err().to_string();
|
let err = cluster.validate().unwrap_err().to_string();
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
Reference in New Issue
Block a user