Skip to main content

hermod/server/
config.rs

1//! Haskell-compatible YAML configuration types for `hermod-tracer`
2//!
3//! [`TracerConfig`] mirrors the Haskell `TracerConfig` record from
4//! `cardano-tracer`.  Field names use Haskell camelCase via
5//! `#[serde(rename = "...")]` so that existing `cardano-tracer` YAML config
6//! files work with `hermod-tracer` unchanged.
7//!
8//! See `config/hermod-tracer.yaml` in the repository for a fully-annotated
9//! example with all options and their defaults.
10
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::path::{Path, PathBuf};
15
16/// Top-level tracer configuration
17#[derive(Debug, Clone, Deserialize, Serialize)]
18pub struct TracerConfig {
19    /// Cardano network magic
20    #[serde(rename = "networkMagic")]
21    pub network_magic: u32,
22
23    /// How to connect to forwarder nodes
24    pub network: Network,
25
26    /// Number of trace objects to request per round-trip (default 100)
27    #[serde(rename = "loRequestNum")]
28    pub lo_request_num: Option<u16>,
29
30    /// Frequency of EKG metric polls in seconds (default 1.0)
31    #[serde(rename = "ekgRequestFreq")]
32    pub ekg_request_freq: Option<f64>,
33
34    /// Enable EKG HTTP endpoint at this address
35    #[serde(rename = "hasEKG")]
36    pub has_ekg: Option<Endpoint>,
37
38    /// Enable Prometheus HTTP endpoint at this address
39    #[serde(rename = "hasPrometheus")]
40    pub has_prometheus: Option<Endpoint>,
41
42    /// Re-forwarding configuration
43    #[serde(rename = "hasForwarding")]
44    pub has_forwarding: Option<ReForwardingConfig>,
45
46    /// Log output configurations (at least one required)
47    pub logging: Vec<LoggingParams>,
48
49    /// Log rotation parameters
50    pub rotation: Option<RotationParams>,
51
52    /// Verbosity level for tracer's own logging
53    pub verbosity: Option<Verbosity>,
54
55    /// If true, strip `_total`/`_int`/`_double` suffixes from Prometheus metric names
56    #[serde(rename = "metricsNoSuffix")]
57    pub metrics_no_suffix: Option<bool>,
58
59    /// Whether to request all metrics (true) or only updated metrics (false)
60    #[serde(rename = "ekgRequestFull")]
61    pub ekg_request_full: Option<bool>,
62
63    /// Extra labels to attach to Prometheus metrics
64    #[serde(rename = "prometheusLabels")]
65    pub prometheus_labels: Option<HashMap<String, String>>,
66}
67
68impl TracerConfig {
69    /// Parse from a YAML file path
70    pub fn from_file(path: &Path) -> Result<Self> {
71        let content = std::fs::read_to_string(path)
72            .with_context(|| format!("reading config file {}", path.display()))?;
73        Self::from_yaml(&content)
74    }
75
76    /// Parse from a YAML string
77    pub fn from_yaml(yaml: &str) -> Result<Self> {
78        serde_yaml::from_str(yaml).context("parsing TracerConfig YAML")
79    }
80
81    /// Number of traces to request per round-trip
82    pub fn lo_request_num(&self) -> u16 {
83        self.lo_request_num.unwrap_or(100)
84    }
85
86    /// EKG poll frequency in seconds
87    pub fn ekg_request_freq(&self) -> f64 {
88        self.ekg_request_freq.unwrap_or(1.0)
89    }
90}
91
92/// How to connect to forwarder nodes
93#[derive(Debug, Clone, Deserialize, Serialize)]
94#[serde(tag = "tag", content = "contents")]
95pub enum Network {
96    /// Listen on this address for forwarder connections
97    AcceptAt(Address),
98    /// Connect out to these addresses (each is a forwarder)
99    ConnectTo(Vec<Address>),
100}
101
102/// A network address — either a Unix socket path or a TCP host:port
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Address {
105    /// Unix domain socket
106    LocalPipe(PathBuf),
107    /// TCP address
108    RemoteSocket(String, u16),
109}
110
111impl Address {
112    /// Display as a string (for node ID assignment)
113    pub fn to_node_id(&self) -> String {
114        match self {
115            Address::LocalPipe(p) => p.display().to_string(),
116            Address::RemoteSocket(host, port) => format!("{}:{}", host, port),
117        }
118    }
119}
120
121impl<'de> Deserialize<'de> for Address {
122    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
123        let s = String::deserialize(de)?;
124        // If string looks like "host:port" and port is a valid u16, treat as TCP
125        if let Some(idx) = s.rfind(':') {
126            let potential_port = &s[idx + 1..];
127            if let Ok(port) = potential_port.parse::<u16>() {
128                let host = s[..idx].to_string();
129                // Sanity check: host should not contain '/' (which would indicate a path)
130                if !host.contains('/') {
131                    return Ok(Address::RemoteSocket(host, port));
132                }
133            }
134        }
135        Ok(Address::LocalPipe(PathBuf::from(s)))
136    }
137}
138
139impl Serialize for Address {
140    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
141        match self {
142            Address::LocalPipe(p) => s.serialize_str(&p.display().to_string()),
143            Address::RemoteSocket(host, port) => s.serialize_str(&format!("{}:{}", host, port)),
144        }
145    }
146}
147
148/// An HTTP endpoint (host + port)
149#[derive(Debug, Clone, Deserialize, Serialize)]
150pub struct Endpoint {
151    /// Hostname or IP address
152    #[serde(rename = "epHost")]
153    pub ep_host: String,
154    /// Port number
155    #[serde(rename = "epPort")]
156    pub ep_port: u16,
157}
158
159impl Endpoint {
160    /// Return `"host:port"` string
161    pub fn to_addr(&self) -> String {
162        format!("{}:{}", self.ep_host, self.ep_port)
163    }
164}
165
166/// Per-logging-destination configuration
167#[derive(Debug, Clone, Deserialize, Serialize)]
168pub struct LoggingParams {
169    /// Root directory for log files
170    #[serde(rename = "logRoot")]
171    pub log_root: PathBuf,
172
173    /// Whether to use file-based or journal-based logging
174    #[serde(rename = "logMode")]
175    pub log_mode: LogMode,
176
177    /// Human-readable or machine-readable format
178    #[serde(rename = "logFormat")]
179    pub log_format: LogFormat,
180}
181
182/// Logging mode
183#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
184pub enum LogMode {
185    /// Write to files under `log_root`
186    FileMode,
187    /// Write to the system journal (journald)
188    JournalMode,
189}
190
191/// Log format
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
193pub enum LogFormat {
194    /// Human-readable text
195    ForHuman,
196    /// Machine-readable JSON
197    ForMachine,
198}
199
200/// Log rotation parameters
201#[derive(Debug, Clone, Deserialize, Serialize)]
202pub struct RotationParams {
203    /// How often to check for rotation (seconds)
204    #[serde(rename = "rpFrequencySecs")]
205    pub rp_frequency_secs: u32,
206
207    /// Rotate when the current file exceeds this size (bytes)
208    #[serde(rename = "rpLogLimitBytes")]
209    pub rp_log_limit_bytes: u64,
210
211    /// Delete files older than this many hours
212    #[serde(rename = "rpMaxAgeHours")]
213    pub rp_max_age_hours: u64,
214
215    /// Always keep at least this many of the newest files
216    #[serde(rename = "rpKeepFilesNum")]
217    pub rp_keep_files_num: u32,
218}
219
220/// Tracer's own log verbosity
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
222pub enum Verbosity {
223    /// Log everything
224    Maximum,
225    /// Log errors only
226    ErrorsOnly,
227    /// Log nothing
228    Minimum,
229}
230
231/// Re-forwarding configuration
232///
233/// Receives traces and relays them to a downstream acceptor socket,
234/// optionally filtering by namespace prefix.
235#[derive(Debug, Clone, Deserialize, Serialize)]
236pub struct ReForwardingConfig {
237    /// Where to accept downstream connections (or connect to downstream)
238    pub network: Network,
239
240    /// Optional namespace prefix filters; only matching traces are forwarded
241    #[serde(rename = "namespaceFilters")]
242    pub namespace_filters: Option<Vec<Vec<String>>>,
243
244    /// Forwarding options (queue size, reconnect delay, etc.)
245    #[serde(rename = "forwarderOpts")]
246    pub forwarder_opts: TraceOptionForwarder,
247}
248
249/// Forwarder options within a re-forwarding config
250#[derive(Debug, Clone, Deserialize, Serialize)]
251pub struct TraceOptionForwarder {
252    /// Outbound queue capacity
253    #[serde(rename = "queueSize", default = "default_queue_size")]
254    pub queue_size: usize,
255}
256
257fn default_queue_size() -> usize {
258    1000
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    const MINIMAL_YAML: &str = r#"
266networkMagic: 42
267network:
268  tag: AcceptAt
269  contents: "/tmp/hermod.sock"
270logging:
271- logRoot: "/tmp/hermod-logs"
272  logMode: FileMode
273  logFormat: ForMachine
274"#;
275
276    const COMPLETE_YAML: &str = r#"
277networkMagic: 42
278network:
279  tag: ConnectTo
280  contents:
281  - "/tmp/hermod.sock"
282loRequestNum: 100
283ekgRequestFreq: 2
284hasEKG:
285  epHost: 127.0.0.1
286  epPort: 9754
287hasPrometheus:
288  epHost: 127.0.0.1
289  epPort: 9753
290logging:
291- logRoot: "/tmp/hermod-logs-human"
292  logMode: FileMode
293  logFormat: ForHuman
294- logRoot: "/tmp/hermod-logs"
295  logMode: FileMode
296  logFormat: ForMachine
297rotation:
298  rpFrequencySecs: 15
299  rpKeepFilesNum: 1
300  rpLogLimitBytes: 50000
301  rpMaxAgeHours: 1
302verbosity: ErrorsOnly
303"#;
304
305    #[test]
306    fn test_parse_minimal_yaml() {
307        let cfg = TracerConfig::from_yaml(MINIMAL_YAML).unwrap();
308        assert_eq!(cfg.network_magic, 42);
309        assert!(matches!(cfg.network, Network::AcceptAt(_)));
310        if let Network::AcceptAt(addr) = &cfg.network {
311            assert_eq!(*addr, Address::LocalPipe("/tmp/hermod.sock".into()));
312        }
313        assert_eq!(cfg.logging.len(), 1);
314        assert_eq!(cfg.logging[0].log_format, LogFormat::ForMachine);
315        assert_eq!(cfg.lo_request_num(), 100); // default
316        assert!((cfg.ekg_request_freq() - 1.0).abs() < f64::EPSILON); // default
317    }
318
319    #[test]
320    fn test_parse_complete_yaml() {
321        let cfg = TracerConfig::from_yaml(COMPLETE_YAML).unwrap();
322        assert_eq!(cfg.network_magic, 42);
323        assert!(matches!(cfg.network, Network::ConnectTo(_)));
324        if let Network::ConnectTo(addrs) = &cfg.network {
325            assert_eq!(addrs.len(), 1);
326            assert_eq!(addrs[0], Address::LocalPipe("/tmp/hermod.sock".into()));
327        }
328        assert_eq!(cfg.lo_request_num(), 100);
329        assert!((cfg.ekg_request_freq() - 2.0).abs() < f64::EPSILON);
330        assert!(cfg.has_ekg.is_some());
331        assert!(cfg.has_prometheus.is_some());
332        let prom = cfg.has_prometheus.as_ref().unwrap();
333        assert_eq!(prom.ep_host, "127.0.0.1");
334        assert_eq!(prom.ep_port, 9753);
335        assert_eq!(cfg.logging.len(), 2);
336        assert_eq!(cfg.logging[0].log_format, LogFormat::ForHuman);
337        assert_eq!(cfg.logging[1].log_format, LogFormat::ForMachine);
338        let rot = cfg.rotation.as_ref().unwrap();
339        assert_eq!(rot.rp_frequency_secs, 15);
340        assert_eq!(rot.rp_log_limit_bytes, 50000);
341        assert_eq!(rot.rp_max_age_hours, 1);
342        assert_eq!(rot.rp_keep_files_num, 1);
343        assert_eq!(cfg.verbosity, Some(Verbosity::ErrorsOnly));
344    }
345
346    #[test]
347    fn test_address_parsing_unix() {
348        let addr: Address = serde_yaml::from_str("\"/tmp/my.sock\"").unwrap();
349        assert_eq!(addr, Address::LocalPipe("/tmp/my.sock".into()));
350    }
351
352    #[test]
353    fn test_address_parsing_tcp() {
354        let addr: Address = serde_yaml::from_str("\"127.0.0.1:9999\"").unwrap();
355        assert_eq!(addr, Address::RemoteSocket("127.0.0.1".to_string(), 9999));
356    }
357
358    #[test]
359    fn test_lo_request_num_default() {
360        let cfg = TracerConfig::from_yaml(MINIMAL_YAML).unwrap();
361        assert_eq!(cfg.lo_request_num(), 100);
362    }
363}