Skip to main content

dynomite/admin/
cluster_info.rs

1//! Plaintext, postmortem-shaped dump of one node's state.
2//!
3//! The output mirrors what an operator wants to attach to a bug
4//! report: build info, the sanitized config, the ring view, the
5//! peer table, queue depths, gossip churn, recent events, and a
6//! coarse memory / file-descriptor accounting block. The shape is
7//! deliberately ASCII-only and grep-friendly: each section opens
8//! with a `=== <name> ===` header followed by `key=value` lines or
9//! per-row entries; no JSON, no YAML, no smart quotes.
10//!
11//! [`ClusterInfoSnapshot`] is the value type: every field is owned
12//! and `Clone`. [`format_text`] renders a snapshot to any
13//! [`std::io::Write`].
14//!
15//! Two helpers exist to obtain a snapshot:
16//!
17//! * [`ClusterInfoSnapshot::synthetic`] - hand-built for tests.
18//! * [`gather_from_handle`] (re-exported below) - assembles a
19//!   snapshot from a live [`crate::embed::ServerHandle`] plus a
20//!   [`RecentEvents`] ring buffer; this is what dynomited wires
21//!   up at boot.
22//!
23//! The snapshot deliberately does not include any secret fields:
24//! Redis `requirepass` values, AUTH passwords, and the contents
25//! of TLS / reconciliation key files are omitted. Paths to those
26//! files are included so an operator can see whether the node is
27//! configured for secure mode without leaking the material.
28
29use std::collections::VecDeque;
30use std::fmt::Write as _;
31use std::io::{self, Write};
32use std::sync::Arc;
33use std::time::{SystemTime, UNIX_EPOCH};
34
35use parking_lot::Mutex;
36
37use serde::Serialize;
38
39use crate::cluster::peer::PeerState;
40use crate::cluster::pool::ServerPool;
41use crate::conf::ConfPool;
42use crate::embed::ServerHandle;
43use crate::hashkit::DynToken;
44use crate::stats::PoolField;
45
46/// Maximum number of recent events retained in the ring buffer.
47///
48/// # Examples
49///
50/// ```
51/// assert_eq!(dynomite::admin::cluster_info::MAX_RECENT_EVENTS, 50);
52/// ```
53pub const MAX_RECENT_EVENTS: usize = 50;
54
55/// One section of the dump expressed as a name plus an ordered
56/// list of `(key, value)` pairs. Sections always render the same
57/// way, so authors can append a new field without touching the
58/// formatter.
59///
60/// # Examples
61///
62/// ```
63/// use dynomite::admin::cluster_info::Section;
64/// let s = Section::with_pairs("build", &[("version", "0.0.1")]);
65/// assert_eq!(s.name(), "build");
66/// assert_eq!(s.pairs().len(), 1);
67/// ```
68#[derive(Clone, Debug, Default, Eq, PartialEq)]
69pub struct Section {
70    name: String,
71    pairs: Vec<(String, String)>,
72}
73
74impl Section {
75    /// Build a section with the supplied name and list of
76    /// `(key, value)` pairs. Both keys and values are copied
77    /// into owned strings so the section may outlive its inputs.
78    ///
79    /// # Examples
80    ///
81    /// ```
82    /// use dynomite::admin::cluster_info::Section;
83    /// let s = Section::with_pairs("hello", &[("k", "v")]);
84    /// assert_eq!(s.pairs()[0].1, "v");
85    /// ```
86    #[must_use]
87    pub fn with_pairs(name: &str, pairs: &[(&str, &str)]) -> Self {
88        Self {
89            name: name.to_string(),
90            pairs: pairs
91                .iter()
92                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
93                .collect(),
94        }
95    }
96
97    /// Section name (without the framing `===` markers).
98    #[must_use]
99    pub fn name(&self) -> &str {
100        &self.name
101    }
102
103    /// Borrow the ordered pair list.
104    #[must_use]
105    pub fn pairs(&self) -> &[(String, String)] {
106        &self.pairs
107    }
108
109    /// Append a `(key, value)` pair to the section.
110    pub fn push<K: Into<String>, V: Into<String>>(&mut self, key: K, value: V) {
111        self.pairs.push((key.into(), value.into()));
112    }
113}
114
115/// One free-form row inside the `peers` or `recent_events`
116/// section. The text has already been rendered to an ASCII line.
117#[derive(Clone, Debug, Default, Eq, PartialEq)]
118pub struct RowSection {
119    name: String,
120    rows: Vec<String>,
121}
122
123impl RowSection {
124    /// Build an empty row-section.
125    #[must_use]
126    pub fn new(name: &str) -> Self {
127        Self {
128            name: name.to_string(),
129            rows: Vec::new(),
130        }
131    }
132
133    /// Section name.
134    #[must_use]
135    pub fn name(&self) -> &str {
136        &self.name
137    }
138
139    /// Borrow the row list.
140    #[must_use]
141    pub fn rows(&self) -> &[String] {
142        &self.rows
143    }
144
145    /// Append a row.
146    pub fn push<S: Into<String>>(&mut self, row: S) {
147        self.rows.push(row.into());
148    }
149}
150
151/// One entry in the recent-events ring buffer.
152///
153/// Stored as a flat tuple so the producer (the gossip / lifecycle
154/// taps) does not have to depend on the cluster-info module to
155/// emit events.
156///
157/// # Examples
158///
159/// ```
160/// use dynomite::admin::cluster_info::RecentEvent;
161/// let e = RecentEvent::new(1_700_000_000, "peer_up", "arnold");
162/// assert_eq!(e.kind, "peer_up");
163/// assert_eq!(e.detail, "arnold");
164/// ```
165#[derive(Clone, Debug, Eq, PartialEq)]
166pub struct RecentEvent {
167    /// Wall-clock time (seconds since the unix epoch).
168    pub ts_secs: u64,
169    /// Short event kind: `peer_up`, `peer_down`, `gossip_round`,
170    /// `config_reloaded`, `restart`. Free-form ASCII; consumers
171    /// match by exact string.
172    pub kind: String,
173    /// Optional payload. ASCII; spaces are allowed.
174    pub detail: String,
175}
176
177impl RecentEvent {
178    /// Build a new event.
179    #[must_use]
180    pub fn new<K: Into<String>, D: Into<String>>(ts_secs: u64, kind: K, detail: D) -> Self {
181        Self {
182            ts_secs,
183            kind: kind.into(),
184            detail: detail.into(),
185        }
186    }
187}
188
189/// Bounded ring buffer of recent lifecycle events.
190///
191/// The ring is held inside an [`Arc<Mutex<...>>`] so the gossip
192/// task and the dump endpoint can share it without contention on
193/// the steady-state path. New entries push to the back; once the
194/// length reaches [`MAX_RECENT_EVENTS`] the oldest entry is
195/// dropped.
196///
197/// # Examples
198///
199/// ```
200/// use dynomite::admin::cluster_info::{RecentEvent, RecentEvents};
201/// let log = RecentEvents::new();
202/// log.push(RecentEvent::new(1, "restart", ""));
203/// assert_eq!(log.snapshot().len(), 1);
204/// ```
205#[derive(Clone, Debug, Default)]
206pub struct RecentEvents {
207    inner: Arc<Mutex<VecDeque<RecentEvent>>>,
208}
209
210impl RecentEvents {
211    /// Construct a fresh, empty ring.
212    #[must_use]
213    pub fn new() -> Self {
214        Self {
215            inner: Arc::new(Mutex::new(VecDeque::with_capacity(MAX_RECENT_EVENTS))),
216        }
217    }
218
219    /// Append `event`, dropping the oldest entry when the ring
220    /// is full.
221    pub fn push(&self, event: RecentEvent) {
222        let mut guard = self.inner.lock();
223        if guard.len() == MAX_RECENT_EVENTS {
224            guard.pop_front();
225        }
226        guard.push_back(event);
227    }
228
229    /// Cloned snapshot of the ring contents (oldest first).
230    #[must_use]
231    pub fn snapshot(&self) -> Vec<RecentEvent> {
232        self.inner.lock().iter().cloned().collect()
233    }
234
235    /// Number of events currently buffered.
236    #[must_use]
237    pub fn len(&self) -> usize {
238        self.inner.lock().len()
239    }
240
241    /// True when the ring has no entries.
242    #[must_use]
243    pub fn is_empty(&self) -> bool {
244        self.inner.lock().is_empty()
245    }
246}
247
248/// Owned snapshot ready to be rendered.
249///
250/// Sections are stored in the canonical render order:
251/// `build`, `config`, `ring`, `peers`, `queues`, `gossip`,
252/// `recent_events`, `memory`, `fds`. Producers should fill every
253/// section even when the underlying data is unavailable; the
254/// formatter happily emits empty sections so the resulting text
255/// has a stable shape across releases.
256///
257/// # Examples
258///
259/// ```
260/// use dynomite::admin::cluster_info::ClusterInfoSnapshot;
261/// let snap = ClusterInfoSnapshot::synthetic();
262/// assert_eq!(snap.build.name(), "build");
263/// ```
264#[derive(Clone, Debug, Default, Eq, PartialEq)]
265pub struct ClusterInfoSnapshot {
266    /// Build identity: version, git sha, build profile.
267    pub build: Section,
268    /// Sanitized configuration view.
269    pub config: Section,
270    /// Ring distribution / vnode counts.
271    pub ring: Section,
272    /// Per-peer rows.
273    pub peers: RowSection,
274    /// Queue-depth gauges.
275    pub queues: Section,
276    /// Gossip churn metrics.
277    pub gossip: Section,
278    /// Bounded ring of lifecycle events.
279    pub recent_events: RowSection,
280    /// Memory accounting block.
281    pub memory: Section,
282    /// Open file-descriptor accounting block.
283    pub fds: Section,
284}
285
286impl ClusterInfoSnapshot {
287    /// Hand-built snapshot useful for unit tests and the doc
288    /// example. Every section is populated with a single
289    /// representative entry.
290    ///
291    /// # Examples
292    ///
293    /// ```
294    /// use dynomite::admin::cluster_info::{format_text, ClusterInfoSnapshot};
295    /// let snap = ClusterInfoSnapshot::synthetic();
296    /// let mut out = Vec::new();
297    /// format_text(&snap, &mut out).unwrap();
298    /// let text = String::from_utf8(out).unwrap();
299    /// assert!(text.contains("=== build ==="));
300    /// ```
301    #[must_use]
302    pub fn synthetic() -> Self {
303        let mut snap = Self {
304            build: Section::with_pairs(
305                "build",
306                &[
307                    ("version", "0.0.1"),
308                    ("git_sha", "unknown"),
309                    ("build_profile", "debug"),
310                ],
311            ),
312            config: Section::with_pairs(
313                "config",
314                &[
315                    ("data_store", "redis"),
316                    ("listen", "0.0.0.0:8102"),
317                    ("dyn_listen", "0.0.0.0:8101"),
318                    ("rack", "rack-1"),
319                    ("dc", "dc-1"),
320                ],
321            ),
322            ring: Section::with_pairs(
323                "ring",
324                &[
325                    ("distribution", "vnode"),
326                    ("vnodes", "1"),
327                    ("tokens_per_node", "1"),
328                ],
329            ),
330            peers: RowSection::new("peers"),
331            queues: Section::with_pairs(
332                "queues",
333                &[
334                    ("dispatcher_inflight", "0"),
335                    ("backend_supervisor_pending", "0"),
336                    ("hint_store_size", "0"),
337                ],
338            ),
339            gossip: Section::with_pairs(
340                "gossip",
341                &[
342                    ("churn_total", "0"),
343                    ("phi_alarms_total", "0"),
344                    ("heartbeats_sent", "0"),
345                    ("heartbeats_received", "0"),
346                ],
347            ),
348            recent_events: RowSection::new("recent_events"),
349            memory: Section::with_pairs("memory", &[("rss_bytes", "unavailable")]),
350            fds: Section::with_pairs("fds", &[("total", "unavailable")]),
351        };
352        snap.peers.push(
353            "peer_id=local dc=dc-1 rack=rack-1 status=Normal phi=0.00 last_seen_ms=0 tokens=1",
354        );
355        snap.recent_events
356            .push("1970-01-01T00:00:00Z restart local-bootstrap");
357        snap
358    }
359}
360
361/// Render `snap` to `w` as ASCII text. Each section is preceded
362/// by a `=== <name> ===` header line, then either `key=value`
363/// pairs (one per line) or free-form rows. Sections render in
364/// the canonical order documented on
365/// [`ClusterInfoSnapshot`]; each section ends with a single
366/// trailing blank line so consumers can split on `\n\n`.
367///
368/// # Errors
369///
370/// Returns the underlying [`std::io::Error`] when the writer
371/// rejects a write.
372///
373/// # Examples
374///
375/// ```
376/// use dynomite::admin::cluster_info::{format_text, ClusterInfoSnapshot};
377/// let snap = ClusterInfoSnapshot::synthetic();
378/// let mut out = Vec::new();
379/// format_text(&snap, &mut out).unwrap();
380/// let text = String::from_utf8(out).unwrap();
381/// for header in [
382///     "=== build ===",
383///     "=== config ===",
384///     "=== ring ===",
385///     "=== peers ===",
386///     "=== queues ===",
387///     "=== gossip ===",
388///     "=== recent_events ===",
389///     "=== memory ===",
390///     "=== fds ===",
391/// ] {
392///     assert!(text.contains(header), "missing {header}");
393/// }
394/// ```
395pub fn format_text<W: Write>(snap: &ClusterInfoSnapshot, w: &mut W) -> io::Result<()> {
396    write_pair_section(w, &snap.build)?;
397    write_pair_section(w, &snap.config)?;
398    write_pair_section(w, &snap.ring)?;
399    write_row_section(w, &snap.peers)?;
400    write_pair_section(w, &snap.queues)?;
401    write_pair_section(w, &snap.gossip)?;
402    write_row_section(w, &snap.recent_events)?;
403    write_pair_section(w, &snap.memory)?;
404    write_pair_section(w, &snap.fds)?;
405    Ok(())
406}
407
408fn write_pair_section<W: Write>(w: &mut W, s: &Section) -> io::Result<()> {
409    writeln!(w, "=== {} ===", s.name())?;
410    for (k, v) in &s.pairs {
411        writeln!(w, "{k}={v}")?;
412    }
413    writeln!(w)
414}
415
416fn write_row_section<W: Write>(w: &mut W, s: &RowSection) -> io::Result<()> {
417    writeln!(w, "=== {} ===", s.name())?;
418    for r in &s.rows {
419        writeln!(w, "{r}")?;
420    }
421    writeln!(w)
422}
423
424// ----- snapshot builders -------------------------------------------------
425
426/// Compile-time crate version.
427#[must_use]
428pub fn build_version() -> &'static str {
429    env!("CARGO_PKG_VERSION")
430}
431
432/// Best-effort short git SHA. Looks at the `DYNOMITE_GIT_SHA`
433/// environment variable at compile time and falls back to
434/// `unknown` when unset. The dynomited build script (if any) is
435/// expected to populate the variable; the engine library does
436/// not require it.
437#[must_use]
438pub fn build_git_sha() -> &'static str {
439    option_env!("DYNOMITE_GIT_SHA").unwrap_or("unknown")
440}
441
442/// Returns `release` when compiled with optimisations, `debug`
443/// otherwise.
444#[must_use]
445pub fn build_profile() -> &'static str {
446    if cfg!(debug_assertions) {
447        "debug"
448    } else {
449        "release"
450    }
451}
452
453/// Assemble the `build` section.
454#[must_use]
455pub fn build_section() -> Section {
456    Section::with_pairs(
457        "build",
458        &[
459            ("version", build_version()),
460            ("git_sha", build_git_sha()),
461            ("build_profile", build_profile()),
462        ],
463    )
464}
465
466/// Set of YAML field names whose values must never appear in the
467/// dump. Paths to key files are fine; the file contents and
468/// passwords are not.
469const SECRET_FIELDS: &[&str] = &["redis_requirepass"];
470
471/// True when `key` names a sensitive configuration field.
472#[must_use]
473pub fn is_secret_config_field(key: &str) -> bool {
474    SECRET_FIELDS.contains(&key)
475}
476
477/// Build the sanitized `config` section from a [`ConfPool`].
478///
479/// Only fields useful in postmortems are included; secret
480/// material (Redis AUTH password) is masked as `redacted`. The
481/// emission order is fixed so the rendered text is reproducible.
482///
483/// # Examples
484///
485/// ```
486/// use dynomite::admin::cluster_info::config_section;
487/// use dynomite::conf::ConfPool;
488/// let mut p = ConfPool::default();
489/// p.apply_defaults();
490/// let s = config_section(&p);
491/// assert_eq!(s.name(), "config");
492/// // No secret value ever leaks.
493/// for (_, v) in s.pairs() {
494///     assert!(!v.contains("super-secret-password"));
495/// }
496/// ```
497#[must_use]
498pub fn config_section(pool: &ConfPool) -> Section {
499    let mut s = Section::with_pairs("config", &[]);
500    push_config_listeners(&mut s, pool);
501    push_config_topology(&mut s, pool);
502    push_config_security(&mut s, pool);
503    push_config_runtime(&mut s, pool);
504    s
505}
506
507fn push_config_listeners(s: &mut Section, pool: &ConfPool) {
508    let listen = pool
509        .listen
510        .as_ref()
511        .map_or_else(|| "unset".to_string(), endpoint_to_string);
512    let dyn_listen = pool
513        .dyn_listen
514        .as_ref()
515        .map_or_else(|| "unset".to_string(), endpoint_to_string);
516    let stats_listen = pool
517        .stats_listen
518        .as_ref()
519        .map_or_else(|| "unset".to_string(), endpoint_to_string);
520    let data_store = match pool.data_store {
521        Some(0) => "redis".to_string(),
522        Some(1) => "memcache".to_string(),
523        Some(2) => "noxu".to_string(),
524        Some(n) => format!("unknown({n})"),
525        None => "unset".to_string(),
526    };
527    s.push("data_store", data_store);
528    s.push("listen", listen);
529    s.push("dyn_listen", dyn_listen);
530    s.push("stats_listen", stats_listen);
531}
532
533fn push_config_topology(s: &mut Section, pool: &ConfPool) {
534    s.push("rack", pool.rack.clone().unwrap_or_else(|| "unset".into()));
535    s.push(
536        "dc",
537        pool.datacenter.clone().unwrap_or_else(|| "unset".into()),
538    );
539    s.push("env", pool.env.clone().unwrap_or_else(|| "unset".into()));
540    s.push(
541        "distribution",
542        pool.resolved_distribution().as_str().to_string(),
543    );
544    s.push(
545        "hash",
546        pool.hash
547            .map_or_else(|| "unset".to_string(), |h| h.as_str().to_string()),
548    );
549}
550
551fn push_config_security(s: &mut Section, pool: &ConfPool) {
552    s.push(
553        "secure_server_option",
554        pool.secure_server_option
555            .clone()
556            .unwrap_or_else(|| "unset".into()),
557    );
558    s.push(
559        "pem_key_file",
560        pool.pem_key_file.clone().unwrap_or_else(|| "unset".into()),
561    );
562    s.push(
563        "recon_key_file",
564        pool.recon_key_file
565            .clone()
566            .unwrap_or_else(|| "unset".into()),
567    );
568    s.push(
569        "recon_iv_file",
570        pool.recon_iv_file.clone().unwrap_or_else(|| "unset".into()),
571    );
572    s.push(
573        "redis_requirepass",
574        if pool.redis_requirepass.is_some() {
575            "redacted".to_string()
576        } else {
577            "unset".to_string()
578        },
579    );
580}
581
582fn push_config_runtime(s: &mut Section, pool: &ConfPool) {
583    s.push(
584        "timeout_ms",
585        pool.timeout
586            .map_or_else(|| "unset".to_string(), |n| n.to_string()),
587    );
588    s.push(
589        "preconnect",
590        pool.preconnect
591            .map_or_else(|| "unset".to_string(), |b| b.to_string()),
592    );
593    s.push(
594        "auto_eject_hosts",
595        pool.auto_eject_hosts
596            .map_or_else(|| "unset".to_string(), |b| b.to_string()),
597    );
598    s.push(
599        "enable_hinted_handoff",
600        pool.enable_hinted_handoff
601            .map_or_else(|| "unset".to_string(), |b| b.to_string()),
602    );
603    s.push(
604        "enable_gossip",
605        pool.enable_gossip
606            .map_or_else(|| "unset".to_string(), |b| b.to_string()),
607    );
608    s.push(
609        "read_consistency",
610        pool.read_consistency
611            .clone()
612            .unwrap_or_else(|| "unset".into()),
613    );
614    s.push(
615        "write_consistency",
616        pool.write_consistency
617            .clone()
618            .unwrap_or_else(|| "unset".into()),
619    );
620}
621
622fn endpoint_to_string(l: &crate::conf::ConfListen) -> String {
623    format!("{}:{}", l.name(), l.port())
624}
625
626/// Build the `ring` section from the resolved distribution and
627/// the live ring snapshot.
628#[must_use]
629pub fn ring_section(distribution: &str, ring_entries: usize, tokens_per_node: usize) -> Section {
630    let mut s = Section::with_pairs("ring", &[]);
631    s.push("distribution", distribution);
632    s.push("vnodes", ring_entries.to_string());
633    s.push("tokens_per_node", tokens_per_node.to_string());
634    s
635}
636
637/// Format a single peer row.
638#[must_use]
639pub fn peer_row(
640    peer_id: &str,
641    dc: &str,
642    rack: &str,
643    state: PeerState,
644    phi: f64,
645    last_seen_ms: i64,
646    tokens: usize,
647) -> String {
648    format!(
649        "peer_id={peer_id} dc={dc} rack={rack} status={status} phi={phi:.2} \
650         last_seen_ms={last_seen_ms} tokens={tokens}",
651        status = state.name(),
652    )
653}
654
655/// One peer rendered as a typed, machine-parseable ring row.
656///
657/// This is the JSON-friendly counterpart to the free-form
658/// [`RowSection`] used by the plaintext dump: every field is a
659/// named value so a consumer (such as `dyn-admin ring-status`)
660/// can deserialize the whole ring without scraping text.
661///
662/// # Examples
663///
664/// ```
665/// use dynomite::admin::cluster_info::RingPeer;
666/// let p = RingPeer {
667///     node: "10.0.0.1:8101".into(),
668///     dc: "dc1".into(),
669///     rack: "r1".into(),
670///     tokens: vec![0, 2_147_483_648],
671///     state: "NORMAL".into(),
672///     is_local: true,
673/// };
674/// assert_eq!(p.tokens.len(), 2);
675/// ```
676#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
677pub struct RingPeer {
678    /// Node label: `host:port` for a remote peer, or the local
679    /// node's endpoint.
680    pub node: String,
681    /// Datacenter name.
682    pub dc: String,
683    /// Rack name.
684    pub rack: String,
685    /// Token list as the `u32` integers the engine uses for ring
686    /// lookups.
687    pub tokens: Vec<u32>,
688    /// Lifecycle state name (`JOINING`, `NORMAL`, `DOWN`, ...).
689    pub state: String,
690    /// True for the local peer.
691    pub is_local: bool,
692}
693
694/// Typed, JSON-serializable view of the whole ring.
695///
696/// Produced by [`gather_ring_from_pool`] and served verbatim on
697/// the stats server's `/ring` route.
698///
699/// # Examples
700///
701/// ```
702/// use dynomite::admin::cluster_info::RingSnapshot;
703/// let snap = RingSnapshot { peers: Vec::new() };
704/// assert!(snap.peers.is_empty());
705/// ```
706#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize)]
707pub struct RingSnapshot {
708    /// One entry per peer the pool knows about.
709    pub peers: Vec<RingPeer>,
710}
711
712/// Build a [`RingSnapshot`] from a live [`ServerPool`]: every
713/// peer's endpoint, dc, rack, tokens, and lifecycle state, read
714/// under the pool's existing peer `RwLock`.
715///
716/// # Examples
717///
718/// ```
719/// use std::sync::Arc;
720///
721/// use dynomite::admin::cluster_info::gather_ring_from_pool;
722/// use dynomite::cluster::peer::{Peer, PeerEndpoint, PeerState};
723/// use dynomite::cluster::pool::{PoolConfig, ServerPool};
724/// use dynomite::hashkit::DynToken;
725///
726/// let local = Peer::new(
727///     0, PeerEndpoint::tcp("127.0.0.1".into(), 8101), "r1".into(),
728///     "dc1".into(), vec![DynToken::from_u32(0)], true, true, false,
729/// );
730/// let pool = ServerPool::new(PoolConfig::default(), vec![local]);
731/// let ring = gather_ring_from_pool(&pool);
732/// assert_eq!(ring.peers.len(), 1);
733/// assert!(ring.peers[0].is_local);
734/// ```
735#[must_use]
736pub fn gather_ring_from_pool(pool: &ServerPool) -> RingSnapshot {
737    let peers = pool.peers().read();
738    let rows = peers
739        .iter()
740        .map(|p| RingPeer {
741            node: format!("{}:{}", p.endpoint().host(), p.endpoint().port()),
742            dc: p.dc().to_string(),
743            rack: p.rack().to_string(),
744            tokens: p.tokens().iter().map(DynToken::get_int).collect(),
745            state: p.state().name().to_string(),
746            is_local: p.is_local(),
747        })
748        .collect();
749    RingSnapshot { peers: rows }
750}
751
752/// Build the `queues` section from a stats snapshot.
753#[must_use]
754pub fn queues_section(snap: &crate::stats::Snapshot) -> Section {
755    let mut s = Section::with_pairs("queues", &[]);
756    let dispatcher = snap.pool.metrics[PoolField::DnodeClientInQueue.index()]
757        + snap.pool.metrics[PoolField::DnodeClientOutQueue.index()];
758    let backend = snap.server.metrics[crate::stats::ServerField::InQueue.index()]
759        + snap.server.metrics[crate::stats::ServerField::OutQueue.index()];
760    let hint_store = snap.pool.metrics[PoolField::PeerInQueue.index()]
761        + snap.pool.metrics[PoolField::RemotePeerInQueue.index()];
762    s.push("dispatcher_inflight", dispatcher.to_string());
763    s.push("backend_supervisor_pending", backend.to_string());
764    s.push("hint_store_size", hint_store.to_string());
765    s
766}
767
768/// Build the `gossip` section from a stats snapshot. Only the
769/// counters tracked today are surfaced; missing fields are
770/// emitted as `unavailable`.
771#[must_use]
772pub fn gossip_section(snap: &crate::stats::Snapshot) -> Section {
773    let mut s = Section::with_pairs("gossip", &[]);
774    let churn = snap.pool.metrics[PoolField::StatsCount.index()];
775    // Phi >= 8.0 is the conviction threshold defined in
776    // failure_detector::DEFAULT_THRESHOLD; we surface a count
777    // of peers currently above it as the alarm gauge.
778    let alarms: i64 = snap
779        .failure
780        .peer_phi
781        .iter()
782        .filter(|p| p.phi >= 8.0)
783        .count()
784        .try_into()
785        .unwrap_or(i64::MAX);
786    let transitions: i64 = snap
787        .failure
788        .peer_state_transitions
789        .iter()
790        .map(|t| i64::try_from(t.count).unwrap_or(i64::MAX))
791        .sum();
792    s.push("churn_total", transitions.to_string());
793    s.push("phi_alarms_total", alarms.to_string());
794    s.push("stats_count", churn.to_string());
795    s.push("heartbeats_sent", "unavailable".to_string());
796    s.push("heartbeats_received", "unavailable".to_string());
797    s
798}
799
800/// Build the `memory` section by scraping `/proc/self/status` on
801/// Linux. Other platforms emit `unavailable`.
802#[must_use]
803pub fn memory_section(dyn_memory: i64) -> Section {
804    let mut s = Section::with_pairs("memory", &[]);
805    let rss = read_proc_status_kb("VmRSS").map_or_else(
806        || "unavailable".to_string(),
807        |kb| (kb.saturating_mul(1024)).to_string(),
808    );
809    let vms = read_proc_status_kb("VmSize").map_or_else(
810        || "unavailable".to_string(),
811        |kb| (kb.saturating_mul(1024)).to_string(),
812    );
813    s.push("rss_bytes", rss);
814    s.push("vms_bytes", vms);
815    s.push("hint_store_bytes", "unavailable".to_string());
816    s.push("mbuf_pool_bytes", dyn_memory.to_string());
817    s
818}
819
820/// Build the `fds` section by counting `/proc/self/fd` on Linux.
821/// Other platforms emit `unavailable`.
822#[must_use]
823pub fn fds_section() -> Section {
824    let mut s = Section::with_pairs("fds", &[]);
825    let total = match std::fs::read_dir("/proc/self/fd") {
826        Ok(rd) => rd.count().to_string(),
827        Err(_) => "unavailable".to_string(),
828    };
829    s.push("total", total);
830    s.push("listening", "unavailable".to_string());
831    s.push("peer_links", "unavailable".to_string());
832    s
833}
834
835/// Read `/proc/self/status` on Linux and parse the value (in
836/// kilobytes) of the named line. Returns `None` on any error
837/// or when the field is absent.
838fn read_proc_status_kb(field: &str) -> Option<u64> {
839    let s = std::fs::read_to_string("/proc/self/status").ok()?;
840    for line in s.lines() {
841        let Some(rest) = line.strip_prefix(field) else {
842            continue;
843        };
844        // Accept either `Field:\tNNN kB` or `Field: NNN kB`.
845        let rest = rest.trim_start_matches(':').trim();
846        let mut it = rest.split_whitespace();
847        let n_str = it.next()?;
848        return n_str.parse::<u64>().ok();
849    }
850    None
851}
852
853/// Render an iso-8601 ASCII UTC timestamp from `ts_secs`.
854/// Tolerates pre-1970 inputs by emitting `1970-01-01T00:00:00Z`.
855#[must_use]
856pub fn render_timestamp(ts_secs: u64) -> String {
857    let mut out = String::with_capacity(20);
858    let (y, mo, d, h, mi, se) = secs_to_components(ts_secs);
859    let _ = write!(out, "{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{se:02}Z");
860    out
861}
862
863/// Decompose `ts_secs` into `(year, month, day, hour, minute,
864/// second)` UTC components without a third-party calendar
865/// dependency. Years through `9999` are representable; inputs
866/// past that horizon saturate.
867fn secs_to_components(ts_secs: u64) -> (u64, u64, u64, u64, u64, u64) {
868    let secs_per_day: u64 = 86_400;
869    let days = ts_secs / secs_per_day;
870    let day_rem = ts_secs % secs_per_day;
871    let hour = day_rem / 3_600;
872    let minute = (day_rem % 3_600) / 60;
873    let second = day_rem % 60;
874    let (year, month, day) = days_to_civil(days);
875    (year, month, day, hour, minute, second)
876}
877
878/// Howard Hinnant's `civil_from_days` adapted to non-negative
879/// inputs and `u64` arithmetic. Returns a `(year, month, day)`
880/// triple; the algorithm is documented at
881/// <https://howardhinnant.github.io/date_algorithms.html>.
882fn days_to_civil(days_since_epoch: u64) -> (u64, u64, u64) {
883    let serial = days_since_epoch + 719_468;
884    let era = serial / 146_097;
885    let day_of_era = serial - era * 146_097;
886    let year_of_era =
887        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
888    let mut year = year_of_era + era * 400;
889    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
890    let month_phase = (5 * day_of_year + 2) / 153;
891    let day = day_of_year - (153 * month_phase + 2) / 5 + 1;
892    let month = if month_phase < 10 {
893        month_phase + 3
894    } else {
895        month_phase.saturating_sub(9)
896    };
897    if month <= 2 {
898        year += 1;
899    }
900    (year, month, day)
901}
902
903/// Render the `recent_events` section from the snapshot of a
904/// [`RecentEvents`] ring.
905#[must_use]
906pub fn recent_events_section(events: &[RecentEvent]) -> RowSection {
907    let mut sec = RowSection::new("recent_events");
908    for e in events {
909        let detail = if e.detail.is_empty() {
910            String::new()
911        } else {
912            format!(" {}", e.detail)
913        };
914        sec.push(format!(
915            "{} {}{}",
916            render_timestamp(e.ts_secs),
917            e.kind,
918            detail
919        ));
920    }
921    sec
922}
923
924/// Assemble a snapshot from a live [`ServerHandle`] plus a
925/// shared [`RecentEvents`] ring.
926///
927/// This is the call dynomited makes when the operator hits
928/// `GET /cluster-info.txt`: it folds together the build, config,
929/// ring, peers, queues, gossip, recent-events, memory, and fds
930/// sections so the response shape is independent of the binary
931/// layer.
932#[must_use]
933pub fn gather_from_handle(
934    handle: &ServerHandle,
935    pool: &ConfPool,
936    events: &RecentEvents,
937) -> ClusterInfoSnapshot {
938    let stats = handle.stats();
939    let ring = handle.ring();
940    let peers_view = handle.peers();
941    let tokens_per_node = peers_view
942        .iter()
943        .find(|p| p.is_local)
944        .map_or(0, |p| p.tokens.len());
945    let dist = pool.resolved_distribution().as_str();
946    let mut peer_rows = RowSection::new("peers");
947    let _now_secs = SystemTime::now()
948        .duration_since(UNIX_EPOCH)
949        .map_or(0, |d| d.as_secs());
950    for p in &peers_view {
951        // PeerSnapshot doesn't carry the gossip last-seen
952        // timestamp today; emit 0 so the field is present but
953        // honestly empty. Updating PeerSnapshot to surface
954        // `last_state_ts_secs()` is tracked as a follow-up.
955        let last_seen_ms: i64 = 0;
956        let row = peer_row(
957            &peer_label(p),
958            &p.dc,
959            &p.rack,
960            p.state,
961            phi_for(&stats, p.idx),
962            last_seen_ms,
963            p.tokens.len(),
964        );
965        peer_rows.push(row);
966    }
967    ClusterInfoSnapshot {
968        build: build_section(),
969        config: config_section(pool),
970        ring: ring_section(dist, ring.entries.len(), tokens_per_node),
971        peers: peer_rows,
972        queues: queues_section(&stats),
973        gossip: gossip_section(&stats),
974        recent_events: recent_events_section(&events.snapshot()),
975        memory: memory_section(stats.dyn_memory),
976        fds: fds_section(),
977    }
978}
979
980fn peer_label(p: &crate::embed::PeerSnapshot) -> String {
981    if p.is_local {
982        "local".to_string()
983    } else {
984        format!("{}:{}", p.host, p.port)
985    }
986}
987
988fn phi_for(stats: &crate::stats::Snapshot, peer_idx: u32) -> f64 {
989    stats
990        .failure
991        .peer_phi
992        .iter()
993        .find(|p| p.peer_idx == peer_idx)
994        .map_or(0.0, |p| p.phi)
995}
996
997#[cfg(test)]
998mod tests {
999    use super::*;
1000
1001    #[test]
1002    fn synthetic_snapshot_renders_all_sections() {
1003        let snap = ClusterInfoSnapshot::synthetic();
1004        let mut buf = Vec::new();
1005        format_text(&snap, &mut buf).expect("format");
1006        let text = String::from_utf8(buf).expect("ascii");
1007        for header in [
1008            "=== build ===",
1009            "=== config ===",
1010            "=== ring ===",
1011            "=== peers ===",
1012            "=== queues ===",
1013            "=== gossip ===",
1014            "=== recent_events ===",
1015            "=== memory ===",
1016            "=== fds ===",
1017        ] {
1018            assert!(text.contains(header), "missing {header}");
1019        }
1020        assert!(text.is_ascii());
1021    }
1022
1023    #[test]
1024    fn config_section_redacts_requirepass() {
1025        let mut pool = ConfPool::default();
1026        pool.apply_defaults();
1027        pool.redis_requirepass = Some("super-secret-password".into());
1028        let sec = config_section(&pool);
1029        let pair = sec
1030            .pairs()
1031            .iter()
1032            .find(|(k, _)| k == "redis_requirepass")
1033            .expect("present");
1034        assert_eq!(pair.1, "redacted");
1035        let mut buf = Vec::new();
1036        let snap = ClusterInfoSnapshot {
1037            config: sec,
1038            ..ClusterInfoSnapshot::synthetic()
1039        };
1040        format_text(&snap, &mut buf).unwrap();
1041        let text = String::from_utf8(buf).unwrap();
1042        assert!(!text.contains("super-secret-password"));
1043    }
1044
1045    #[test]
1046    fn ring_buffer_drops_oldest_when_full() {
1047        let log = RecentEvents::new();
1048        for i in 0..(MAX_RECENT_EVENTS + 5) {
1049            log.push(RecentEvent::new(i as u64, "tick", ""));
1050        }
1051        let snap = log.snapshot();
1052        assert_eq!(snap.len(), MAX_RECENT_EVENTS);
1053        assert_eq!(snap.first().unwrap().ts_secs, 5);
1054        assert_eq!(snap.last().unwrap().ts_secs, (MAX_RECENT_EVENTS + 4) as u64);
1055    }
1056
1057    #[test]
1058    fn timestamp_renders_known_epoch() {
1059        assert_eq!(render_timestamp(0), "1970-01-01T00:00:00Z");
1060        // 2026-05-27T00:00:00Z
1061        assert_eq!(render_timestamp(1_779_840_000), "2026-05-27T00:00:00Z");
1062    }
1063
1064    #[test]
1065    fn is_secret_reports_known_fields() {
1066        assert!(is_secret_config_field("redis_requirepass"));
1067        assert!(!is_secret_config_field("listen"));
1068    }
1069
1070    #[test]
1071    fn peer_row_is_ascii_and_well_formed() {
1072        let row = peer_row(
1073            "arnold",
1074            "dc-arnold",
1075            "rack-1",
1076            PeerState::Normal,
1077            0.42,
1078            120,
1079            3,
1080        );
1081        assert!(row.is_ascii());
1082        assert!(row.contains("phi=0.42"));
1083        assert!(row.contains("status=NORMAL"));
1084        assert!(row.contains("tokens=3"));
1085    }
1086
1087    #[test]
1088    fn build_profile_is_one_of_two_values() {
1089        let p = build_profile();
1090        assert!(p == "debug" || p == "release");
1091    }
1092}